diff --git a/README.md b/README.md index cf8d5089..f3747534 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,7 @@ It's a memory layer for Claude Code that (with the default sqlite backend) runs - **[uv](https://docs.astral.sh/uv/getting-started/installation/)** for environment management — the bootstrap scripts below install it for you if it's missing. - **Claude Code** installed -SQLite ships with Python; `sqlite-vec` is installed as a pip dependency — nothing else to set up. - -better-memory's default embeddings backend (`BETTER_MEMORY_EMBEDDINGS_BACKEND=ollama`) needs [Ollama](https://ollama.com/) running locally with the `nomic-embed-text` model pulled (`ollama pull nomic-embed-text`) — the bootstrap scripts no longer install or pull this for you. Set `BETTER_MEMORY_EMBEDDINGS_BACKEND=sqlite` instead to skip Ollama entirely (pure-SQL trigram-FTS5 backend, no model downloads). +SQLite ships with Python — nothing else to set up. All local retrieval is pure in-SQL FTS5 (word + trigram); there is no embedding model and no external service to install or run. ## Storage backends @@ -116,18 +114,14 @@ One env var roots the runtime filesystem layout: |---|---|---| | `BETTER_MEMORY_HOME` | `~/.better-memory` | Root dir for `memory.db`, `knowledge.db`, `spool/`, `knowledge-base/` | | `BETTER_MEMORY_WIRING_AUTOCHECK` | (unset = on) | Session-start wiring drift autocheck: self-repairs `~/.claude.json`, `~/.claude/settings.json` hooks/env, the CLAUDE.md managed block, skill links, and the per-repo post-commit hook. Set to `off` (case-insensitive) to disable; any other value leaves it enabled. See [Doctor](#doctor). | -| `OLLAMA_HOST` | `http://localhost:11434` | Ollama endpoint | -| `EMBED_MODEL` | `nomic-embed-text` | Embedding model (must produce 768-dim vectors) | | `AUDIT_LOG_RETRIEVED` | `true` | Whether `memory.retrieve` writes per-result audit rows | -| `BETTER_MEMORY_EMBEDDINGS_BACKEND` | `ollama` | `ollama` (default) — local Ollama at `OLLAMA_HOST`; `sqlite` — pure-SQL trigram-FTS5 fusion, no model downloads and no in-memory state. | | `BETTER_MEMORY_STORAGE_BACKEND` | unset | `sqlite` or `agentcore`. Explicit override of the storage backend; when unset, `$BETTER_MEMORY_HOME/settings.json` (written by `better-memory agentcore init`) decides, falling back to `sqlite`. The env var always wins over settings.json. | | `BETTER_MEMORY_AUTO_PRUNE` | (unset = `false`) | When set to `1`, the auto-retention runner (which fires on `memory.retrieve`, throttled to once per 24h) ALSO hard-deletes archived observations older than 365 days. **Irreversible.** Default is archive-only (status flip, reversible). Opt in only if you actively want disk space reclaimed. | | `BETTER_MEMORY_PROJECT` | unset | Force the project name for all calls in this process. Highest-priority project-resolution signal — overrides both the `.better-memory` file and the git-derived name. Designed for subprocess scoping (e.g. ralph's executor sets it per-iteration so subagent observations land in the PBI's target_repo regardless of the worktree's cwd). Empty/whitespace-only values are treated as unset. | | `BETTER_MEMORY_INJECT_MODE` | `legacy` (config default; `deferred` is the recommended and currently-deployed setting) | `deferred`: SessionStart injects only general-scope standing rules + a one-line index; everything else surfaces via the contextual channel as it becomes relevant. `legacy`: the original full bootstrap dump. Unknown values coerce to `legacy`. | | `BETTER_MEMORY_CONTEXT_INJECT_MODE` | `both` | Contextual memory-injection hook trigger: `userprompt`, `pretool`, `both` (default), or `off`. PreToolUse fires once per session (any tool), latched. | -| `BETTER_MEMORY_CONTEXT_VEC_FLOOR` | `0.55` | Cosine floor for the contextual channel's vector-evidence leg. A memory injects only with a text match or a cosine ≥ floor; calibrated precision-first (see the deferred-injection spec). | | `BETTER_MEMORY_BOOTSTRAP_TOP_N` | `5` | Legacy-mode only: project-scoped items the SessionStart bootstrap renders in full; the rest collapse into a one-line index. `0` = full dump. | -| `BETTER_MEMORY_CONTEXT_MIN_HITS` | `2` | **Deprecated, unused** — superseded by the evidence gate (BM25 / vector floor). Kept for back-compat only. | +| `BETTER_MEMORY_CONTEXT_MIN_HITS` | `2` | **Deprecated, unused** — superseded by the evidence gate (BM25 match, or a keyword-hit fallback when unavailable). Kept for back-compat only. | | `BETTER_MEMORY_CONTEXT_MAX_ITEMS` | `3` | Max memories the contextual-injection hook injects per firing. | | `BETTER_MEMORY_CONTEXT_REINJECT_TURNS` | `0` | Turns before a contextually-injected memory can be re-injected. `0` = never re-inject. A turn is one firing of the `contextual_inject` hook (each user prompt, plus each matched tool call in mode `both`), not one user prompt-response cycle. | @@ -174,8 +168,8 @@ The server registers 22 tools, grouped below. Full schemas are in [`website/mcp- | Tool | Purpose | |---|---| | `memory.observe(content, component?, theme?, trigger_type?, outcome?, tech?, scope?)` | Record an observation. Returns `{"id": ...}`. | -| `memory.retrieve(query?, project?, tech?, phase?, polarity?, limit_per_bucket?)` | Distilled reflections in `do` / `dont` / `neutral` buckets, ranked by a Wilson-score hit-rate prior; pass `query` (a plain-language task description) to fuse in BM25 + vector relevance. One shortlist slot per bucket is reserved for an under-rated memory so new lessons earn a score. Drains spool first. | -| `memory.retrieve_observations(query?, component?, theme?, outcome?, episode_id?, project?, limit?)` | Raw-observation drill-down with hybrid FTS5 + vector search. | +| `memory.retrieve(query?, project?, tech?, phase?, polarity?, limit_per_bucket?)` | Distilled reflections in `do` / `dont` / `neutral` buckets, ranked by a Wilson-score hit-rate prior; pass `query` (a plain-language task description) to fuse in BM25 relevance. One shortlist slot per bucket is reserved for an under-rated memory so new lessons earn a score. Drains spool first. | +| `memory.retrieve_observations(query?, component?, theme?, outcome?, episode_id?, project?, limit?)` | Raw-observation drill-down with hybrid FTS5 search (word + trigram BM25 legs, RRF-fused). | | `memory.record_use(id, outcome?)` | Stamp reinforcement outcome on a memory after validation. | **Semantic memory** — user-stated facts and preferences (current truth, not history). @@ -265,7 +259,7 @@ New-Item -ItemType Junction -Path "$env:USERPROFILE\.claude\skills\start-better- ```bash uv sync # install deps -uv run pytest # full suite (requires Ollama running for integration marker) +uv run pytest # full suite (integration marker needs real AWS / a browser) uv run pytest -m "not integration" # unit tests only uv run ruff check . # lint ``` @@ -280,9 +274,6 @@ It speaks JSON-RPC over stdio — pipe `initialize` / `tools/list` / `tools/call ## Troubleshooting -**"Ollama unreachable" on startup.** -Make sure Ollama is running (`ollama serve` on macOS/Linux; the tray app on Windows) and that `nomic-embed-text` is pulled (`ollama pull nomic-embed-text`). The MCP server continues booting and serves `knowledge.*` tools, but `memory.observe` / `memory.retrieve` will error until Ollama is up. - **MCP server not appearing in Claude Code after editing `~/.claude.json`.** MCP servers don't hot-reload. Restart Claude Code. @@ -307,7 +298,7 @@ See `docs/superpowers/specs/2026-04-06-better-memory-design.md` for the original Three shipped upgrades sit on top of that foundation (specs: `2026-07-23-retrieval-quality-design.md`, `2026-07-23-deferred-injection-design.md`): - **Wilson-score ranking.** Reflections and semantic memories rank by the lower bound of their observed hit rate — `(useful + overlooked) / (useful + overlooked + ignored)` — so a memory that helps 3 times out of 4 serves outranks one that helped 67 times out of 192. No raw-count rich-get-richer. One shortlist slot per bucket is reserved for memories with fewer than 3 ratings (the **exploration slot**), so new lessons get served, rated, and scored instead of starving; those serves are tagged (`via_exploration`) and excluded from headline usefulness metrics. -- **Deferred, evidence-gated injection.** SessionStart injects only your standing rules; each prompt (and the session's first tool call) is then scored against the store with three legs — BM25 text match, vector cosine (reflections and semantic memories are embedded at write time, self-healed on retrieve, backfillable via `python -m better_memory.cli.backfill_embeddings`), and the Wilson prior for ranking only. A memory injects solely on positive relevance evidence; popularity can never force an irrelevant injection. Ollama outages cost at most one bounded stall per minute (file-persisted circuit breaker shared across hook processes). +- **Deferred, evidence-gated injection.** SessionStart injects only your standing rules; each prompt (and the session's first tool call) is then scored against the store against an evidence gate — a BM25 text match for reflections, a keyword-hit fallback for semantic memories (which have no FTS substrate of their own) — with the Wilson prior fused in for ranking only. A memory injects solely on positive relevance evidence; popularity can never force an irrelevant injection. - **Evidence-anchored ratings.** Non-`ignored` ratings require a one-line receipt, enforced server-side and stored on the exposure row; the management UI shows each memory's rating-evidence history. Ratings are the fuel for everything above, so their variance is the system's noise floor — the receipts anchor them to observable events. ## Observation lifecycle diff --git a/better_memory/_diag.py b/better_memory/_diag.py index 296cb971..b18f39a5 100644 --- a/better_memory/_diag.py +++ b/better_memory/_diag.py @@ -7,17 +7,13 @@ spawned server's stderr, so without the file mirror these lines are discarded when running under Claude Code. -The gate resolves from the ``diag_logging`` config field, with the -``BETTER_MEMORY_EMBED_LOG`` environment variable kept as an emergency -override (env wins) so diagnostics can still be flipped without editing -config. The env var is named ``BETTER_MEMORY_EMBED_LOG`` for historical -reasons (originally added to localize Ollama embed hangs). +The gate resolves solely from the ``diag_logging`` config field (backed by +``BETTER_MEMORY_DIAG_LOGGING``). """ from __future__ import annotations import contextlib -import os import sys import threading import time @@ -33,26 +29,14 @@ # call fires, ``config`` has finished importing and ``get_config()`` is safe. _ENABLED: bool | None = None -# Values of ``BETTER_MEMORY_EMBED_LOG`` that mean "off"; any other non-empty -# value means "on", preserving the historic env-var semantics. -_ENV_OFF_VALUES = frozenset({"0", "false", "no", "off"}) - _LOCK = threading.Lock() _FILE_HANDLE: object | None = None # actually a text file, typed loosely to avoid import _FILE_INIT_ATTEMPTED = False def _resolve_enabled() -> bool: - """Resolve whether diagnostics are on. - - ``BETTER_MEMORY_EMBED_LOG`` is an emergency override: when set to a - non-empty value it wins outright (env wins), keeping the run-time-flip - workflow intact. Otherwise the gate falls back to the ``diag_logging`` - field on :class:`~better_memory.config.Config`. - """ - raw = os.environ.get("BETTER_MEMORY_EMBED_LOG", "").strip() - if raw: - return raw.lower() not in _ENV_OFF_VALUES + """Resolve whether diagnostics are on from the ``diag_logging`` field on + :class:`~better_memory.config.Config`.""" try: from better_memory.config import get_config @@ -126,8 +110,8 @@ def step(fn: str, msg: str, **fields: object) -> None: """Emit an intermediate ``[bm-trace step ...]`` line. Use inside a method already wrapped in :func:`trace` to mark a - sub-step (e.g. "about to call self._embedder.embed", "SAVEPOINT - open", "commit"). Cheap when diagnostics are off. + sub-step (e.g. "obs_id_minted", "SAVEPOINT open", "commit"). Cheap + when diagnostics are off. """ if not enabled(): return diff --git a/better_memory/async_bridge.py b/better_memory/async_bridge.py deleted file mode 100644 index ad59de6e..00000000 --- a/better_memory/async_bridge.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Bridge sync code into a fresh-thread, fresh-event-loop async runner. - -Encapsulates the four traps that bit us in PRs #11–#14: -1. asyncio.run() fails inside pytest-asyncio's auto-mode running loop. -2. httpx.AsyncClient pools are bound to their creation loop. -3. SQLite connections are bound to their creation thread. -4. Setup errors before the inner try/except escape silently if only - loop.run_until_complete(...) is wrapped. -""" - -from __future__ import annotations - -import asyncio -import threading -from collections.abc import Callable, Coroutine - - -class WorkerTimeout(TimeoutError): - """Raised when run_async_in_worker exceeds its timeout. - - The worker thread is daemon=True and is abandoned (Python threads - can't be cancelled). It continues to completion in the background; - any locks held by the coroutine stay held until then. - """ - - -def run_async_in_worker[T]( - coro_factory: Callable[[], Coroutine[object, object, T]], - *, - timeout: float | None = None, -) -> T: - """Run an async coroutine in a fresh thread with a fresh event loop. - - The factory is invoked INSIDE the worker thread, so any resources - it constructs (sqlite connections, httpx clients) are bound to - that thread and that loop. - - The entire worker body is wrapped in try/except BaseException so - setup errors before the awaitable starts (e.g. sqlite3.connect() - raising) propagate cleanly. - """ - result_holder: list[T] = [] - exc_holder: list[BaseException] = [] - - def _worker() -> None: - try: - coro = coro_factory() - loop = asyncio.new_event_loop() - try: - value = loop.run_until_complete(coro) - result_holder.append(value) - finally: - loop.close() - except BaseException as exc: # noqa: BLE001 — re-raised on caller - exc_holder.append(exc) - - thread = threading.Thread(target=_worker, daemon=True) - thread.start() - thread.join(timeout=timeout) - if thread.is_alive(): - raise WorkerTimeout( - f"worker did not complete within {timeout}s" - ) - if exc_holder: - raise exc_holder[0] - return result_holder[0] diff --git a/better_memory/cli/backfill_embeddings.py b/better_memory/cli/backfill_embeddings.py deleted file mode 100644 index 30624802..00000000 --- a/better_memory/cli/backfill_embeddings.py +++ /dev/null @@ -1,144 +0,0 @@ -"""One-shot embedding backfill for reflections + semantic memories. - -Run at deploy after migration 0014: - - python -m better_memory.cli.backfill_embeddings - -Idempotent: only rows missing a vector are embedded. The lazy self-heal in -memory.retrieve covers stragglers afterwards; this exists so the historical -corpus doesn't wait to be retrieved before becoming searchable. - -One event loop and one embedder for the whole job (the embedder's -httpx.AsyncClient is loop-bound); batches of 50 per HTTP request. -""" - -from __future__ import annotations - -import argparse -import asyncio -import json -import sys -from pathlib import Path - -import sqlite_vec - -from better_memory.db.connection import connect -from better_memory.db.schema import apply_migrations -from better_memory.services.reflection import _embedding_source_text - -_BATCH = 50 - - -def backfill(conn, embedder) -> dict[str, int]: - stats = {"reflections": 0, "semantics": 0, "skipped": 0} - - refl = conn.execute( - """SELECT r.id, r.title, r.use_cases, r.hints FROM reflections r - WHERE r.status IN ('pending_review', 'confirmed') - AND r.id NOT IN (SELECT reflection_id FROM reflection_embeddings)""" - ).fetchall() - sems = conn.execute( - """SELECT id, content FROM semantic_memories - WHERE id NOT IN (SELECT memory_id FROM semantic_embeddings)""" - ).fetchall() - - jobs = [ - ("reflections", "reflection_embeddings", "reflection_id", r["id"], - _embedding_source_text(r["title"], r["use_cases"], - json.loads(r["hints"]))) - for r in refl - ] + [ - ("semantics", "semantic_embeddings", "memory_id", s["id"], s["content"]) - for s in sems - ] - - async def _embed_all() -> list[list[list[float]] | None]: - out = [] - for i in range(0, len(jobs), _BATCH): - texts = [j[4] for j in jobs[i:i + _BATCH]] - try: - out.append(await embedder.embed_batch(texts)) - except Exception: - out.append(None) - return out - - batches = asyncio.run(_embed_all()) if jobs else [] - - for bi, vectors in enumerate(batches): - chunk = jobs[bi * _BATCH:(bi + 1) * _BATCH] - if vectors is None: - stats["skipped"] += len(chunk) - continue - for (kind, table, col, row_id, _), vec in zip(chunk, vectors): - # DELETE-then-INSERT under a per-row SAVEPOINT: vec0 tables - # historically mishandle UPSERT, and a concurrent writer - # (memory.retrieve's self-heal) may have already inserted the same - # primary key while we were embedding this snapshot. Without the - # DELETE a UNIQUE-constraint collision on one row would previously - # abort the whole batch and roll back every row we'd already - # written this run. - # - # The savepoint is load-bearing: without it, a DELETE that - # succeeds followed by an INSERT that raises would leave the - # DELETE pending in the open transaction, and the batch-level - # commit() below would durably erase the pre-existing embedding - # (exactly the concurrent-writer row this code claims to defend). - # ROLLBACK TO SAVEPOINT undoes the DELETE alongside the failed - # INSERT so the pre-existing row survives untouched. - sp = f"bf_{bi}_{row_id.replace('-', '')[:16]}" - conn.execute(f"SAVEPOINT {sp}") - try: - conn.execute( - f"DELETE FROM {table} WHERE {col} = ?", (row_id,)) - conn.execute( - f"INSERT INTO {table} ({col}, embedding) VALUES (?, ?)", - (row_id, sqlite_vec.serialize_float32(vec))) - except Exception: - conn.execute(f"ROLLBACK TO SAVEPOINT {sp}") - conn.execute(f"RELEASE SAVEPOINT {sp}") - stats["skipped"] += 1 - else: - conn.execute(f"RELEASE SAVEPOINT {sp}") - stats[kind] += 1 - conn.commit() - return stats - - -def main(argv: list[str] | None = None) -> None: - from better_memory.config import get_config - from better_memory.embeddings.ollama import OllamaEmbedder - - ap = argparse.ArgumentParser() - ap.add_argument("--home", default=None, - help="BETTER_MEMORY_HOME override (default: config)") - args = ap.parse_args(argv) - - config = get_config() - db = Path(args.home) / "memory.db" if args.home else config.memory_db - conn = connect(db) - try: - apply_migrations(conn) - - if config.embeddings_backend != "ollama": - print("embeddings backend is not ollama; nothing to backfill") - return - - embedder = OllamaEmbedder() - try: - stats = backfill(conn, embedder) - finally: - try: - asyncio.run(embedder.aclose()) - except Exception: - pass - print(f"backfilled reflections={stats['reflections']} " - f"semantics={stats['semantics']} skipped={stats['skipped']}") - if stats["skipped"]: - print("warning: some rows skipped (Ollama unreachable?); " - "re-run later or let retrieve self-heal them", file=sys.stderr) - finally: - conn.close() - - -if __name__ == "__main__": - main() diff --git a/better_memory/config.py b/better_memory/config.py index 6cd1a279..7b1b6604 100644 --- a/better_memory/config.py +++ b/better_memory/config.py @@ -10,10 +10,8 @@ knowledge-base/ settings.json (optional; persists storage_backend selection) -Default home is ``~/.better-memory``. External-service knobs -(``OLLAMA_HOST``, ``EMBED_MODEL``, ``AUDIT_LOG_RETRIEVED``, -``BETTER_MEMORY_EMBEDDINGS_BACKEND``) are separate env vars because they're -orthogonal to path layout. Injection-tuning knobs +Default home is ``~/.better-memory``. ``AUDIT_LOG_RETRIEVED`` is a separate +env var because it's orthogonal to path layout. Injection-tuning knobs (``BETTER_MEMORY_BOOTSTRAP_TOP_N``, ``BETTER_MEMORY_CONTEXT_MIN_HITS``, ``BETTER_MEMORY_CONTEXT_MAX_ITEMS``, ``BETTER_MEMORY_CONTEXT_REINJECT_TURNS``) control content injection strategies. @@ -30,10 +28,6 @@ from better_memory import _diag from better_memory._common import resolve_home -_DEFAULT_OLLAMA_HOST = "http://localhost:11434" -_DEFAULT_EMBED_MODEL = "nomic-embed-text" -_DEFAULT_EMBEDDINGS_BACKEND = "ollama" -_VALID_EMBEDDINGS_BACKENDS = ("ollama", "sqlite") _DEFAULT_STORAGE_BACKEND = "sqlite" _VALID_STORAGE_BACKENDS = ("sqlite", "agentcore") _SETTINGS_FILE = "settings.json" @@ -220,12 +214,9 @@ class Config: knowledge_db: Path knowledge_base: Path spool_dir: Path - ollama_host: str - embed_model: str audit_log_retrieved: bool auto_prune: bool diag_logging: bool - embeddings_backend: Literal["ollama", "sqlite"] storage_backend: Literal["sqlite", "agentcore"] context_inject_mode: Literal["userprompt", "pretool", "both", "off"] bootstrap_top_n: int @@ -233,11 +224,12 @@ class Config: context_max_items: int context_reinject_turns: int inject_mode: Literal["deferred", "legacy"] - context_vec_floor: float - # NOTE: context_min_hits is DEPRECATED. The three-leg evidence-gated - # scorer in services/relevant.py (BM25 / vector cosine / keyword-hit - # fallback) replaced the old pure keyword-hits floor; contextual_inject.py - # no longer reads this field. Kept for back-compat with any external + # NOTE: context_min_hits is DEPRECATED. The evidence-gated scorer in + # services/relevant.py (BM25 qualifiers, with a keyword-hit fallback + # when no FTS substrate is available -- or the backend's + # relevance_ranks in agentcore mode) replaced the old pure + # keyword-hits floor; contextual_inject.py no longer reads this + # field. Kept for back-compat with any external # BETTER_MEMORY_CONTEXT_MIN_HITS overrides that still resolve here. @@ -257,32 +249,12 @@ def _resolve_context_inject_mode() -> Literal["userprompt", "pretool", "both", " return raw # type: ignore[return-value] -def _resolve_embeddings_backend() -> Literal["ollama", "sqlite"]: - raw = os.environ.get("BETTER_MEMORY_EMBEDDINGS_BACKEND", _DEFAULT_EMBEDDINGS_BACKEND) - if raw not in _VALID_EMBEDDINGS_BACKENDS: - raise ValueError( - f"BETTER_MEMORY_EMBEDDINGS_BACKEND must be one of " - f"{_VALID_EMBEDDINGS_BACKENDS}, got {raw!r}" - ) - return raw # type: ignore[return-value] - - def _resolve_inject_mode() -> Literal["deferred", "legacy"]: raw = (os.environ.get("BETTER_MEMORY_INJECT_MODE") or "legacy").strip().lower() # Fail-safe: anything unrecognised means today's behaviour. return "deferred" if raw == "deferred" else "legacy" -def _resolve_vec_floor() -> float: - raw = os.environ.get("BETTER_MEMORY_CONTEXT_VEC_FLOOR") - if raw is None: - return 0.55 - try: - return min(1.0, max(0.0, float(raw))) - except ValueError: - return 0.55 - - def _read_settings_storage_backend(home: Path) -> str | None: """Read ``storage_backend`` from ``/settings.json``. @@ -366,12 +338,9 @@ def get_config() -> Config: knowledge_db=home / "knowledge.db", knowledge_base=home / "knowledge-base", spool_dir=home / "spool", - ollama_host=_resolve_str("OLLAMA_HOST", _DEFAULT_OLLAMA_HOST), - embed_model=_resolve_str("EMBED_MODEL", _DEFAULT_EMBED_MODEL), audit_log_retrieved=_resolve_bool("AUDIT_LOG_RETRIEVED", default=True), auto_prune=_resolve_bool("BETTER_MEMORY_AUTO_PRUNE", default=False), diag_logging=_resolve_bool("BETTER_MEMORY_DIAG_LOGGING", default=False), - embeddings_backend=_resolve_embeddings_backend(), storage_backend=storage_backend, context_inject_mode=_resolve_context_inject_mode(), bootstrap_top_n=_resolve_nonneg_int("BETTER_MEMORY_BOOTSTRAP_TOP_N", 5), @@ -379,5 +348,4 @@ def get_config() -> Config: context_max_items=_resolve_nonneg_int("BETTER_MEMORY_CONTEXT_MAX_ITEMS", 3), context_reinject_turns=_resolve_nonneg_int("BETTER_MEMORY_CONTEXT_REINJECT_TURNS", 0), inject_mode=_resolve_inject_mode(), - context_vec_floor=_resolve_vec_floor(), ) diff --git a/better_memory/db/migrations/0018_drop_vec_tables.sql b/better_memory/db/migrations/0018_drop_vec_tables.sql new file mode 100644 index 00000000..3e8f36ae --- /dev/null +++ b/better_memory/db/migrations/0018_drop_vec_tables.sql @@ -0,0 +1,8 @@ +-- Migration 0018: local vector search removed; vec0 tables dropped. +-- sqlite-vec module is still loaded by db/connection.py this release, +-- which is what makes these DROPs legal. IF EXISTS keeps the script +-- idempotent, so the non-atomic executescript hazard (#27) cannot +-- strand it half-applied in a harmful state. +DROP TABLE IF EXISTS observation_embeddings; +DROP TABLE IF EXISTS reflection_embeddings; +DROP TABLE IF EXISTS semantic_embeddings; diff --git a/better_memory/embeddings/__init__.py b/better_memory/embeddings/__init__.py deleted file mode 100644 index 9f26a359..00000000 --- a/better_memory/embeddings/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Embedding clients for better-memory.""" - -from better_memory.embeddings.ollama import EmbeddingError, OllamaEmbedder - -__all__ = ["OllamaEmbedder", "EmbeddingError"] diff --git a/better_memory/embeddings/ollama.py b/better_memory/embeddings/ollama.py deleted file mode 100644 index a1f7f180..00000000 --- a/better_memory/embeddings/ollama.py +++ /dev/null @@ -1,289 +0,0 @@ -"""Ollama embedding client. - -Wraps the local Ollama HTTP API's ``/api/embed`` endpoint. Exposes a single -:class:`OllamaEmbedder` class with async ``embed`` / ``embed_batch`` methods. - -Design choices: - * Uses the newer ``POST /api/embed`` endpoint which supports batching - natively (request body ``{"model": "...", "input": "text" | [...]}`` and - response body ``{"embeddings": [[...], ...]}``). - * Every returned vector is length-checked against ``expected_dim`` (768 for - ``nomic-embed-text``). A mismatch raises :class:`EmbeddingError` rather - than silently padding / truncating. - * Transient transport failures (``httpx.TransportError`` — connect errors, - connect/read timeouts, protocol errors, read errors, etc.) and 5xx - responses are retried with exponential backoff. 4xx responses fail - immediately — they indicate a configuration error (missing model, bad - request body) that retrying will not fix. - * Any ``httpx.HTTPError`` that escapes the specific branches is wrapped in - :class:`EmbeddingError` so callers only ever need to catch a single - type. Exception chaining via ``raise ... from exc`` preserves the - original cause. -""" - -from __future__ import annotations - -import asyncio -import time -from datetime import UTC, datetime -from types import TracebackType -from typing import Any -from uuid import uuid4 - -import httpx - -from better_memory import _diag -from better_memory.config import get_config - -# Diagnostic logging for embed calls. Off by default; enable with -# BETTER_MEMORY_EMBED_LOG=1 to localize hang-on-first-call freezes that the -# MCP server has hit intermittently on Windows. Each ``_post_embed`` call -# writes a paired ``[bm-embed start ...]`` / ``[bm-embed done ...]`` line via -# the shared diagnostic logger (stderr + ``{home}/logs/diag.log``) with a -# short call-id, attempt counter, and elapsed ms. A ``start`` line with no -# matching ``done`` localises the hang to ``httpx.AsyncClient.post()``. -from better_memory._diag import log as _embed_log - - -class EmbeddingError(RuntimeError): - """Raised when embedding generation fails.""" - - -class OllamaEmbedder: - """Async embedding client for a local Ollama server. - - Parameters - ---------- - host: - Base URL of the Ollama server. Defaults to ``get_config().ollama_host``. - model: - Name of the embedding model. Defaults to ``get_config().embed_model``. - timeout: - Per-request timeout in seconds. - max_retries: - Maximum number of attempts per call (not retries *in addition to* the - first attempt — a value of ``3`` means up to three total attempts). - backoff_base: - Base for exponential backoff. Sleep between attempt ``i`` and ``i+1`` - is ``backoff_base * 2**i`` seconds. - expected_dim: - Required length of every returned embedding vector. Defaults to 768 to - match ``nomic-embed-text``. - client: - Optional pre-built :class:`httpx.AsyncClient`. When supplied, the - caller owns its lifecycle; :meth:`aclose` will not close it. When - omitted, the embedder creates its own client and closes it in - :meth:`aclose`. - """ - - def __init__( - self, - host: str | None = None, - model: str | None = None, - *, - timeout: float = 30.0, - max_retries: int = 3, - backoff_base: float = 0.5, - expected_dim: int = 768, - client: httpx.AsyncClient | None = None, - ) -> None: - if max_retries < 1: - raise ValueError("max_retries must be >= 1") - - cfg = get_config() - self._host = host if host is not None else cfg.ollama_host - self._model = model if model is not None else cfg.embed_model - self._timeout = timeout - self._max_retries = max_retries - self._backoff_base = backoff_base - self._expected_dim = expected_dim - - if client is None: - self._client = httpx.AsyncClient(base_url=self._host, timeout=timeout) - self._owns_client = True - else: - self._client = client - self._owns_client = False - - # ------------------------------------------------------------------ public - async def embed(self, text: str) -> list[float]: - """Return a single embedding vector for ``text``.""" - with _diag.trace("OllamaEmbedder.embed", text_len=len(text)): - embeddings = await self._post_embed(text) - if len(embeddings) != 1: - raise EmbeddingError( - f"Expected 1 embedding for single input, got {len(embeddings)}" - ) - vec = embeddings[0] - self._check_dim(vec) - return vec - - async def embed_batch(self, texts: list[str]) -> list[list[float]]: - """Return one embedding vector per input string. - - An empty ``texts`` list returns ``[]`` without issuing an HTTP request. - """ - with _diag.trace("OllamaEmbedder.embed_batch", n=len(texts)): - if not texts: - return [] - - embeddings = await self._post_embed(texts) - if len(embeddings) != len(texts): - raise EmbeddingError( - f"Expected {len(texts)} embeddings, got {len(embeddings)}" - ) - for vec in embeddings: - self._check_dim(vec) - return embeddings - - async def aclose(self) -> None: - """Close the underlying client if this instance created it.""" - if self._owns_client: - await self._client.aclose() - - async def __aenter__(self) -> OllamaEmbedder: - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: TracebackType | None, - ) -> None: - await self.aclose() - - # ----------------------------------------------------------------- helpers - async def _post_embed(self, payload_input: str | list[str]) -> list[list[float]]: - """Issue ``POST /api/embed`` with retries, return the ``embeddings`` list.""" - body: dict[str, Any] = {"model": self._model, "input": payload_input} - - call_id = uuid4().hex[:8] - n = 1 if isinstance(payload_input, str) else len(payload_input) - _embed_log( - f"[bm-embed start cid={call_id} model={self._model} host={self._host} " - f"n={n} ts={datetime.now(UTC).isoformat()}]" - ) - t_call = time.monotonic() - - def _done(status: str) -> None: - attempt_ms = int((time.monotonic() - t_attempt) * 1000) - total_ms = int((time.monotonic() - t_call) * 1000) - _embed_log( - f"[bm-embed done cid={call_id} status={status} " - f"attempt_ms={attempt_ms} total_ms={total_ms}]" - ) - - last_exc: Exception | None = None - for attempt in range(self._max_retries): - t_attempt = time.monotonic() - _embed_log( - f"[bm-embed attempt cid={call_id} n_attempt={attempt + 1}/" - f"{self._max_retries}]" - ) - # Log right before AND right after the httpx.post call so we can - # tell whether a hang sits inside the network round-trip or - # somewhere on either side (event-loop handoff, retry sleep, etc). - # Also capture the current asyncio loop id so we can spot the - # "client bound to a different loop than the caller" deadlock - # documented in reflection 7a4fdb8e. - try: - loop_id = id(asyncio.get_running_loop()) - except RuntimeError: - loop_id = -1 - _embed_log( - f"[bm-embed pre-post cid={call_id} loop_id={loop_id} " - f"client_loop_id={id(getattr(self._client, '_state', None))}]" - ) - try: - response = await self._client.post("/api/embed", json=body) - _embed_log( - f"[bm-embed post-returned cid={call_id} " - f"status={response.status_code} " - f"elapsed_ms={int((time.monotonic() - t_attempt) * 1000)}]" - ) - except httpx.TransportError as exc: - last_exc = exc - if attempt + 1 >= self._max_retries: - _done("transport_error_final") - raise EmbeddingError( - f"Failed to reach Ollama at {self._host} after " - f"{self._max_retries} attempts: {exc}" - ) from exc - _done("transport_error_retry") - await asyncio.sleep(self._backoff_base * (2**attempt)) - continue - except httpx.HTTPError as exc: - # Any other httpx error (e.g. InvalidURL, UnsupportedProtocol, - # a non-transport RequestError subclass) is surfaced as an - # EmbeddingError so callers never see raw httpx exceptions. - _done("http_error") - raise EmbeddingError( - f"Unexpected httpx error talking to Ollama: {exc}" - ) from exc - except BaseException as exc: - # Diagnostic last-resort: name the exception type that escapes - # the post call but isn't covered by the handlers above (e.g. - # asyncio.CancelledError, RuntimeError from wrong-loop bind). - # Logs the type AND the wallclock timestamp so we can measure - # any gap between the raise and the final embed exit log. - _embed_log( - f"[bm-embed escaped cid={call_id} " - f"type={type(exc).__name__} " - f"msg={exc!r} " - f"elapsed_ms={int((time.monotonic() - t_attempt) * 1000)} " - f"ts={datetime.now(UTC).isoformat()}]" - ) - _done(f"escaped_{type(exc).__name__}") - raise - - if 500 <= response.status_code < 600: - last_exc = httpx.HTTPStatusError( - f"{response.status_code} {response.reason_phrase}", - request=response.request, - response=response, - ) - if attempt + 1 >= self._max_retries: - _done(f"http_{response.status_code}_final") - raise EmbeddingError( - f"Ollama returned {response.status_code} after " - f"{self._max_retries} attempts: {response.text}" - ) from last_exc - _done(f"http_{response.status_code}_retry") - await asyncio.sleep(self._backoff_base * (2**attempt)) - continue - - if 400 <= response.status_code < 500: - _done(f"http_{response.status_code}") - raise EmbeddingError( - f"Ollama returned {response.status_code}: {response.text}" - ) - - # 2xx — parse and return. - try: - data = response.json() - except ValueError as exc: - _done("non_json") - raise EmbeddingError( - f"Ollama returned non-JSON body: {response.text!r}" - ) from exc - - embeddings = data.get("embeddings") - if not isinstance(embeddings, list): - _done("missing_embeddings") - raise EmbeddingError( - f"Ollama response missing 'embeddings' list: {data!r}" - ) - _done("ok") - return embeddings # type: ignore[no-any-return] - - # Should be unreachable — the loop always either returns or raises. - raise EmbeddingError( - f"Exhausted {self._max_retries} retries without a response" - ) from last_exc - - def _check_dim(self, vec: list[float]) -> None: - if len(vec) != self._expected_dim: - raise EmbeddingError( - f"Embedding dimension mismatch: expected {self._expected_dim}, " - f"got {len(vec)}" - ) diff --git a/better_memory/embeddings/sync_embed.py b/better_memory/embeddings/sync_embed.py deleted file mode 100644 index 972b3d79..00000000 --- a/better_memory/embeddings/sync_embed.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Sync facade over the async embedder, for thread-bound sync services. - -Two hazards this class exists to contain: - -1. ``httpx.AsyncClient`` is bound to the event loop that first uses it, - and ``run_async_in_worker`` runs a fresh loop per call — so the - embedder must be CONSTRUCTED inside the worker coroutine and closed - there. The factory is invoked per call; ``OllamaEmbedder`` construction - is cheap and contacts nothing. -2. Retrieval must never hang on a dead Ollama. Any failure opens a - circuit breaker: for ``cooldown`` seconds every call returns ``None`` - immediately. Worst case is one bounded stall per cooldown window. - -Returns ``None`` on every failure path — callers treat a missing vector -as "no vec leg", never as an error. - -The in-memory ``_down_until`` breaker is per-process, so it does nothing -for hook invocations, which are short-lived subprocesses that exit and -restart on every event. ``down_state_file`` persists the cooldown deadline -to disk (wall-clock ``time.time()``, not the monotonic ``clock``) so a -fresh hook process can see that Ollama was recently marked down and skip -straight to ``None`` instead of re-probing a dead endpoint on every event. -""" - -from __future__ import annotations - -import time -from collections.abc import Callable -from pathlib import Path -from typing import Any - -from better_memory.async_bridge import run_async_in_worker - -#: Bridge-level hard stop. The wiring uses OllamaEmbedder(timeout=5.0, -#: max_retries=1), so a healthy-but-slow call ends well inside this; the -#: bridge timeout is the backstop against pathological hangs. -_WORKER_TIMEOUT = 15.0 -_DEFAULT_COOLDOWN = 60.0 - - -class SyncEmbedder: - def __init__( - self, - factory: Callable[[], Any] | None, - *, - clock: Callable[[], float] = time.monotonic, - cooldown: float = _DEFAULT_COOLDOWN, - timeout: float = _WORKER_TIMEOUT, - down_state_file: Path | None = None, - ) -> None: - self._factory = factory - self._clock = clock - self._cooldown = cooldown - self._timeout = timeout - self._down_until = 0.0 - self._down_file = down_state_file - - def embed_text(self, text: str) -> list[float] | None: - return self._run(lambda emb: emb.embed(text)) - - def embed_batch(self, texts: list[str]) -> list[list[float]] | None: - return self._run(lambda emb: emb.embed_batch(texts)) - - def _run(self, op: Callable[[Any], Any]): - if self._factory is None: - return None - if self._clock() < self._down_until: - return None - if self._down_file is not None and self._file_down(): - return None - - factory = self._factory - - async def _go(): - emb = factory() - try: - return await op(emb) - finally: - aclose = getattr(emb, "aclose", None) - if aclose is not None: - await aclose() - - try: - return run_async_in_worker(_go, timeout=self._timeout) - except Exception: - self._down_until = self._clock() + self._cooldown - self._write_down_file() - return None - - def _file_down(self) -> bool: - down = self._down_file - if down is None: - return False - try: - until = float(down.read_text(encoding="utf-8").strip()) - except Exception: - return False - return time.time() < until - - def _write_down_file(self) -> None: - down = self._down_file - if down is None: - return - try: - down.parent.mkdir(parents=True, exist_ok=True) - down.write_text( - str(time.time() + self._cooldown), encoding="utf-8") - except Exception: - pass diff --git a/better_memory/hooks/contextual_inject.py b/better_memory/hooks/contextual_inject.py index f0bb7291..1fc04b44 100644 --- a/better_memory/hooks/contextual_inject.py +++ b/better_memory/hooks/contextual_inject.py @@ -2,11 +2,13 @@ current prompt or tool-input. Gated by BETTER_MEMORY_CONTEXT_INJECT_MODE (userprompt | pretool | both | off). Never raises; always exits 0. -Candidates are scored via retrieve_relevant's three-leg evidence gate (BM25 / -vector cosine / keyword-hit fallback — see services/relevant.py) and capped -at cfg.context_max_items. A per-session SeenStore dedups injected memories -across turns within a run (cfg.context_reinject_turns controls re-injection -after N turns). Survivors are recorded as 'contextual' exposures (best-effort; +Candidates are scored via retrieve_relevant's evidence gate (BM25 +qualifiers, a keyword-hit fallback when no FTS substrate is available, or +the backend's relevance_ranks in agentcore mode — see +services/relevant.py) and capped at cfg.context_max_items. A per-session +SeenStore dedups injected memories across turns within a run +(cfg.context_reinject_turns controls re-injection after N turns). +Survivors are recorded as 'contextual' exposures (best-effort; a write failure never blocks injection) and counted in rating_diagnostics for observability (contextual_fired_userprompt/pretool, contextual_injected, contextual_suppressed_floor, contextual_suppressed_dedup). @@ -16,7 +18,7 @@ sentinel file, race-safe across parallel hook processes): the installed matcher is unscoped (all tools), so without the latch every tool call would re-run the full retrieval path. Later PreToolUse events in the same -session short-circuit on the sentinel before any DB/embedder work. +session short-circuit on the sentinel before any DB work. UserPromptSubmit is unaffected by the latch. """ from __future__ import annotations @@ -30,8 +32,6 @@ from better_memory.config import get_config, project_name from better_memory.db.connection import connect -from better_memory.embeddings.ollama import OllamaEmbedder -from better_memory.embeddings.sync_embed import SyncEmbedder from better_memory.hooks._error_log import record_hook_error from better_memory.services.context_seen import SeenStore, prune_stale from better_memory.services.relevant import format_relevant, retrieve_relevant @@ -112,7 +112,7 @@ def main() -> None: if event == "PreToolUse": # Atomic O_CREAT|O_EXCL claim: if another parallel hook # process already fired for this session, we return False - # and short-circuit before opening any DB / embedder. + # and short-circuit before opening any DB connection. if not seen.try_claim_pretool_fired(): raise _SkipInjection() # module-local sentinel; caught below seen.bump_turn() @@ -121,15 +121,15 @@ def main() -> None: # operational state (the exposure ledger) lives in the local # memory.db regardless of backend — build_backend threads this # conn through as the backend's exposure-ledger connection. The - # BM25/vec legs below still require the SQLITE-CONTENT - # substrate (reflection_fts / *_embeddings), which agentcore has - # none of, so retrieve_relevant still gets conn=None for - # agentcore — that parameter means "FTS/vec index available", - # not "any local connection at all". retrieve_relevant itself - # detects agentcore (conn=None + supports_synthesis=False) and + # BM25 legs below still require the SQLITE-CONTENT + # substrate (reflection_fts), which agentcore has none of, so + # retrieve_relevant still gets conn=None for agentcore -- that + # parameter means "FTS index available", not "any local + # connection at all". retrieve_relevant itself detects + # agentcore (conn=None + supports_synthesis=False) and # substitutes backend.relevance_ranks — a server-side semantic - # search — for the BM25/vec legs, so agentcore's evidence gate - # is no longer purely keyword-based despite conn=None here. + # search — for the BM25 legs, so agentcore's evidence gate is + # no longer purely keyword-based despite conn=None here. with closing(connect(cfg.memory_db)) as conn: _bump_diagnostic( conn, cfg, @@ -139,21 +139,12 @@ def main() -> None: backend = build_backend( config=cfg, memory_conn=conn, - embedder=None, session_id=session_id or None, project=project, ) - sync_embedder = None - if cfg.embeddings_backend == "ollama": - sync_embedder = SyncEmbedder( - lambda: OllamaEmbedder(timeout=5.0, max_retries=1), - down_state_file=cfg.home / "state" / "embed_down_until", - ) items = retrieve_relevant( backend, query=query, project=project, conn=conn if cfg.storage_backend == "sqlite" else None, - sync_embedder=sync_embedder, - vec_floor=cfg.context_vec_floor, max_items=cfg.context_max_items, ) had_candidates = bool(items) diff --git a/better_memory/hooks/session_bootstrap.py b/better_memory/hooks/session_bootstrap.py index 1f63dc21..e0f89c1f 100644 --- a/better_memory/hooks/session_bootstrap.py +++ b/better_memory/hooks/session_bootstrap.py @@ -108,7 +108,6 @@ def main() -> None: backend = build_backend( config=cfg, memory_conn=conn, - embedder=None, session_id=session_id or None, project=project_name(Path(cwd_str)), ) diff --git a/better_memory/mcp/_util.py b/better_memory/mcp/_util.py index 6baf892a..64d9b9b8 100644 --- a/better_memory/mcp/_util.py +++ b/better_memory/mcp/_util.py @@ -34,10 +34,11 @@ def run_best_effort( silently dropped the exception, so a broken background path could fail invisibly for weeks. - When ``diag_cid`` is provided and ``BETTER_MEMORY_EMBED_LOG=1`` is on, - emits a ``[bm-retrieve step= cid=... ms=N status=ok|error]`` - line through the shared diagnostic logger so callers can localize - which step is slow. + When ``diag_cid`` is provided and diagnostic logging is on + (``Config.diag_logging`` / ``BETTER_MEMORY_DIAG_LOGGING``), emits a + ``[bm-retrieve step= cid=... ms=N status=ok|error]`` line + through the shared diagnostic logger so callers can localize which + step is slow. """ t0 = time.monotonic() status = "ok" diff --git a/better_memory/mcp/server.py b/better_memory/mcp/server.py index 66e20a58..7cbb21e8 100644 --- a/better_memory/mcp/server.py +++ b/better_memory/mcp/server.py @@ -46,9 +46,6 @@ import asyncio import sqlite3 -import sys -import urllib.error -import urllib.request from collections.abc import Callable, Coroutine from dataclasses import dataclass from pathlib import Path @@ -61,8 +58,6 @@ from better_memory.config import get_config, project_name from better_memory.db.connection import connect from better_memory.db.schema import apply_migrations -from better_memory.embeddings.ollama import OllamaEmbedder -from better_memory.embeddings.sync_embed import SyncEmbedder from better_memory.mcp._util import resolve_session_id as _resolve_session_id from better_memory.mcp.handlers import ( EpisodeToolHandlers, @@ -90,41 +85,6 @@ _MEMORY_MIGRATIONS = Path(__file__).parent.parent / "db" / "migrations" _KNOWLEDGE_MIGRATIONS = Path(__file__).parent.parent / "db" / "knowledge_migrations" -_OLLAMA_PROBE_TIMEOUT_SEC = 2.0 - - -def _probe_ollama(host: str) -> None: - """Log a clear stderr warning if Ollama isn't reachable. Never raise. - - Called once at startup; purely informational. The server continues in - either case — knowledge-only tools (``knowledge.search``, ``knowledge.list``) - don't need Ollama, and embedding-dependent tools raise a clean - ``EmbeddingError`` the first time they're invoked against a down host. - """ - url = host.rstrip("/") + "/api/tags" - try: - with urllib.request.urlopen( # noqa: S310 — local-only URL - url, timeout=_OLLAMA_PROBE_TIMEOUT_SEC - ) as response: - if response.status == 200: - return - print( - f"[better-memory] WARNING: Ollama probe at {url} returned " - f"HTTP {response.status}; memory.observe / memory.retrieve " - "may fail until this is resolved.", - file=sys.stderr, - flush=True, - ) - except (urllib.error.URLError, TimeoutError, OSError) as exc: - print( - f"[better-memory] WARNING: Ollama unreachable at {host} " - f"({type(exc).__name__}: {exc}). memory.observe and memory.retrieve " - "will fail until Ollama is running; knowledge.* tools still work.", - file=sys.stderr, - flush=True, - ) - - # --------------------------------------------------------------------------- factory @@ -136,14 +96,12 @@ class ServerContext: (tests, future hooks) can introspect or reuse the wired-up backend without rebuilding it. ``backend`` is the live ``StorageBackend``; ``memory_conn`` is the underlying sqlite connection (None for non-sqlite - backends in future plans); ``embedder`` is whatever was passed to the - backend (None for the sqlite/FTS5 embeddings backend, which indexes via - DB triggers and needs no Python embedder). + backends in future plans). No embedder is ever constructed — sqlite + (FTS5) indexes via DB triggers and needs no Python embedder. """ backend: StorageBackend memory_conn: sqlite3.Connection | None = None - embedder: Any = None def create_server() -> tuple[ @@ -154,11 +112,11 @@ def create_server() -> tuple[ """Wire services and register tools. Returns a ``(server, cleanup, ctx)`` triple where ``cleanup`` is an - idempotent async function that closes the two SQLite connections and - the embedder's HTTP client, and ``ctx`` is a :class:`ServerContext` - bundling the live :class:`StorageBackend`, its underlying memory - connection, and the embedder (if any). Callers must await ``cleanup`` - on shutdown (typically in a ``finally`` around ``server.run``). + idempotent async function that closes the two SQLite connections, and + ``ctx`` is a :class:`ServerContext` bundling the live + :class:`StorageBackend` and its underlying memory connection. Callers + must await ``cleanup`` on shutdown (typically in a ``finally`` around + ``server.run``). """ config = get_config() @@ -167,28 +125,6 @@ def create_server() -> tuple[ knowledge_conn = connect(config.knowledge_db) apply_migrations(knowledge_conn, migrations_dir=_KNOWLEDGE_MIGRATIONS) - # Embedder is only built for the ollama backend. For sqlite, FTS5 - # triggers handle indexing automatically (see migration 0011) and no - # embedder is needed. - embedder: OllamaEmbedder | None = None - if config.embeddings_backend == "ollama": - # One embedder per server. Construction is cheap and does NOT contact - # Ollama (see OllamaEmbedder.__init__); the first embed() call does. - embedder = OllamaEmbedder() - # Cheap reachability probe against Ollama. Warn (to stderr) if it's - # down but do not block startup — knowledge.* tools still work - # without Ollama, and if Ollama comes up later, memory.observe / - # memory.retrieve will succeed on their next call without a restart. - _probe_ollama(config.ollama_host) - - # Fresh short-timeout embedder per bridge call (loop-bound client); - # shared instance so the breaker state is process-wide. - sync_embedder: SyncEmbedder | None = None - if embedder is not None: - sync_embedder = SyncEmbedder( - lambda: OllamaEmbedder(timeout=5.0, max_retries=1) - ) - # Concurrency invariant: the memory-side services below # (EpisodeService, ObservationService, ReflectionSynthesisService, # RetentionService, SpoolService, SemanticMemoryService, @@ -203,7 +139,7 @@ def create_server() -> tuple[ # assumption breaks and the services must be reworked to use per-task # connections (or a connection pool with explicit checkout). episodes = EpisodeService(memory_conn) - observations = ObservationService(memory_conn, embedder=embedder, episodes=episodes) + observations = ObservationService(memory_conn, episodes=episodes) # Resolve project + session id ONCE at startup. Per-handler code can # still override per-call (handlers continue to read args.get("project") @@ -220,8 +156,6 @@ def create_server() -> tuple[ backend: StorageBackend = build_backend( config=config, memory_conn=memory_conn, - embedder=embedder, - sync_embedder=sync_embedder, session_id=startup_session_id, project=startup_project, ) @@ -229,10 +163,10 @@ def create_server() -> tuple[ # Reflection synthesis is driven by the IDE-LLM via two MCP tools # (memory.synthesize_next_get_context / _apply). The service no # longer holds a chat client. - reflections = ReflectionSynthesisService(memory_conn, sync_embedder=sync_embedder) + reflections = ReflectionSynthesisService(memory_conn) retention = RetentionService(conn=memory_conn) memory_rating = MemoryRatingService(memory_conn) - semantic = SemanticMemoryService(memory_conn, sync_embedder=sync_embedder) + semantic = SemanticMemoryService(memory_conn) session_bootstrap = SessionBootstrapService(memory_conn) knowledge = KnowledgeService( @@ -318,11 +252,11 @@ async def _call_tool( cleaned = False async def cleanup() -> None: - """Close SQLite connections and the embedder HTTP client. + """Close the two SQLite connections. Idempotent: safe to call multiple times. SQLite ``Connection.close`` - is a no-op after the first call, and we guard the embedder close with - a local flag so we don't double-close its httpx client either. + is a no-op after the first call, and the ``cleaned`` flag guards + against re-entry. """ nonlocal cleaned if cleaned: @@ -336,19 +270,10 @@ async def cleanup() -> None: knowledge_conn.close() except Exception: # noqa: BLE001 — best-effort shutdown pass - # In the sqlite backend no embedder is built (FTS5 triggers handle - # indexing). Guard against the None case so this cleanup stays - # idempotent across both backends. - if embedder is not None: - try: - await embedder.aclose() - except Exception: # noqa: BLE001 — best-effort shutdown - pass return server, cleanup, ServerContext( backend=backend, memory_conn=memory_conn, - embedder=embedder, ) diff --git a/better_memory/mcp/tools.py b/better_memory/mcp/tools.py index 90a48b99..5c933015 100644 --- a/better_memory/mcp/tools.py +++ b/better_memory/mcp/tools.py @@ -200,8 +200,8 @@ def tool_definitions( description=( "Retrieve raw observations matching given filters. Drill-down " "tool — use memory.retrieve for the distilled-reflections " - "default. With ``query``, results are ranked by hybrid " - "FTS5 + sqlite-vec relevance; without, ordered created_at " + "default. With ``query``, results are ranked by BM25/FTS5 " + "relevance; without, ordered created_at " "DESC. ``episode_id`` and ``theme`` filters are ignored " "in query mode." ), diff --git a/better_memory/search/hybrid.py b/better_memory/search/hybrid.py index 4135250b..bf71d3e3 100644 --- a/better_memory/search/hybrid.py +++ b/better_memory/search/hybrid.py @@ -1,17 +1,18 @@ -"""Hybrid search over observations (FTS5 BM25 + sqlite-vec kNN + RRF fusion). +"""Hybrid search over observations (word-FTS5 BM25 + trigram-FTS5 BM25 + RRF fusion). -This module is deliberately **pure SQLite**: it never calls the embedder. -Callers that want semantic search must supply a ``query_vector`` themselves. -This keeps the layer unit-testable without mocking Ollama and lets the caller -reuse one embedding across multiple search calls (e.g. three bucket searches -per retrieve). +This module is deliberately **pure SQLite**: it never calls an embedder -- +there is no vector/embedding leg (removed in remove-ollama-embeddings Task 7, +which deleted the sqlite-vec kNN leg and its ``query_vector`` parameter). +Both BM25 legs run over the same ``query_text``. Algorithm --------- 1. Build a base ``WHERE`` clause from :class:`SearchFilters`. -2. Run FTS5 BM25 top-K (if ``query_text`` supplied) and sqlite-vec kNN top-K - (if ``query_vector`` supplied). Both are cheap serial queries — the "parallel" - in the plan means "independently scored", not concurrent execution. +2. Run word-FTS5 BM25 top-K (``observation_fts``) and trigram-FTS5 BM25 top-K + (``observation_trigram_fts``, which catches substring matches the word + tokenizer misses) -- both keyed off ``query_text``. Cheap serial queries + -- the "parallel" in the plan means "independently scored", not concurrent + execution. 3. Fuse candidate lists with Reciprocal Rank Fusion: ``score(d) = Σ 1 / (rrf_k + rank_source(d))`` across sources. 4. Multiply by the reinforcement multiplier @@ -28,8 +29,6 @@ from datetime import UTC, datetime, timedelta from typing import Any, Literal -import sqlite_vec - from better_memory._common import default_clock Outcome = Literal["success", "failure", "neutral"] @@ -84,8 +83,6 @@ def hybrid_search( conn: sqlite3.Connection, *, query_text: str | None = None, - query_vector: list[float] | None = None, - second_source: Literal["vec0", "trigram", "none"] = "vec0", filters: SearchFilters = _DEFAULT_FILTERS, limit: int = 10, candidate_k: int = 50, @@ -96,62 +93,40 @@ def hybrid_search( ) -> list[SearchResult]: """Run hybrid search and return the top ``limit`` results. - ``second_source`` selects the companion ranker that fuses with word-FTS5 - BM25 via RRF: - * ``"vec0"`` (default) — sqlite-vec kNN over ``query_vector``. - * ``"trigram"`` — FTS5 BM25 over ``observation_trigram_fts`` using - ``query_text``. Used by the ``sqlite`` embeddings backend. - * ``"none"`` — skip the second source; only word-FTS5 runs. - The RRF source tag stays ``"vec"`` for both vec0 and trigram so downstream - code that treats it as "the second source" is unaffected. + Always fuses two BM25 legs over the same ``query_text`` via RRF: + word-level FTS5 (``observation_fts``) and trigram-level FTS5 + (``observation_trigram_fts``, which catches substring matches the word + tokenizer misses). There is no vector/embedding leg (removed in + remove-ollama-embeddings Task 7, along with the ``query_vector`` / + ``second_source`` parameters this function used to accept). """ - if query_text is None and query_vector is None and second_source != "trigram": - return [] - if second_source == "trigram" and (query_text is None or not query_text.strip()): + if query_text is None or not query_text.strip(): return [] now = (clock or default_clock)() where_sql, where_params = _build_where(filters, now=now) - # Gather candidate rowids from each active source. - fts_ids: list[str] = [] - second_ids: list[str] = [] - - if query_text is not None and query_text.strip(): - fts_ids = _fts_candidates( - conn, - query_text=query_text, - where_sql=where_sql, - where_params=where_params, - candidate_k=candidate_k, - ) - if second_source == "vec0" and query_vector is not None: - second_ids = _vec_candidates( - conn, - query_vector=query_vector, - where_sql=where_sql, - where_params=where_params, - candidate_k=candidate_k, - ) - elif second_source == "trigram" and query_text is not None and query_text.strip(): - second_ids = _trigram_candidates( - conn, - query_text=query_text, - where_sql=where_sql, - where_params=where_params, - candidate_k=candidate_k, - ) - # second_source == "none" → second_ids stays [] - - if not fts_ids and not second_ids: + fts_ids = _fts_candidates( + conn, + query_text=query_text, + where_sql=where_sql, + where_params=where_params, + candidate_k=candidate_k, + ) + trigram_ids = _trigram_candidates( + conn, + query_text=query_text, + where_sql=where_sql, + where_params=where_params, + candidate_k=candidate_k, + ) + + if not fts_ids and not trigram_ids: return [] - # Fuse with Reciprocal Rank Fusion. The second-source tag stays "vec" so - # downstream rank-bookkeeping (which treats it as "the companion source") - # is unchanged whether vec0 or trigram populated it. candidates: dict[str, _Candidate] = {} _add_rrf_ranks(candidates, fts_ids, source="fts", rrf_k=rrf_k) - _add_rrf_ranks(candidates, second_ids, source="vec", rrf_k=rrf_k) + _add_rrf_ranks(candidates, trigram_ids, source="trigram", rrf_k=rrf_k) if not candidates: return [] @@ -259,43 +234,6 @@ def _fts_candidates( return [r["id"] for r in rows] -def _vec_candidates( - conn: sqlite3.Connection, - *, - query_vector: list[float], - where_sql: str, - where_params: list[Any], - candidate_k: int, -) -> list[str]: - """Return observation ids ordered by vec distance (closest first).""" - # sqlite-vec's kNN operator only accepts ``embedding MATCH ? AND k = ?`` - # without extra predicates, so we fetch the top candidates first and then - # filter/order by joining to observations afterwards. - blob = sqlite_vec.serialize_float32(query_vector) - knn_rows = conn.execute( - "SELECT observation_id, distance " - "FROM observation_embeddings " - "WHERE embedding MATCH ? AND k = ? " - "ORDER BY distance", - (blob, candidate_k), - ).fetchall() - if not knn_rows: - return [] - - # Build the filtered set by joining to observations. - ids_in_order = [r["observation_id"] for r in knn_rows] - placeholders = ",".join("?" for _ in ids_in_order) - sql = f"SELECT o.id AS id FROM observations o WHERE o.id IN ({placeholders})" - params: list[Any] = list(ids_in_order) - if where_sql: - sql += " AND " + where_sql - params.extend(where_params) - - allowed = {r["id"] for r in conn.execute(sql, params).fetchall()} - # Preserve kNN order while filtering. - return [i for i in ids_in_order if i in allowed] - - def _trigram_candidates( conn: sqlite3.Connection, *, diff --git a/better_memory/services/observation.py b/better_memory/services/observation.py index cfad6fed..2fe62888 100644 --- a/better_memory/services/observation.py +++ b/better_memory/services/observation.py @@ -1,9 +1,8 @@ """Observation write-path service. Handles creation of episodic ``observations`` rows plus the supporting -``observation_embeddings`` vector row and ``audit_log`` entry, and records -re-use / outcome signals that move ``reinforcement_score`` up (success) or -down (failure). +``audit_log`` entry, and records re-use / outcome signals that move +``reinforcement_score`` up (success) or down (failure). Retrieval lives in later phases. @@ -14,26 +13,11 @@ 1. Resolves the active episode via the injected :class:`EpisodeService`, opening a background episode if none is active. This call commits on - success (background episode creation is its own transaction). See the - caveat below. - 2. Calls the embedder. A slow / broken Ollama server causes this step to - fail; steps 3+ do not run. - 3. Opens a SAVEPOINT, inserts the observation (AI trigger populates the - FTS content-linked virtual table), inserts the embedding into the - ``vec0`` table, and writes an audit row. All four statements succeed - together or the SAVEPOINT rolls them all back. - 4. Commits the transaction. - -Fail-fast caveat ----------------- -If the embedder fails in step 2, a background episode may have been -committed in step 1 and left with zero observations attached. Subsequent -successful ``create`` calls on the same session will reuse that background -episode (via ``active_episode``), so the stranding is bounded to "one -orphan background episode per session that hit embed failure before any -successful write". Phase 2 accepts this trade-off; a future refactor of -``EpisodeService.open_background`` to support an "in-savepoint" mode -could restore the stricter guarantee. + success (background episode creation is its own transaction). + 2. Opens a SAVEPOINT, inserts the observation (AI trigger populates the + FTS content-linked virtual table) and writes an audit row. Both + statements succeed together or the SAVEPOINT rolls them all back. + 3. Commits the transaction. If the SAVEPOINT is rolled back on error, the FTS trigger's side-effects are undone along with the base-table row because SQLite FTS5 triggers participate @@ -46,8 +30,7 @@ ``content='observations'`` FTS5 external-content table with AFTER INSERT / UPDATE / DELETE triggers that mirror writes automatically. Therefore *no* direct insert into ``observation_fts`` is required — the trigger -handles it. The ``vec0`` ``observation_embeddings`` table, on the other -hand, is NOT trigger-populated and must be written manually. +handles it. """ from __future__ import annotations @@ -59,8 +42,6 @@ from typing import Any, Literal from uuid import uuid4 -import sqlite_vec - from better_memory import _diag from better_memory._common import default_clock, get_session_id from better_memory.config import get_config, project_name @@ -110,7 +91,6 @@ class ObservationService: def __init__( self, conn: sqlite3.Connection, - embedder: Any = None, *, clock: Callable[[], datetime] | None = None, project_resolver: Callable[[], str] | None = None, @@ -120,7 +100,6 @@ def __init__( episodes: EpisodeService | None = None, ) -> None: self._conn = conn - self._embedder = embedder self._clock: Callable[[], datetime] = clock or default_clock self._project_resolver: Callable[[], str] = ( project_resolver if project_resolver is not None else project_name @@ -156,7 +135,7 @@ async def create( tech: str | None = None, scope: str = "project", ) -> str: - """Insert a new observation, embedding and audit row; return its id.""" + """Insert a new observation and audit row; return its id.""" fn = "ObservationService.create" with _diag.trace(fn, scope=scope, content_len=len(content)): if scope not in ("project", "general"): @@ -192,20 +171,6 @@ async def create( episode_id = active.id _diag.step(fn, "reusing_active_episode", episode_id=episode_id) - # Compute the embedding BEFORE opening the observation SAVEPOINT so a - # slow / broken Ollama server does not hold an open SAVEPOINT. Note - # that the episode lookup / background-open above has already - # committed if a new background was created — see module docstring. - # When embedder is None (sqlite backend) skip the vec0 path entirely; - # FTS5 triggers populate both the word and trigram indexes for us. - vec_blob: bytes | None = None - if self._embedder is not None: - _diag.step(fn, "about_to_embed", model=getattr(self._embedder, "_model", "?")) - vector = await self._embedder.embed(content) - _diag.step(fn, "embed_returned", dim=len(vector)) - vec_blob = sqlite_vec.serialize_float32(vector) - _diag.step(fn, "vec_serialized", bytes=len(vec_blob)) - now = self._clock().isoformat() conn = self._conn @@ -239,14 +204,6 @@ async def create( ), ) - if vec_blob is not None: - _diag.step(fn, "insert_embedding_row") - conn.execute( - "INSERT INTO observation_embeddings (observation_id, embedding) " - "VALUES (?, ?)", - (obs_id, vec_blob), - ) - _diag.step(fn, "write_audit_row") self._write_audit( entity_id=obs_id, @@ -291,23 +248,14 @@ async def retrieve( ) -> BucketedResults: """Run three outcome-filtered hybrid searches and return them bucketed. - The query is embedded *once* and the vector is reused for all three - sub-searches (success / failure / neutral), saving two embedder - round-trips per retrieve. + FTS5/trigram BM25 only — there is no embedder, so no vector leg. """ resolved_project = project if project is not None else self._project_resolver() - # Embed the query once, if any, so vector search is available to every - # bucket without paying three embed calls. - query_vector: list[float] | None = None - if query is not None and query.strip() and self._embedder is not None: - query_vector = await self._embedder.embed(query) - # Sanitise before FTS5 MATCH: user queries like ``better-memory retrieval`` # would otherwise be parsed by FTS5 as ``-memory`` column-exclusion and # resolve to [] (via the safety net in hybrid._fts_candidates), yielding - # zero hits for any hyphenated term. The embedder still receives the - # raw query — operator chars don't affect semantic similarity. + # zero hits for any hyphenated term. fts_query_text = ( sanitize_fts5_query(query) if query is not None else None ) or None @@ -322,12 +270,9 @@ async def retrieve( def _run(outcome: Outcome, limit: int) -> list[SearchResult]: filters = SearchFilters(outcome=outcome, **base_kwargs) - second_source = "vec0" if self._embedder is not None else "trigram" return hybrid_search( self._conn, query_text=fts_query_text, - query_vector=query_vector, - second_source=second_source, filters=filters, limit=limit, candidate_k=candidate_k, @@ -483,8 +428,9 @@ async def list_observations( - Order: ``created_at DESC, rowid DESC``. Cap at ``limit``. Query mode (``query`` is given): - - Embed the query and route through :func:`hybrid_search`. FTS5 + - sqlite-vec results, RRF-fused, ranked by relevance. + - Route through :func:`hybrid_search`: word-FTS5 + trigram-FTS5 + BM25, RRF-fused, ranked by relevance. There is no embedder, so + no vector leg. - The simple filters that ``SearchFilters`` natively supports (``project``, ``component``, ``outcome``) are honoured; ``status`` and ``window_days`` are disabled (drill-down should see all @@ -578,15 +524,9 @@ async def _list_observations_via_hybrid_search( status=None, window_days=None, ) - second_source = "vec0" if self._embedder is not None else "trigram" - vector: list[float] | None = None - if self._embedder is not None: - vector = await self._embedder.embed(query) results = hybrid_search( self._conn, query_text=fts_query_text, - query_vector=vector, - second_source=second_source, filters=filters, limit=limit, clock=self._clock, diff --git a/better_memory/services/reflection.py b/better_memory/services/reflection.py index 2fb07b02..14e579b6 100644 --- a/better_memory/services/reflection.py +++ b/better_memory/services/reflection.py @@ -1,25 +1,30 @@ """Reflection synthesis service. Orchestrates per-episode synthesis: for each closed-but-unsynthesized -episode, loads its observations and tech-filtered reflections, calls the -LLM once, and applies new/augment/merge/ignore actions atomically inside -a per-episode SAVEPOINT. +episode, loads its observations and tech-filtered reflections, hands +them to the driving agent's LLM call out of process, and applies the +returned new/augment/merge/ignore actions atomically inside a +per-episode SAVEPOINT. This module provides: - Typed read models for LLM consumption (:class:`ReflectionForPrompt`, :class:`ObservationForPrompt`). - Per-episode types: :class:`EpisodeForPrompt`, :class:`EpisodeContext`, :class:`EpisodeQueueCounts`, :class:`SynthesisStep`. -- :class:`ReflectionSynthesisService` with ``synthesize_next`` as the - primary entry point. +- :class:`ReflectionSynthesisService` with ``get_next_pending_context`` / + ``apply_decision`` as the primary entry points. Design notes: - The service owns writes within its own transaction envelope (SAVEPOINT + commit), matching the convention used by ObservationService and EpisodeService. -- The LLM client is injected via a ``ChatCompleter`` Protocol so - tests can swap :class:`better_memory.llm.fake.FakeChat` in - without touching Ollama. +- The service holds no chat client and never calls an LLM itself. + ``get_next_pending_context`` returns the episode's prompt context for + the driving agent (Claude, via the ``better-memory-synthesize`` skill) + to complete out of process; the agent's parsed decision is submitted + back via ``apply_decision``. This two-call split replaces the old + single ``synthesize_next`` call, which embedded an Ollama chat-client + call between load and apply. """ from __future__ import annotations @@ -31,11 +36,8 @@ from dataclasses import dataclass from datetime import datetime, timedelta -import sqlite_vec - from better_memory import _diag from better_memory._common import default_clock, env_session_id -from better_memory.embeddings.sync_embed import SyncEmbedder from better_memory.search.query import sanitize_fts5_query from better_memory.services.scoring import wilson_lower_bound @@ -53,37 +55,6 @@ }) -def _embedding_source_text(title: str, use_cases: str, hints: list[str]) -> str: - """What gets embedded for a reflection: the discriminating text.""" - return "\n".join([title, use_cases, *hints]) - - -def _write_reflection_embedding( - conn: sqlite3.Connection, - reflection_id: str, - vector: list[float] | None, -) -> None: - """DELETE+INSERT the vec0 row for one reflection's embedding. - - Shared by :class:`ReflectionSynthesisService` (synthesis writes) and - :class:`ReflectionService` (UI-driven ``update_text`` writes) so both - write paths keep ``reflection_embeddings`` in sync with the - reflection's current title/use_cases/hints. DELETE+INSERT rather than - UPSERT because vec0 tables historically mishandle UPSERT. - """ - if vector is None: - return - conn.execute( - "DELETE FROM reflection_embeddings WHERE reflection_id = ?", - (reflection_id,), - ) - conn.execute( - "INSERT INTO reflection_embeddings (reflection_id, embedding) " - "VALUES (?, ?)", - (reflection_id, sqlite_vec.serialize_float32(vector)), - ) - - def _later_ts(a: str | None, b: str | None) -> str | None: """Return the later of two ISO-8601 timestamps, treating NULL as -inf. @@ -330,10 +301,6 @@ def _parse_merge(item: object) -> MergeAction: ) -#: Max embeddings written per retrieve call by the lazy self-heal. Keeps -#: worst-case added latency bounded; cli.backfill_embeddings is the bulk path. -SELF_HEAL_BATCH_CAP = 20 - #: A memory with fewer than this many rated exposures is "untested": its #: Wilson score is statistically meaningless, so it competes for the #: reserved exploration slot instead of the proven slots. @@ -370,14 +337,9 @@ def __init__( conn: sqlite3.Connection, *, clock: Callable[[], datetime] | None = None, - sync_embedder: SyncEmbedder | None = None, ) -> None: self._conn = conn self._clock: Callable[[], datetime] = clock or default_clock - self._sync_embedder = sync_embedder - - def _store_embedding(self, reflection_id: str, vector: list[float] | None) -> None: - _write_reflection_embedding(self._conn, reflection_id, vector) @staticmethod def _normalize_tech(tech: str | None) -> str | None: @@ -777,7 +739,6 @@ def _apply_new( actions: list[NewAction], *, project: str, - embed_tasks: list[tuple[str, str]] | None = None, ) -> int: """Insert new reflections + their source links + consume observations. @@ -785,12 +746,6 @@ def _apply_new( don't exist in the DB are dropped. Entries whose entire source list turns out to be invalid are skipped silently. - When ``embed_tasks`` is supplied, the blocking embed call is NOT - made here; instead ``(reflection_id, source_text)`` pairs are - appended for the caller to embed AFTER the write transaction - commits (see :meth:`apply_decision` and #97). When ``None``, the - embed still runs inline for direct callers. - Returns the count of reflections actually inserted (may be smaller than ``len(actions)`` when entries are dropped for lack of valid sources). @@ -844,17 +799,6 @@ def _apply_new( [now, *valid_sources], ) - if self._sync_embedder is not None: - source_text = _embedding_source_text( - action.title, action.use_cases, action.hints, - ) - if embed_tasks is not None: - embed_tasks.append((reflection_id, source_text)) - else: - self._store_embedding( - reflection_id, - self._sync_embedder.embed_text(source_text), - ) created += 1 return created @@ -904,7 +848,6 @@ def _filter_existing_observations( def _apply_augment( self, actions: list[AugmentAction], - embed_tasks: list[tuple[str, str]] | None = None, ) -> int: """Apply augment actions: append hints, rewrite use_cases, bump confidence, link new sources, recompute evidence count. @@ -916,12 +859,6 @@ def _apply_augment( - ``add_source_observation_ids`` filtered to existing obs; ``INSERT OR IGNORE`` dedupes against existing source rows. - When ``embed_tasks`` is supplied, the blocking embed call is NOT - made here; instead ``(reflection_id, source_text)`` pairs are - appended for the caller to embed AFTER the write transaction - commits (see :meth:`apply_decision` and #97). When ``None``, the - embed still runs inline for direct callers. - Returns the count of reflections actually augmented. """ augmented = 0 @@ -1017,20 +954,6 @@ def _apply_augment( ), ) - if self._sync_embedder is not None: - final_use_cases = (action.rewrite_use_cases - if action.rewrite_use_cases is not None - else row["use_cases"]) - source_text = _embedding_source_text( - row["title"], final_use_cases, merged_hints, - ) - if embed_tasks is not None: - embed_tasks.append((action.reflection_id, source_text)) - else: - self._store_embedding( - action.reflection_id, - self._sync_embedder.embed_text(source_text), - ) augmented += 1 return augmented @@ -1182,13 +1105,7 @@ def _apply_merge(self, actions: list[MergeAction]) -> int: ), ) - # Target text is untouched by merge (counters/evidence only), so - # no re-embed; drop the superseded source's vector so it stops - # competing for kNN slots. - self._conn.execute( - "DELETE FROM reflection_embeddings WHERE reflection_id = ?", - (action.source_id,), - ) + # Target text is untouched by merge (counters/evidence only). merged += 1 return merged @@ -1305,12 +1222,6 @@ def apply_decision( f"(synthesized_at={row['synthesized_at']})" ) - # Collect embed inputs during the DB writes so the blocking Ollama - # call happens AFTER commit, outside the WAL writer lock — see #97. - # Missing vectors self-heal on first retrieval via - # _heal_missing_embeddings, so a crash between the two commits - # only costs one round of embedding on the next lookup. - embed_tasks: list[tuple[str, str]] = [] self._conn.execute("SAVEPOINT episode_synthesize") try: active_rows = self._conn.execute( @@ -1320,12 +1231,8 @@ def apply_decision( ).fetchall() active_ids = [r["id"] for r in active_rows] - created = self._apply_new( - response.new, project=project, embed_tasks=embed_tasks, - ) - augmented = self._apply_augment( - response.augment, embed_tasks=embed_tasks, - ) + created = self._apply_new(response.new, project=project) + augmented = self._apply_augment(response.augment) merged = self._apply_merge(response.merge) ignored = self._apply_ignore(response.ignore) auto_ignored = self._auto_ignore_unused(active_ids) @@ -1338,17 +1245,6 @@ def apply_decision( self._conn.execute("RELEASE SAVEPOINT episode_synthesize") self._conn.commit() - # Now that the main write transaction is committed, run the batched - # embed call. A slow or dead Ollama can no longer stall other - # connections; the vector write is a second, short transaction. - if embed_tasks and self._sync_embedder is not None: - texts = [t for _, t in embed_tasks] - vectors = self._sync_embedder.embed_batch(texts) - if vectors is not None: - for (rid, _), vec in zip(embed_tasks, vectors): - self._store_embedding(rid, vec) - self._conn.commit() - counts = { "created": created, "augmented": augmented, @@ -1364,81 +1260,22 @@ def apply_decision( ) # --------------------------------------------------------- retrieve_reflections - def _heal_missing_embeddings(self, rows) -> None: - """Embed up to SELF_HEAL_BATCH_CAP candidates that lack vectors. - - Historical rows and write-time failures repair themselves on their - first relevant retrieval. Entirely best-effort; one embed_batch call. - """ - if self._sync_embedder is None or not rows: - return - ids = [r["id"] for r in rows] - placeholders = ",".join("?" for _ in ids) - have = { - row[0] for row in self._conn.execute( - f"SELECT reflection_id FROM reflection_embeddings " - f"WHERE reflection_id IN ({placeholders})", ids, - ) - } - todo = [r for r in rows if r["id"] not in have][:SELF_HEAL_BATCH_CAP] - if not todo: - return - texts = [ - _embedding_source_text(r["title"], r["use_cases"], - json.loads(r["hints"])) - for r in todo - ] - vectors = self._sync_embedder.embed_batch(texts) - if vectors is None: - return - for r, vec in zip(todo, vectors): - self._store_embedding(r["id"], vec) - self._conn.commit() - - def _vec_ranks(self, query_vector, candidate_ids) -> dict[str, int]: - """reflection_id -> vec rank (0 = closest) among the candidates. - - sqlite-vec kNN accepts only ``embedding MATCH ? AND k = ?`` — no - extra predicates — so fetch top-k then filter, exactly as - search/hybrid.py:_vec_candidates does. - """ - if query_vector is None or not candidate_ids: - return {} - try: - knn = self._conn.execute( - "SELECT reflection_id FROM reflection_embeddings " - "WHERE embedding MATCH ? AND k = ? ORDER BY distance", - (sqlite_vec.serialize_float32(query_vector), - max(len(candidate_ids), 50)), - ).fetchall() - except sqlite3.OperationalError: - return {} - wanted = set(candidate_ids) - out: dict[str, int] = {} - for row in knn: - if row[0] in wanted: - out[row[0]] = len(out) - return out - def _fuse_by_relevance( - self, rows: list, *, query: str, query_vector=None, rrf_k: int = 60, + self, rows: list, *, query: str, rrf_k: int = 60, ) -> list: - """Re-order ``rows`` by RRF fusion of popularity rank, BM25 rank, and - vector rank. + """Re-order ``rows`` by RRF fusion of popularity rank and BM25 rank. ``rows`` arrives in Wilson-prior order (hit-rate lower bound, then confidence, then recency). We compute a second ranking over the same ids by BM25 relevance against ``reflection_fts`` (title / use_cases / - hints), and (when ``query_vector`` is available) a third ranking by - vector distance against ``reflection_embeddings``, then fuse all - available legs with reciprocal rank fusion: + hints), then fuse both legs with reciprocal rank fusion: - score(d) = 1/(k + pop_rank(d)) + 1/(k + rel_rank(d)) + 1/(k + vec_rank(d)) + score(d) = 1/(k + pop_rank(d)) + 1/(k + rel_rank(d)) - matching :mod:`better_memory.search.hybrid`. Rows a given leg does not - match keep only the other terms, so relevance *promotes* without ever - discarding — a query that matches nothing on any leg degrades exactly - to the previous behaviour. + matching :mod:`better_memory.search.hybrid`. Rows the BM25 leg does + not match keep only the popularity term, so relevance *promotes* + without ever discarding — a query that matches nothing degrades + exactly to the previous behaviour. Tokens are OR-ed rather than AND-ed: ``sanitize_fts5_query`` joins bare terms, which FTS5 reads as implicit AND, and a natural-language task @@ -1475,9 +1312,8 @@ def _fuse_by_relevance( rel_rows = [] rel_rank = {r["id"]: i for i, r in enumerate(rel_rows)} - vec_rank = self._vec_ranks(query_vector, ids) - if not rel_rank and not vec_rank: + if not rel_rank: return rows scored = [] @@ -1486,20 +1322,9 @@ def _fuse_by_relevance( rr = rel_rank.get(row["id"]) if rr is not None: score += 1.0 / (rrf_k + rr) - vr = vec_rank.get(row["id"]) - if vr is not None: - score += 1.0 / (rrf_k + vr) - # RRF sums are symmetric: a row with (pop_rank=a, vec_rank=b) ties - # exactly with one at (pop_rank=b, vec_rank=a). Break such ties - # toward the closer semantic match (lower vec_rank) before - # falling back to pop_rank — otherwise a strong vec-only match - # (this method's whole point) can lose a coin-flip tie to a - # popularity-only row. vec_rank absent (no embedder/no vec leg) - # collapses to sys.maxsize for every row, so the tiebreak is a - # no-op and behaviour matches the shipped two-leg fusion exactly. - scored.append((score, vr if vr is not None else sys.maxsize, pop_rank, row)) - scored.sort(key=lambda t: (-t[0], t[1], t[2])) - return [row for _, _, _, row in scored] + scored.append((score, pop_rank, row)) + scored.sort(key=lambda t: (-t[0], t[1])) + return [row for _, _, row in scored] def _bucket_item(self, r) -> dict: """Convert one reflections row into the dict shape returned to callers.""" @@ -1600,14 +1425,7 @@ def retrieve_reflections( rows.sort(key=_wilson_score, reverse=True) if query: - self._heal_missing_embeddings(rows) - query_vector = ( - self._sync_embedder.embed_text(query) - if self._sync_embedder is not None else None - ) - rows = self._fuse_by_relevance( - rows, query=query, query_vector=query_vector, - ) + rows = self._fuse_by_relevance(rows, query=query) _diag.step(fn, "relevance_fused", n_rows=len(rows)) # Convert None (unlimited) to sys.maxsize so the loop body has a definite int. @@ -1724,11 +1542,9 @@ def __init__( conn: sqlite3.Connection, *, clock: Callable[[], datetime] | None = None, - sync_embedder: SyncEmbedder | None = None, ) -> None: self._conn = conn self._clock: Callable[[], datetime] = clock or default_clock - self._sync_embedder = sync_embedder def confirm(self, *, reflection_id: str) -> None: """pending_review → confirmed; no-op on confirmed; raise on retired/superseded.""" @@ -1791,12 +1607,6 @@ def update_text( Blocked on retired/superseded — once a reflection has left the active set, mutating its text would silently change the audit trail. - - Re-embeds when constructed with a ``sync_embedder``: the vector - indexes ``title + use_cases + hints`` (see - ``_embedding_source_text``), so editing use_cases/hints without - refreshing the vector would leave a stale embedding pointing at - superseded text. No-op when ``sync_embedder`` is ``None``. """ if not use_cases or not use_cases.strip(): raise ValueError("use_cases must not be empty") @@ -1809,7 +1619,7 @@ def update_text( if not hint_list: raise ValueError("hints must not be empty") row = self._conn.execute( - "SELECT title, status FROM reflections WHERE id = ?", + "SELECT status FROM reflections WHERE id = ?", (reflection_id,), ).fetchone() if row is None: @@ -1820,20 +1630,11 @@ def update_text( f"Cannot edit reflection in status {status!r}" ) now = self._clock().isoformat() - # Compute the embedding BEFORE the UPDATE opens sqlite3's implicit - # write transaction — see #97. Ollama is blocking and would otherwise - # hold the WAL writer lock across a multi-second network call. - vector = None - if self._sync_embedder is not None: - vector = self._sync_embedder.embed_text(_embedding_source_text( - row["title"], use_cases, hint_list, - )) self._conn.execute( "UPDATE reflections SET use_cases = ?, hints = ?, updated_at = ? " "WHERE id = ?", (use_cases, json.dumps(hint_list), now, reflection_id), ) - _write_reflection_embedding(self._conn, reflection_id, vector) self._conn.commit() def promote_to_general(self, *, reflection_id: str) -> None: diff --git a/better_memory/services/relevant.py b/better_memory/services/relevant.py index 5ee7335a..1215dc67 100644 --- a/better_memory/services/relevant.py +++ b/better_memory/services/relevant.py @@ -1,25 +1,23 @@ """Relevance filter over the curated memory set (semantic + reflections). -Three-leg evidence-gated scorer, replacing the old pure-keyword -hits-x-activation model: a memory injects only when it has positive -relevance EVIDENCE -- +Evidence-gated scorer, replacing the old pure-keyword hits-x-activation +model: a memory injects only when it has positive relevance EVIDENCE -- - BM25 match against ``reflection_fts`` (title / use_cases / hints), or -- vector cosine similarity >= ``vec_floor`` against its embedding, or -- (only when the vec/FTS legs are structurally unavailable -- no sqlite - ``conn``, or no embedder for semantics) a keyword-hit floor as a - degraded fallback. +- (only when that leg is structurally unavailable -- no sqlite ``conn`` + for reflections; always, for semantics, which have no FTS substrate at + all) a keyword-hit floor as a degraded fallback. The Wilson lower-bound prior (see ``services.scoring``) never qualifies a memory by itself -- it only RANKS among qualifiers via reciprocal rank -fusion (RRF), alongside the BM25 and vector ranks. Popularity forcing -irrelevant injections was the old failure mode (13% useful as bootstrap); -the gate exists specifically to close it. +fusion (RRF), alongside the BM25 rank. Popularity forcing irrelevant +injections was the old failure mode (13% useful as bootstrap); the gate +exists specifically to close it. Fetches the small, already-ranked sets through the StorageBackend -abstraction (works on sqlite AND agentcore); the BM25/vec legs additionally -require a raw sqlite ``conn``. Agentcore (``conn=None`` AND -``supports_synthesis=False``) replaces the BM25/vec legs wholesale with +abstraction (works on sqlite AND agentcore); the BM25 leg additionally +requires a raw sqlite ``conn``. Agentcore (``conn=None`` AND +``supports_synthesis=False``) replaces the BM25 leg wholesale with ``backend.relevance_ranks`` -- a server-side semantic-search rank map -- and falls back to the keyword-hit floor ONLY when that lookup itself FAILS (``relevance_ranks`` returns ``None`` -- an AWS error). A @@ -32,23 +30,19 @@ """ from __future__ import annotations -import math import sqlite3 from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime from typing import Any -import sqlite_vec - from better_memory.search.query import sanitize_fts5_query from better_memory.services.keywords import count_keyword_hits, extract_keywords from better_memory.services.scoring import wilson_lower_bound -#: Keyword-hit floor used only when the FTS/vec legs are structurally -#: unavailable (no sqlite conn for reflections; no query vector, or no -#: sqlite conn, for semantics -- which have no FTS substrate at all and -#: whose vec leg needs a conn even when the embedder itself is healthy). +#: Keyword-hit floor used only when the FTS leg is structurally +#: unavailable (no sqlite conn, for reflections; always, for semantics, +#: which have no FTS substrate at all). _FALLBACK_MIN_HITS = 2 #: Reciprocal rank fusion constant, matching search/hybrid.py and @@ -99,61 +93,6 @@ def _bm25_qualifiers(conn: sqlite3.Connection | None, query: str) -> dict[str, i return {row[0]: i for i, row in enumerate(rows)} -def _vec_qualifiers( - conn: sqlite3.Connection | None, - table: str, - id_col: str, - query_vector: list[float] | None, - vec_floor: float, - candidate_ids: list[str] | None = None, -) -> dict[str, int]: - """id -> vec rank for rows within the cosine floor. - - Vectors are unit-norm, so cosine >= c <=> L2 distance <= sqrt(2*(1-c)). - - Step 3a probe (2026-07-23, sqlite-vec as vendored in this repo's - .venv): a vec0 kNN query for [1,0,0,0] against a stored [0,1,0,0] row - returned ``distance=1.4142135381698608`` -- sqrt(2) at float32 - precision, not 2.0. Confirms sqlite-vec's ``distance`` column is plain - L2 distance, NOT squared L2. The floor comparison below is against the - plain (unsquared) distance only; the squared-distance branch that a - defensive dual-check would need is dead code and has been omitted. - - ``candidate_ids`` scopes the result to the caller's candidate set, - matching ``ReflectionService._vec_ranks`` (reflection.py) -- sqlite-vec - kNN accepts only ``embedding MATCH ? AND k = ?`` (no extra predicates), - so we fetch top-k and filter in Python. ``k`` scales with - ``max(len(candidate_ids), 50)`` so the window is dense over what the - caller actually ranks, instead of being drowned by neighbours from - other projects or retired records (whose vectors persist -- retire - only UPDATEs status). ``None`` preserves the global-scope behaviour - for callers (e.g. ``SqliteBackend.relevance_ranks``) that intentionally - want the unfiltered rank map; ``[]`` short-circuits to ``{}``. - """ - if conn is None or query_vector is None: - return {} - if candidate_ids is not None and not candidate_ids: - return {} - max_dist = math.sqrt(2.0 * (1.0 - vec_floor)) - k = max(len(candidate_ids), 50) if candidate_ids is not None else 50 - try: - rows = conn.execute( - f"SELECT {id_col}, distance FROM {table} " - f"WHERE embedding MATCH ? AND k = ? ORDER BY distance", - (sqlite_vec.serialize_float32(query_vector), k), - ).fetchall() - except sqlite3.OperationalError: - return {} - wanted: set[str] | None = set(candidate_ids) if candidate_ids is not None else None - out: dict[str, int] = {} - for row in rows: - if wanted is not None and row[0] not in wanted: - continue - if float(row[1]) <= max_dist: - out[row[0]] = len(out) - return out - - def _wilson_for(useful: int, overlooked: int, ignored: int) -> float: positive = useful + overlooked n = useful + overlooked + ignored @@ -161,8 +100,8 @@ def _wilson_for(useful: int, overlooked: int, ignored: int) -> float: def _rrf_score(candidates: list[dict]) -> list[tuple[float, dict]]: - """RRF-fuse the Wilson prior with the BM25/vec ranks already stashed - on each candidate dict (``bm_rank`` / ``vec_rank``, ``None`` if absent). + """RRF-fuse the Wilson prior with the BM25 rank already stashed + on each candidate dict (``bm_rank``, ``None`` if absent). The prior rank is computed fresh here (desc by Wilson score) rather than carried in, since it only makes sense relative to the other @@ -176,8 +115,6 @@ def _rrf_score(candidates: list[dict]) -> list[tuple[float, dict]]: present_ranks = [prior_rank[c["id"]]] if c["bm_rank"] is not None: present_ranks.append(c["bm_rank"]) - if c["vec_rank"] is not None: - present_ranks.append(c["vec_rank"]) score = sum(1.0 / (_RRF_K + rank) for rank in present_ranks) scored.append((score, c)) return scored @@ -189,22 +126,20 @@ def retrieve_relevant( query: str, project: str, conn: sqlite3.Connection | None = None, - sync_embedder: Any = None, - vec_floor: float = 0.55, max_items: int = 3, include_neutral: bool = False, now: Callable[[], datetime] | None = None, ) -> list[RelevantMemory]: """Gate + rank curated memories (semantic + reflections) for ``query``. - A memory is returned only if it clears the evidence gate: a BM25 match, - a vector cosine >= ``vec_floor``, or (only when that leg is structurally - unavailable, e.g. no query vector) a keyword-hit fallback. Among - qualifiers, ranking is RRF over the Wilson prior plus whichever of - BM25/vec ranks are present. + A memory is returned only if it clears the evidence gate: a BM25 match + (reflections), or (only when that leg is structurally unavailable -- + no sqlite ``conn`` for reflections; always, for semantics, which have + no FTS leg at all) a keyword-hit fallback. Among qualifiers, ranking + is RRF over the Wilson prior plus the BM25 rank when present. Agentcore backends (``conn=None`` AND ``supports_synthesis=False``) - replace the BM25/vec legs with ``backend.relevance_ranks`` -- a + replace the BM25 leg with ``backend.relevance_ranks`` -- a server-side semantic-search rank map fused into the same RRF -- and fall back to the keyword-hit floor only when that lookup FAILS (returns ``None`` -- an AWS error), never merely because it found @@ -230,33 +165,13 @@ def retrieve_relevant( semantic = [] bm = _bm25_qualifiers(conn, query) - qvec = sync_embedder.embed_text(query) if sync_embedder is not None else None - # Collect the actual candidate id set before the vec query so kNN is - # restricted to it (issue #104): the vec tables are global across - # projects and retained/retired rows, so an unbounded k=50 kNN can be - # entirely filled by neighbours the caller will never rank -- starving - # the evidence gate for the caller's own reflections/semantics. refl_bucket_order = ["do", "dont"] + (["neutral"] if include_neutral else []) - refl_candidate_ids = [ - str(r.get("id")) - for bucket in refl_bucket_order - for r in (buckets.get(bucket) or []) - ] - sem_candidate_ids = [str(getattr(s, "id", "")) for s in (semantic or [])] - vec_r = _vec_qualifiers( - conn, "reflection_embeddings", "reflection_id", qvec, vec_floor, - refl_candidate_ids, - ) - vec_s = _vec_qualifiers( - conn, "semantic_embeddings", "memory_id", qvec, vec_floor, - sem_candidate_ids, - ) keywords = extract_keywords(query) # fallback evidence only fts_unavailable = conn is None # Agentcore evidence gate (design spec 2026-07-24-agentcore-parity- - # design.md §3): when the caller has no sqlite FTS/vec substrate + # design.md §3): when the caller has no sqlite FTS substrate # (conn=None) AND the backend is agentcore-flavored -- # supports_synthesis=False, the existing Protocol capability flag that # already distinguishes AgentCoreBackend from SqliteBackend everywhere @@ -303,13 +218,12 @@ def retrieve_relevant( kw_hits = count_keyword_hits(text, keywords) in_bm = r_id in bm - in_vec = r_id in vec_r in_backend_rank = agentcore_mode and ("reflection", r_id) in rank_map fallback_ok = ( (agentcore_kw_fallback if agentcore_mode else fts_unavailable) and kw_hits >= _FALLBACK_MIN_HITS ) - if not (in_bm or in_vec or in_backend_rank or fallback_ok): + if not (in_bm or in_backend_rank or fallback_ok): continue refl_candidates.append({ @@ -324,7 +238,6 @@ def retrieve_relevant( rank_map.get(("reflection", r_id)) if agentcore_mode else bm.get(r_id) ), - "vec_rank": vec_r.get(r_id), # storage.protocol.retrieve guarantees times_overlooked/ # times_ignored on both backends (sqlite columns; agentcore # copies its internal overlooked counter and hardcodes @@ -343,21 +256,19 @@ def retrieve_relevant( content = getattr(s, "content", "") or "" kw_hits = count_keyword_hits(content, keywords) - in_vec = s_id in vec_s in_backend_rank = agentcore_mode and ("semantic", s_id) in rank_map - # Semantics have no FTS/BM25 leg at all, so the keyword fallback - # applies whenever the vec leg is structurally unavailable: no - # query vector (no embedder / embed failure) OR no sqlite conn - # to query against. In agentcore mode the vec leg is replaced - # wholesale by the backend rank map, so the fallback there fires - # only per the same agentcore_kw_fallback signal as reflections - # (relevance_ranks returned None == AWS error; a genuinely empty - # {} does NOT trigger it), not merely "conn is None". + # Semantics have no FTS/BM25 leg and no vec leg either, so the + # keyword-hit floor is their only evidence leg outside agentcore + # mode -- it is always "on" here (there is no other leg to gate + # it behind). In agentcore mode the backend rank map replaces it, + # so the fallback there fires only per the same + # agentcore_kw_fallback signal as reflections (relevance_ranks + # returned None == AWS error; a genuinely empty {} does NOT + # trigger it). fallback_ok = ( - agentcore_kw_fallback if agentcore_mode - else (qvec is None or conn is None) + agentcore_kw_fallback if agentcore_mode else True ) and kw_hits >= _FALLBACK_MIN_HITS - if not (in_vec or in_backend_rank or fallback_ok): + if not (in_backend_rank or fallback_ok): continue sem_candidates.append({ @@ -367,10 +278,9 @@ def retrieve_relevant( "useful_count": int(getattr(s, "useful_count", 0) or 0), "age_days": _age_days(getattr(s, "updated_at", None), _now), "hits": kw_hits if (in_backend_rank or fallback_ok) else 0, - "bm_rank": None, - "vec_rank": ( + "bm_rank": ( rank_map.get(("semantic", s_id)) - if agentcore_mode else vec_s.get(s_id) + if agentcore_mode else None ), "wilson": _wilson_for( int(getattr(s, "useful_count", 0) or 0), diff --git a/better_memory/services/retention.py b/better_memory/services/retention.py index 64aed6dc..0ff60654 100644 --- a/better_memory/services/retention.py +++ b/better_memory/services/retention.py @@ -199,9 +199,11 @@ def _prune(self, *, prune_age_days: int) -> int: but correct — the spec doesn't require pruning sourced rows. The FTS5 observation_fts table is kept in sync by an AFTER - DELETE trigger on observations. The vec0 observation_embeddings - virtual table has no such trigger, so this method deletes - embedding rows explicitly before removing the observations. + DELETE trigger on observations. (Prior to remove-ollama-embeddings + Task 9, this method also explicitly deleted rows from the vec0 + observation_embeddings table, which had no DELETE trigger of its + own; that table was dropped by migration 0018 along with local + vector search, so there is nothing left to clean up here.) """ threshold = ( self._clock() - timedelta(days=prune_age_days) @@ -225,33 +227,10 @@ def _prune(self, *, prune_age_days: int) -> int: if not eligible_ids: return 0 - # Both DELETEs run inside a single SAVEPOINT — if the second - # raises, the first is rolled back, preventing observations - # from outliving their embeddings (which a later - # ``conn.commit()`` from another service would otherwise - # persist). - self._conn.execute("SAVEPOINT retention_prune") - try: - self._delete_embeddings(eligible_ids) - deleted = self._delete_observations(eligible_ids) - except Exception: - self._conn.execute("ROLLBACK TO SAVEPOINT retention_prune") - self._conn.execute("RELEASE SAVEPOINT retention_prune") - raise - else: - self._conn.execute("RELEASE SAVEPOINT retention_prune") + deleted = self._delete_observations(eligible_ids) self._conn.commit() return deleted - def _delete_embeddings(self, ids: list[str]) -> None: - """Delete embedding rows (no AFTER DELETE trigger on vec0).""" - placeholders = ", ".join("?" * len(ids)) - self._conn.execute( - f"DELETE FROM observation_embeddings " - f"WHERE observation_id IN ({placeholders})", - ids, - ) - def _delete_observations(self, ids: list[str]) -> int: """Delete observation rows (FTS5 trigger handles observation_fts).""" placeholders = ", ".join("?" * len(ids)) diff --git a/better_memory/services/semantic.py b/better_memory/services/semantic.py index 35ea8e4f..178f5e9c 100644 --- a/better_memory/services/semantic.py +++ b/better_memory/services/semantic.py @@ -15,10 +15,7 @@ from datetime import datetime from uuid import uuid4 -import sqlite_vec - from better_memory._common import default_clock, env_session_id -from better_memory.embeddings.sync_embed import SyncEmbedder from better_memory.services.scoring import wilson_lower_bound @@ -57,25 +54,9 @@ def __init__( conn: sqlite3.Connection, *, clock: Callable[[], datetime] | None = None, - sync_embedder: SyncEmbedder | None = None, ) -> None: self._conn = conn self._clock: Callable[[], datetime] = clock or default_clock - self._sync_embedder = sync_embedder - - def _store_embedding(self, memory_id: str, vector: list[float] | None) -> None: - if vector is None: - return - # DELETE+INSERT: vec0 tables historically mishandle UPSERT. - self._conn.execute( - "DELETE FROM semantic_embeddings WHERE memory_id = ?", - (memory_id,), - ) - self._conn.execute( - "INSERT INTO semantic_embeddings (memory_id, embedding) " - "VALUES (?, ?)", - (memory_id, sqlite_vec.serialize_float32(vector)), - ) def create( self, *, content: str, project: str, scope: str = "project" @@ -88,11 +69,6 @@ def create( raise ValueError("content must not be empty") memory_id = uuid4().hex now = self._clock().isoformat() - # Compute the embedding BEFORE the INSERT opens sqlite3's implicit - # write transaction — see #97. A blocking Ollama call inside the - # WAL writer lock starves every other connection until commit. - vector = (self._sync_embedder.embed_text(content) - if self._sync_embedder is not None else None) self._conn.execute( """ INSERT INTO semantic_memories @@ -101,38 +77,24 @@ def create( """, (memory_id, content, project, scope, now, now), ) - self._store_embedding(memory_id, vector) self._conn.commit() return memory_id def update_text(self, *, id: str, content: str) -> None: if not content.strip(): raise ValueError("content must not be empty") - # Validate existence BEFORE spending the (blocking) embed. Without - # this check a stale id from MCP / UI would pay the full ~15s - # Ollama worker timeout before failing — see #125 review. - if self._conn.execute( - "SELECT 1 FROM semantic_memories WHERE id = ?", (id,), - ).fetchone() is None: - raise ValueError(f"semantic memory not found: {id}") now = self._clock().isoformat() - # Compute the embedding BEFORE the UPDATE opens sqlite3's implicit - # write transaction — see #97. - vector = (self._sync_embedder.embed_text(content) - if self._sync_embedder is not None else None) cur = self._conn.execute( "UPDATE semantic_memories SET content = ?, updated_at = ? " "WHERE id = ?", (content, now, id), ) if cur.rowcount == 0: - # Race: row was deleted between our SELECT and UPDATE. Rare, - # but roll back the implicit BEGIN so we don't strand the WAL + # Roll back the implicit BEGIN so we don't strand the WAL # write lock for callers sharing this connection. Mirrors # ObservationService.set_outcome (better_memory/services/observation.py:435). self._conn.rollback() raise ValueError(f"semantic memory not found: {id}") - self._store_embedding(id, vector) self._conn.commit() def set_scope(self, *, id: str, scope: str) -> None: @@ -194,10 +156,6 @@ def create_from_observation( memory_id = uuid4().hex now = self._clock().isoformat() - # Compute the embedding BEFORE opening the SAVEPOINT — see #97. - # The blocking Ollama call must not run under the WAL writer lock. - vector = (self._sync_embedder.embed_text(row["content"]) - if self._sync_embedder is not None else None) self._conn.execute("SAVEPOINT promote_observation") try: self._conn.execute( @@ -214,7 +172,6 @@ def create_from_observation( "WHERE id = ?", (now, observation_id), ) - self._store_embedding(memory_id, vector) except BaseException: self._conn.execute("ROLLBACK TO SAVEPOINT promote_observation") self._conn.execute("RELEASE SAVEPOINT promote_observation") diff --git a/better_memory/skills/memory-feedback.md b/better_memory/skills/memory-feedback.md index c62bdbfe..649ab2b5 100644 --- a/better_memory/skills/memory-feedback.md +++ b/better_memory/skills/memory-feedback.md @@ -43,8 +43,8 @@ If the work was opened with `memory.start_episode(...)` and the goal is now reso ```python # At decision time — no evidence yet obs_id = memory.observe( - content="Switched to async embedder to allow batch requests.", - component="embeddings/ollama", + content="Switched observation retrieval to word+trigram FTS5 BM25 RRF fusion.", + component="search/hybrid", trigger_type="decision", outcome="neutral", ) diff --git a/better_memory/skills/memory-retrieve.md b/better_memory/skills/memory-retrieve.md index b82dbd7b..e395b8bb 100644 --- a/better_memory/skills/memory-retrieve.md +++ b/better_memory/skills/memory-retrieve.md @@ -10,14 +10,17 @@ When to use: before starting any meaningful coding task, or when entering a new (`do` / `dont` / `neutral`). ALWAYS pass `query` — a plain-language description of the task at hand — or you get the same generic top-ranked lessons every session regardless of what you're working on. - `query` fuses BM25 + vector search via reciprocal rank fusion against - a Wilson-score usefulness prior. Also filter by `project`, `tech`, - `phase`, `polarity`, `limit_per_bucket` (default 5 per bucket). Use - this as the default at the start of work to find generalised lessons. + `query` fuses a BM25 match against `reflection_fts` with the + Wilson-score usefulness prior via reciprocal rank fusion — a query + that matches nothing on the BM25 leg degrades to the Wilson-only + order. Also filter by `project`, `tech`, `phase`, `polarity`, + `limit_per_bucket` (default 5 per bucket). Use this as the default at + the start of work to find generalised lessons. - `memory.retrieve_observations` — raw observations. Supports a free-text - `query` (hybrid FTS5 + sqlite-vec) plus filters like `component`. Use - this to drill into a specific incident or hunt for an exact prior - decision. + `query` (word-level + trigram-level FTS5 BM25, fused by reciprocal + rank fusion — no embedding model involved) plus filters like + `component`. Use this to drill into a specific incident or hunt for an + exact prior decision. ## Steps @@ -66,13 +69,14 @@ raw observations with `memory.retrieve_observations`: ```python result = memory.retrieve_observations( - query="async bridge ollama transport error", + query="episodes tab hidden when agentcore flag is false", component="ui", limit=10, ) ``` -With `query`, results are ranked by hybrid FTS5 + sqlite-vec relevance; +With `query`, results are ranked by word+trigram FTS5 BM25 relevance +(fused via reciprocal rank fusion — no embedding model involved); without, they are ordered newest-first. `episode_id` and `theme` filters are ignored in query mode. @@ -80,8 +84,9 @@ filters are ignored in query mode. `memory.retrieve(query?, project?, tech?, phase?, polarity?, limit_per_bucket?)` — reflections, bucketed by polarity. `query` ranks -by BM25 + vector RRF fused with the Wilson-score usefulness prior; -omitting it returns the same generic top-ranked lessons every time. +by BM25 (against `reflection_fts`) reciprocal-rank-fused with the +Wilson-score usefulness prior; omitting it returns the same generic +top-ranked lessons every time. `limit_per_bucket` defaults to 5. No component/scope/window filters; use `retrieve_observations` for those. diff --git a/better_memory/storage/agentcore.py b/better_memory/storage/agentcore.py index d4fc3ffc..c0362f5d 100644 --- a/better_memory/storage/agentcore.py +++ b/better_memory/storage/agentcore.py @@ -516,10 +516,9 @@ def _fetch(namespace: str) -> list[dict[str, Any]]: buckets[bucket].append(parsed) # Local import: EXPLORATION_RATED_FLOOR lives in the reflection - # synthesis service (services/reflection.py), which pulls in - # sqlite_vec and friends — keep that off agentcore's module-level - # import path (mirrors the local exposure_log imports elsewhere in - # this class). + # synthesis service (services/reflection.py); importing at module + # scope would invert the storage→services layering (mirrors the + # local exposure_log imports elsewhere in this class). from better_memory.services.reflection import EXPLORATION_RATED_FLOOR # Only worth reserving an exploration slot when there's room for at diff --git a/better_memory/storage/factory.py b/better_memory/storage/factory.py index 258d0e01..6b8d2c54 100644 --- a/better_memory/storage/factory.py +++ b/better_memory/storage/factory.py @@ -5,7 +5,7 @@ import os import sqlite3 from pathlib import Path -from typing import Any, Protocol +from typing import Protocol from better_memory.storage.protocol import StorageBackend from better_memory.storage.sqlite import SqliteBackend @@ -33,28 +33,15 @@ def build_backend( *, config: _ConfigLike, memory_conn: sqlite3.Connection | None, - embedder: Any = None, - sync_embedder: Any = None, session_id: str | None, project: str, ) -> StorageBackend: - """Construct the StorageBackend implementation appropriate for the config. - - ``sync_embedder``, when provided, must be the caller's already-built - process-wide ``SyncEmbedder`` (see ``mcp/server.py``). It is forwarded - as-is to ``SqliteBackend`` so its internal ``_semantic`` / ``_synthesis`` - services share the same circuit breaker as the caller's write-path - tools instead of each backend build spinning up an independent one. - Unused by the agentcore path, which has no local semantic/synthesis - services of its own. - """ + """Construct the StorageBackend implementation appropriate for the config.""" if config.storage_backend == "sqlite": if memory_conn is None: raise ValueError("sqlite backend requires memory_conn") return SqliteBackend( memory_conn=memory_conn, - embedder=embedder, - sync_embedder=sync_embedder, session_id=session_id, project=project, ) diff --git a/better_memory/storage/protocol.py b/better_memory/storage/protocol.py index de633961..3dfdfba8 100644 --- a/better_memory/storage/protocol.py +++ b/better_memory/storage/protocol.py @@ -447,10 +447,12 @@ def relevance_ranks( 2026-07-24-agentcore-parity-design.md §3). SqliteBackend: a thin wrapper over its existing BM25 - (``reflection_fts``) + vector legs, RRF-merged per kind. Its own - legs never raise (they degrade internally), so it always returns a - dict, never ``None``. Provided for protocol completeness only -- - ``retrieve_relevant``'s sqlite path keeps calling those legs + (``reflection_fts``) leg only -- the vector leg that used to also + cover semantics was removed in remove-ollama-embeddings Task 7, so + ``"semantic"`` in ``kinds`` never contributes any ranks here. Its + own leg never raises (it degrades internally), so it always + returns a dict, never ``None``. Provided for protocol completeness + only -- ``retrieve_relevant``'s sqlite path keeps calling that leg directly against its own ``conn`` parameter and never calls this method, so sqlite's contextual-gate behavior is unaffected by this method's existence. diff --git a/better_memory/storage/sqlite.py b/better_memory/storage/sqlite.py index e86fdef7..3925f141 100644 --- a/better_memory/storage/sqlite.py +++ b/better_memory/storage/sqlite.py @@ -6,15 +6,6 @@ Held state: - ``memory_conn`` — open sqlite3 Connection -- ``embedder`` — forwarded to ObservationService. May be ``None`` for the - sqlite (FTS5) embeddings backend, which indexes via DB triggers instead - of a Python embedder. -- ``sync_embedder`` — caller-owned ``SyncEmbedder`` forwarded to - ``SemanticMemoryService`` / ``ReflectionSynthesisService`` as-is. The - backend does NOT construct its own — it must be the same process-wide - instance the caller (e.g. ``mcp/server.py``) built, so its circuit - breaker state is shared across the write-path tools and this backend's - ``retrieve``/``semantic`` methods rather than split into two breakers. - ``session_id`` — used for episode lookups, ratings, exposures. ``None`` means "defer resolution to env-var fallback at first write" (ObservationService re-resolves from ``CLAUDE_SESSION_ID`` / @@ -51,21 +42,16 @@ def __init__( self, *, memory_conn: sqlite3.Connection, - embedder: Any = None, - sync_embedder: Any = None, session_id: str | None, project: str, ) -> None: self._conn = memory_conn - self._embedder = embedder - self._sync_embedder = sync_embedder self._session_id: str | None = session_id self._project = project self._project_resolver = lambda: self._project self._episodes = EpisodeService(memory_conn) self._observations = ObservationService( memory_conn, - embedder=embedder, session_id=session_id, project_resolver=self._project_resolver, episodes=self._episodes, @@ -73,10 +59,8 @@ def __init__( self._reflection = ReflectionService(memory_conn) self._memory_rating = MemoryRatingService(memory_conn) self._session_bootstrap = SessionBootstrapService(memory_conn) - self._semantic = SemanticMemoryService(memory_conn, sync_embedder=sync_embedder) - self._synthesis = ReflectionSynthesisService( - memory_conn, sync_embedder=sync_embedder - ) + self._semantic = SemanticMemoryService(memory_conn) + self._synthesis = ReflectionSynthesisService(memory_conn) # ----- Capability flags ----- @@ -304,18 +288,26 @@ def relevance_ranks( top_k: int = 50, ) -> dict[tuple[str, str], int]: """Thin protocol-completeness wrapper over the existing BM25 - (``reflection_fts``) + vector legs (``services/relevant.py``'s - ``_bm25_qualifiers`` / ``_vec_qualifiers``), RRF-merged per kind. - Always returns a dict, never ``None`` -- its underlying legs - already degrade internally (a missing conn, no query vector, or a - malformed FTS query all resolve to empty results, not an error) so - there is no failure mode to signal here (see the Protocol + (``reflection_fts``) leg in ``services/relevant.py``'s + ``_bm25_qualifiers``. Always returns a dict, never ``None`` -- the + underlying leg already degrades internally (a missing conn or a + malformed FTS query both resolve to empty results, not an error) + so there is no failure mode to signal here (see the Protocol docstring's ``None`` vs ``{}`` contract, which only agentcore's AWS-error path actually exercises). + Semantic memories have no FTS/BM25 substrate of their own, and the + vector leg that used to cover them (``_vec_qualifiers`` over + ``semantic_embeddings`` / ``reflection_embeddings``) was removed in + remove-ollama-embeddings Task 7 along with this backend's + ``sync_embedder`` plumbing -- so ``"semantic"`` in ``kinds`` never + contributes any ranks here. That matches ``retrieve_relevant``'s + own sqlite path, which always falls back to its keyword-hit floor + for semantics regardless of this method (see module docstring). + NOT consumed by ``retrieve_relevant``'s own sqlite path -- that - function keeps calling those helpers directly against its own - ``conn`` parameter (see its ``agentcore_mode`` gate, which is + function keeps calling ``_bm25_qualifiers`` directly against its + own ``conn`` parameter (see its ``agentcore_mode`` gate, which is False for any backend reporting ``supports_synthesis=True``), so this method's existence changes zero sqlite contextual-gate behavior. It exists purely so SqliteBackend satisfies the full @@ -324,21 +316,13 @@ def relevance_ranks( if not (query or "").strip(): return {} # Local import: services/relevant.py is the contextual-gate module; - # importing its private ranking helpers here (rather than - # duplicating the BM25/vec SQL) keeps the two legs byte-identical + # importing its private BM25 helper here (rather than duplicating + # the SQL) keeps this leg byte-identical to _bm25_qualifiers # without creating a module-load-time cycle (relevant.py itself # never imports storage). - from better_memory.services.relevant import ( - _RRF_K, - _bm25_qualifiers, - _vec_qualifiers, - ) + from better_memory.services.relevant import _RRF_K, _bm25_qualifiers q = query.strip() - qvec = ( - self._sync_embedder.embed_text(q) - if self._sync_embedder is not None else None - ) def _rrf_merge(rank_maps: list[dict[str, int]]) -> dict[str, int]: scores: dict[str, float] = {} @@ -351,17 +335,8 @@ def _rrf_merge(rank_maps: list[dict[str, int]]) -> dict[str, int]: out: dict[tuple[str, str], int] = {} if "reflection" in kinds: bm = _bm25_qualifiers(self._conn, q) - vec_r = _vec_qualifiers( - self._conn, "reflection_embeddings", "reflection_id", qvec, 0.55, - ) - for rid, rank in _rrf_merge([bm, vec_r]).items(): + for rid, rank in _rrf_merge([bm]).items(): out[("reflection", rid)] = rank - if "semantic" in kinds: - vec_s = _vec_qualifiers( - self._conn, "semantic_embeddings", "memory_id", qvec, 0.55, - ) - for rid, rank in _rrf_merge([vec_s]).items(): - out[("semantic", rid)] = rank return out # ----- Reflection lifecycle ----- diff --git a/better_memory/ui/app.py b/better_memory/ui/app.py index 9703c4e0..053e7fe9 100644 --- a/better_memory/ui/app.py +++ b/better_memory/ui/app.py @@ -15,40 +15,12 @@ from better_memory.config import get_config, project_name, resolve_home from better_memory.db.connection import connect -from better_memory.embeddings.ollama import OllamaEmbedder -from better_memory.embeddings.sync_embed import SyncEmbedder from better_memory.services.episode import EpisodeService from better_memory.services.reflection import ReflectionService from better_memory.storage.factory import build_backend from better_memory.ui import queries -class _Unset: - """Sentinel type distinguishing "caller did not pass sync_embedder" - (auto-detect from config) from an explicit ``sync_embedder=None`` - (force-disable, e.g. in tests that don't want any embedding side - effects). A dedicated class (rather than a bare ``object()``) lets - pyright narrow the parameter type via ``isinstance`` in create_app. - """ - - __slots__ = () - - -_UNSET = _Unset() - - -def _build_sync_embedder() -> SyncEmbedder | None: - """Build the shared SyncEmbedder used by every write-path service. - - Only wired for the ollama embeddings backend — sqlite (FTS5) indexes - via DB triggers instead of a Python embedder (mirrors the same gate - in better_memory/mcp/server.py and better_memory/storage/sqlite.py). - """ - if get_config().embeddings_backend != "ollama": - return None - return SyncEmbedder(lambda: OllamaEmbedder(timeout=5.0, max_retries=1)) - - def _reflection_drawer_detail(app: Flask, id: str) -> SimpleNamespace | None: """Compose the drawer view model: row via backend.reflection_get; provenance via the local conn ONLY when the backend supports it (gated out in @@ -71,7 +43,6 @@ def create_app( inactivity_poll_interval: float = 30.0, start_watchdog: bool = True, db_path: Path | None = None, - sync_embedder: SyncEmbedder | None | _Unset = _UNSET, ) -> Flask: """Build and return a configured Flask app. @@ -86,14 +57,6 @@ def create_app( If ``False``, skip starting the background watchdog thread. ``_check_idle`` is still registered so tests can drive it synchronously without spawning threads. - sync_embedder: - Shared :class:`SyncEmbedder` passed to every UI write-path - service (``ReflectionService``, ``SemanticMemoryService``) so - UI-driven writes embed exactly like MCP-driven writes. Defaults - to auto-detecting from ``get_config().embeddings_backend`` via - :func:`_build_sync_embedder`. Pass explicitly (including - ``None``) to override — tests use this to inject a fake - embedder or to force-disable embedding. """ app = Flask(__name__) @@ -101,23 +64,13 @@ def create_app( resolved_db = db_path if db_path is not None else resolve_home() / "memory.db" db_conn = connect(resolved_db) - resolved_sync_embedder: SyncEmbedder | None = ( - _build_sync_embedder() - if isinstance(sync_embedder, _Unset) - else sync_embedder - ) - app.extensions["db_connection"] = db_conn app.extensions["episode_service"] = EpisodeService(conn=db_conn) - app.extensions["reflection_service"] = ReflectionService( - conn=db_conn, sync_embedder=resolved_sync_embedder, - ) - app.extensions["sync_embedder"] = resolved_sync_embedder + app.extensions["reflection_service"] = ReflectionService(conn=db_conn) app.extensions["db_path"] = resolved_db app.extensions["backend"] = build_backend( config=get_config(), memory_conn=db_conn, - sync_embedder=resolved_sync_embedder, session_id=None, project=project_name(), ) @@ -705,9 +658,7 @@ def observation_promote_to_semantic( from markupsafe import escape conn = app.extensions["db_connection"] scope = request.form.get("scope") or "project" - svc = SemanticMemoryService( - conn, sync_embedder=app.extensions["sync_embedder"], - ) + svc = SemanticMemoryService(conn) try: svc.create_from_observation(observation_id=id, scope=scope) except ValueError as exc: diff --git a/docs/agentcore-ui/agentcore-mapping.md b/docs/agentcore-ui/agentcore-mapping.md index 0fc42d2a..5e1783da 100644 --- a/docs/agentcore-ui/agentcore-mapping.md +++ b/docs/agentcore-ui/agentcore-mapping.md @@ -38,7 +38,7 @@ Observations and Semantic tabs — kept once, HARD). | Semantic tab shell (filter form + panel mount) | Semantic | read | none (static render) | Pure static shell, zero DB access, hardcoded initial filters; all data arrives via the /semantic/panel sub-request. Backend-agnostic. | app.py:487; templates/semantic.html | | List / filter / search semantic memories (panel) | Semantic | read | `semantic_list` (ListMemoryRecords / RetrieveMemoryRecords) | Working method fans out to list/retrieve and maps into the same SemanticMemory dataclass with every counter; Wilson ranking is Python over those counters. Exposure side-effect stays local. Only wiring: route through backend.semantic_list instead of raw SQL. | agentcore.py:1246, 1285, 1691; services/semantic.py:220 | | Semantic memory detail drawer | Semantic | read | `_get_semantic_record` (GetMemoryRecord) + local exposure ledger | Single-record read maps to GetMemoryRecord (needs a thin public get-one accessor); evidence leg reads the local session_memory_exposure ledger. Both serviceable. | agentcore.py:1193, 1285; queries.py:800 | -| Create semantic memory | Semantic | write | `semantic_observe` (BatchCreateMemoryRecords) | Direct one-to-one: inserts content as a BASE record under userPreference strategy, namespace from scope, content-hash dedup. Local vec0 write is a design non-goal. | agentcore.py:1199; _agentcore_strategies.py:60 | +| Create semantic memory | Semantic | write | `semantic_observe` (BatchCreateMemoryRecords) | Direct one-to-one: inserts content as a BASE record under userPreference strategy, namespace from scope, content-hash dedup. (Sqlite mode has no local embedding write either any more — embeddings were removed repo-wide in remove-ollama-embeddings.) | agentcore.py:1199; _agentcore_strategies.py:60 | | Update semantic memory text | Semantic | write | `semantic_update_text` (BatchUpdateMemoryRecords) | Direct one-to-one: get + batch_update with full metadata snapshot (reserved keys stripped), transient-404 retry. Re-embedding is AWS-managed. | agentcore.py:1383, 1012 | | Set semantic memory scope (project<->general) | Semantic | write | `semantic_set_scope` (BatchUpdateMemoryRecords namespaces move) | Direct one-to-one: validates scope, moves record between project/general semantic namespaces. Scope is namespace membership rather than a column. | agentcore.py:1410 | | Delete semantic memory | Semantic | write | `semantic_delete` (BatchDeleteMemoryRecords) | Direct one-to-one: removes record, AWS reclaims embedding. Minor: raises on not-found vs sqlite idempotency (trivial wrapper). | agentcore.py:1444 | @@ -50,7 +50,6 @@ Observations and Semantic tabs — kept once, HARD). | Purge all hook errors (Clear all) | Diagnostics | write | local `hook_errors` | Unfiltered DELETE against local operational state; no backend involvement is correct. | design §1 (line 54) | | Hook error recording (producer — INSERT) | Diagnostics | write | local `hook_errors` | record_hook_error INSERTs on its own local connection, no StorageBackend abstraction (hooks must never fail). | design §1 (lines 46-55) | | Retention run recording (producer — INSERT) | Diagnostics | write | local `retention_runs` | _record_run INSERTs into the local ledger via raw sqlite. UI only reads it. | design Non-goals (line 140) | -| UI SyncEmbedder acquisition | UI infra | read | none (config: embeddings_backend) | Pure config read orthogonal to storage_backend; returns None unless ollama selected. Local embedding writes are a non-goal, so the embedder is inert in agentcore mode. | app.py:38-47,86-97; design Non-goals | | Project scoping / resolution (project_name()) | UI infra | read | `project_name()` backend-independent; maps to actorId/namespace | Pure cwd/env/filesystem resolution, identical in both modes; project value is already AgentCore's actorId namespace. (Caveat: reflections route unions raw-SQL distinct projects — see HARD dropdown items.) | config.py; _agentcore_strategies.py:99,119; storage/session.py | | Session identity resolution (absent in UI) | UI infra | read | none (local exposure ledger) | UI is session-agnostic; browses by project. The only cross-session read (exposure ledger) stays local. Nothing for agentcore to serve. | design §1; protocol.py:262-306 | | Left-rail tab navigation (Episodes capability gating) | UI infra | read | `supports_episodes` (protocol.py:57-64, False) | Authoritative capability data already exists; implementing the gate is threading one boolean into create_app/base.html. No AWS call. | protocol.py:57-64; agentcore.py:106; base.html | @@ -72,7 +71,7 @@ Observations and Semantic tabs — kept once, HARD). | Reflections list panel (filtered rows) | Reflections | read | agentcore `list_memory_records` (new flat method + status decision) | Record fetch/parse exists, but status model mismatches (pending_review/confirmed don't exist server-side; only active/promoted/retired), retrieve() returns polarity-bucketed Wilson-ranked results not the flat list, and the six-filter surface is only partial. | queries.py:221; agentcore.py:317-408,838-842; design §3 | | Reflection detail drawer (row + provenance) | Reflections | read | undecided (get_memory_record for row; provenance join none) | The row is fetchable, but the load-bearing provenance join reflection_sources->observations->episodes has no backing: no link table, observations are events, episodes unsupported. | queries.py:385; agentcore.py:106-107,1496-1503 | | Confirm reflection (pending_review -> confirmed) | Reflections | write | agentcore `_mutate_namespace_and_status` (no 'confirmed' in model) | Mechanics trivial, but there is no confirm method on the protocol and the pending_review->confirmed gate is not part of agentcore's status model — 'confirmed' would be neither produced nor read back meaningfully. Needs a data-model decision. | services/reflection.py:1639; agentcore.py:1507-1576,378-381; protocol.py:241-247 | -| Edit reflection text (use_cases + hints) in place | Reflections | write | agentcore `batch_update_memory_records` (no wired reflection text-update method) | No reflection text-update method exists (only semantic_update_text). A new body RMW is needed; AWS-extracted records' editability of those fields is unresolved, and the vec0 re-embed is absent by design. | services/reflection.py:1683; agentcore.py:1055-1111; design Non-goals | +| Edit reflection text (use_cases + hints) in place | Reflections | write | agentcore `batch_update_memory_records` (no wired reflection text-update method) | No reflection text-update method exists (only semantic_update_text). A new body RMW is needed; AWS-extracted records' editability of those fields is unresolved. (The sqlite-mode edit no longer re-embeds either — that vec0 write was removed repo-wide in remove-ollama-embeddings.) | services/reflection.py:1683; agentcore.py:1055-1111; design Non-goals | | Episode timeline (list grouped by day) | Episodes | read | undecided (episode concept absent) | AgentCore groups by sessionId, has no episode record; the panel needs an episode entity plus the reflection->source-observation->episode cross-join. list_episodes returns []. Episode parity is a design non-goal. Moot (tab hidden). | queries.py:32-94; agentcore.py:1496-1503; design line 140 | | Unclosed-episode banner | Episodes | read | undecided (no open/closed lifecycle) | COUNT looks trivial but depends on an episodes table with ended_at lifecycle AgentCore does not model (supports_episodes=False; close_* are no-ops). Moot (tab hidden). | queries.py:696-711; agentcore.py:1476-1494; design line 140 | | Episode detail drawer (open row) | Episodes | read | undecided (episode entity + join absent) | Three raw-SQL reads including the reflection->source-observation->episode cross-join; records carry no episode_id and there is no episode record. Moot (tab hidden). | queries.py:124-194; agentcore.py:106-107; design line 140 | @@ -80,7 +79,7 @@ Observations and Semantic tabs — kept once, HARD). | Close episode (set outcome/close_reason) | Episodes | write | undecided (episode lifecycle absent) | UPDATE across episodes + episode_sessions modelling the lifecycle AgentCore lacks; close_episode_by_id is a no-op returning ''. Closing is meaningless without an episode concept. Moot (tab hidden). | app.py:255-288; services/episode.py:272; agentcore.py:1486-1494 | | Recent ratings table (last 20 rated exposures) | Diagnostics | read | agentcore `GetMemoryRecord` (title resolution) + local `session_memory_exposure` | Left side resolves locally, but the LEFT JOIN to reflections/semantic_memories for the display title is an un-wired cross-store join needing N per-id AWS fetches, with no guarantee archived records still resolve. | design §1,§2; agentcore.py:864,980,1194; protocol.py:262-306 | | Overlooked total (SUM times_overlooked) | Diagnostics | read | agentcore `ListMemoryRecords` (client-side sum) | The counter is read per record, but there is no aggregate/SUM API; the total needs an unbounded paginated enumeration across four namespaces summed client-side — a real cost/design decision. | design lines 16-21; agentcore.py:856,875,392-408,1272-1276 | -| Retention archive/prune of observations | Diagnostics | write | undecided (content-mutation engine) | Rules A/B/C read reflection_sources->reflections->episodes and UPDATE observations SET status='archived', then DELETE observations + embeddings — all content tables AWS-side, joined across a graph AgentCore does not model. Retention parity is a non-goal. | design line 140; protocol.py:58-64 | +| Retention archive/prune of observations | Diagnostics | write | undecided (content-mutation engine) | Rules A/B/C read reflection_sources->reflections->episodes and UPDATE observations SET status='archived', then DELETE observations (the `observation_embeddings` table this also used to prune was dropped in migration 0018) — all content tables AWS-side, joined across a graph AgentCore does not model. Retention parity is a non-goal. | design line 140; protocol.py:58-64 | | UI sqlite connection acquisition (shared conn) | UI infra | read | agentcore `RetrieveMemoryRecords`/`ListMemoryRecords` (new read layer) | app.py unconditionally opens local memory.db and never touches the factory; the entire read layer is raw SQL through queries.py which no backend method serves. Needs a backend threaded into create_app AND a from-scratch read layer. | app.py:83-92; factory.py:32-111; db/connection.py | | Write-path service instantiation (Episode/Reflection/Semantic services) | UI infra | write | agentcore StorageBackend methods (rewire every write route) | UI hands services a raw sqlite3.Connection, so even writes bypass the backend and land locally. Semantic CRUD + reflection promote/retire exist but nothing is wired; EpisodeService writes have no analogue. Named core infrastructure gap. | app.py:93-96; protocol.py:152-247; agentcore.py:9-15,106 | diff --git a/docs/agentcore-ui/data-sources.md b/docs/agentcore-ui/data-sources.md index c835984b..b2153e2a 100644 --- a/docs/agentcore-ui/data-sources.md +++ b/docs/agentcore-ui/data-sources.md @@ -81,7 +81,6 @@ touching only **operational-state** tables are safe in agentcore mode. | Diagnostics | Hook errors panel | `hook_errors` | | Diagnostics | Hook error detail drawer | `hook_errors` | | Diagnostics | Retention runs panel | `retention_runs` | -| UI infra | SyncEmbedder acquisition (config-only, no table) | — | | UI infra | Project scoping / resolution (cwd/env, no table) | — | | UI infra | Session identity resolution (absent in UI) | — | | UI infra | Left-rail tab navigation (static Jinja) | — | @@ -100,7 +99,7 @@ gap — while operational-state writes (hook-error delete/purge) are correctly l | Populate Project filter dropdown with distinct project values | read | `GET /observations` (route `observations`) → `templates/observations.html`, includes `fragments/observation_filter_form.html` | `observations` | `queries.observation_distinct_projects` | No | Raw SQL (`SELECT DISTINCT project FROM observations`) on `sqlite3.Connection`; bypasses `StorageBackend`. `observations` is a MEMORY-CONTENT table → AWS-side under `AgentCoreBackend`, so this direct local read would see nothing in agentcore mode without `RetrieveMemoryRecords` parity. Parity gap. | | List panel — filtered, day-grouped list (project/status/outcome/component filters, 30s auto-refresh) | read | `GET /observations/panel` (route `observations_panel`) → `fragments/panel_observations.html` → `fragments/observation_row.html`; driven by `fragments/observation_filter_form.html` (`hx-include #observation-filter-form`) | `observations` | `queries.observation_list_for_ui` | No | Raw SQL `SELECT` with optional `WHERE` on project/status/outcome/component, `ORDER BY created_at DESC`, `LIMIT`; bypasses `StorageBackend`. Reads `reinforcement_score`/`status`/`episode_id`. Content table → AWS-side in agentcore; direct local read is a parity gap. NOTE: this route does NOT read filter-form `project` the way `distinct_projects` does — it just passes args through. | | Detail drawer — full record plus audit trail and linked reflections | read | `GET /observations//drawer` (route `observation_drawer`) → `templates/fragments/observation_drawer.html` | `observations`, `audit_log`, `reflections`, `reflection_sources` | `queries.observation_detail` | No | Three raw `SELECT`s on one connection, all bypass `StorageBackend`: (1) full observation row; (2) `audit_log WHERE entity_type='observation' AND entity_id=?` (operational-state, ALWAYS local); (3) linked reflections via `reflections JOIN reflection_sources`. Content tables → AWS-side; audit stays local. In agentcore mode audit works but content portions need AgentCore reads. 404 if absent. | -| Promote observation to semantic memory — create `semantic_memories` row + mark observation consumed (shown only when `status=='active'`) | write | `POST /observations//promote-to-semantic` (route `observation_promote_to_semantic`) → form in `fragments/observation_drawer.html` (scope select); response `fragments/observation_promoted_card.html`; `HX-Trigger 'observations-changed semantic-changed'` | `observations`, `semantic_memories`, `semantic_embeddings` | `SemanticMemoryService.create_from_observation` | No | WRITE goes through the SERVICE layer but that service uses raw `self._conn.execute` inside `SAVEPOINT promote_observation`, NOT the `StorageBackend` Protocol. Steps: `SELECT content/project/status` (ValueError if missing/not active/empty); `INSERT semantic_memories`; `UPDATE observations SET status='consumed_without_reflection', status_changed_at`; `DELETE+INSERT` into vec0 `semantic_embeddings` (only when sync_embedder present). All content tables → AWS-side; this path would need `AgentCoreBackend` (CreateMemoryRecord + status update). Errors render a card-error 400; success emits `HX-Trigger` refreshing both panels. | +| Promote observation to semantic memory — create `semantic_memories` row + mark observation consumed (shown only when `status=='active'`) | write | `POST /observations//promote-to-semantic` (route `observation_promote_to_semantic`) → form in `fragments/observation_drawer.html` (scope select); response `fragments/observation_promoted_card.html`; `HX-Trigger 'observations-changed semantic-changed'` | `observations`, `semantic_memories` | `SemanticMemoryService.create_from_observation` | No | WRITE goes through the SERVICE layer but that service uses raw `self._conn.execute` inside `SAVEPOINT promote_observation`, NOT the `StorageBackend` Protocol. Steps: `SELECT content/project/status` (ValueError if missing/not active/empty); `INSERT semantic_memories`; `UPDATE observations SET status='consumed_without_reflection', status_changed_at` (no embedding write any more — the `semantic_embeddings` vec0 table and its sync_embedder plumbing were removed repo-wide in remove-ollama-embeddings). All content tables → AWS-side; this path would need `AgentCoreBackend` (CreateMemoryRecord + status update). Errors render a card-error 400; success emits `HX-Trigger` refreshing both panels. | ## Reflections tab @@ -113,7 +112,7 @@ gap — while operational-state writes (hook-error delete/purge) are correctly l | Edit form (pre-populate use_cases/hints) | read | `GET /reflections//edit` (route `reflection_edit_form`) → `fragments/reflection_edit_form.html` | `reflections`, `reflection_sources`, `observations`, `episodes` | `queries.reflection_detail` | No | Reuses `queries.reflection_detail` to load current use_cases/hints into the edit form. Direct sqlite3. Content tables → AWS-side. | | Confirm reflection (pending_review → confirmed) | write | `POST /reflections//confirm` (route `reflection_confirm`) → re-renders `fragments/reflection_drawer.html`, `HX-Trigger reflection-changed` | `reflections` | `ReflectionService.confirm` | No | `ReflectionService` (constructed with raw db_conn at app setup, ~line 94) runs `UPDATE reflections SET status='confirmed', updated_at=?` and commits. No-op on already-confirmed (no timestamp bump); ValueError on retired/superseded → route 409. Direct sqlite3. Content → in agentcore this status write would need `UpdateMemoryRecord`, which it does not do — parity gap. | | Retire reflection (pending_review/confirmed → retired) | write | `POST /reflections//retire` (route `reflection_retire`) → re-renders `fragments/reflection_drawer.html`, `HX-Trigger reflection-changed` | `reflections` | `ReflectionService.retire` | No | `UPDATE reflections SET status='retired', updated_at=?` then commit on raw db_conn. No-op on already-retired; ValueError on superseded/other → 409. Direct sqlite3. Content write → AWS-side; current path writes local sqlite directly. | -| Edit reflection text (use_cases + hints) in place | write | `POST /reflections//edit` (route `reflection_edit_save`) → re-renders `fragments/reflection_drawer.html`, `HX-Trigger reflection-changed` | `reflections`, `reflection_embeddings` | `ReflectionService.update_text` | No | `UPDATE reflections SET use_cases=?, hints=?, updated_at=?` (hints stored as `json.dumps(list[str])` from newline-separated input). When a `SyncEmbedder` is present, re-embeds via `_write_reflection_embedding` = `DELETE+INSERT` into vec0 `reflection_embeddings` over title+use_cases+hints. Single commit. Blocked (ValueError→409) on retired/superseded; empty use_cases/hints caught at route → 400. Direct sqlite3. Both tables content → AWS-side; local vec0 re-embed has no agentcore counterpart (vector search moves to `RetrieveMemoryRecords`). | +| Edit reflection text (use_cases + hints) in place | write | `POST /reflections//edit` (route `reflection_edit_save`) → re-renders `fragments/reflection_drawer.html`, `HX-Trigger reflection-changed` | `reflections` | `ReflectionService.update_text` | No | `UPDATE reflections SET use_cases=?, hints=?, updated_at=?` (hints stored as `json.dumps(list[str])` from newline-separated input). Single commit — no re-embed any more (`SyncEmbedder` and the `reflection_embeddings` vec0 table it wrote to were removed repo-wide in remove-ollama-embeddings). Blocked (ValueError→409) on retired/superseded; empty use_cases/hints caught at route → 400. Direct sqlite3. Content → AWS-side; vector search there moves to `RetrieveMemoryRecords`. | | Promote reflection to general scope (project → general) | write | `POST /reflections//promote` (route `reflection_promote`) → re-renders `fragments/reflection_drawer.html`, `HX-Trigger reflection-changed` | `reflections` | `ReflectionService.promote_to_general` | No | `UPDATE reflections SET scope='general', updated_at=?` then commit on raw db_conn. Idempotent no-op when already general (no timestamp bump); ValueError on retired/superseded → 409. Direct sqlite3. Content write → AWS-side. (Drawer 'promote to general', separate from the deferred 'promote to knowledge base' action.) | ## Episodes tab @@ -139,11 +138,11 @@ gap — while operational-state writes (hook-error delete/purge) are correctly l | Tab shell (filter form + panel mount) | read | `GET /semantic` (`app.py:487 semantic()`) → `templates/semantic.html`, includes `fragments/semantic_filter_form.html`, lazy-loads panel via `hx-get semantic_panel` (`hx-trigger: load, every 30s, semantic-changed from:body`) | — | (none — static shell render, no DB call) | No | Pure shell render, no DB access. `initial_filters={scope_filter:'', search:''}` passed to template. All data arrives via `/semantic/panel`. | | List / filter / search (panel) | read | `GET /semantic/panel` (`app.py:495 semantic_panel()`) → `fragments/panel_semantic.html` (includes `semantic_create_form.html` + `semantic_row.html` per row) | `semantic_memories`, `session_memory_exposure`, `rating_diagnostics` | `SemanticMemoryService.list_for_project` (`services/semantic.py:220`) | No | Service runs RAW SQL directly on the connection (NOT `StorageBackend`). Reads content columns + rating counters, ranks by Wilson lower bound (useful+overlooked positives over rated exposures) in Python, tiebreak created_at DESC. CRUCIAL: this READ has WRITE side-effects — when `track_exposure=True` and `CLAUDE_SESSION_ID` set it INSERTs one `source='retrieve'` row per returned memory into `session_memory_exposure` (dedup via `NOT EXISTS`); when session id missing it bumps `rating_diagnostics metric='session_id_missing'`. Agentcore: `semantic_memories` content (AWS-side / `RetrieveMemoryRecords`); `session_memory_exposure` + `rating_diagnostics` operational (local). Scope filter: None=project rows + all general; 'project'=project only; 'general'=general only. search LIKE-escaped (`%`,`_`,`\`). | | Detail drawer | read | `GET /semantic//drawer` (`app.py:560 semantic_drawer()`) → `fragments/semantic_drawer.html`; triggered by `hx-get` on `semantic_row.html` into `#semantic-drawer` | `semantic_memories`, `session_memory_exposure` | inline raw SQL in `app.py` + `queries.fetch_rating_evidence` (`queries.py:800`) | No | Two direct sqlite reads, no service/backend: (1) inline `SELECT` of the `semantic_memories` row (content, scope, timestamps, `useful_count`/`times_misled`/`times_overlooked`); `abort(404)` if absent. (2) `fetch_rating_evidence` reads `session_memory_exposure WHERE memory_kind='semantic' AND evidence IS NOT NULL`, newest first, LIMIT 10. Agentcore: row = content (AWS-side); exposure evidence = operational (local). Drawer also hosts the edit form posting to `semantic_update`. | -| Create semantic memory | write | `POST /semantic` (`app.py:514 semantic_create()`) → `HX-Trigger 'semantic-changed'`; form in `fragments/semantic_create_form.html` (`hx-post semantic_create`, error into `#semantic-create-error`) | `semantic_memories`, `semantic_embeddings` | `SemanticMemoryService.create` (`services/semantic.py:80`) | No | Service `INSERT`s into `semantic_memories` (id=uuid4 hex, content, project, scope, created_at/updated_at) then, if sync_embedder present, `DELETE+INSERT` embedding into vec0 `semantic_embeddings`. Single commit envelope. Validates scope in `('project','general')` and non-empty content, ValueError → 400 card. Direct SQL, NOT `StorageBackend`. Agentcore: both content (AWS-side); create maps to `CreateMemoryRecord`, embeddings AWS-managed. | -| Update semantic memory text | write | `POST /semantic//update` (`app.py:589 semantic_update()`) → `HX-Trigger 'semantic-changed'`; edit form in `fragments/semantic_drawer.html` | `semantic_memories`, `semantic_embeddings` | `SemanticMemoryService.update_text` (`services/semantic.py:105`) | No | `UPDATE semantic_memories SET content, updated_at WHERE id`; `rowcount==0` → rollback + ValueError('semantic memory not found') → 400 card. Re-embeds via `_store_embedding` (`DELETE+INSERT semantic_embeddings`) when sync_embedder present. Direct SQL, NOT `StorageBackend`. Agentcore: content + embeddings AWS-side (`UpdateMemoryRecord`). | +| Create semantic memory | write | `POST /semantic` (`app.py:514 semantic_create()`) → `HX-Trigger 'semantic-changed'`; form in `fragments/semantic_create_form.html` (`hx-post semantic_create`, error into `#semantic-create-error`) | `semantic_memories` | `SemanticMemoryService.create` (`services/semantic.py:80`) | No | Service `INSERT`s into `semantic_memories` (id=uuid4 hex, content, project, scope, created_at/updated_at). Single commit — no embedding write any more (the `semantic_embeddings` vec0 table and its sync_embedder plumbing were removed repo-wide in remove-ollama-embeddings). Validates scope in `('project','general')` and non-empty content, ValueError → 400 card. Direct SQL, NOT `StorageBackend`. Agentcore: content AWS-side; create maps to `CreateMemoryRecord`, embeddings AWS-managed. | +| Update semantic memory text | write | `POST /semantic//update` (`app.py:589 semantic_update()`) → `HX-Trigger 'semantic-changed'`; edit form in `fragments/semantic_drawer.html` | `semantic_memories` | `SemanticMemoryService.update_text` (`services/semantic.py:105`) | No | `UPDATE semantic_memories SET content, updated_at WHERE id`; `rowcount==0` → rollback + ValueError('semantic memory not found') → 400 card. No re-embed any more (`_store_embedding` / `semantic_embeddings` removed repo-wide in remove-ollama-embeddings). Direct SQL, NOT `StorageBackend`. Agentcore: content AWS-side (`UpdateMemoryRecord`), embeddings AWS-managed. | | Set scope (project ↔ general) | write | `POST /semantic//scope` (`app.py:533 semantic_scope()`) → `HX-Trigger 'semantic-changed'`; scope toggle buttons in `fragments/semantic_row.html` | `semantic_memories` | `SemanticMemoryService.set_scope` (`services/semantic.py:126`) | No | `UPDATE semantic_memories SET scope, updated_at WHERE id` (always bumps updated_at, even setting same value). `rowcount==0` → rollback + ValueError → 400. Validates scope in `('project','general')`. No embedding change. Direct SQL, NOT `StorageBackend`. Agentcore: content metadata (AWS-side). | -| Delete semantic memory | write | `POST /semantic//delete` (`app.py:550 semantic_delete()`) → `HX-Trigger 'semantic-changed'`; delete button in `fragments/semantic_row.html` | `semantic_memories` | `SemanticMemoryService.delete` (`services/semantic.py:213`) | No | `DELETE FROM semantic_memories WHERE id`, idempotent (no error if absent), single commit. Does NOT explicitly delete the `semantic_embeddings` row (relies on FK/orphan tolerance). Direct SQL, NOT `StorageBackend`. Agentcore: content (`DeleteMemoryRecord` AWS-side). | -| Promote observation to semantic memory | write | `POST /observations//promote-to-semantic` (`app.py:651 observation_promote_to_semantic()`) → `fragments/observation_promoted_card.html`, `HX-Trigger 'observations-changed semantic-changed'` (invoked from Observations tab drawer, writes into the semantic domain) | `observations`, `semantic_memories`, `semantic_embeddings` | `SemanticMemoryService.create_from_observation` (`services/semantic.py:148`) | No | Within `SAVEPOINT promote_observation`: `SELECT` observations row (must be `status='active'`, non-empty content), `INSERT` new `semantic_memories` row (observation content+project, requested scope), `UPDATE observations SET status='consumed_without_reflection', status_changed_at`, and (if sync_embedder) store embedding in `semantic_embeddings`. ValueError on invalid scope / missing / already-consumed → 400. Direct SQL, NOT `StorageBackend`. Agentcore: cross-table content write — all three AWS-side; the atomic SAVEPOINT crossing both would need a distributed equivalent. | +| Delete semantic memory | write | `POST /semantic//delete` (`app.py:550 semantic_delete()`) → `HX-Trigger 'semantic-changed'`; delete button in `fragments/semantic_row.html` | `semantic_memories` | `SemanticMemoryService.delete` (`services/semantic.py:213`) | No | `DELETE FROM semantic_memories WHERE id`, idempotent (no error if absent), single commit. (There is no `semantic_embeddings` row to worry about any more — that vec0 table was dropped in migration 0018.) Direct SQL, NOT `StorageBackend`. Agentcore: content (`DeleteMemoryRecord` AWS-side). | +| Promote observation to semantic memory | write | `POST /observations//promote-to-semantic` (`app.py:651 observation_promote_to_semantic()`) → `fragments/observation_promoted_card.html`, `HX-Trigger 'observations-changed semantic-changed'` (invoked from Observations tab drawer, writes into the semantic domain) | `observations`, `semantic_memories` | `SemanticMemoryService.create_from_observation` (`services/semantic.py:148`) | No | Within `SAVEPOINT promote_observation`: `SELECT` observations row (must be `status='active'`, non-empty content), `INSERT` new `semantic_memories` row (observation content+project, requested scope), `UPDATE observations SET status='consumed_without_reflection', status_changed_at` (no embedding write any more — removed repo-wide in remove-ollama-embeddings). ValueError on invalid scope / missing / already-consumed → 400. Direct SQL, NOT `StorageBackend`. Agentcore: cross-table content write — both AWS-side; the atomic SAVEPOINT crossing both would need a distributed equivalent. | ## Diagnostics tab (hook errors + retention runs) @@ -159,15 +158,14 @@ gap — while operational-state writes (hook-error delete/purge) are correctly l | Purge all hook errors (Clear all button) | write | `POST /diagnostics/hook-errors/purge` (Clear all button in `fragments/panel_hook_errors.html`); returns `HX-Trigger: hook-errors-changed` | `hook_errors` | inline `conn.execute()` + `conn.commit()` in `app.py` `hook_errors_purge()` route (no service, no `queries.py`) | No | `DELETE FROM hook_errors` (unfiltered) as raw SQL + `conn.commit()`. Bypasses service/`StorageBackend`. `hook_errors` local operational state, so this stays local in agentcore mode. | | Hook error recording (data producer — populates the panel; not UI-triggered) | write | none — runs inside Claude hook subprocesses (broad except block), not the UI | `hook_errors` | `better_memory.hooks._error_log.record_hook_error` | No | `INSERT INTO hook_errors (id, created_at, hook_name, exception_type, exception_message, traceback, cwd)` using its OWN `connect(resolve_home()/'memory.db')` connection, not the UI's or any `StorageBackend`. Swallows all exceptions (hooks must not fail). This is why the diagnostics panel reads `hook_errors` directly — pure local operational state, ALWAYS local `memory.db`; no backend abstraction on this path at all. | | Retention run recording (data producer — populates the retention-runs panel; not UI-triggered) | write | none — invoked from `memory.retrieve` after spool drain (24h-guarded), not the UI | `retention_runs` | `better_memory.services.retention_scheduler.RetentionScheduler._record_run` (guarded by `maybe_run`; wraps `services.retention.RetentionService.run`) | No | `INSERT INTO retention_runs (run_at, archived_via_*, pruned, triggered_by)` via raw `conn.execute` + commit; no `StorageBackend`. `_too_soon()` also does `SELECT 1 FROM retention_runs` (24h guard). `retention_runs` operational — ALWAYS local. The UI only READS this table; it never triggers a retention run. | -| Retention archive/prune of observations (side effect of a retention run; not UI-triggered) | write | none — MCP/scheduler path (`RetentionScheduler.maybe_run`), not the UI | `observations`, `reflection_sources`, `reflections`, `episodes`, `observation_embeddings`, `observation_fts` | `better_memory.services.retention.RetentionService.run / run_archive / _prune` (rules A/B/C + prune) | No | `run_archive` `UPDATE`s `observations SET status='archived'` inside a `SAVEPOINT` (rules read `reflection_sources`/`reflections`/`episodes`); `_prune` `DELETE`s from `observations` (+ explicit `DELETE FROM observation_embeddings`; `observation_fts` kept via AFTER DELETE trigger). All raw SQL — no `StorageBackend`. `observations`/`reflections` content → AWS-side; this whole content-mutation path is local-sqlite-only. Its ONLY UI-visible footprint is the aggregate counts persisted into the local `retention_runs` ledger, which the retention-runs panel reads. In agentcore mode the archive/prune semantics would have to be reimplemented AWS-side (or against a local content mirror). | +| Retention archive/prune of observations (side effect of a retention run; not UI-triggered) | write | none — MCP/scheduler path (`RetentionScheduler.maybe_run`), not the UI | `observations`, `reflection_sources`, `reflections`, `episodes`, `observation_fts` | `better_memory.services.retention.RetentionService.run / run_archive / _prune` (rules A/B/C + prune) | No | `run_archive` `UPDATE`s `observations SET status='archived'` inside a `SAVEPOINT` (rules read `reflection_sources`/`reflections`/`episodes`); `_prune` `DELETE`s from `observations` (`observation_fts` kept in sync via AFTER DELETE trigger). No `observation_embeddings` step any more — that vec0 table was dropped in migration 0018. All raw SQL — no `StorageBackend`. `observations`/`reflections` content → AWS-side; this whole content-mutation path is local-sqlite-only. Its ONLY UI-visible footprint is the aggregate counts persisted into the local `retention_runs` ledger, which the retention-runs panel reads. In agentcore mode the archive/prune semantics would have to be reimplemented AWS-side (or against a local content mirror). | ## UI infrastructure (connection/backend acquisition, session/project resolution) | Feature | Op | UI surface | Tables | Query / Service | Through backend? | Notes | |---------|----|-----------|--------|-----------------|------------------|-------| | UI sqlite connection acquisition (single shared conn for the whole app) | read | `create_app()` app setup in `better_memory/ui/app.py:83-92` (no route); stored in `app.extensions['db_connection']` | — | `better_memory.db.connection.connect(resolve_home() / 'memory.db')` | No | CRITICAL: `app.py` never imports `storage/factory.py`, `build_backend`, `resolve_storage_backend`, or any `StorageBackend`. It unconditionally opens the LOCAL `memory.db` as a `sqlite3.Connection` (WAL, `foreign_keys=ON`, sqlite-vec, Row factory, `busy_timeout=5000`) regardless of `config.storage_backend`. In agentcore mode the content tables (`observations`, `reflections`, `semantic_memories`, `episodes`) live AWS-side, so this locally-opened conn would NOT contain them — the UI is not agentcore-aware and would show empty/partial content. Operational-state tables (`session_memory_exposure`, `hook_errors`, `retention_runs`, `audit_log`) stay local and would be present. `db_path` overridable via the `db_path` kwarg (tests) else `resolve_home()/memory.db`. | -| UI SyncEmbedder acquisition for write-path services | read | `create_app()` via `_build_sync_embedder()` in `better_memory/ui/app.py:38-47,86-97`; stored in `app.extensions['sync_embedder']` | — | `_build_sync_embedder()` gated on `get_config().embeddings_backend == 'ollama'` | No | Config read only (`embeddings_backend`), no DB/table. Returns None unless ollama backend (sqlite FTS5 indexes via DB triggers). Shared instance threaded into `ReflectionService` and every per-route `SemanticMemoryService` so UI writes embed identically to MCP writes. Orthogonal to `storage_backend` (`embeddings_backend` is a separate knob). | -| Write-path service instantiation bound to the raw connection (EpisodeService, ReflectionService, SemanticMemoryService) | write | `app.extensions` setup `app.py:93-96` (EpisodeService, ReflectionService) + lazily per-route `SemanticMemoryService(conn, sync_embedder=...)` in `/semantic*` and `/observations//promote-to-semantic` | `episodes`, `reflections`, `semantic_memories` | `EpisodeService(conn=db_conn)`; `ReflectionService(conn=db_conn, sync_embedder=...)`; `SemanticMemoryService(conn, sync_embedder=...)` | No | CRITICAL: services receive the raw `sqlite3.Connection`, NOT a `StorageBackend` Protocol object. Even UI WRITES bypass the `StorageBackend` abstraction entirely — they go straight to local sqlite via the service layer. In agentcore mode these services would still write CONTENT to the local `memory.db`, diverging from the AWS-side records the MCP path writes via `AgentCoreBackend`. This is the core infrastructure gap for agentcore parity. | +| Write-path service instantiation bound to the raw connection (EpisodeService, ReflectionService, SemanticMemoryService) | write | `app.extensions` setup `app.py:93-96` (EpisodeService, ReflectionService) + lazily per-route `SemanticMemoryService(conn)` in `/semantic*` and `/observations//promote-to-semantic` | `episodes`, `reflections`, `semantic_memories` | `EpisodeService(conn=db_conn)`; `ReflectionService(conn=db_conn)`; `SemanticMemoryService(conn)` (no `sync_embedder` param any more — that plumbing, and the `_build_sync_embedder()` / `embeddings_backend` config it depended on, was removed repo-wide in remove-ollama-embeddings) | No | CRITICAL: services receive the raw `sqlite3.Connection`, NOT a `StorageBackend` Protocol object. Even UI WRITES bypass the `StorageBackend` abstraction entirely — they go straight to local sqlite via the service layer. In agentcore mode these services would still write CONTENT to the local `memory.db`, diverging from the AWS-side records the MCP path writes via `AgentCoreBackend`. This is the core infrastructure gap for agentcore parity. | | Project scoping / resolution for list panels | read | `GET /episodes/panel`, `GET /episodes/banner`, `GET /reflections`, `GET /reflections/panel`, `GET /semantic/panel`, `POST /semantic`, `POST /semantic//...` (app.py) — via `project=project_name()` | — | `better_memory.config.project_name()` | No | No table access; pure cwd/env/filesystem resolution. Order: `BETTER_MEMORY_PROJECT` env > `/.better-memory` override file (first non-empty line, cwd only not ancestors) > git-root walk (`_walk_for_git_root`, worktree-aware, memoized by abs path) > `'general'`. cwd is the UI subprocess's cwd (the shell that launched `python -m better_memory.ui`). Reflections route also unions `project_name()` with `queries.reflection_distinct_projects(conn)` so the current project is always selectable. | | Session identity resolution (absent in the UI) | read | none — no route resolves a session id | — | n/a (`get_session_id` / `env_session_id` from `_common.py` are NOT called anywhere in `better_memory/ui`) | No | The UI is session-AGNOSTIC. Unlike the MCP/hook planes it never resolves `CLAUDE_SESSION_ID`/`CLAUDE_CODE_SESSION_ID`. It browses data scoped by PROJECT, never by session. The `session_memory_exposure` ledger on the Diagnostics tab is read across all sessions (`ORDER BY rated_at DESC LIMIT 20`), not the current one. Relevant for agentcore: session scoping that AgentCore relies on (`sessionId` grouping) has no analogue in the UI. | | Left-rail tab navigation (capability gating) | read | `templates/base.html` `.rail-nav` (rendered by every page: `episodes.html`, `observations.html`, `reflections.html`, `semantic.html`, `diagnostics.html` via `active_tab`) | — | static Jinja markup; only input is the `active_tab` string passed by each route | No | CRITICAL: the tab list is STATICALLY hardcoded — 5 fixed links (01 Episodes, 02 Observations, 03 Reflections, 04 Semantic, 05 Diagnostics). There is NO capability gating: `supports_episodes` is never referenced anywhere in `better_memory/ui` (grep confirms zero matches for `supports_episodes`/`storage_backend`/`agentcore`). The stated design intent to hide the Episodes tab when `supports_episodes=False` (agentcore) is NOT implemented — consistent with `app.py` being backend-unaware. Implementing it requires threading the backend/capability into `create_app` and `base.html`. | diff --git a/docs/agentcore-ui/gaps/edit-aws-extracted-reflection-text.md b/docs/agentcore-ui/gaps/edit-aws-extracted-reflection-text.md index b5feb299..f113f29c 100644 --- a/docs/agentcore-ui/gaps/edit-aws-extracted-reflection-text.md +++ b/docs/agentcore-ui/gaps/edit-aws-extracted-reflection-text.md @@ -25,11 +25,13 @@ In sqlite mode the UI lets the maintainer edit the two **text** fields in place: - **Save:** `POST /reflections//edit` (route `reflection_edit_save`) calls `ReflectionService.update_text`, which runs `UPDATE reflections SET use_cases=?, hints=?, updated_at=? WHERE id=?` - (hints stored as `json.dumps(list[str])` from newline-separated input), then — when a - `SyncEmbedder` is present — re-embeds title+use_cases+hints via a `DELETE+INSERT` into the - vec0 `reflection_embeddings` table. Single commit. Empty use_cases/hints → 400 at the - route; retired/superseded → `ValueError` → 409. On success it re-renders - `fragments/reflection_drawer.html` with `HX-Trigger: reflection-changed`. + (hints stored as `json.dumps(list[str])` from newline-separated input). Single commit. + [Correction: the re-embed step this paragraph originally described (`SyncEmbedder` → + vec0 `reflection_embeddings`) was removed repo-wide in remove-ollama-embeddings; the + table no longer exists (migration 0018). `update_text` today is just the `UPDATE` + above.] Empty use_cases/hints → 400 at the route; retired/superseded → `ValueError` → + 409. On success it re-renders `fragments/reflection_drawer.html` with + `HX-Trigger: reflection-changed`. The mental model in sqlite mode is simple: the maintainer **owns** the reflection text. Synthesis writes it once; nothing else rewrites it; the edit is the last word. @@ -38,7 +40,9 @@ Synthesis writes it once; nothing else rewrites it; the edit is the last word. ### The sqlite source `reflections.use_cases` and `reflections.hints` are plain columns on the local -`reflections` table (with a mirrored vec0 `reflection_embeddings` row). `update_text` owns +`reflections` table. [Correction: this used to also say "with a mirrored vec0 +`reflection_embeddings` row" — that table was dropped in migration 0018 +(remove-ollama-embeddings); see the section-1 correction above.] `update_text` owns those columns outright. All of this bypasses `StorageBackend` today — the UI hands `ReflectionService` a raw `sqlite3.Connection` (`app.py:93-96`), so even the write never touches the backend abstraction (see `docs/agentcore-ui/data-sources.md`, Reflections tab). @@ -81,9 +85,11 @@ that namespace, distinguished by a body marker: write — the crediting path already documents last-writer-wins, agentcore.py:1084-1088 — so even detecting the clobber after the fact is not free.) 3. **The vec0 re-embed leg has no agentcore counterpart** and is a design non-goal — - embeddings are AWS-managed; vector search moves to `RetrieveMemoryRecords`. (This is not - the blocker, just a note: the sqlite `update_text` also touches `reflection_embeddings`, - which simply drops away in agentcore mode.) + embeddings are AWS-managed; vector search moves to `RetrieveMemoryRecords`. [Correction: + this point originally framed the vec0 leg as something the sqlite `update_text` "also + touches" and that "simply drops away in agentcore mode" — in fact `update_text` no longer + touches any embedding table in EITHER mode; the vec0 write was removed repo-wide, not + agentcore-specifically dropped. See the section-1 correction above.] So: **migrated** reflection text is cleanly editable (we own the body). **AWS-extracted** reflection text is editable *mechanically* but the edit is not durable against the extraction diff --git a/docs/agentcore-ui/gaps/retention-superseded-by-event-expiry.md b/docs/agentcore-ui/gaps/retention-superseded-by-event-expiry.md index d6ff3464..547a3087 100644 --- a/docs/agentcore-ui/gaps/retention-superseded-by-event-expiry.md +++ b/docs/agentcore-ui/gaps/retention-superseded-by-event-expiry.md @@ -28,8 +28,10 @@ archive rules atomically inside `SAVEPOINT retention_archive`, then an optional - **Rule C — `_archive_rule_c_no_outcome_episode`**: observation's episode has `outcome='no_outcome'` and `ended_at` ≥ `retention_days` old. - **Prune — `_prune`**: hard-`DELETE` archived observations older than `prune_age_days` (default - 365) that have **no** `reflection_sources` rows (sourced rows are kept for the audit trail); - also explicitly `DELETE`s the matching `observation_embeddings`. + 365) that have **no** `reflection_sources` rows (sourced rows are kept for the audit trail). + [Correction: this bullet originally also said prune explicitly deletes the matching + `observation_embeddings` row; that table was dropped in migration 0018 (remove-ollama-embeddings) + and `_prune` no longer touches it.] Each run records one row in the local `retention_runs` ledger via `RetentionScheduler._record_run` (`services/retention_scheduler.py`), guarded by a 24h @@ -60,7 +62,9 @@ the UI never writes runs). The UI has **no** button to trigger a run — it only safe in agentcore mode (same as `hook_errors`). - The **engine that produces the counts**, however, is *content-mutation*: rules A/B/C read the `reflection_sources → reflections → episodes` graph and `UPDATE observations SET status='archived'`; - `_prune` `DELETE`s from `observations` + `observation_embeddings`. Every one of those tables is + `_prune` `DELETE`s from `observations`. [Correction: this used to also say "+ + `observation_embeddings`" — that table was dropped in migration 0018 + (remove-ollama-embeddings); see the section-1 correction above.] Every one of those tables is AWS-side in agentcore mode. **Why the engine cannot run against AgentCore (live-API facts):** diff --git a/docs/agentcore-ui/write-spec.md b/docs/agentcore-ui/write-spec.md index 63455e23..7711dc57 100644 --- a/docs/agentcore-ui/write-spec.md +++ b/docs/agentcore-ui/write-spec.md @@ -19,8 +19,10 @@ delete/purge — plus the actions that must be **hidden/disabled** in agentcore `create_app()` MUST build a backend via `better_memory.storage.factory.build_backend(config=…, memory_conn=, -sync_embedder=…, session_id=None, project=project_name())` and store it in +session_id=None, project=project_name())` and store it in `app.extensions['backend']`, then route all **content** writes through backend methods. +(`build_backend` no longer takes a `sync_embedder` param — that plumbing was removed +repo-wide in remove-ollama-embeddings.) - **Two connections coexist.** The local `sqlite3.Connection` is STILL opened and kept in `app.extensions['db_connection']` — it is the `local_conn` passed to `build_backend` and it @@ -57,15 +59,15 @@ Legend for **destination**: **AWS** = durable content, goes to AgentCore memory | R1 | **Promote reflection to general scope** (`POST /reflections//promote`) | `ReflectionService.promote_to_general` → `UPDATE reflections SET scope='general', updated_at` on raw conn | `backend.promote_reflection(reflection_id=id)` → `AgentCoreBackend.promote_reflection` (agentcore.py:1578) → `_mutate_namespace_and_status(new_namespaces=[resolve_namespace("general","reflections")], new_status="promoted")` → **UpdateMemoryRecord / BatchUpdateMemoryRecords** (moves record project→general reflections namespace, status→`promoted`). Declared on `protocol.py:241`. | AWS | | R2 | **Retire reflection** (`POST /reflections//retire`) | `ReflectionService.retire` → `UPDATE reflections SET status='retired', updated_at` on raw conn | `backend.retire_reflection(reflection_id=id)` → `AgentCoreBackend.retire_reflection` (agentcore.py:1585) → `_mutate_namespace_and_status(new_namespaces=[resolve_namespace(actor_id,"retired")], new_status="retired")` → **UpdateMemoryRecord / BatchUpdateMemoryRecords** (moves to `projects/{project}/retired/`, status→`retired`; handles AWS-extracted and migrated records). Declared on `protocol.py:245`. | AWS | | R3 | **Confirm reflection** (`POST /reflections//confirm`) | `ReflectionService.confirm` → `UPDATE reflections SET status='confirmed'` on raw conn | **HIDDEN/DISABLED** in agentcore mode via `supports_reflection_confirm=False`. Reason: agentcore's reflection status model is `active`/`promoted`/`retired` with **no `pending_review` gate** — reflections are born `active` (== sqlite `confirmed`). There is no confirm method on the protocol and `confirmed` would be neither produced nor read back. Route returns 404/hidden button; never wired. | — | -| R4 | **Edit reflection text** (use_cases + hints) (`POST /reflections//edit`) | `ReflectionService.update_text` → `UPDATE reflections SET use_cases, hints, updated_at` + local vec0 re-embed on raw conn | **HIDDEN/DISABLED by default** in agentcore mode via `supports_reflection_text_edit=False`. Reason: reflection text is AI-managed by the AgentCore extraction pipeline; there is **no wired reflection text-update backend method** (only `semantic_update_text` exists), AWS-extracted records' field editability is unresolved, and the vec0 re-embed has no agentcore counterpart. This is a genuine open decision (see below); DEFAULT is disable. If later enabled, it maps to a new `backend.reflection_update_text(id, use_cases, hints)` → `batch_update_memory_records` body RMW (agentcore.py:1055) → **BatchUpdateMemoryRecords**. | AWS (if enabled) | +| R4 | **Edit reflection text** (use_cases + hints) (`POST /reflections//edit`) | `ReflectionService.update_text` → `UPDATE reflections SET use_cases, hints, updated_at` on raw conn (no embedding write any more — removed repo-wide in remove-ollama-embeddings) | **HIDDEN/DISABLED by default** in agentcore mode via `supports_reflection_text_edit=False`. Reason: reflection text is AI-managed by the AgentCore extraction pipeline; there is **no wired reflection text-update backend method** (only `semantic_update_text` exists), and AWS-extracted records' field editability is unresolved. This is a genuine open decision (see below); DEFAULT is disable. If later enabled, it maps to a new `backend.reflection_update_text(id, use_cases, hints)` → `batch_update_memory_records` body RMW (agentcore.py:1055) → **BatchUpdateMemoryRecords**. | AWS (if enabled) | | R5 | Rating evidence receipts re-render after any reflection write | `queries.fetch_rating_evidence` on `session_memory_exposure` | **UNCHANGED — LOCAL.** Reads `session_memory_exposure` on `app.extensions['db_connection']` in both modes (session-operational state; `protocol.py:262` list_session_exposures reads the same local table). | LOCAL | ### Semantic memories (full CRUD, all AWS in agentcore mode) | # | UI write | Current sqlite path | AgentCore-mode path (backend method → AWS API) | Dest | |---|----------|---------------------|-----------------------------------------------|------| -| S1 | **Create semantic memory** (`POST /semantic`) | `SemanticMemoryService.create` → `INSERT semantic_memories` + local vec0 embed | `backend.semantic_observe(content=…, project=…, scope=…)` → `AgentCoreBackend.semantic_observe` (agentcore.py:1199) → inserts a BASE record under the userPreference strategy, namespace from scope (`resolve_namespace(actor,"semantic")`), content-hash dedup → **BatchCreateMemoryRecords**. Returns record id. Embedding is AWS-managed (local vec0 write is a non-goal). Declared `protocol.py:152`. | AWS | -| S2 | **Update semantic memory text** (`POST /semantic//update`) | `SemanticMemoryService.update_text` → `UPDATE semantic_memories SET content` + local vec0 re-embed | `backend.semantic_update_text(id=id, content=…)` → `AgentCoreBackend.semantic_update_text` (agentcore.py:1383) → get + batch_update with full metadata snapshot (reserved keys stripped), transient-404 retry → **BatchUpdateMemoryRecords**. Re-embedding AWS-managed. Declared `protocol.py:173`. | AWS | +| S1 | **Create semantic memory** (`POST /semantic`) | `SemanticMemoryService.create` → `INSERT semantic_memories` (no local embedding write any more — removed repo-wide in remove-ollama-embeddings) | `backend.semantic_observe(content=…, project=…, scope=…)` → `AgentCoreBackend.semantic_observe` (agentcore.py:1199) → inserts a BASE record under the userPreference strategy, namespace from scope (`resolve_namespace(actor,"semantic")`), content-hash dedup → **BatchCreateMemoryRecords**. Returns record id. Embedding is AWS-managed. Declared `protocol.py:152`. | AWS | +| S2 | **Update semantic memory text** (`POST /semantic//update`) | `SemanticMemoryService.update_text` → `UPDATE semantic_memories SET content` (no local re-embed any more — removed repo-wide in remove-ollama-embeddings) | `backend.semantic_update_text(id=id, content=…)` → `AgentCoreBackend.semantic_update_text` (agentcore.py:1383) → get + batch_update with full metadata snapshot (reserved keys stripped), transient-404 retry → **BatchUpdateMemoryRecords**. Re-embedding AWS-managed. Declared `protocol.py:173`. | AWS | | S3 | **Set scope (project ↔ general)** (`POST /semantic//scope`) | `SemanticMemoryService.set_scope` → `UPDATE semantic_memories SET scope` | `backend.semantic_set_scope(id=id, scope=…)` → `AgentCoreBackend.semantic_set_scope` (agentcore.py:1410) → validates scope, moves the record between `projects/{project}/semantic/` and `general/semantic/` namespaces (scope is namespace membership, not a column) → **BatchUpdateMemoryRecords** (namespace move). Declared `protocol.py:177`. | AWS | | S4 | **Delete semantic memory** (`POST /semantic//delete`) | `SemanticMemoryService.delete` → `DELETE FROM semantic_memories` (idempotent) | `backend.semantic_delete(id=id)` → `AgentCoreBackend.semantic_delete` (agentcore.py:1444) → removes the record; AWS reclaims the embedding → **BatchDeleteMemoryRecords**. Minor semantic difference: agentcore raises on not-found vs sqlite idempotency — wrap so the route stays idempotent (treat not-found as success). Declared `protocol.py:181`. | AWS | @@ -73,7 +75,7 @@ Legend for **destination**: **AWS** = durable content, goes to AgentCore memory | # | UI write | Current sqlite path | AgentCore-mode path | Dest | |---|----------|---------------------|---------------------|------| -| O1 | **Promote observation → semantic** (`POST /observations//promote-to-semantic`) | `SemanticMemoryService.create_from_observation` → `SAVEPOINT`: SELECT observation, INSERT `semantic_memories`, UPDATE `observations SET status='consumed_without_reflection'`, vec0 embed | **HIDDEN/DISABLED** in agentcore mode. Reason: the whole **Observations tab is hidden** (`supports_observations=False`) — observations are immutable `CreateEvents` with no mutable status, so the "mark consumed" half has no equivalent, and the `SAVEPOINT` crosses the event and record planes with no distributed transaction. The create half alone would be `semantic_observe`, but the action is not offered because its consume half cannot be honored. Never wired. | — | +| O1 | **Promote observation → semantic** (`POST /observations//promote-to-semantic`) | `SemanticMemoryService.create_from_observation` → `SAVEPOINT`: SELECT observation, INSERT `semantic_memories`, UPDATE `observations SET status='consumed_without_reflection'` (no embedding write any more — removed repo-wide in remove-ollama-embeddings) | **HIDDEN/DISABLED** in agentcore mode. Reason: the whole **Observations tab is hidden** (`supports_observations=False`) — observations are immutable `CreateEvents` with no mutable status, so the "mark consumed" half has no equivalent, and the `SAVEPOINT` crosses the event and record planes with no distributed transaction. The create half alone would be `semantic_observe`, but the action is not offered because its consume half cannot be honored. Never wired. | — | ### Diagnostics — hook errors (operational; stays LOCAL in both modes) @@ -95,7 +97,7 @@ Legend for **destination**: **AWS** = durable content, goes to AgentCore memory | Action | Gate flag | Reason | |--------|-----------|--------| | Confirm reflection (R3) | `supports_reflection_confirm=False` | No `pending_review` gate; reflections born `active`. `confirmed` has no server-side meaning. | -| Inline reflection text-edit (R4) | `supports_reflection_text_edit=False` (DEFAULT; open decision) | Reflection text is AI/extraction-pipeline managed; no wired text-update method; AWS-extracted editability unresolved; vec0 re-embed has no counterpart. | +| Inline reflection text-edit (R4) | `supports_reflection_text_edit=False` (DEFAULT; open decision) | Reflection text is AI/extraction-pipeline managed; no wired text-update method; AWS-extracted editability unresolved. | | Promote observation→semantic (O1) | `supports_observations=False` | Observations are immutable events with no status lifecycle — cannot mark consumed; SAVEPOINT crosses event+record planes. | | Whole Observations tab | `supports_observations=False` | No observation concept in agentcore mode (events handled transparently). Also removes all provenance/linked-reflection sections. | | Whole Episodes tab + Close episode | `supports_episodes=False` (exists) | AgentCore groups events by `sessionId`; no episode entity; `close_*` are no-ops. | diff --git a/docs/hooks-setup.md b/docs/hooks-setup.md index 84f2b4ab..e247e6fd 100644 --- a/docs/hooks-setup.md +++ b/docs/hooks-setup.md @@ -19,13 +19,16 @@ below. The `contextual_inject` hook is gated by `BETTER_MEMORY_CONTEXT_INJECT_MODE` (`userprompt` \| `pretool` \| `both` (default) \| `off`). It runs in-process -against `memory.db` and gates each candidate through a three-leg evidence -check (BM25 match against `reflection_fts`, or vector cosine similarity ->= `BETTER_MEMORY_CONTEXT_VEC_FLOOR` (default 0.55), or — only when both -those legs are structurally unavailable — a keyword-hit fallback), then -ranks qualifiers by reciprocal rank fusion over a Wilson-score usefulness -prior; it injects the top matches (capped at `BETTER_MEMORY_CONTEXT_MAX_ITEMS`) -and never blocks a turn. `PreToolUse` hosts two independent registrations — +against `memory.db` and gates each candidate through a two-leg evidence +check (a BM25 match against `reflection_fts`, or — only when that leg is +structurally unavailable, or for semantic memories, which have no FTS +substrate of their own — a keyword-hit fallback), then ranks qualifiers +by reciprocal rank fusion over a Wilson-score usefulness prior; it +injects the top matches (capped at `BETTER_MEMORY_CONTEXT_MAX_ITEMS`) and +never blocks a turn. On agentcore, both kinds are instead gated by the +backend's own server-side `relevance_ranks`, falling back to the +keyword-hit floor only when that lookup itself fails. `PreToolUse` hosts +two independent registrations — `contextual_inject`, unscoped (matches every tool) but latched to one real firing per session (later tool calls short-circuit on a state file before touching the DB), and `commit_checkpoint`, scoped to `Bash` and gated to diff --git a/docs/superpowers/plans/2026-09-01-remove-ollama-embeddings.md b/docs/superpowers/plans/2026-09-01-remove-ollama-embeddings.md new file mode 100644 index 00000000..2fbdd156 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-remove-ollama-embeddings.md @@ -0,0 +1,337 @@ +# Remove Ollama and Local Embeddings Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Delete the Ollama embedding subsystem and all local vector search so no better-memory process contacts Ollama or computes embeddings in any mode. + +**Architecture:** Sever wiring first (nothing constructs an embedder), then strip services inside-out (every `sync_embedder`/`embedder` parameter defaults to `None`, so each strip is independently green), then delete the orphaned `embeddings/` package, drop the vec0 tables by migration, and sync docs. Retrieval collapses to FTS5/BM25 + keyword fallback (sqlite storage) and server-side `relevance_ranks` (agentcore storage). + +**Tech Stack:** Python 3.12, sqlite3 + sqlite-vec (load retained this release), FTS5, pytest, Pyright. + +**Spec:** `docs/superpowers/specs/2026-09-01-remove-ollama-embeddings-design.md` + +## Global Constraints + +- Full `pytest` suite AND Pyright green after every task — no task may end with a dangling import or parameter. +- `sqlite_vec.load()` in `better_memory/db/connection.py:39` is NOT removed (needed by migration 0015; dep removal is a follow-up release). +- Historical docs (`docs/superpowers/specs/*`, `plans/*`, `archive/*`) are never edited. +- Env vars `OLLAMA_HOST`, `EMBED_MODEL`, `BETTER_MEMORY_EMBEDDINGS_BACKEND`, `BETTER_MEMORY_EMBED_LOG` become silently unread — no deprecation shims, no warnings, no error paths. +- Commit after every task; conventional-commit style messages matching repo history. + +## Guardrails (from better-memory reflections — surfaced before drafting) + +- **[[website-readme-sync]]** (conf 0.95, evidence 7, used 27×): every `better_memory/` change syncs `website/configuration.md`, `website/architecture.md`, `website/index.md`, `website/mcp-tools.md`, `website/observation-lifecycle.md`, `README.md` in the same PR. Module-level docstrings that enumerate env vars are part of the code change (Task 3), NOT the docs task. No MCP tools are added/removed here, so tool-count strings should NOT change — verify, don't edit. +- **[[verify-red-is-red]]** (conf 0.75, evidence 2): Task 1's FTS regression test is EXPECTED GREEN immediately — it is a verification pin, not a red-green TDD cycle. If it is RED, stop: the spec's real concern (FTS gated on the old knob) is live, and un-gating becomes a new task inserted before Task 2. +- **[[planning-retrieval]]** (conf 0.9): satisfied — planning + implementation memories and the standards doc were retrieved before drafting this plan. + +Dismissed as not applicable: freeze-localization logging (0.55 — no debugging task here), fail-fast ordering comments (0.55 — Task 4 preserves the existing rollback comment verbatim), Playwright CSS text-transform (0.8 — no UI restyling), TypeScript Partial (0.6 — not TypeScript), mkstemp fd leak (0.6 — no temp files). + +--- + +### Task 1: FTS-substrate verification + regression pin — **confidence 90%** + +The spec's single real concern, done first because a failure changes the plan. + +**Files:** +- Create: `tests/services/test_fts_substrate.py` +- Read (verify only): `better_memory/db/migrations/0002_episodic.sql`, `0014_semantic_embeddings.sql`, all other `db/migrations/*.sql` + +**Interfaces:** +- Consumes: `ObservationService` (existing), `retrieve_relevant` from `better_memory/services/relevant.py` (existing). +- Produces: nothing new — a regression test later tasks must keep green. + +- [ ] **Step 1: Verify FTS trigger DDL is unconditional** + +Run: `grep -n -i "fts" better_memory/db/migrations/*.sql` and `grep -rn "embeddings_backend" better_memory/db/` +Expected: FTS virtual tables + triggers created unconditionally in migration SQL; zero hits for `embeddings_backend` under `db/`. If FTS creation is conditional anywhere, STOP and report — un-gating becomes a new task before Task 2. + +- [ ] **Step 2: Write the regression test** + +```python +"""Pins the FTS5/BM25 evidence leg as unconditional. + +After the Ollama/vector removal, BM25 is the only local evidence leg for +sqlite-storage retrieval. This test constructs services with NO embedder +(the only construction mode after this branch) and proves observe -> FTS +row -> BM25-evidenced retrieval works end to end. +EXPECTED GREEN from the moment it is written (verification pin, not TDD red). +""" +from better_memory.db.connection import connect_memory # adjust to the real helper used by sibling service tests +from better_memory.services.observation import ObservationService + + +def test_observe_populates_fts_without_embedder(tmp_path): + conn = connect_memory(tmp_path / "memory.db") # same fixture idiom as tests/services/test_observation.py + svc = ObservationService(conn, episodes=None) # no embedder argument + svc.observe(content="growatt inverter polling uses Timespan.hour", project="p1") + fts_rows = conn.execute( + "SELECT count(*) FROM observations_fts WHERE observations_fts MATCH 'growatt'" + ).fetchone()[0] + assert fts_rows >= 1 + + +def test_retrieve_relevant_returns_bm25_evidence_without_embedder(tmp_path): + # Same substrate as above; retrieval must surface the item on BM25 + # evidence alone. Reuse the backend fixture from tests/services/test_relevant.py. + conn = connect_memory(tmp_path / "memory.db") + backend = make_sqlite_backend(conn) # the fixture/helper test_relevant.py already uses + svc = ObservationService(conn, episodes=None) + svc.observe(content="growatt inverter polling uses Timespan.hour", project="p1") + items = retrieve_relevant( + backend, query="growatt inverter polling", project="p1", + conn=conn, sync_embedder=None, max_items=5, + ) # after Task 6 the sync_embedder argument disappears; drop it then + assert any("growatt" in str(item).lower() for item in items) +``` + +The implementer MUST adapt fixture/table names to what `tests/services/test_observation.py` and `tests/services/test_relevant.py` actually use (e.g. the real FTS table name may be `observation_fts` or reflection-scoped) — the assertion shape (observe → FTS MATCH hit → retrieval hit with no embedder anywhere) is the requirement, not the literal identifiers above. + +- [ ] **Step 3: Run the new tests** + +Run: `pytest tests/services/test_fts_substrate.py -v` +Expected: PASS (green pin). If FAIL: stop the plan, report — spec's contingency activates. + +- [ ] **Step 4: Full suite + Pyright** + +Run: `pytest -q && pyright` +Expected: green. + +- [ ] **Step 5: Commit** + +```bash +git add tests/services/test_fts_substrate.py +git commit -m "test: pin FTS5 substrate as unconditional local evidence leg" +``` + +--- + +### Task 2: Sever wiring — server, hook, UI, CLI — **confidence 93%** + +After this task nothing in the codebase CONSTRUCTS an embedder; services still accept the (defaulted) parameters until Tasks 4–6. + +**Files:** +- Modify: `better_memory/mcp/server.py` (delete lines at :64-65 imports, :96 `_probe_ollama`, :146 `ServerContext.embedder`, :173-189 construction, :182 probe call, :206/:223-224/:232/:235 args, :342-344 aclose) +- Modify: `better_memory/hooks/contextual_inject.py` (:33-34 imports, :147-149 build, :156 vec_floor arg) +- Modify: `better_memory/ui/app.py` (:18-19 imports, :40-49 `_build_sync_embedder`, :74/:90/:104 param plumbing) +- Delete: `better_memory/cli/backfill_embeddings.py`, `tests/cli/test_backfill_embeddings.py` +- Test: `tests/mcp/test_server_sqlite.py`, `tests/mcp/test_server_backend_dispatch.py`, `tests/mcp/test_server_integration.py`, `tests/ui/test_app.py`, `tests/hooks/test_contextual_inject.py`, `tests/e2e/test_tripwires.py` + `tests/e2e/test_sqlite_negative.py` + `tests/e2e/test_sqlite_journey.py` (drop OllamaEmbedder skip-markers / embedder-related env setup and comments) + +**Interfaces:** +- Consumes: services' existing `embedder=None` / `sync_embedder=None` keyword defaults (verify each default exists before removing the argument at the call site; if any parameter is required, pass nothing and fix the signature in the same edit). +- Produces: `create_server_context()` (or the real factory name in `mcp/server.py`) builds all services with no embedder arguments; `ServerContext` has no `embedder` field; UI `create_app`/`app.py` entry has no `sync_embedder` parameter. + +- [ ] **Step 1: Update wiring tests first** + +In the five test files: delete tests asserting `OllamaEmbedder` is constructed under the ollama backend / probe behavior; update `SyncEmbedder`-tracking dispatch tests to assert services receive no embedder. Keep tests asserting sqlite-mode behavior (no embedder) — they describe the new universal reality; strip their backend-env setup. + +- [ ] **Step 2: Run updated tests to verify they fail against current code** + +Run: `pytest tests/mcp tests/ui tests/hooks -q` +Expected: failures where tests now assert no-embedder wiring (current code still builds embedders under default config). + +- [ ] **Step 3: Apply the wiring deletions** (file list above, line anchors from the blast-radius map). Also delete the stale comment `better_memory/services/reflection.py:334` pointing at the backfill CLI. + +- [ ] **Step 4: Full suite + Pyright** + +Run: `pytest -q && pyright` +Expected: green. `tests/embeddings/*` still passes (package still exists, directly tested — untouched until Task 8). + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor: stop constructing embedders in server, hook, UI; delete backfill CLI" +``` + +--- + +### Task 3: Delete config knobs + env reads + module docstrings — **confidence 95%** + +**Files:** +- Modify: `better_memory/config.py` (:14 docstring env list, :33-36 defaults, :223-228 fields `ollama_host`/`embed_model`/`embeddings_backend`, :236 `context_vec_floor`, :260-266 `_resolve_embeddings_backend`, :369-374/:382 resolution) +- Modify: `better_memory/_diag.py` (:11 comment, :53 `BETTER_MEMORY_EMBED_LOG` read), `better_memory/mcp/_util.py` (:37 comment) +- Test: `tests/test_config.py` (4 knob tests out), `tests/test_diag.py` (2), `tests/setup/test_engine.py` (1), `tests/e2e_meta/test_env_helper_contract.py`, `tests/e2e_meta/test_env_bleed.py` (drop the deleted vars from contract lists), plus any e2e fixture that sets `BETTER_MEMORY_EMBEDDINGS_BACKEND` in its environment setup (grep `tests/` for the var name) + +**Interfaces:** +- Consumes: Task 2 (no remaining readers of the four fields — verify with `grep -rn "embeddings_backend\|ollama_host\|embed_model\|context_vec_floor" better_memory/` before editing; `services/relevant.py`'s `vec_floor` parameter keeps its plain default until Task 6). +- Produces: `Config` dataclass without the four fields — later tasks and all call sites rely on their absence. + +- [ ] **Step 1: Update config/diag/setup/e2e-meta tests** to assert the fields and env reads are gone (attribute access raises `AttributeError`; env contract lists shrink). +- [ ] **Step 2: Run those tests — expect failures against current code.** `pytest tests/test_config.py tests/test_diag.py -q` +- [ ] **Step 3: Apply the deletions**, INCLUDING the `config.py` module docstring's env-var enumeration ([[website-readme-sync]]: docstrings are code, this task, not Task 10). +- [ ] **Step 4: Full suite + Pyright green.** `pytest -q && pyright` +- [ ] **Step 5: Commit** — `git commit -am "refactor: remove ollama/embeddings/vec-floor config knobs and EMBED_LOG diag"` + +--- + +### Task 4: Strip `services/semantic.py` — **confidence 95%** + +**Files:** +- Modify: `better_memory/services/semantic.py` (:21 import, ctor `sync_embedder` param, :66 `_store_embedding`, :94/:121/:199 embed calls) +- Delete: `tests/services/test_semantic_embedding_write.py` +- Test: remaining semantic service tests (drop fake-embedder fixtures) + +**Interfaces:** +- Consumes: Task 2 (no caller passes `sync_embedder`). +- Produces: `SemanticMemoryService(conn)` — no embedder parameter. `update_text(*, id, content)` behavior: plain UPDATE; `rowcount == 0` → rollback + `ValueError(f"semantic memory not found: {id}")`. + +- [ ] **Step 1: Delete `test_semantic_embedding_write.py`; update remaining semantic tests** to construct the service without embedder and assert `update_text` on a missing id raises `ValueError` (rowcount path). +- [ ] **Step 2: Run — expect failures** (ctor signature still has the param, SELECT guard still present). `pytest tests/services -k semantic -q` +- [ ] **Step 3: Strip the service.** `update_text` becomes: + +```python +def update_text(self, *, id: str, content: str) -> None: + if not content.strip(): + raise ValueError("content must not be empty") + now = self._clock().isoformat() + cur = self._conn.execute( + "UPDATE semantic_memories SET content = ?, updated_at = ? " + "WHERE id = ?", + (content, now, id), + ) + if cur.rowcount == 0: + # Roll back the implicit BEGIN so we don't strand the WAL + # write lock for callers sharing this connection. Mirrors + # ObservationService.set_outcome (better_memory/services/observation.py:435). + self._conn.rollback() + raise ValueError(f"semantic memory not found: {id}") + self._conn.commit() +``` + +(The PR #125 `SELECT 1` fast-fail and the pre-embed comments go — they existed only to protect a now-deleted embed. The rollback comment is preserved near-verbatim; it guards the WAL lock, which is still real.) + +- [ ] **Step 4: Full suite + Pyright green.** +- [ ] **Step 5: Commit** — `git commit -am "refactor: remove embedding writes from SemanticMemoryService"` + +--- + +### Task 5: Strip `services/reflection.py` — **confidence 92%** + +Largest single-file strip; the #97 `embed_tasks` machinery makes it fiddly. Mitigation folded in: Step 1 maps every deletion target by grep before editing. + +**Files:** +- Modify: `better_memory/services/reflection.py` (:34 sqlite_vec import, ctor `sync_embedder`, :379 `_store_embedding`, :856/:1032/:1828 embed calls, :1346 batch embed, :1367-1397 `_heal_missing_embeddings`, :1398+ `_vec_ranks`, :1605 query embed, the `embed_tasks` parameters on `_apply_new`/`_apply_augment` and their collect-embed-after-commit caller logic in `apply_decision`) +- Delete: `tests/services/test_reflection_embedding_write.py` +- Test: `tests/services/test_reflection.py` (drop embedder fixtures/params) + +**Interfaces:** +- Consumes: Tasks 2–3. +- Produces: `ReflectionSynthesisService(conn)` — no embedder parameter; `_apply_new(actions, *, project)` / `_apply_augment(...)` without `embed_tasks`; retrieval ranks from BM25 + prior only (vec rank leg gone). + +- [ ] **Step 1: Map deletions** — `grep -n "sync_embedder\|embed_tasks\|_store_embedding\|_heal_missing\|_vec_ranks\|sqlite_vec\|embed_text\|embed_batch" better_memory/services/reflection.py` and list every hit against the anchors above; anything unaccounted for gets read before deletion. +- [ ] **Step 2: Delete `test_reflection_embedding_write.py`; update `test_reflection.py`** (no embedder fixtures; synthesis asserts row writes only). Run: expect failures against current signatures. +- [ ] **Step 3: Strip the service.** `apply_decision` returns to: SAVEPOINT → `_apply_new`/`_apply_augment` write rows → commit. No post-commit embed pass. +- [ ] **Step 4: Full suite + Pyright green.** +- [ ] **Step 5: Commit** — `git commit -am "refactor: remove embedding writes, heal, and vec ranks from reflection synthesis"` + +--- + +### Task 6: Strip `services/relevant.py` + `services/observation.py` — **confidence 93%** + +**Files:** +- Modify: `better_memory/services/relevant.py` (:42 import, :102-150 `_vec_qualifiers`, :233 qvec, :246-253 vec legs, `sync_embedder`/`vec_floor` params of `retrieve_relevant`) +- Modify: `better_memory/services/observation.py` (:62 import, ctor `embedder` param, :199-region vec0 write path) +- Delete: `tests/services/test_vec_fusion.py`, `tests/services/_embedding_fakes.py` +- Test: `tests/services/test_relevant.py`, `tests/services/test_observation.py`, `tests/services/test_observation_sqlite.py`, `tests/hooks/test_contextual_inject.py` + +**Interfaces:** +- Consumes: Task 2 (hook already passes no `sync_embedder`/`vec_floor`). +- Produces: `retrieve_relevant(backend, *, query, project, conn, max_items, include_neutral, now)` — exact surviving signature; evidence gate = BM25 match OR agentcore `relevance_ranks` membership OR keyword fallback when `conn is None` for sqlite... **No** — keyword fallback fires when FTS is structurally unavailable, agentcore branch unchanged: preserve the existing gate semantics minus the vec leg, and keep the docstring's agentcore paragraph intact. `ObservationService(conn, episodes=...)` — no embedder parameter. + +- [ ] **Step 1: Update tests** — delete `test_vec_fusion.py` + `_embedding_fakes.py`; in `test_relevant.py` remove vec-leg cases and embedder fixtures, keep BM25/keyword/agentcore gate cases; `test_observation*` drop embedder args and the vec0-skip marker test. Run: expect signature failures. +- [ ] **Step 2: Strip `relevant.py`** — remove `_vec_qualifiers`, `qvec`, both `vec_r`/`vec_s` legs and their RRF inputs; `fts_unavailable = conn is None` logic and the agentcore `relevance_ranks` branch stay byte-identical. +- [ ] **Step 3: Strip `observation.py`** — remove `embedder` param and the vec0 write; the `# When embedder is None ... skip the vec0 path` comment becomes dead and goes with it. +- [ ] **Step 4: Full suite + Pyright green.** +- [ ] **Step 5: Commit** — `git commit -am "refactor: remove vector legs from retrieval and observation write path"` + +--- + +### Task 7: Collapse `search/hybrid.py` + `storage/sqlite.py` — **confidence 92%** + +**Files:** +- Modify: `better_memory/search/hybrid.py` (:31 import, :83-88 signature/`second_source`, :128 vec0 check, :151 kNN, :271 serialize) +- Modify: `better_memory/storage/sqlite.py` (:339 query embed in `search_hybrid`, ctor `sync_embedder` if present) +- Test: `tests/search/test_hybrid.py`, `tests/storage/test_sqlite_backend.py`, `tests/storage/test_factory.py` + +**Interfaces:** +- Consumes: Tasks 2, 6. +- Produces: `hybrid_search(conn, query, ...)` FTS5-only (no `query_vector`/`second_source` params). Decision rule, pre-made: KEEP the module and function name (callers + docs reference it); fold into the caller ONLY if the reviewer flags the survivor as trivial. + +- [ ] **Step 1: Update `test_hybrid.py`** — delete vector-leg cases (~half of 18), keep BM25 ranking/tie cases; update backend/factory tests to embedder-less construction. Run: expect failures. +- [ ] **Step 2: Strip both files.** +- [ ] **Step 3: Full suite + Pyright green.** +- [ ] **Step 4: Commit** — `git commit -am "refactor: collapse hybrid search to FTS5-only"` + +--- + +### Task 8: Delete the `embeddings/` package — **confidence 96%** + +**Files:** +- Delete: `better_memory/embeddings/` (ollama.py, sync_embed.py, `__init__.py`), `tests/embeddings/` (3 files) +- Verify: `better_memory/async_bridge.py` remaining consumers + +**Interfaces:** +- Consumes: Tasks 2–7 (zero imports remain — verified, not assumed). +- Produces: issue-#96 verdict for the PR description. + +- [ ] **Step 1: Verify orphanhood** — `grep -rn "embeddings\.\|OllamaEmbedder\|SyncEmbedder\|EmbeddingError" better_memory/ tests/ --include="*.py"` → only hits inside the two directories being deleted. +- [ ] **Step 2: #96 verdict** — `grep -rn "run_async_in_worker\|async_bridge" better_memory/ --include="*.py"` excluding `embeddings/`. Zero non-embeddings callers → `async_bridge.py` is dead too: delete it + its tests, PR claims `Fixes #96`. Any other caller → keep `async_bridge.py`, #96 stays open, note the narrowing for the PR description. +- [ ] **Step 3: Delete the directories** (+ `async_bridge.py` per verdict). +- [ ] **Step 4: Full suite + Pyright green.** +- [ ] **Step 5: Commit** — `git commit -am "refactor: delete ollama embeddings package"` (append `\n\nRemoves the SyncEmbedder worker bridge behind #96.` if verdict positive). + +--- + +### Task 9: Migration 0015 — drop vec0 tables — **confidence 92%** + +**Files:** +- Create: `better_memory/db/migrations/0015_drop_vec_tables.sql` +- Test: `tests/db/test_schema.py` (:59 FTS+vec assertion, :1057-1058 semantic_embeddings test), new migration test in the same file + +**Interfaces:** +- Consumes: `sqlite_vec.load()` still active in `db/connection.py` (Global Constraints). +- Produces: post-migration schema without the three vec tables — Task 1's pin still green (FTS untouched). + +- [ ] **Step 1: Trigger sweep** — `grep -n -i "embeddings" better_memory/db/migrations/0*.sql` → confirm no trigger/view references the vec tables (investigator found none); add `DROP TRIGGER IF EXISTS` lines only for actual hits. +- [ ] **Step 2: Write the migration test** (schema-test idiom of `test_schema.py`): build a DB through 0014, insert a row into each vec table, apply 0015 → tables absent from `sqlite_master`; apply 0015 again on a fresh connection → no error (idempotent). Update :59/:1057 tests to assert absence. Run: expect FAIL (no 0015 yet). +- [ ] **Step 3: Write the migration:** + +```sql +-- 0015: local vector search removed; vec0 tables dropped. +-- sqlite-vec module is still loaded by db/connection.py this release, +-- which is what makes these DROPs legal. IF EXISTS keeps the script +-- idempotent, so the non-atomic executescript hazard (#27) cannot +-- strand it half-applied in a harmful state. +DROP TABLE IF EXISTS observation_embeddings; +DROP TABLE IF EXISTS reflection_embeddings; +DROP TABLE IF EXISTS semantic_embeddings; +``` + +- [ ] **Step 4: Full suite + Pyright green.** +- [ ] **Step 5: Commit** — `git commit -am "feat(db): migration 0015 drops vec0 embedding tables"` + +--- + +### Task 10: Docs + website sync — **confidence 94%** + +Guardrail [[website-readme-sync]] (conf 0.95): this task is mandatory, same PR. + +**Files:** +- Modify: `website/configuration.md` (env table: remove `OLLAMA_HOST`, `EMBED_MODEL`, `BETTER_MEMORY_EMBEDDINGS_BACKEND`, `BETTER_MEMORY_EMBED_LOG` rows), `website/architecture.md` (retrieval + synthesis prose: no embed step, evidence legs per storage mode), `website/index.md` (tagline/showcase if it claims vector search or ollama), `website/observation-lifecycle.md` (if synthesis prose mentions embedding), `website/mcp-tools.md` (only if tool descriptions mention vectors — tool COUNT must not change), `README.md` (prerequisites: drop Ollama/nomic-embed-text; troubleshooting: drop ollama entries), living `docs/agentcore-ui/agentcore-mapping.md:53` + `docs/agentcore-ui/write-spec.md` re-embedding mentions. +- Never touch: `docs/superpowers/**`. + +- [ ] **Step 1: Grep the living docs** — `grep -rn -i "ollama\|embed\|vec0\|nomic" website/ README.md docs/agentcore-ui/ --include="*.md"` and triage every hit: update, or record "historical, untouched". +- [ ] **Step 2: Apply edits.** Per guardrail: every factual token in REWRITTEN lines verified against the post-branch source (function names, env vars, table names) — no carrying forward. +- [ ] **Step 3: Run `graphify update .`** (project CLAUDE.md requirement) so the graph drops the deleted subsystem. +- [ ] **Step 4: Commit** — `git commit -am "docs: remove ollama/embeddings from website, README, living agentcore docs"` + +--- + +### Task 11: Final sweep + follow-up issue — **confidence 95%** + +**Files:** none new (verification + GitHub). + +- [ ] **Step 1: Remnant grep** — `grep -rn -i "ollama\|sync_embed\|embed_model\|embeddings_backend\|vec_floor\|_vec_" better_memory/ tests/ --include="*.py"` → expected survivors ONLY: `sqlite_vec` import/load in `db/connection.py`, migration SQL files, migration-0015 test. Anything else is a missed deletion — fix it here. +- [ ] **Step 2: Full suite + Pyright, one last time.** `pytest -q && pyright` +- [ ] **Step 3: File the follow-up issue** — `gh issue create -R emp3thy/better-memory --title "Remove sqlite-vec dependency and connection load" --body "..."`: body notes it requires all live DBs to have run migration 0015 (drop the `sqlite_vec.load()` at `db/connection.py:39`, the import, the pyproject dependency, and the two connection tests that cover the load). +- [ ] **Step 4: Commit any Step-1 fixes**, then hand off to the whole-branch review per subagent-driven-development (most capable model), then `superpowers:finishing-a-development-branch`. PR: squash-merge, description lists closed issues (`Fixes #96` only per Task 8's verdict) and the docs-sync note per [[website-readme-sync]]. diff --git a/docs/superpowers/specs/2026-09-01-remove-ollama-embeddings-design.md b/docs/superpowers/specs/2026-09-01-remove-ollama-embeddings-design.md new file mode 100644 index 00000000..6f52d954 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-remove-ollama-embeddings-design.md @@ -0,0 +1,186 @@ +# Remove Ollama and local embeddings — design + +**Date:** 2026-09-01 +**Status:** Approved (section-by-section walkthrough in brainstorming session) +**Scope decision:** Clean break (option A of A/B/C), single feature branch, single PR. + +## Context + +better-memory has two orthogonal backend knobs: + +- `storage_backend`: `sqlite` | `agentcore` — where memories live. +- `embeddings_backend`: `ollama` | `sqlite` — how local retrieval ranks. The + `sqlite` value means FTS5/BM25 only, with no embedder constructed anywhere. + +The `ollama` embeddings path is the only consumer of Ollama and of local +vector search (sqlite-vec vec0 tables). The sole user runs `agentcore` +storage at home (server-side semantic search; local vectors computed and +discarded) and `sqlite` embeddings at work (Ollama disallowed there). The +ollama path has caused real bugs: #97 (write lock held across blocking +embeds) and, pending confirmation, #96 (blocking `thread.join` from async +handlers). Decision: delete the Ollama/embedding subsystem entirely. + +## Goals + +- No better-memory process contacts Ollama or computes embeddings, in any mode. +- Retrieval collapses to one architecture per storage mode: + - sqlite storage: BM25 over FTS5 (trigger-maintained) + keyword fallback — + byte-for-byte the existing `embeddings_backend=sqlite` behaviour. + - agentcore storage: server-side `relevance_ranks` semantic search, unchanged. +- Existing databases shed their vec0 tables via migration. +- Stale environment variables on any machine are silently unread — no errors, + no deprecation shims. + +## Non-goals + +- Removing the `sqlite-vec` pip dependency this release. `db/connection.py` + keeps `sqlite_vec.load()` so migration 0015 can `DROP` the vec0 virtual + tables (SQLite cannot drop a virtual table whose module is unregistered). + Dep + load removal is a follow-up release, tracked by a new issue filed at + branch end. +- Rewriting historical migrations (0001/0002/0014 still create vec tables; + 0015 drops them — fresh installs create-then-drop, accepted). +- Editing historical docs (specs/plans/archive keep their ollama mentions). +- Replacing local vector quality with anything new. BM25 + keyword is the + accepted local retrieval floor. + +## Component changes + +### Config (`config.py`, `_diag.py`, `mcp/_util.py`) + +Delete fields `ollama_host`, `embed_model`, `embeddings_backend`, +`context_vec_floor`; delete `_resolve_embeddings_backend` and the +`_DEFAULT_OLLAMA_HOST` / `_DEFAULT_EMBED_MODEL` / +`_DEFAULT_EMBEDDINGS_BACKEND` / `_VALID_EMBEDDINGS_BACKENDS` constants and +the env-var doc comment. Delete the `BETTER_MEMORY_EMBED_LOG` read in +`_diag.py` and its comments. Env vars `OLLAMA_HOST`, `EMBED_MODEL`, +`BETTER_MEMORY_EMBEDDINGS_BACKEND`, `BETTER_MEMORY_EMBED_LOG` become unread. +`storage_backend` resolution and all other knobs untouched. + +### Wiring (`mcp/server.py`, `hooks/contextual_inject.py`, `ui/app.py`, `cli/`) + +- `mcp/server.py`: delete `_probe_ollama` and its startup call; delete + embedder/sync_embedder imports and construction; delete + `ServerContext.embedder` and the `aclose()` cleanup branch; construct + `ObservationService`, `build_backend`, `ReflectionSynthesisService`, + `SemanticMemoryService` without embedder arguments. +- `hooks/contextual_inject.py`: delete embedder build and `vec_floor` + pass-through; keep `conn` (feeds BM25 in sqlite mode). This structurally + fixes the agentcore wasted-embed defect (query embedded then discarded + because `_vec_qualifiers` returns `{}` for `conn=None`). +- `ui/app.py`: delete `_build_sync_embedder` and the `sync_embedder` + parameter/resolution. UI semantic edits no longer re-embed; agentcore + re-embeds server-side, sqlite FTS triggers reindex on UPDATE. +- `cli/backfill_embeddings.py`: delete the file and the stale comment + referencing it in `reflection.py`. + +### Services and search + +- `services/semantic.py`: remove `sync_embedder` param, `_store_embedding`, + and the three embed calls. `update_text` reverts to plain UPDATE + + rowcount `ValueError`; the PR #125 `SELECT 1` fast-fail guard existed only + to avoid a wasted embed and goes with it. The rollback-on-zero-rowcount + stays (guards the WAL lock). +- `services/reflection.py`: remove `sync_embedder` param, `_store_embedding`, + `_heal_missing_embeddings`, `_vec_ranks`, the retrieval query embed, batch + embeds, the `sqlite_vec` import, and the whole `embed_tasks` + collect-then-embed-after-commit machinery introduced by #97/#125. +- `services/relevant.py`: remove `qvec`, `_vec_qualifiers`, both vec legs, + the `vec_floor` parameter, and the `sqlite_vec` import. Evidence gate + becomes: BM25 match, OR agentcore `relevance_ranks` membership (branch + untouched), OR keyword fallback when FTS is structurally unavailable. RRF + fusion over Wilson prior + BM25 rank unchanged. +- `services/observation.py`: remove the async `embedder` parameter and the + vec0 write path. +- `storage/sqlite.py`: `search_hybrid` stops embedding the query. +- `search/hybrid.py`: remove the vector leg; collapses to FTS5/BM25 only. If + the survivor is a trivial wrapper, fold it into its caller — decided at + implementation with the reviewer, not silently. + +### Schema + +New `db/migrations/0015_drop_vec_tables.sql`: + +```sql +DROP TABLE IF EXISTS observation_embeddings; +DROP TABLE IF EXISTS reflection_embeddings; +DROP TABLE IF EXISTS semantic_embeddings; +``` + +Legal because the sqlite-vec module load survives this release. Implementation +verifies no triggers/views reference these tables (grep migrations 0001–0014) +and adds `DROP TRIGGER IF EXISTS` lines only if found. Idempotent DROPs make +issue #27's non-atomic executescript hazard harmless here — a rerun completes +the migration. Row data in the three tables is destroyed; vectors are derived +data never read after this branch. + +### Deletions (whole artifacts) + +- `better_memory/embeddings/` (ollama.py, sync_embed.py, `__init__.py`) +- `better_memory/cli/backfill_embeddings.py` +- `tests/embeddings/` (3 files), `tests/cli/test_backfill_embeddings.py`, + `tests/services/test_vec_fusion.py`, + `tests/services/test_reflection_embedding_write.py`, + `tests/services/test_semantic_embedding_write.py`, + `tests/services/_embedding_fakes.py` + +### Tests + +~25 files modified to drop embedder fakes/params/skip-markers (relevant, +hybrid, sqlite backend, factory, connection — sqlite-vec load tests stay — +ui, mcp server/dispatch/integration, e2e journey/negative/tripwires, +env-helper contract, contextual_inject, config/diag/setup). Two vec-table +schema tests flip to asserting the tables are gone post-0015. + +Added: + +- FTS-substrate regression test: under default config, observe → FTS row + exists → retrieval returns it via BM25 evidence. Pins the only remaining + local evidence leg as unconditional. +- Migration 0015 test: apply on a DB with populated vec tables → gone; + rerun clean. + +Gate: full pytest suite and Pyright green after every task on the branch. + +### Docs (guardrail: website-sync reflection, confidence 1.0) + +Update living docs: `website/configuration.md` env table, +`website/architecture.md` retrieval/synthesis prose, `website/index.md` +tagline/showcase, `README.md`, living `docs/agentcore-ui/*` SyncEmbedder and +re-embedding mentions. Historical `docs/superpowers/**` untouched. Run +`graphify update .` after code changes land. + +### Issues and release + +- PR claims `Fixes #96` only after confirming the deleted embed path is the + sole `run_async_in_worker`/`thread.join` caller chain; otherwise #96 stays + open with a scope-narrowing comment. +- #27 untouched. New follow-up issue: remove sqlite-vec dep + connection load. +- Single squash-merge PR per repo convention. + +## Assumption surface + +**Real concerns (1):** + +1. FTS5 substrate must be unconditional — BM25 becomes the only local + evidence leg. Believed unconditional (triggers live in migrations), but if + indexing was ever gated on the old embeddings knob, sqlite-storage + retrieval starves. Mitigation: explicit verification task in the plan + + the FTS-substrate regression test. If gating is found, making triggers + unconditional becomes a plan task, not a silent fix. + +**Verified safe (3):** + +1. agentcore mode makes zero local embed/vec calls (read `storage/agentcore.py`, + `mcp/handlers/*`, `mcp/server.py:261-263`). +2. Stale env vars on any machine become unread no-ops. +3. vec0 DROP is legal because the module load survives this release. + +**Minor/accepted (4):** + +1. Inert `BETTER_MEMORY_EMBED_LOG` entry remains in the user's `.claude.json` + MCP registration until manually removed. +2. Historical docs keep ollama mentions. +3. PR #125's `embed_tasks` machinery is deleted the same day it merged (the + merge was still correct — it unblocked the database immediately). +4. Fresh installs create-then-drop vec tables via 0015. diff --git a/pyproject.toml b/pyproject.toml index 5b751732..2a483061 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "better-memory" version = "0.1.0" -description = "Local-first AI memory system (MCP server + SQLite + sqlite-vec + Ollama embeddings)" +description = "Local-first AI memory system (MCP server + SQLite FTS5)" readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } diff --git a/tests/cli/test_backfill_embeddings.py b/tests/cli/test_backfill_embeddings.py deleted file mode 100644 index 266e84fc..00000000 --- a/tests/cli/test_backfill_embeddings.py +++ /dev/null @@ -1,188 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from better_memory.cli.backfill_embeddings import backfill -from better_memory.db.connection import connect -from better_memory.db.schema import apply_migrations -from tests.services._embedding_fakes import FakeEmbedder - - -@pytest.fixture -def conn(tmp_memory_db: Path): - c = connect(tmp_memory_db) - apply_migrations(c) - try: - yield c - finally: - c.close() - - -def _seed_reflection(conn, rid, status="pending_review"): - conn.execute( - """INSERT INTO reflections (id, title, project, phase, polarity, - use_cases, hints, confidence, created_at, updated_at, status) - VALUES (?, 'T', 'p', 'general', 'do', 'uc', '[]', 0.5, - '2026-01-01', '2026-01-01', ?)""", (rid, status)) - conn.commit() - - -def _seed_semantic(conn, sid): - conn.execute( - """INSERT INTO semantic_memories (id, content, project, scope, - created_at, updated_at) - VALUES (?, 'fact', 'p', 'project', '2026-01-01', '2026-01-01')""", - (sid,)) - conn.commit() - - -def test_backfills_reflections_and_semantics(conn): - _seed_reflection(conn, "r1") - _seed_semantic(conn, "s1") - stats = backfill(conn, FakeEmbedder()) - assert stats == {"reflections": 1, "semantics": 1, "skipped": 0} - assert conn.execute("SELECT COUNT(*) FROM reflection_embeddings").fetchone()[0] == 1 - assert conn.execute("SELECT COUNT(*) FROM semantic_embeddings").fetchone()[0] == 1 - - -def test_idempotent(conn): - _seed_reflection(conn, "r1") - fake = FakeEmbedder() - backfill(conn, fake) - assert backfill(conn, fake) == {"reflections": 0, "semantics": 0, "skipped": 0} - - -def test_retired_reflections_skipped(conn): - _seed_reflection(conn, "r1", status="retired") - assert backfill(conn, FakeEmbedder()) == { - "reflections": 0, "semantics": 0, "skipped": 0} - - -def test_embed_failure_counted_as_skipped(conn): - _seed_reflection(conn, "r1") - stats = backfill(conn, FakeEmbedder(fail=True)) - assert stats == {"reflections": 0, "semantics": 0, "skipped": 1} - - -def test_insert_error_on_one_row_does_not_kill_the_whole_batch(conn): - """A per-row INSERT failure (e.g. UNIQUE collision from a concurrent - self-heal writer landing between our snapshot and our INSERT, or a plain - lock) must be counted as skipped rather than aborting the entire run. - Before the fix the exception propagated and rolled back every row we'd - already written this run.""" - import sqlite3 - - _seed_reflection(conn, "r1") - _seed_reflection(conn, "r2") - - class _FlakyConn: - """Proxy the real connection but fail the INSERT for r1 only. - - sqlite3.Connection disallows attribute assignment, so monkeypatch - doesn't work — mirror test_spool.py's _ExplodingCommitConn trick.""" - - def __init__(self, inner): - self._inner = inner - self.calls = 0 - - def execute(self, sql, *args, **kwargs): - if sql.startswith("INSERT INTO reflection_embeddings") and ( - args and args[0] and args[0][0] == "r1" - ): - self.calls += 1 - raise sqlite3.IntegrityError("UNIQUE constraint failed") - return self._inner.execute(sql, *args, **kwargs) - - def __getattr__(self, name): - return getattr(self._inner, name) - - proxy = _FlakyConn(conn) - stats = backfill(proxy, FakeEmbedder()) - # r1 failed → skipped; r2 landed. - assert stats["reflections"] == 1 - assert stats["skipped"] == 1 - assert proxy.calls == 1 - assert conn.execute( - "SELECT COUNT(*) FROM reflection_embeddings" - ).fetchone()[0] == 1 - - -def test_failed_insert_does_not_destroy_race_landed_pre_existing_embedding( - conn, -): - """The precise race #101 defends against: SELECT-snapshot picks r1 - (no embedding), a concurrent memory.retrieve self-heal writes r1's - embedding, then backfill's DELETE-then-INSERT arrives. If the INSERT - fails, the DELETE must be rolled back — otherwise the batch-level - commit() would durably erase the race-landed embedding, leaving r1 - with no vector at all. The per-row SAVEPOINT is what keeps that from - happening. - """ - import sqlite3 - - import sqlite_vec - - _seed_reflection(conn, "r1") - _seed_reflection(conn, "r2") - - class _RacyConn: - """Simulate the self-heal race: after backfill's SELECT-missing - picks up r1, insert r1's embedding via a side channel (the - concurrent writer), then fail backfill's own INSERT for r1.""" - - def __init__(self, inner): - self._inner = inner - self._race_landed = False - - def execute(self, sql, *args, **kwargs): - result = self._inner.execute(sql, *args, **kwargs) - # After the SELECT-missing snapshot, land the race row. - if ( - not self._race_landed - and sql.lstrip().upper().startswith("SELECT R.ID") - ): - self._race_landed = True - self._inner.execute( - "INSERT INTO reflection_embeddings " - "(reflection_id, embedding) VALUES (?, ?)", - ("r1", sqlite_vec.serialize_float32([0.5] * 768)), - ) - self._inner.commit() - return result - - def __getattr__(self, name): - return getattr(self._inner, name) - - # Also fail r1's INSERT after DELETE — simulates the UNIQUE collision - # the self-heal race can raise on real sqlite-vec builds. Compose the - # two proxies. - class _AlsoFailR1Insert: - def __init__(self, inner): - self._inner = inner - - def execute(self, sql, *args, **kwargs): - if sql.startswith("INSERT INTO reflection_embeddings") and ( - args and args[0] and args[0][0] == "r1" - ): - raise sqlite3.IntegrityError("UNIQUE constraint failed") - return self._inner.execute(sql, *args, **kwargs) - - def __getattr__(self, name): - return getattr(self._inner, name) - - proxy = _AlsoFailR1Insert(_RacyConn(conn)) - stats = backfill(proxy, FakeEmbedder()) - - assert stats["reflections"] == 1 # r2 - assert stats["skipped"] == 1 # r1 - # The pre-existing race-landed embedding for r1 must have survived - # the failed DELETE-then-INSERT via the per-row SAVEPOINT rollback. - r1_rows = conn.execute( - "SELECT COUNT(*) FROM reflection_embeddings WHERE reflection_id = 'r1'" - ).fetchone()[0] - assert r1_rows == 1, ( - "SAVEPOINT rollback failed: race-landed r1 embedding was durably " - "erased by the batch commit" - ) diff --git a/tests/db/test_migration_0011_trigram_fts.py b/tests/db/test_migration_0011_trigram_fts.py index 274ca58a..756e3d7f 100644 --- a/tests/db/test_migration_0011_trigram_fts.py +++ b/tests/db/test_migration_0011_trigram_fts.py @@ -1,4 +1,4 @@ -"""Migration 0011 — trigram FTS5 table for the sqlite embeddings backend.""" +"""Migration 0011 — trigram FTS5 table for sqlite-backed observation search.""" from __future__ import annotations import sqlite3 diff --git a/tests/db/test_schema.py b/tests/db/test_schema.py index 09082c99..22da3305 100644 --- a/tests/db/test_schema.py +++ b/tests/db/test_schema.py @@ -2,13 +2,15 @@ from __future__ import annotations +import shutil import sqlite3 +import struct from pathlib import Path import pytest from better_memory.db.connection import connect -from better_memory.db.schema import apply_migrations +from better_memory.db.schema import _DEFAULT_MIGRATIONS_DIR, apply_migrations # --------------------------------------------------------------------------- # Helpers @@ -56,16 +58,15 @@ def test_apply_migrations_creates_core_tables(tmp_memory_db: Path) -> None: def test_apply_migrations_creates_virtual_tables(tmp_memory_db: Path) -> None: - """FTS5 and sqlite-vec virtual tables exist after migration.""" + """FTS5 virtual tables exist after migration; the vec0 embedding + tables do not — migration 0018 drops them (local vector search + removed).""" conn = connect(tmp_memory_db) try: apply_migrations(conn) virtual = _virtual_table_names(conn) - expected = { - "observation_fts", - "observation_embeddings", - } - assert expected.issubset(virtual), f"Missing virtual tables: {expected - virtual}" + assert "observation_fts" in virtual + assert "observation_embeddings" not in virtual finally: conn.close() @@ -1055,14 +1056,16 @@ def test_idx_episodes_pending_synth_partial_index_exists(tmp_memory_db: Path) -> def test_0014_semantic_embeddings_table(tmp_memory_db: Path) -> None: - """Migration 0014 creates the semantic_embeddings vec0 virtual table.""" + """Migration 0014 created the semantic_embeddings vec0 virtual table; + migration 0018 drops it again (local vector search removed), so a full + ``apply_migrations`` run leaves it absent.""" conn = connect(tmp_memory_db) try: apply_migrations(conn) row = conn.execute( "SELECT name FROM sqlite_master WHERE name='semantic_embeddings'" ).fetchone() - assert row is not None + assert row is None finally: conn.close() @@ -1081,3 +1084,88 @@ def test_0016_rating_evidence_column(tmp_memory_db): cols = [r[1] for r in conn.execute("PRAGMA table_info(session_memory_exposure)")] assert "evidence" in cols conn.close() + + +def _insert_dummy_vec_row(conn: sqlite3.Connection, table: str, id_col: str, row_id: str) -> None: + """Insert one row into a vec0 table using a real 768-dim float vector. + + sqlite-vec is loaded by :func:`connect`, so the ``vec0`` module is + available on ``conn`` without any extra setup here. + """ + embedding = struct.pack("768f", *(0.0 for _ in range(768))) + conn.execute( + f"INSERT INTO {table} ({id_col}, embedding) VALUES (?, ?)", + (row_id, embedding), + ) + + +def test_0018_drop_vec_tables(tmp_memory_db: Path, tmp_path: Path) -> None: + """Migration 0018 drops the three vec0 embedding tables now that local + vector search has been removed. + + Builds the schema through 0017 (vec0 tables still present), inserts a + real row into each so the DROP has live data to remove, then applies + the full (real) migration set — which is only 0018 left pending — and + asserts all three tables are gone. A rerun on a fresh connection must + not error (DROP TABLE IF EXISTS keeps 0018 idempotent). It also + executes the raw 0018 SQL file directly, twice in a row, against the + already-migrated DB — independent of the migration ledger's + already-applied bookkeeping — to pin the ``IF EXISTS`` contract on + the SQL text itself. + """ + pre_dir = tmp_path / "migs_pre_0018" + pre_dir.mkdir() + for f in sorted(_DEFAULT_MIGRATIONS_DIR.glob("[0-9][0-9][0-9][0-9]_*.sql")): + if f.name.startswith("0018_"): + continue + shutil.copy(f, pre_dir / f.name) + + conn = connect(tmp_memory_db) + try: + apply_migrations(conn, migrations_dir=pre_dir) + + _insert_dummy_vec_row(conn, "observation_embeddings", "observation_id", "obs-1") + _insert_dummy_vec_row(conn, "reflection_embeddings", "reflection_id", "refl-1") + _insert_dummy_vec_row(conn, "semantic_embeddings", "memory_id", "mem-1") + conn.commit() + + # Apply against the real, default migrations directory: 0001-0017 + # are already recorded as applied, so only 0018 actually runs. + applied = apply_migrations(conn) + assert "0018" in applied + + for table in ( + "observation_embeddings", + "reflection_embeddings", + "semantic_embeddings", + ): + row = conn.execute( + "SELECT name FROM sqlite_master WHERE name = ?", (table,) + ).fetchone() + assert row is None, f"{table} should be dropped by migration 0018" + finally: + conn.close() + + # Idempotency: a fresh connection against the same DB file re-applies + # cleanly even though 0018 is already recorded as applied. + conn2 = connect(tmp_memory_db) + try: + applied_again = apply_migrations(conn2) + assert applied_again == [] + row = conn2.execute( + "SELECT name FROM sqlite_master WHERE name = 'observation_embeddings'" + ).fetchone() + assert row is None + + # Direct rerun-idempotency: executescript the raw 0018 SQL file + # content twice against the migrated DB, bypassing the migration + # ledger entirely, and assert neither run errors. This pins the + # ``DROP TABLE IF EXISTS`` contract in the SQL text itself, not + # just the ledger's "already applied, skip it" bookkeeping. + sql_0018 = (_DEFAULT_MIGRATIONS_DIR / "0018_drop_vec_tables.sql").read_text( + encoding="utf-8" + ) + conn2.executescript(sql_0018) + conn2.executescript(sql_0018) + finally: + conn2.close() diff --git a/tests/e2e/_env.py b/tests/e2e/_env.py index cabfe4ce..4e13f557 100644 --- a/tests/e2e/_env.py +++ b/tests/e2e/_env.py @@ -21,8 +21,8 @@ vars are dropped case-insensitively (automatic: allowlist construction never copies them in the first place). * Pins: ``BETTER_MEMORY_HOME=/.better-memory``, ``BETTER_MEMORY_PROJECT``, - ``CLAUDE_SESSION_ID``, ``BETTER_MEMORY_EMBEDDINGS_BACKEND=sqlite`` and a - poisoned ``OLLAMA_HOST`` so nothing can ever reach a real Ollama daemon. + ``CLAUDE_SESSION_ID``, and a poisoned ``OLLAMA_HOST`` so nothing can ever + reach a real Ollama daemon. """ from __future__ import annotations @@ -116,7 +116,6 @@ def isolated_env(tmp_home: Path, **pins: str | None) -> dict[str, str]: _set(env, "BETTER_MEMORY_HOME", str(Path(tmp_home) / ".better-memory")) _set(env, "BETTER_MEMORY_PROJECT", DEFAULT_PROJECT) _set(env, "CLAUDE_SESSION_ID", DEFAULT_SESSION_ID) - _set(env, "BETTER_MEMORY_EMBEDDINGS_BACKEND", "sqlite") _set(env, "OLLAMA_HOST", POISONED_OLLAMA_HOST) # 4. Caller pins (override defaults; None removes). diff --git a/tests/e2e/test_sqlite_journey.py b/tests/e2e/test_sqlite_journey.py index e6c1e1ab..6f46d7e9 100644 --- a/tests/e2e/test_sqlite_journey.py +++ b/tests/e2e/test_sqlite_journey.py @@ -384,8 +384,9 @@ async def test_observe_retrieve_record_use_offline( the ratings at process exit). The <30s wall-clock budget around the observe call is the tripwire for - a regression reintroducing the Ollama embed path (3 retries against the - poisoned host blow past it or raise EmbeddingError). + a regression reintroducing embedder construction into mcp/server.py + (3 retries against the poisoned host blow past it or raise + EmbeddingError). """ env = isolated_env(clean_slate_home) bm_home = clean_slate_home / ".better-memory" @@ -409,8 +410,8 @@ async def test_observe_retrieve_record_use_offline( )["id"] observe_elapsed = time.monotonic() - t0 assert observe_elapsed < 30, ( - f"observe took {observe_elapsed:.1f}s — sqlite embeddings " - "bypass lost? (Ollama retry path reintroduced)" + f"observe took {observe_elapsed:.1f}s — no-embedder wiring " + "lost? (embedder construction reintroduced into mcp/server.py)" ) assert isinstance(obs_a, str) and obs_a diff --git a/tests/e2e/test_sqlite_negative.py b/tests/e2e/test_sqlite_negative.py index c07f8c9a..6766860f 100644 --- a/tests/e2e/test_sqlite_negative.py +++ b/tests/e2e/test_sqlite_negative.py @@ -1,24 +1,20 @@ """e2e-sqlite-ollama-absent-default-backend (design §1C row C8, task 5). -The path a pip-install user actually hits: no Ollama installed, no -BETTER_MEMORY_EMBEDDINGS_BACKEND exported, so the DEFAULT backend -('ollama', config.py _DEFAULT_EMBEDDINGS_BACKEND) is in force and -OLLAMA_HOST points nowhere. Pins the documented partial-availability -contract: - -* server boot survives (startup probe is warn-only — mcp/server.py - _probe_ollama) with a clear stderr warning; -* memory.observe fails IN BAND (CallToolResult.isError, EmbeddingError - text from embeddings/ollama.py) — never a protocol crash; -* the failure is fast: <30s timed ONLY around the observe call; -* knowledge tools keep working; -* KNOWN-DEFECT PIN (design §4 item 10): the failed observe leaves an - orphan episode row behind. +Task 2 (remove-ollama-embeddings) deleted ``create_server``'s embedder +construction and Ollama probe entirely: no code path builds an +``OllamaEmbedder``/``SyncEmbedder`` or contacts Ollama any more, regardless +of ``BETTER_MEMORY_EMBEDDINGS_BACKEND``. The original "ollama absent +default backend degrades in-band" contract this module pinned no longer +applies -- there is nothing left to degrade. + +This module now pins the inverse, stronger contract: the pip-install +default path (no ``BETTER_MEMORY_EMBEDDINGS_BACKEND`` exported, no Ollama +installed) just WORKS -- ``memory.observe`` succeeds, fast, with no stderr +warning and no orphan episode -- because no embedding is ever attempted. """ from __future__ import annotations -import socket import sqlite3 import time from contextlib import closing @@ -28,55 +24,38 @@ from tests.e2e.conftest import mcp_session -def _refused_loopback_port() -> int: - """A loopback port with provably no listener: bind an ephemeral port, - close it, use it immediately. Connects fail with an instant - ECONNREFUSED — no DNS, no route, no 30s connect timeout. (Review fix: - the previous hardcoded port 9 is not guaranteed free — Windows' - optional Simple TCP/IP Services runs a discard listener there, which - would turn this test into a hang/false pass.)""" - with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: - sock.bind(("127.0.0.1", 0)) - return sock.getsockname()[1] - - -OLLAMA_ABSENT_HOST = f"http://127.0.0.1:{_refused_loopback_port()}" - - def _text_of(result: object) -> str: return "".join( getattr(block, "text", "") for block in getattr(result, "content", []) ) -async def test_ollama_absent_default_backend_degrades_in_band( +async def test_ollama_absent_default_backend_succeeds_without_embedder( clean_slate_home: Path, tmp_path: Path ) -> None: - """Default (ollama) embeddings + unreachable daemon: warn, degrade, persist. - - Regressions this flips red on (design C8): - * fatal startup probe — initialize() would raise, bricking every - Ollama-less install; - * EmbeddingError escaping as a server crash instead of isError=True; - * retry/timeout inflation (~90s stalls per observe) — the <30s budget; - * silent trigram auto-fallback (observe stops erroring — a deliberate - contract change that must be made consciously); - * the orphan-episode fix landing (see the pinned assertion below). - - KNOWN-DEFECT PIN — orphan episode on failed observe (design §4 item 10): - ObservationService.create commits a background episode in step 1, then - the embed in step 2 fails against the dead host, so the episode row is - orphaned (episodes == 1, observations == 0). When the product fix lands - (episode write deferred/rolled back on embed failure), DELETE the two - orphan-pin assertions at the bottom of this test — everything else here - is the enduring degradation contract. + """Default (unset) BETTER_MEMORY_EMBEDDINGS_BACKEND + no Ollama running: + memory.observe succeeds -- no probe, no embed attempt, no stderr + warning, no orphan episode. + + Regressions this flips red on (design C8, inverted by Task 2): + * embedder construction or a startup probe being reintroduced into + mcp/server.py -- would reintroduce a stderr warning and/or slow / + error the observe call; + * retry/timeout inflation reappearing (~90s stalls per observe) -- the + generous <10s budget catches it while staying non-flaky; + * the KNOWN-DEFECT orphan-episode behaviour reappearing -- observe now + writes both the background episode AND the observation row in the + same call, since nothing fails partway through any more. """ env = isolated_env( clean_slate_home, - # Remove the harness default 'sqlite' pin: the whole point is the - # real out-of-the-box default ('ollama'). + # BETTER_MEMORY_EMBEDDINGS_BACKEND no longer exists as a config key + # (removed from config.py this branch), and isolated_env carries no + # pin for it either -- this explicit None is a no-op, kept only to + # document that the test deliberately exercises the real + # out-of-the-box default, which now behaves identically because + # nothing reads this variable for embedder construction any more. BETTER_MEMORY_EMBEDDINGS_BACKEND=None, - OLLAMA_HOST=OLLAMA_ABSENT_HOST, ) errlog_path = tmp_path / "server.stderr" # outside the fake home @@ -87,11 +66,6 @@ async def test_ollama_absent_default_backend_degrades_in_band( listed = await session.list_tools() assert "memory.observe" in {tool.name for tool in listed.tools} - # Latency budget timed ONLY around the observe call — never the - # spawn or initialize (design/judge fix). Measured baseline on a - # warm Windows dev machine: 7.7s (3 connection-refused attempts, - # 1.5s total fixed backoff, MCP round-trip). 30s keeps loud - # failure on the ~90s retry-inflation regression this targets. t0 = time.monotonic() r_observe = await session.call_tool( "memory.observe", {"content": "e2e negative-path observation"} @@ -100,30 +74,26 @@ async def test_ollama_absent_default_backend_degrades_in_band( r_knowledge = await session.call_tool("knowledge.list", {}) - assert observe_elapsed < 30.0, f"observe took {observe_elapsed:.1f}s (budget 30s)" - - # In-band tool error, not a protocol crash (ollama.py raises - # EmbeddingError('Failed to reach Ollama at after 3 attempts: ...')). - assert r_observe.isError is True - assert f"Failed to reach Ollama at {OLLAMA_ABSENT_HOST}" in _text_of(r_observe) + assert observe_elapsed < 10.0, ( + f"observe took {observe_elapsed:.1f}s — no-embedder wiring lost? " + "(embedder construction reintroduced into mcp/server.py)" + ) - # Partial-availability contract: knowledge tools are Ollama-independent. + assert not r_observe.isError, _text_of(r_observe) assert not r_knowledge.isError, _text_of(r_knowledge) - # Warn-only startup probe left its breadcrumb on stderr - # (mcp/server.py _probe_ollama; subset match on the host, not the - # full message). + # No startup probe left a stderr breadcrumb -- _probe_ollama is gone. stderr_text = errlog_path.read_text(encoding="utf-8", errors="replace") - assert f"Ollama unreachable at {OLLAMA_ABSENT_HOST}" in stderr_text + assert "Ollama unreachable" not in stderr_text - # KNOWN-DEFECT PIN (see docstring): orphan episode committed, no - # observation row. Post-exit read — the server process is gone, so this - # also proves the episode commit was durable. + # No orphan episode any more: the observe call commits both the + # background episode and the observation row -- nothing fails + # partway through, because no embedding is ever attempted. db = clean_slate_home / ".better-memory" / "memory.db" with closing(sqlite3.connect(db)) as conn: (episodes,) = conn.execute("SELECT COUNT(*) FROM episodes").fetchone() (observations,) = conn.execute( "SELECT COUNT(*) FROM observations" ).fetchone() - assert episodes == 1 # DELETE when the orphan-episode defect is fixed - assert observations == 0 + assert episodes == 1 + assert observations == 1 diff --git a/tests/e2e/test_tripwires.py b/tests/e2e/test_tripwires.py index 29e3c6c1..9c9488e0 100644 --- a/tests/e2e/test_tripwires.py +++ b/tests/e2e/test_tripwires.py @@ -64,13 +64,15 @@ async def test_ollama_zero_traffic_across_journey_and_sync_hooks( ) -> None: """e2e-ollama-zero-traffic-tripwire: the T1 journey never contacts Ollama. - With BETTER_MEMORY_EMBEDDINGS_BACKEND=sqlite (the isolated_env default), - create_server must skip OllamaEmbedder construction AND the /api/tags - startup probe entirely (both live inside the ``embeddings_backend == - 'ollama'`` branch of mcp/server.py). Regressions this flips red on: - hoisting the probe out of that branch "for better diagnostics", or - contextual_inject growing an embedding-based scorer that ignores the - embeddings backend — either records a request here. + Task 2 (remove-ollama-embeddings) deleted OllamaEmbedder construction + and the /api/tags startup probe from mcp/server.py entirely — there is + no longer an ``embeddings_backend == 'ollama'`` branch to skip, so this + holds regardless of BETTER_MEMORY_EMBEDDINGS_BACKEND (the key no longer + exists in config.py, and isolated_env carries no pin for it either -- + the pin was removed this branch). Regressions this flips red on: + reintroducing embedder construction or a startup probe into + mcp/server.py, or contextual_inject growing an embedding-based scorer — + either records a request here. Anti-vacuity (a dead server also makes zero requests): the recorder is first proven to record via a direct self-check request, and the journey @@ -110,8 +112,8 @@ async def test_ollama_zero_traffic_across_journey_and_sync_hooks( obs_id = json.loads(text_of(r_obs.content[0]))["id"] assert obs_id - # Trigram drill-down (embedder is None in sqlite embeddings - # mode; migration 0011's insert triggers make this + # Trigram drill-down (no embedder is ever constructed any + # more; migration 0011's insert triggers make this # synchronous with the observe above). r_drill = await session.call_tool( "memory.retrieve_observations", {"query": "tripwire"} diff --git a/tests/e2e_meta/test_env_bleed.py b/tests/e2e_meta/test_env_bleed.py index 6f10191b..c559e0a5 100644 --- a/tests/e2e_meta/test_env_bleed.py +++ b/tests/e2e_meta/test_env_bleed.py @@ -12,9 +12,9 @@ Mechanism: build a maximally hostile outer env ON TOP of the real ``os.environ`` (poison is added, nothing is stripped — that is the point), then re-run the sqlite-mode T1 slice whose assertions the poisoned -``backend=agentcore`` / ``embeddings=ollama`` values would break if any -fixture inherited them. A decoy ``BETTER_MEMORY_HOME`` dir with a hashed -sentinel pins location bleed independently of pass/fail outcomes. +``backend=agentcore`` value would break if any fixture inherited it. A +decoy ``BETTER_MEMORY_HOME`` dir with a hashed sentinel pins location bleed +independently of pass/fail outcomes. Deviation from the author scenario (documented): HOME/USERPROFILE are redirected to a neutral tmp home in the hostile env. The scenario's @@ -41,8 +41,8 @@ REPO_ROOT = Path(__file__).resolve().parents[2] #: The T1 sqlite happy-path + hook-contract modules: they assert sqlite-mode -#: behavior that an inherited backend=agentcore / embeddings=ollama poison -#: would break, making them the highest-signal bleed detectors. +#: behavior that an inherited backend=agentcore poison would break, making +#: them the highest-signal bleed detectors. INNER_SLICE: tuple[str, ...] = ( "tests/e2e/test_sqlite_journey.py", "tests/e2e/test_hooks_contracts.py", @@ -58,7 +58,6 @@ "BETTER_MEMORY_AGENTCORE_REGION": "us-gov-west-1", "BETTER_MEMORY_PROJECT": "poison-project", "BETTER_MEMORY_CONTEXT_INJECT_MODE": "aggressive", - "BETTER_MEMORY_EMBEDDINGS_BACKEND": "ollama", "OLLAMA_HOST": "http://does-not-exist.invalid:1", "CLAUDE_SESSION_ID": "poison-session", "AWS_PROFILE": "poison", @@ -159,9 +158,8 @@ def test_inner_slice_not_vacuous(bleed_run: BleedRun) -> None: def test_inner_slice_green_under_poisoned_shell(bleed_run: BleedRun) -> None: """sqlite-mode tests still pass with backend=agentcore + POISON ids + - aggressive inject mode + embeddings=ollama in the outer shell — proving - every fixture constructs its env from the explicit allowlist rather than - inheriting.""" + aggressive inject mode in the outer shell — proving every fixture + constructs its env from the explicit allowlist rather than inheriting.""" assert bleed_run.rc == 0, ( f"inner pytest rc={bleed_run.rc}\n" f"--- stdout tail ---\n{_tail(bleed_run.stdout)}\n" diff --git a/tests/e2e_meta/test_env_helper_contract.py b/tests/e2e_meta/test_env_helper_contract.py index 27bcbfd6..9aafce77 100644 --- a/tests/e2e_meta/test_env_helper_contract.py +++ b/tests/e2e_meta/test_env_helper_contract.py @@ -49,7 +49,6 @@ DELIBERATE_PINS = { "BETTER_MEMORY_HOME", "BETTER_MEMORY_PROJECT", - "BETTER_MEMORY_EMBEDDINGS_BACKEND", "CLAUDE_SESSION_ID", "OLLAMA_HOST", } @@ -157,7 +156,6 @@ def test_pins_override_none_removes_and_extras_added(self, tmp_path: Path) -> No def test_default_pin_values(self, tmp_path: Path) -> None: env = isolated_env(tmp_path) assert env["BETTER_MEMORY_HOME"] == str(tmp_path / ".better-memory") - assert env["BETTER_MEMORY_EMBEDDINGS_BACKEND"] == "sqlite" assert env["OLLAMA_HOST"] == POISONED_OLLAMA_HOST assert env["CLAUDE_SESSION_ID"] == "e2e-session-1" assert env["BETTER_MEMORY_PROJECT"] == "e2e-project" @@ -580,9 +578,10 @@ def test_run_hook_pipes_stdin_and_returns_triple(self, tmp_path: Path) -> None: async def test_mcp_session_boots_real_server_hermetically(self, tmp_path: Path) -> None: """The proven-helper smoke for every T1/T2 builder: the real MCP - server boots offline on a virgin fake home (sqlite embeddings, - poisoned OLLAMA_HOST), answers list_tools + memory.retrieve, and all - disk writes land under the fake home.""" + server boots offline on a virgin fake home (isolated_env pins + BETTER_MEMORY_HOME and poisons OLLAMA_HOST, though the server never + talks to Ollama any more), answers list_tools + memory.retrieve, + and all disk writes land under the fake home.""" home = tmp_path / "home" home.mkdir() env = isolated_env(home) diff --git a/tests/embeddings/__init__.py b/tests/embeddings/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/embeddings/test_ollama_integration.py b/tests/embeddings/test_ollama_integration.py deleted file mode 100644 index d4e70ef3..00000000 --- a/tests/embeddings/test_ollama_integration.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Integration tests for :mod:`better_memory.embeddings.ollama`. - -These tests hit a real Ollama server at ``$OLLAMA_HOST`` (default -``http://localhost:11434``) and require the ``nomic-embed-text`` model to be -pulled. The whole module is skipped automatically when Ollama is not -reachable, so the default ``pytest`` run (even without ``-m`` filtering) stays -green on machines that don't have Ollama running. - -Run explicitly with:: - - uv run pytest -m integration -""" - -from __future__ import annotations - -import math - -import httpx -import pytest - -from better_memory.config import get_config -from better_memory.embeddings.ollama import OllamaEmbedder - -pytestmark = pytest.mark.integration - - -def _ollama_reachable() -> bool: - host = get_config().ollama_host - try: - resp = httpx.get(f"{host}/api/tags", timeout=1.0) - except httpx.HTTPError: - return False - return resp.status_code == 200 - - -# Skip the whole module if Ollama isn't reachable, even when ``-m integration`` -# is passed. This keeps CI runs sane and lets developers without Ollama use the -# marker without hitting confusing connection errors. -if not _ollama_reachable(): - pytest.skip("Ollama not reachable on configured OLLAMA_HOST", allow_module_level=True) - - -async def test_embed_returns_768_floats() -> None: - async with OllamaEmbedder() as embedder: - vec = await embedder.embed("hello world") - - assert isinstance(vec, list) - assert len(vec) == 768 - assert all(isinstance(x, float) for x in vec) - - -async def test_embed_batch_returns_three_768_vectors() -> None: - async with OllamaEmbedder() as embedder: - batch = await embedder.embed_batch(["alpha", "beta", "gamma"]) - - assert len(batch) == 3 - for vec in batch: - assert len(vec) == 768 - assert all(isinstance(x, float) for x in vec) - - -async def test_embed_single_matches_batch_result_for_same_text() -> None: - async with OllamaEmbedder() as embedder: - single = await embedder.embed("alpha") - batch = await embedder.embed_batch(["alpha"]) - - assert len(batch) == 1 - assert len(single) == len(batch[0]) == 768 - for a, b in zip(single, batch[0], strict=True): - assert math.isclose(a, b, rel_tol=0.0, abs_tol=1e-6), (a, b) diff --git a/tests/embeddings/test_ollama_unit.py b/tests/embeddings/test_ollama_unit.py deleted file mode 100644 index c21be6c1..00000000 --- a/tests/embeddings/test_ollama_unit.py +++ /dev/null @@ -1,478 +0,0 @@ -"""Unit tests for :mod:`better_memory.embeddings.ollama`. - -These tests use :class:`httpx.MockTransport` to stub responses so they run -without Ollama. Async tests rely on ``asyncio_mode = "auto"`` from pyproject. -""" - -from __future__ import annotations - -import json -from typing import Any - -import httpx -import pytest - -from better_memory.embeddings.ollama import EmbeddingError, OllamaEmbedder - -# A deterministic 768-length vector used in stubbed responses. -_VEC_768 = [0.1] * 768 -_VEC_512 = [0.1] * 512 - - -def _make_client(handler: Any, *, base_url: str = "http://localhost:11434") -> httpx.AsyncClient: - """Return an ``AsyncClient`` wired to a ``MockTransport`` of ``handler``.""" - transport = httpx.MockTransport(handler) - return httpx.AsyncClient(transport=transport, base_url=base_url) - - -async def test_embed_sends_post_to_api_embed_with_correct_body() -> None: - captured: dict[str, Any] = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["method"] = request.method - captured["url"] = str(request.url) - captured["body"] = json.loads(request.content.decode("utf-8")) - return httpx.Response(200, json={"embeddings": [_VEC_768]}) - - client = _make_client(handler) - embedder = OllamaEmbedder(model="nomic-embed-text", client=client) - try: - out = await embedder.embed("hello") - finally: - await embedder.aclose() - await client.aclose() - - assert out == _VEC_768 - assert captured["method"] == "POST" - assert captured["url"] == "http://localhost:11434/api/embed" - assert captured["body"] == {"model": "nomic-embed-text", "input": "hello"} - - -async def test_embed_returns_vector_from_response_body() -> None: - distinctive = [float(i) / 1000 for i in range(768)] - - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"embeddings": [distinctive]}) - - client = _make_client(handler) - embedder = OllamaEmbedder(client=client) - try: - out = await embedder.embed("x") - finally: - await client.aclose() - - assert out == distinctive - assert len(out) == 768 - - -async def test_embed_dimension_mismatch_raises_embedding_error() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"embeddings": [_VEC_512]}) - - client = _make_client(handler) - embedder = OllamaEmbedder(client=client) - try: - with pytest.raises(EmbeddingError) as excinfo: - await embedder.embed("x") - finally: - await client.aclose() - - msg = str(excinfo.value) - assert "768" in msg - assert "512" in msg - - -async def test_embed_batch_sends_single_request_with_list_input() -> None: - captured: dict[str, Any] = {} - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - captured["body"] = json.loads(request.content.decode("utf-8")) - return httpx.Response(200, json={"embeddings": [_VEC_768, _VEC_768]}) - - client = _make_client(handler) - embedder = OllamaEmbedder(model="nomic-embed-text", client=client) - try: - out = await embedder.embed_batch(["a", "b"]) - finally: - await client.aclose() - - assert call_count == 1 - assert captured["body"] == {"model": "nomic-embed-text", "input": ["a", "b"]} - assert len(out) == 2 - assert out[0] == _VEC_768 - assert out[1] == _VEC_768 - - -async def test_embed_batch_empty_returns_empty_without_request() -> None: - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - return httpx.Response(500, json={"error": "should not be called"}) - - client = _make_client(handler) - embedder = OllamaEmbedder(client=client) - try: - out = await embedder.embed_batch([]) - finally: - await client.aclose() - - assert out == [] - assert call_count == 0 - - -async def test_embed_retries_on_5xx_then_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: - sleeps: list[float] = [] - - async def fake_sleep(delay: float) -> None: - sleeps.append(delay) - - monkeypatch.setattr("better_memory.embeddings.ollama.asyncio.sleep", fake_sleep) - - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - if call_count == 1: - return httpx.Response(503, json={"error": "temporarily unavailable"}) - return httpx.Response(200, json={"embeddings": [_VEC_768]}) - - client = _make_client(handler) - embedder = OllamaEmbedder(client=client, max_retries=3, backoff_base=0.5) - try: - out = await embedder.embed("hi") - finally: - await client.aclose() - - assert out == _VEC_768 - assert call_count == 2 - assert sleeps == [0.5] # one retry => single backoff call at attempt=0 - - -async def test_embed_retries_exhausted_raises_embedding_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - sleeps: list[float] = [] - - async def fake_sleep(delay: float) -> None: - sleeps.append(delay) - - monkeypatch.setattr("better_memory.embeddings.ollama.asyncio.sleep", fake_sleep) - - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - return httpx.Response(503, json={"error": "nope"}) - - client = _make_client(handler) - embedder = OllamaEmbedder(client=client, max_retries=3, backoff_base=0.5) - try: - with pytest.raises(EmbeddingError) as excinfo: - await embedder.embed("hi") - finally: - await client.aclose() - - # max_retries=3 means 3 attempts total, 2 backoffs in between. - assert call_count == 3 - assert sleeps == [0.5, 1.0] - assert "503" in str(excinfo.value) - - -async def test_embed_4xx_raises_immediately_without_retry( - monkeypatch: pytest.MonkeyPatch, -) -> None: - sleeps: list[float] = [] - - async def fake_sleep(delay: float) -> None: - sleeps.append(delay) - - monkeypatch.setattr("better_memory.embeddings.ollama.asyncio.sleep", fake_sleep) - - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - return httpx.Response(400, json={"error": "model not found"}) - - client = _make_client(handler) - embedder = OllamaEmbedder(client=client, max_retries=3) - try: - with pytest.raises(EmbeddingError) as excinfo: - await embedder.embed("hi") - finally: - await client.aclose() - - assert call_count == 1 - assert sleeps == [] - assert "400" in str(excinfo.value) - assert "model not found" in str(excinfo.value) - - -async def test_embed_connect_error_is_retried_then_succeeds( - monkeypatch: pytest.MonkeyPatch, -) -> None: - sleeps: list[float] = [] - - async def fake_sleep(delay: float) -> None: - sleeps.append(delay) - - monkeypatch.setattr("better_memory.embeddings.ollama.asyncio.sleep", fake_sleep) - - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - if call_count == 1: - raise httpx.ConnectError("connection refused") - return httpx.Response(200, json={"embeddings": [_VEC_768]}) - - client = _make_client(handler) - embedder = OllamaEmbedder(client=client, max_retries=3, backoff_base=0.5) - try: - out = await embedder.embed("hi") - finally: - await client.aclose() - - assert out == _VEC_768 - assert call_count == 2 - assert sleeps == [0.5] - - -async def test_embed_connect_error_exhausted_raises_embedding_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - sleeps: list[float] = [] - - async def fake_sleep(delay: float) -> None: - sleeps.append(delay) - - monkeypatch.setattr("better_memory.embeddings.ollama.asyncio.sleep", fake_sleep) - - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - raise httpx.ConnectError("refused") - - client = _make_client(handler) - embedder = OllamaEmbedder(client=client, max_retries=2, backoff_base=0.5) - try: - with pytest.raises(EmbeddingError) as excinfo: - await embedder.embed("hi") - finally: - await client.aclose() - - assert call_count == 2 - assert sleeps == [0.5] - assert isinstance(excinfo.value.__cause__, httpx.ConnectError) - - -async def test_embed_empty_string_still_calls_ollama() -> None: - captured: dict[str, Any] = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["body"] = json.loads(request.content.decode("utf-8")) - return httpx.Response(200, json={"embeddings": [_VEC_768]}) - - client = _make_client(handler) - embedder = OllamaEmbedder(client=client) - try: - out = await embedder.embed("") - finally: - await client.aclose() - - assert out == _VEC_768 - assert captured["body"]["input"] == "" - - -async def test_embed_batch_dimension_mismatch_raises() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"embeddings": [_VEC_768, _VEC_512]}) - - client = _make_client(handler) - embedder = OllamaEmbedder(client=client) - try: - with pytest.raises(EmbeddingError) as excinfo: - await embedder.embed_batch(["a", "b"]) - finally: - await client.aclose() - - msg = str(excinfo.value) - assert "768" in msg - assert "512" in msg - - -async def test_injected_client_is_not_closed_by_aclose() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"embeddings": [_VEC_768]}) - - client = _make_client(handler) - embedder = OllamaEmbedder(client=client) - try: - await embedder.embed("hi") - await embedder.aclose() - # The injected client must still be usable. - assert not client.is_closed - finally: - await client.aclose() - - -async def test_context_manager_delegates_to_aclose() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"embeddings": [_VEC_768]}) - - client = _make_client(handler) - async with OllamaEmbedder(client=client) as embedder: - out = await embedder.embed("x") - assert out == _VEC_768 - # Injected client survives. - assert not client.is_closed - await client.aclose() - - -async def test_embed_read_timeout_is_retried_then_succeeds( - monkeypatch: pytest.MonkeyPatch, -) -> None: - sleeps: list[float] = [] - - async def fake_sleep(delay: float) -> None: - sleeps.append(delay) - - monkeypatch.setattr("better_memory.embeddings.ollama.asyncio.sleep", fake_sleep) - - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - if call_count == 1: - raise httpx.ReadTimeout("slow first load") - return httpx.Response(200, json={"embeddings": [_VEC_768]}) - - client = _make_client(handler) - embedder = OllamaEmbedder(client=client, max_retries=3, backoff_base=0.5) - try: - out = await embedder.embed("hi") - finally: - await client.aclose() - - assert out == _VEC_768 - assert call_count == 2 - assert sleeps == [0.5] - - -async def test_embed_read_timeout_exhausted_raises_embedding_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - sleeps: list[float] = [] - - async def fake_sleep(delay: float) -> None: - sleeps.append(delay) - - monkeypatch.setattr("better_memory.embeddings.ollama.asyncio.sleep", fake_sleep) - - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - raise httpx.ReadTimeout("still slow") - - client = _make_client(handler) - embedder = OllamaEmbedder(client=client, max_retries=3, backoff_base=0.5) - try: - with pytest.raises(EmbeddingError) as excinfo: - await embedder.embed("hi") - finally: - await client.aclose() - - assert call_count == 3 - assert sleeps == [0.5, 1.0] - assert isinstance(excinfo.value.__cause__, httpx.ReadTimeout) - - -async def test_embed_remote_protocol_error_is_retried( - monkeypatch: pytest.MonkeyPatch, -) -> None: - sleeps: list[float] = [] - - async def fake_sleep(delay: float) -> None: - sleeps.append(delay) - - monkeypatch.setattr("better_memory.embeddings.ollama.asyncio.sleep", fake_sleep) - - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - if call_count == 1: - raise httpx.RemoteProtocolError("peer closed connection") - return httpx.Response(200, json={"embeddings": [_VEC_768]}) - - client = _make_client(handler) - embedder = OllamaEmbedder(client=client, max_retries=3, backoff_base=0.5) - try: - out = await embedder.embed("hi") - finally: - await client.aclose() - - assert out == _VEC_768 - assert call_count == 2 - assert sleeps == [0.5] - - -async def test_embed_unknown_http_error_is_wrapped_as_embedding_error() -> None: - def handler(request: httpx.Request) -> httpx.Response: - raise httpx.HTTPError("synthetic") - - client = _make_client(handler) - embedder = OllamaEmbedder(client=client, max_retries=3) - try: - with pytest.raises(EmbeddingError) as excinfo: - await embedder.embed("hi") - finally: - await client.aclose() - - # The raw httpx exception is wrapped, not re-raised. - assert isinstance(excinfo.value.__cause__, httpx.HTTPError) - assert not isinstance(excinfo.value, httpx.HTTPError) - - -async def test_max_retries_below_one_raises_value_error() -> None: - with pytest.raises(ValueError, match="max_retries must be >= 1"): - OllamaEmbedder(max_retries=0) - - -async def test_defaults_come_from_get_config(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("OLLAMA_HOST", "http://example.invalid:9999") - monkeypatch.setenv("EMBED_MODEL", "custom-model") - - captured: dict[str, Any] = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["url"] = str(request.url) - captured["body"] = json.loads(request.content.decode("utf-8")) - return httpx.Response(200, json={"embeddings": [_VEC_768]}) - - # We still inject the transport (so no real network), but omit host/model - # to verify they're read from config. - client = _make_client(handler, base_url="http://example.invalid:9999") - embedder = OllamaEmbedder(client=client) - try: - await embedder.embed("hi") - finally: - await client.aclose() - - assert captured["url"] == "http://example.invalid:9999/api/embed" - assert captured["body"]["model"] == "custom-model" diff --git a/tests/embeddings/test_sync_embed.py b/tests/embeddings/test_sync_embed.py deleted file mode 100644 index f3a89f73..00000000 --- a/tests/embeddings/test_sync_embed.py +++ /dev/null @@ -1,108 +0,0 @@ -"""SyncEmbedder: sync facade over the async embedder for thread-bound code. - -Fresh embedder per call (loop-bound AsyncClient), closed in the worker; -60s circuit breaker so an Ollama outage costs one stall per cooldown -window instead of one per call. -""" -from __future__ import annotations - -from better_memory.embeddings.sync_embed import SyncEmbedder -from tests.services._embedding_fakes import FakeEmbedder - - -class FakeClock: - def __init__(self): - self.t = 1000.0 - - def __call__(self) -> float: - return self.t - - -class TestSyncEmbedder: - def test_embed_text_returns_vector_and_closes_embedder(self): - fake = FakeEmbedder() - s = SyncEmbedder(lambda: fake) - vec = s.embed_text("hello") - assert vec is not None and len(vec) == 768 - assert fake.calls == ["hello"] - assert fake.closed == 1 - - def test_embed_batch_returns_vectors(self): - fake = FakeEmbedder() - s = SyncEmbedder(lambda: fake) - out = s.embed_batch(["a", "b"]) - assert out is not None and len(out) == 2 - - def test_failure_returns_none_and_opens_breaker(self): - fake = FakeEmbedder(fail=True) - clock = FakeClock() - s = SyncEmbedder(lambda: fake, clock=clock) - assert s.embed_text("x") is None - assert fake.calls == ["x"] - # Breaker open: the embedder is not touched again. - assert s.embed_text("y") is None - assert fake.calls == ["x"] - - def test_breaker_closes_after_cooldown(self): - clock = FakeClock() - calls = [] - - class FlakyThenGood(FakeEmbedder): - async def embed(self, text): - calls.append(text) - if len(calls) == 1: - raise RuntimeError("down") - return [0.1] * 768 - - s = SyncEmbedder(FlakyThenGood, clock=clock, cooldown=60.0) - assert s.embed_text("first") is None - clock.t += 61.0 - assert s.embed_text("second") is not None - assert calls == ["first", "second"] - - def test_none_factory_disables_everything(self): - s = SyncEmbedder(None) - assert s.embed_text("x") is None - assert s.embed_batch(["x"]) is None - - def test_embedder_without_aclose_is_fine(self): - class Bare: - async def embed(self, text): - return [0.1] * 768 - - s = SyncEmbedder(Bare) - assert s.embed_text("x") is not None - - -class TestFilePersistedCooldown: - def test_failure_writes_down_file_and_second_instance_skips(self, tmp_path): - down = tmp_path / "embed_down_until" - fake = FakeEmbedder(fail=True) - s1 = SyncEmbedder(lambda: fake, down_state_file=down) - assert s1.embed_text("x") is None - assert down.exists() - # Fresh instance = fresh hook process. Must skip without touching - # the embedder. - fake2 = FakeEmbedder() - s2 = SyncEmbedder(lambda: fake2, down_state_file=down) - assert s2.embed_text("y") is None - assert fake2.calls == [] - - def test_expired_down_file_allows_embedding(self, tmp_path): - down = tmp_path / "embed_down_until" - down.write_text("1.0", encoding="utf-8") # epoch 1970 — expired - fake = FakeEmbedder() - s = SyncEmbedder(lambda: fake, down_state_file=down) - assert s.embed_text("x") is not None - - def test_corrupt_down_file_is_ignored(self, tmp_path): - down = tmp_path / "embed_down_until" - down.write_text("not-a-number", encoding="utf-8") - fake = FakeEmbedder() - s = SyncEmbedder(lambda: fake, down_state_file=down) - assert s.embed_text("x") is not None - - def test_no_file_param_keeps_old_behaviour(self): - fake = FakeEmbedder() - s = SyncEmbedder(lambda: fake) - assert s.embed_text("x") is not None diff --git a/tests/hooks/test_contextual_inject.py b/tests/hooks/test_contextual_inject.py index cb937e96..c3cbcc5e 100644 --- a/tests/hooks/test_contextual_inject.py +++ b/tests/hooks/test_contextual_inject.py @@ -36,17 +36,13 @@ def bm_home(tmp_path: Path, monkeypatch) -> Path: hook.main() falls back to the developer's real ~/.better-memory and writes memory.db, diagnostics, and state/ files there. - Pins ``BETTER_MEMORY_EMBEDDINGS_BACKEND=sqlite`` (mirrors - tests/ui/conftest.py's pin) so the hook never builds a real SyncEmbedder - / OllamaEmbedder and never issues an HTTP call to localhost:11434 -- - config.py's actual default is "ollama", not sqlite. Without this pin - every non-blank-query test here would silently depend on there being no - live Ollama daemon to answer (works today only because no test seeds - embeddings, so the qvec=None fallback path never observably differs). + Task 2 (remove-ollama-embeddings) deleted contextual_inject's SyncEmbedder + / OllamaEmbedder construction entirely, so the hook never issues an HTTP + call to localhost:11434 regardless of BETTER_MEMORY_EMBEDDINGS_BACKEND -- + no env pin is needed to guarantee that any more. """ monkeypatch.setenv("BETTER_MEMORY_HOME", str(tmp_path)) monkeypatch.setenv("BETTER_MEMORY_PROJECT", _PROJECT) - monkeypatch.setenv("BETTER_MEMORY_EMBEDDINGS_BACKEND", "sqlite") conn = connect(tmp_path / "memory.db") try: apply_migrations(conn) @@ -170,7 +166,7 @@ def test_second_run_suppressed_by_seen_store(bm_home, monkeypatch, capsys): def test_below_floor_injects_nothing(bm_home, monkeypatch, capsys): # No BM25 overlap (zero shared tokens with the prompt), no vec leg - # (the bm_home fixture pins embeddings_backend=sqlite -> sync_embedder is + # (the hook never builds a sync_embedder any more -- qvec is always # None), and conn is present so the keyword fallback never kicks in # either: no evidence on any leg -> no injection. _seed_reflection(bm_home, "refl-zebra-flamingo-4", title="zebra flamingo unrelated topic") diff --git a/tests/integration/test_contextual_rating_e2e.py b/tests/integration/test_contextual_rating_e2e.py index 246d5efa..e198187d 100644 --- a/tests/integration/test_contextual_rating_e2e.py +++ b/tests/integration/test_contextual_rating_e2e.py @@ -64,7 +64,7 @@ def test_contextual_injection_full_rating_loop(conn, monkeypatch, tmp_path): # 2. Build the sqlite backend. backend = SqliteBackend( - memory_conn=conn, embedder=None, session_id="e2e-sess", project="proj", + memory_conn=conn, session_id="e2e-sess", project="proj", ) # 3. Retrieve relevant memories for the query; the seeded reflection diff --git a/tests/mcp/conftest.py b/tests/mcp/conftest.py index 67fab92e..7a3a4ec2 100644 --- a/tests/mcp/conftest.py +++ b/tests/mcp/conftest.py @@ -1,36 +1,10 @@ """MCP test fixtures and collection hooks. -When Ollama is not reachable on ``localhost:11434`` we auto-skip the -subprocess-based MCP integration tests so contributors without a running -Ollama can still run the default suite. Non-integration tests in this -directory (e.g. ``test_parse_window.py``) are not affected. +Task 2 (remove-ollama-embeddings) deleted create_server's embedder +construction and Ollama probe entirely, so the subprocess-based MCP +integration tests (``test_server_integration.py``) no longer depend on a +reachable Ollama daemon — the auto-skip this module used to apply has +been removed. """ from __future__ import annotations - -import httpx -import pytest - - -def _ollama_up() -> bool: - """Return ``True`` iff Ollama responds on its default tags endpoint.""" - try: - r = httpx.get("http://localhost:11434/api/tags", timeout=1.0) - return r.status_code == 200 - except httpx.HTTPError: - return False - - -def pytest_collection_modifyitems( - config: pytest.Config, items: list[pytest.Item] -) -> None: - """Skip MCP integration tests when Ollama is not reachable.""" - if _ollama_up(): - return - skip_marker = pytest.mark.skip( - reason="Ollama not reachable at localhost:11434" - ) - for item in items: - path = str(item.path).replace("\\", "/") - if "tests/mcp/" in path and "test_server_integration" in path: - item.add_marker(skip_marker) diff --git a/tests/mcp/test_server_backend_dispatch.py b/tests/mcp/test_server_backend_dispatch.py index 61bcecda..a7b27140 100644 --- a/tests/mcp/test_server_backend_dispatch.py +++ b/tests/mcp/test_server_backend_dispatch.py @@ -44,44 +44,29 @@ def test_synthesis_tools_skipped_when_capability_false() -> None: assert "memory.synthesize_next_apply" not in tool_names -async def test_create_server_shares_one_sync_embedder_with_backend( +async def test_create_server_wires_no_sync_embedder_into_backend( monkeypatch: pytest.MonkeyPatch, tmp_path ) -> None: - """Regression for PR #83: create_server must build exactly ONE - SyncEmbedder and hand it to both the top-level services (reflections/ - semantic) and build_backend (so SqliteBackend's internal _synthesis/ - _semantic reuse it) — not construct a second, independent instance - with its own circuit breaker.""" + """Task 2 (remove-ollama-embeddings): create_server no longer builds any + SyncEmbedder at all — mcp/server.py no longer even imports SyncEmbedder, + let alone constructs one — regardless of + BETTER_MEMORY_EMBEDDINGS_BACKEND. Task 7 went further and removed the + backend's ``embedder``/``sync_embedder`` ctor params entirely (they were + dead pass-throughs once ``relevance_ranks``' vector leg was deleted), so + there is no ``_sync_embedder`` attribute left to wire at all.""" home = tmp_path / "bm" home.mkdir() (home / "knowledge-base").mkdir() monkeypatch.setenv("BETTER_MEMORY_HOME", str(home)) - monkeypatch.setenv("BETTER_MEMORY_EMBEDDINGS_BACKEND", "ollama") - monkeypatch.setenv("OLLAMA_HOST", "http://does-not-exist.invalid:1") monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) import better_memory.mcp.server as server_mod - - created: list[Any] = [] - real_sync_embedder = server_mod.SyncEmbedder - - class _TrackingSyncEmbedder(real_sync_embedder): # type: ignore[misc, valid-type] - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - created.append(self) - - monkeypatch.setattr(server_mod, "SyncEmbedder", _TrackingSyncEmbedder) - from better_memory.storage import SqliteBackend server, cleanup, ctx = server_mod.create_server() try: - assert len(created) == 1, ( - f"expected exactly one process-wide SyncEmbedder, got {len(created)}" - ) assert isinstance(ctx.backend, SqliteBackend) - assert ctx.backend._synthesis._sync_embedder is created[0] - assert ctx.backend._semantic._sync_embedder is created[0] + assert not hasattr(ctx.backend, "_sync_embedder") finally: await cleanup() diff --git a/tests/mcp/test_server_integration.py b/tests/mcp/test_server_integration.py index 610ce507..cff28c98 100644 --- a/tests/mcp/test_server_integration.py +++ b/tests/mcp/test_server_integration.py @@ -2,14 +2,11 @@ These drive the real MCP stdio server as a subprocess via the ``mcp`` client SDK. They use per-test tmpfs for all paths (memory DB, knowledge -DB, spool, knowledge base) and talk to the local Ollama instance on -``http://localhost:11434`` for embeddings (the default -``BETTER_MEMORY_EMBEDDINGS_BACKEND=ollama``). - -``tests/mcp/conftest.py`` auto-skips this module when Ollama is not -reachable, so contributors without a running Ollama can still run the -default suite. If you run on a machine where Ollama listens elsewhere, -point ``OLLAMA_HOST`` at it before invoking pytest. +DB, spool, knowledge base). Task 2 (remove-ollama-embeddings) deleted +create_server's embedder construction and Ollama probe entirely, so this +module no longer talks to Ollama at all and no longer needs a live +Ollama daemon to run — it works identically regardless of +``BETTER_MEMORY_EMBEDDINGS_BACKEND``. Beyond the happy-path round-trips, this module covers the tool-handler error paths that are only reachable through the real dispatch layer: @@ -34,7 +31,7 @@ from better_memory.mcp.server import _tool_definitions -# Allow plenty of room for Ollama's first-call warm-up on a cold model. +# Generous timeout to absorb slow CI subprocess startup and migrations. _CLIENT_TIMEOUT = timedelta(seconds=60) # A structurally valid synthesize_next_apply decision that takes no action. diff --git a/tests/mcp/test_server_sqlite.py b/tests/mcp/test_server_sqlite.py index 108f2f62..2d63b0f8 100644 --- a/tests/mcp/test_server_sqlite.py +++ b/tests/mcp/test_server_sqlite.py @@ -1,10 +1,12 @@ """MCP server boot + roundtrip test for the sqlite (FTS5) backend. -Verifies that ``create_server()`` runs without an OllamaEmbedder when -``BETTER_MEMORY_EMBEDDINGS_BACKEND=sqlite`` even with Ollama -unreachable. ``memory.observe`` and ``memory.retrieve`` must round-trip -without any HTTP call to Ollama, and ``cleanup()`` must not blow up on -a ``None`` embedder. +Verifies that ``create_server()`` runs without constructing any embedder +even with Ollama unreachable. Task 2 (remove-ollama-embeddings) deleted +the ``embeddings_backend == "ollama"`` branch entirely, so this no longer +needs to force ``BETTER_MEMORY_EMBEDDINGS_BACKEND=sqlite`` — the point now +holds regardless of that config value. ``memory.observe`` and +``memory.retrieve`` must round-trip without any HTTP call to Ollama, and +``cleanup()`` must not blow up on a ``None`` embedder. """ from __future__ import annotations @@ -16,20 +18,21 @@ import pytest -def test_create_server_in_sqlite_mode_without_ollama( +def test_create_server_without_ollama( tmp_memory_db: Path, tmp_knowledge_base: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Server should start AND serve observe/retrieve in sqlite mode without Ollama. + """Server should start AND serve observe/retrieve without Ollama. The OLLAMA_HOST is pointed at an unresolvable address so any code path that still tries to embed via HTTP would surface as a clean - failure on the first tool call. Under the sqlite wiring this path - is dead — FTS5 triggers index observations entirely in-database. + failure on the first tool call. No such path exists any more — + create_server never constructs an embedder, regardless of + BETTER_MEMORY_EMBEDDINGS_BACKEND — FTS5 triggers index observations + entirely in-database. """ home = tmp_memory_db.parent - monkeypatch.setenv("BETTER_MEMORY_EMBEDDINGS_BACKEND", "sqlite") monkeypatch.setenv("BETTER_MEMORY_HOME", str(home)) monkeypatch.setenv("OLLAMA_HOST", "http://does-not-exist.invalid:1") diff --git a/tests/search/test_hybrid.py b/tests/search/test_hybrid.py index e53c0365..96ebd220 100644 --- a/tests/search/test_hybrid.py +++ b/tests/search/test_hybrid.py @@ -1,7 +1,7 @@ """Tests for :mod:`better_memory.search.hybrid`. The hybrid search layer is pure-SQLite (no embedder). We manually insert -observations + their vectors so we can control every input deterministically. +observations so we can control every input deterministically. """ from __future__ import annotations @@ -17,14 +17,10 @@ reason="Awaiting Phase 2 episodic service layer — see docs/superpowers/specs/2026-04-20-episodic-memory-design.md" ) -import sqlite_vec - from better_memory.db.connection import connect from better_memory.db.schema import apply_migrations from better_memory.search.hybrid import SearchFilters, hybrid_search -_VEC_DIM = 768 - # --------------------------------------------------------------------------- # Fixtures / helpers @@ -51,13 +47,6 @@ def clock(fixed_now: datetime): return lambda: fixed_now -def _unit_vector(axis: int, dim: int = _VEC_DIM) -> list[float]: - """Return a unit-length 768-vector with 1.0 on one axis.""" - v = [0.0] * dim - v[axis % dim] = 1.0 - return v - - def _seed( conn: sqlite3.Connection, *, @@ -70,11 +59,9 @@ def _seed( reinforcement_score: float = 0.0, scope_path: str | None = None, status: str = "active", - vector: list[float] | None = None, created_at: datetime | None = None, ) -> None: - """Insert a fully-specified observation row and its embedding.""" - vec = vector if vector is not None else _unit_vector(0) + """Insert a fully-specified observation row.""" created = ( (created_at or datetime(2026, 4, 18, 12, 0, 0, tzinfo=UTC)) .isoformat() @@ -101,10 +88,6 @@ def _seed( created, ), ) - conn.execute( - "INSERT INTO observation_embeddings (observation_id, embedding) VALUES (?, ?)", - (obs_id, sqlite_vec.serialize_float32(vec)), - ) conn.commit() @@ -132,39 +115,6 @@ def test_text_only_hybrid_ranks_matching_first(conn: sqlite3.Connection, clock) assert results[0].id == "a" -@_LEGACY_SKIP -def test_vector_only_hybrid_ranks_closer_first(conn: sqlite3.Connection, clock) -> None: - _seed(conn, obs_id="a", content="alpha", vector=_unit_vector(0)) - _seed(conn, obs_id="b", content="beta", vector=_unit_vector(1)) - - # Query vector very close to axis 0 → id "a" should rank first. - q = _unit_vector(0) - results = hybrid_search(conn, query_vector=q, clock=clock) - - assert len(results) == 2 - assert results[0].id == "a" - - -@_LEGACY_SKIP -def test_both_sources_merge_via_rrf(conn: sqlite3.Connection, clock) -> None: - # 'a' matches both FTS and vector; 'b' matches FTS only; 'c' matches - # vector only. 'a' should therefore rank above 'b' and 'c'. - _seed(conn, obs_id="a", content="marker python", vector=_unit_vector(0)) - _seed(conn, obs_id="b", content="marker rust", vector=_unit_vector(5)) - _seed(conn, obs_id="c", content="unrelated word", vector=_unit_vector(1)) - - q_vec = _unit_vector(0) - results = hybrid_search( - conn, - query_text="marker python", - query_vector=q_vec, - clock=clock, - ) - - ids = [r.id for r in results] - assert ids[0] == "a" - - @_LEGACY_SKIP def test_filter_by_project(conn: sqlite3.Connection, clock) -> None: _seed(conn, obs_id="a", content="shared marker", project="alpha") @@ -281,27 +231,24 @@ def test_outcome_filter(conn: sqlite3.Connection, clock) -> None: def test_reinforcement_boosts_same_similarity_item( conn: sqlite3.Connection, clock ) -> None: - # Identical content + identical vector → equal raw similarity. A's - # reinforcement_score is high so it must rank first. + # Identical content -> equal raw BM25 similarity. A's reinforcement_score + # is high so it must rank first. _seed( conn, obs_id="high", content="marker alpha", reinforcement_score=5.0, - vector=_unit_vector(0), ) _seed( conn, obs_id="low", content="marker alpha", reinforcement_score=0.0, - vector=_unit_vector(0), ) results = hybrid_search( conn, query_text="marker alpha", - query_vector=_unit_vector(0), clock=clock, ) ids = [r.id for r in results] @@ -317,20 +264,17 @@ def test_recency_decay_boosts_new( obs_id="new", content="marker alpha", created_at=fixed_now, - vector=_unit_vector(0), ) _seed( conn, obs_id="old", content="marker alpha", created_at=fixed_now - timedelta(days=90), - vector=_unit_vector(0), ) results = hybrid_search( conn, query_text="marker alpha", - query_vector=_unit_vector(0), filters=SearchFilters(window_days=None), clock=clock, ) @@ -382,7 +326,9 @@ def test_search_result_carries_fields(conn: sqlite3.Connection, clock) -> None: # --------------------------------------------------------------------------- -# second_source parameter (vec0 | trigram | none) +# Trigram leg — always-on BM25 companion to word-FTS5 (no toggle any more: +# remove-ollama-embeddings Task 7 deleted the second_source parameter along +# with the vec0 leg it used to select between). # --------------------------------------------------------------------------- # # These tests use a self-contained ``_seed_obs`` helper rather than the legacy @@ -399,7 +345,6 @@ def _seed_obs( project: str = "alpha", outcome: str = "neutral", created_at: datetime | None = None, - vector: list[float] | None = None, ) -> None: """Insert an observation (and its parent episode) compatible with current schema.""" episode_id = f"ep-{obs_id}" @@ -423,23 +368,18 @@ def _seed_obs( """, (obs_id, content, project, outcome, created, created, episode_id), ) - vec = vector if vector is not None else _unit_vector(0) - conn.execute( - "INSERT INTO observation_embeddings (observation_id, embedding) VALUES (?, ?)", - (obs_id, sqlite_vec.serialize_float32(vec)), - ) conn.commit() -def test_hybrid_search_second_source_trigram(conn: sqlite3.Connection, clock) -> None: - """When second_source='trigram', the second source is FTS5 trigram BM25 over observation_trigram_fts.""" +def test_trigram_leg_finds_word_match(conn: sqlite3.Connection, clock) -> None: + """The trigram BM25 leg (always fused in via RRF) surfaces a word match + same as the word-FTS5 leg would.""" _seed_obs(conn, obs_id="hit", content="pytest junit-xml on windows") _seed_obs(conn, obs_id="miss", content="completely unrelated topic") results = hybrid_search( conn, query_text="pytest windows", - second_source="trigram", filters=SearchFilters(window_days=None), limit=2, clock=clock, @@ -449,23 +389,7 @@ def test_hybrid_search_second_source_trigram(conn: sqlite3.Connection, clock) -> assert results[0].id == "hit" -def test_hybrid_search_second_source_none(conn: sqlite3.Connection, clock) -> None: - """When second_source='none', only word-FTS5 BM25 runs (no second source).""" - _seed_obs(conn, obs_id="o1", content="alpha bravo charlie") - - results = hybrid_search( - conn, - query_text="alpha", - second_source="none", - filters=SearchFilters(window_days=None), - limit=5, - clock=clock, - ) - - assert any(r.id == "o1" for r in results) - - -def test_hybrid_search_second_source_trigram_substring_match( +def test_trigram_leg_matches_substring_beyond_word_tokenizer( conn: sqlite3.Connection, clock ) -> None: """Trigram source matches substrings the word tokenizer misses.""" @@ -474,7 +398,6 @@ def test_hybrid_search_second_source_trigram_substring_match( results = hybrid_search( conn, query_text="estin", - second_source="trigram", filters=SearchFilters(window_days=None), limit=5, clock=clock, diff --git a/tests/services/_embedding_fakes.py b/tests/services/_embedding_fakes.py deleted file mode 100644 index 227220e1..00000000 --- a/tests/services/_embedding_fakes.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Shared fake embedder for embedding-path tests.""" -from __future__ import annotations - - -class FakeEmbedder: - def __init__(self, fail: bool = False): - self.calls: list = [] - self.closed = 0 - self.fail = fail - - async def embed(self, text: str) -> list[float]: - self.calls.append(text) - if self.fail: - raise RuntimeError("ollama down") - return [0.1] * 768 - - async def embed_batch(self, texts: list[str]) -> list[list[float]]: - self.calls.append(list(texts)) - if self.fail: - raise RuntimeError("ollama down") - return [[0.1] * 768 for _ in texts] - - async def aclose(self) -> None: - self.closed += 1 - - -class DirectedEmbedder(FakeEmbedder): - """Maps texts containing any trigger phrase to one vector; noise else. - - Lets a test make the query and one reflection/semantic memory - 'semantically identical' while sharing zero tokens -- isolating the - vec leg from BM25/keyword matching. Shared here (was previously - private to test_vec_fusion.py) so any embedding-path test can use it. - """ - - def __init__(self, *triggers: str): - super().__init__() - self.triggers = triggers - - def _vec(self, text: str) -> list[float]: - if any(t in text for t in self.triggers): - return [1.0] + [0.0] * 767 - return [0.0, 1.0] + [0.0] * 766 - - async def embed(self, text): - self.calls.append(text) - return self._vec(text) - - async def embed_batch(self, texts): - self.calls.append(list(texts)) - return [self._vec(t) for t in texts] diff --git a/tests/services/test_audit_trail.py b/tests/services/test_audit_trail.py index 04dd37ed..8fc18961 100644 --- a/tests/services/test_audit_trail.py +++ b/tests/services/test_audit_trail.py @@ -25,20 +25,6 @@ from better_memory.services.episode import EpisodeService from better_memory.services.observation import ObservationService -_VEC_DIM = 768 -_VEC_FIXED = [0.01] * _VEC_DIM - - -class _StubEmbedder: - """Deterministic async embedder — never touches Ollama.""" - - def __init__(self) -> None: - self.calls: list[str] = [] - - async def embed(self, text: str) -> list[float]: - self.calls.append(text) - return list(_VEC_FIXED) - @pytest.fixture def conn(tmp_memory_db: Path) -> Iterator[sqlite3.Connection]: @@ -64,7 +50,6 @@ def _make_service( ) -> ObservationService: return ObservationService( conn, - _StubEmbedder(), clock=fixed_clock, project_resolver=lambda: "test-project", scope_resolver=lambda: None, @@ -205,7 +190,6 @@ async def test_audit_log_retrieved_flag_defaults_to_config( monkeypatch.setenv("AUDIT_LOG_RETRIEVED", "false") svc = ObservationService( conn, - _StubEmbedder(), clock=fixed_clock, project_resolver=lambda: "test-project", scope_resolver=lambda: None, diff --git a/tests/services/test_episode.py b/tests/services/test_episode.py index 2075dbc1..5285c77f 100644 --- a/tests/services/test_episode.py +++ b/tests/services/test_episode.py @@ -472,14 +472,9 @@ class TestObservationServiceEpisodeIntegration: async def test_observation_write_opens_background_episode_lazily(self, conn, fixed_clock): from better_memory.services.observation import ObservationService - class _StubEmbedder: - async def embed(self, text): - return [0.0] * 768 - epsvc = EpisodeService(conn, clock=fixed_clock) obs_svc = ObservationService( conn, - _StubEmbedder(), clock=fixed_clock, project_resolver=lambda: "proj-a", session_id="sess-1", @@ -505,14 +500,9 @@ async def embed(self, text): async def test_observation_accepts_tech_parameter(self, conn, fixed_clock): from better_memory.services.observation import ObservationService - class _StubEmbedder: - async def embed(self, text): - return [0.0] * 768 - epsvc = EpisodeService(conn, clock=fixed_clock) obs_svc = ObservationService( conn, - _StubEmbedder(), clock=fixed_clock, project_resolver=lambda: "proj-a", session_id="sess-1", @@ -530,14 +520,9 @@ async def test_observation_empty_tech_stored_as_null(self, conn, fixed_clock): """tech='' is coerced to NULL on observation writes (matches EpisodeService).""" from better_memory.services.observation import ObservationService - class _StubEmbedder: - async def embed(self, text): - return [0.0] * 768 - epsvc = EpisodeService(conn, clock=fixed_clock) obs_svc = ObservationService( conn, - _StubEmbedder(), clock=fixed_clock, project_resolver=lambda: "proj-a", session_id="sess-1", diff --git a/tests/services/test_fts_substrate.py b/tests/services/test_fts_substrate.py new file mode 100644 index 00000000..1eea622b --- /dev/null +++ b/tests/services/test_fts_substrate.py @@ -0,0 +1,95 @@ +"""Pins the FTS5/BM25 evidence leg as unconditional. + +After the Ollama/vector removal, BM25 is the only local evidence leg for +sqlite-storage retrieval. ``ObservationService`` no longer accepts an +embedder parameter at all (remove-ollama-embeddings Task 6), so this test +proves observe -> FTS row -> BM25-evidenced retrieval works end to end. + +EXPECTED GREEN from the moment it is written (verification pin, not +TDD red) -- see +``.superpowers/sdd/2026-09-01-remove-ollama-embeddings/task-1-brief.md``. + +Deviation from the brief's illustrative pseudo-code +---------------------------------------------------- +The brief named ``retrieve_relevant`` (``better_memory/services/relevant.py``) +as the retrieval leg to exercise together with ``ObservationService``. +That function only ever reads the ``reflections`` and ``semantic_memories`` +tables (via ``backend.retrieve`` / ``backend.semantic_list``) -- it has no +code path that touches ``observations`` at all, so an +``observe() -> retrieve_relevant()`` test could never pass, regardless of +the FTS/embedder question, because the two subsystems don't share a table. + +The actual end-to-end BM25 retrieval path for observations is +``ObservationService.retrieve()``, backed by +``better_memory.search.hybrid.hybrid_search`` -- a module whose own +docstring says it is "deliberately pure SQLite: it never calls the +embedder", and which unconditionally fuses word-FTS5 and trigram-FTS5 +BM25 via RRF -- there is no ``second_source`` parameter to pick a mode +with any more (removed in Task 7 along with the vector leg), since +``ObservationService`` has no embedder path at all any more. That is +the function this test exercises instead, to keep the assertion shape +(observe -> FTS row -> BM25-evidenced retrieval hit -> no embedder +anywhere) truthful to what the code actually does. +""" + +from __future__ import annotations + +import sqlite3 +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from better_memory.db.connection import connect +from better_memory.db.schema import apply_migrations +from better_memory.services.episode import EpisodeService +from better_memory.services.observation import ObservationService + + +@pytest.fixture +def conn(tmp_memory_db: Path) -> Iterator[sqlite3.Connection]: + c = connect(tmp_memory_db) + try: + apply_migrations(c) + yield c + finally: + c.close() + + +async def test_observe_populates_fts_without_embedder( + conn: sqlite3.Connection, +) -> None: + """``ObservationService(conn, episodes=...)`` -- no embedder constructed + at all -- still populates ``observation_fts`` via the AFTER INSERT/UPDATE/ + DELETE triggers defined in 0001_init.sql / 0002_episodic.sql. FTS + population is unconditional: it is not gated on any embeddings backend + (see task-1 Step 1 grep evidence in the task report).""" + svc = ObservationService(conn, episodes=EpisodeService(conn)) + await svc.create( + content="growatt inverter polling uses Timespan.hour", project="p1" + ) + + fts_rows = conn.execute( + "SELECT count(*) FROM observation_fts WHERE observation_fts MATCH 'growatt'" + ).fetchone()[0] + assert fts_rows >= 1 + + +async def test_retrieve_returns_bm25_evidence_without_embedder( + conn: sqlite3.Connection, +) -> None: + """Same substrate as above; ``ObservationService.retrieve()`` must + surface the observation on BM25 evidence alone -- word-FTS5 BM25 plus + trigram-FTS5 BM25, both embedder-free -- when the service is + constructed with no embedder (both legs always run unconditionally; + there is no ``second_source`` parameter to select between them any + more).""" + svc = ObservationService(conn, episodes=EpisodeService(conn)) + await svc.create( + content="growatt inverter polling uses Timespan.hour", project="p1" + ) + + results = await svc.retrieve(query="growatt inverter polling", project="p1") + + hits = results.do + results.dont + results.neutral + assert any("growatt" in r.content.lower() for r in hits) diff --git a/tests/services/test_observation.py b/tests/services/test_observation.py index bd200608..f5edc6f6 100644 --- a/tests/services/test_observation.py +++ b/tests/services/test_observation.py @@ -1,8 +1,9 @@ """Tests for :class:`better_memory.services.observation.ObservationService`. -These tests use an in-memory (temp-file) migrated SQLite database and a mock -embedder — they do NOT contact Ollama. Async tests rely on -``asyncio_mode = "auto"`` from pyproject. +These tests use an in-memory (temp-file) migrated SQLite database. There is +no embedder any more (remove-ollama-embeddings Task 6) -- FTS5/trigram BM25 +is the only evidence leg. Async tests rely on ``asyncio_mode = "auto"`` +from pyproject. """ from __future__ import annotations @@ -18,34 +19,9 @@ from better_memory.db.connection import connect from better_memory.db.schema import apply_migrations -from better_memory.embeddings.ollama import EmbeddingError from better_memory.services.episode import EpisodeService from better_memory.services.observation import ObservationService -# Deterministic 768-length vector used by the mock embedder. -_VEC_768 = [0.01] * 768 - - -class _StubEmbedder: - """Minimal mock of :class:`OllamaEmbedder` for unit tests.""" - - def __init__( - self, - *, - vector: list[float] | None = None, - raise_on_embed: Exception | None = None, - ) -> None: - self._vector = vector if vector is not None else list(_VEC_768) - self._raise = raise_on_embed - self.calls: list[str] = [] - - async def embed(self, text: str) -> list[float]: - self.calls.append(text) - if self._raise is not None: - raise self._raise - return list(self._vector) - - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -70,10 +46,8 @@ def fixed_clock() -> Any: @pytest.fixture def service(conn: sqlite3.Connection, fixed_clock: Any) -> ObservationService: - embedder = _StubEmbedder() return ObservationService( conn, - embedder, clock=fixed_clock, project_resolver=lambda: "test-project", scope_resolver=lambda: None, @@ -142,10 +116,8 @@ async def test_create_stores_scope_path_argument( async def test_create_uses_scope_resolver_when_arg_not_given( conn: sqlite3.Connection, fixed_clock: Any ) -> None: - embedder = _StubEmbedder() svc = ObservationService( conn, - embedder, clock=fixed_clock, project_resolver=lambda: "test-project", scope_resolver=lambda: "auto/scope", @@ -176,8 +148,7 @@ async def test_create_defaults_project_to_project_name_when_no_resolver( module-level :func:`better_memory.config.project_name` for the current cwd.""" from better_memory.config import project_name - embedder = _StubEmbedder() - svc = ObservationService(conn, embedder, clock=fixed_clock, session_id="s", episodes=EpisodeService(conn)) + svc = ObservationService(conn, clock=fixed_clock, session_id="s", episodes=EpisodeService(conn)) obs_id = await svc.create("no resolver") row = conn.execute( "SELECT project FROM observations WHERE id = ?", (obs_id,) @@ -202,17 +173,6 @@ async def test_create_populates_fts_via_trigger( assert any(r["rowid"] == obs_rowid for r in matches) -async def test_create_stores_embedding( - conn: sqlite3.Connection, service: ObservationService -) -> None: - obs_id = await service.create("embedded text") - count = conn.execute( - "SELECT COUNT(*) AS c FROM observation_embeddings WHERE observation_id = ?", - (obs_id,), - ).fetchone()["c"] - assert count == 1 - - async def test_create_writes_audit_row( conn: sqlite3.Connection, service: ObservationService ) -> None: @@ -236,53 +196,6 @@ async def test_create_writes_audit_row( assert detail["scope_path"] is None -async def test_create_rolls_back_on_embedder_failure( - conn: sqlite3.Connection, fixed_clock: Any -) -> None: - bad_embedder = _StubEmbedder(raise_on_embed=EmbeddingError("boom")) - svc = ObservationService( - conn, - bad_embedder, - clock=fixed_clock, - project_resolver=lambda: "test-project", - scope_resolver=lambda: None, - session_id="sess-abc", - episodes=EpisodeService(conn), - ) - - with pytest.raises(EmbeddingError): - await svc.create("doomed") - - # Nothing persisted. - obs_count = conn.execute("SELECT COUNT(*) AS c FROM observations").fetchone()["c"] - emb_count = conn.execute( - "SELECT COUNT(*) AS c FROM observation_embeddings" - ).fetchone()["c"] - audit_count = conn.execute("SELECT COUNT(*) AS c FROM audit_log").fetchone()["c"] - assert obs_count == 0 - assert emb_count == 0 - assert audit_count == 0 - - # Fail-fast contract (Phase 2 caveat): episode lazy-open commits before - # the embed call, so a background episode and its episode_sessions row - # may be present after an embed-failure. Lock the shape so a future - # refactor that tightens the contract (no DB artifacts at all) surfaces - # this test failing loudly. - episodes_rows = conn.execute("SELECT goal, ended_at FROM episodes").fetchall() - # Exactly one background episode (goal NULL, ended_at NULL) may exist. - assert len(episodes_rows) <= 1 - if episodes_rows: - assert episodes_rows[0]["goal"] is None - assert episodes_rows[0]["ended_at"] is None - - session_rows = conn.execute( - "SELECT left_at FROM episode_sessions" - ).fetchall() - assert len(session_rows) <= 1 - if session_rows: - assert session_rows[0]["left_at"] is None - - # --------------------------------------------------------------------------- # record_use() # --------------------------------------------------------------------------- @@ -405,10 +318,8 @@ def test_session_id_resolves_from_env_var( """When CLAUDE_SESSION_ID is set and no session_id kwarg, use the env var.""" from better_memory.services.episode import EpisodeService monkeypatch.setenv("CLAUDE_SESSION_ID", "claude-sess-abc") - embedder = _StubEmbedder() svc = ObservationService( conn, - embedder, clock=fixed_clock, project_resolver=lambda: "test-project", episodes=EpisodeService(conn), @@ -422,10 +333,8 @@ def test_session_id_kwarg_overrides_env_var( """Explicit session_id kwarg beats the env var.""" from better_memory.services.episode import EpisodeService monkeypatch.setenv("CLAUDE_SESSION_ID", "claude-sess-abc") - embedder = _StubEmbedder() svc = ObservationService( conn, - embedder, clock=fixed_clock, project_resolver=lambda: "test-project", session_id="explicit-sess", @@ -440,10 +349,8 @@ def test_session_id_falls_back_to_uuid_when_no_env( """Without CLAUDE_SESSION_ID or explicit kwarg, generate a uuid4.""" from better_memory.services.episode import EpisodeService monkeypatch.delenv("CLAUDE_SESSION_ID", raising=False) - embedder = _StubEmbedder() svc = ObservationService( conn, - embedder, clock=fixed_clock, project_resolver=lambda: "test-project", episodes=EpisodeService(conn), @@ -483,9 +390,8 @@ async def test_filter_by_project_only( session_id="s2", outcome="success", close_reason="goal_complete" ) - embedder = _StubEmbedder() svc = ObservationService( - conn, embedder, clock=fixed_clock, + conn, clock=fixed_clock, project_resolver=lambda: "p", episodes=epsvc, ) @@ -535,9 +441,8 @@ async def test_filter_by_episode_id( ) conn.commit() - embedder = _StubEmbedder() svc = ObservationService( - conn, embedder, clock=fixed_clock, + conn, clock=fixed_clock, project_resolver=lambda: "p", episodes=epsvc, ) @@ -572,9 +477,8 @@ async def test_filter_by_component_theme_outcome( ) conn.commit() - embedder = _StubEmbedder() svc = ObservationService( - conn, embedder, clock=fixed_clock, + conn, clock=fixed_clock, project_resolver=lambda: "p", episodes=epsvc, ) @@ -626,9 +530,8 @@ async def test_orders_newest_first_and_caps_at_limit( ) conn.commit() - embedder = _StubEmbedder() svc = ObservationService( - conn, embedder, clock=fixed_clock, + conn, clock=fixed_clock, project_resolver=lambda: "p", episodes=epsvc, ) @@ -655,14 +558,13 @@ async def test_query_mode_routes_through_hybrid_search( session_id="s1", outcome="success", close_reason="goal_complete" ) - embedder = _StubEmbedder() svc = ObservationService( - conn, embedder, clock=fixed_clock, + conn, clock=fixed_clock, project_resolver=lambda: "p", episodes=epsvc, ) - # Insert two distinct observations via service so embeddings exist. + # Insert two distinct observations via service so FTS rows exist. await svc.create("flamingo migration failed", project="p") await svc.create("pelican lazy-init succeeded", project="p") conn.commit() diff --git a/tests/services/test_observation_retrieve.py b/tests/services/test_observation_retrieve.py index ee8609b7..efd5324e 100644 --- a/tests/services/test_observation_retrieve.py +++ b/tests/services/test_observation_retrieve.py @@ -1,6 +1,7 @@ """Tests for :meth:`ObservationService.retrieve` — the three-bucket API. -Uses a stub embedder so the test doesn't contact Ollama. +There is no embedder any more (remove-ollama-embeddings Task 6); this +exercises the FTS5/trigram BM25-only path. """ from __future__ import annotations @@ -18,22 +19,6 @@ from better_memory.services.episode import EpisodeService from better_memory.services.observation import BucketedResults, ObservationService -_VEC_DIM = 768 -_VEC_FIXED = [0.01] * _VEC_DIM - - -class _StubEmbedder: - """Minimal async embedder that always returns the same vector.""" - - def __init__(self, *, vector: list[float] | None = None) -> None: - self._vector = vector if vector is not None else list(_VEC_FIXED) - self.calls: list[str] = [] - - async def embed(self, text: str) -> list[float]: - self.calls.append(text) - return list(self._vector) - - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -55,18 +40,12 @@ def fixed_clock() -> Any: return lambda: fixed -@pytest.fixture -def embedder() -> _StubEmbedder: - return _StubEmbedder() - - @pytest.fixture def service( - conn: sqlite3.Connection, fixed_clock: Any, embedder: _StubEmbedder + conn: sqlite3.Connection, fixed_clock: Any ) -> ObservationService: return ObservationService( conn, - embedder, clock=fixed_clock, project_resolver=lambda: "test-project", scope_resolver=lambda: None, @@ -158,15 +137,6 @@ async def test_retrieve_buckets_sorted_by_final_score_descending( assert scores == sorted(scores, reverse=True) -async def test_retrieve_embeds_query_once( - service: ObservationService, embedder: _StubEmbedder -) -> None: - await _seed_mix(service) - embedder.calls.clear() - await service.retrieve(query="marker") - assert len(embedder.calls) == 1 - - async def test_retrieve_reinforcement_orders_within_do_bucket( service: ObservationService, conn: sqlite3.Connection, @@ -194,7 +164,7 @@ async def test_retrieve_reinforcement_orders_within_do_bucket( async def test_retrieve_with_no_query_still_returns_bucketed( service: ObservationService, ) -> None: - # query=None and no vector → hybrid_search returns [] for each bucket. + # query=None → hybrid_search returns [] for each bucket. await _seed_mix(service) result = await service.retrieve(query=None) assert result.do == [] @@ -208,10 +178,10 @@ async def test_retrieve_with_hyphenated_query_ranks_fts_match_first( """Regression: ``better-memory`` once crashed FTS5 as ``-memory`` column. The safety net in hybrid search would swallow the error and return ``[]`` - for the FTS path, so users got vector-only hits (or nothing) for any - hyphenated query. After sanitising, the FTS path delivers the matching - row, which then ranks first in RRF fusion because only it contributes - an FTS rank on top of the vector rank. + for the word-FTS path, so users got trigram-only hits (or nothing) for + any hyphenated query. After sanitising, the word-FTS path delivers the + matching row, which then ranks first in RRF fusion because it now + contributes an FTS rank on top of the trigram rank. """ matched_id = await service.create( "better memory retrieval conventions", outcome="success" diff --git a/tests/services/test_observation_sqlite.py b/tests/services/test_observation_sqlite.py index dae2631b..0b9717b0 100644 --- a/tests/services/test_observation_sqlite.py +++ b/tests/services/test_observation_sqlite.py @@ -36,7 +36,6 @@ def service(conn: sqlite3.Connection, fixed_clock) -> ObservationService: episodes = EpisodeService(conn, clock=fixed_clock) return ObservationService( conn, - embedder=None, clock=fixed_clock, project_resolver=lambda: "p", scope_resolver=lambda: None, @@ -45,23 +44,6 @@ def service(conn: sqlite3.Connection, fixed_clock) -> ObservationService: ) -async def test_create_skips_vec0_insert_in_sqlite_mode( - service: ObservationService, conn: sqlite3.Connection -) -> None: - obs_id = await service.create(content="hello tfidf world", outcome="success") - - row = conn.execute( - "SELECT id FROM observations WHERE id = ?", (obs_id,) - ).fetchone() - assert row is not None - - vec_count = conn.execute( - "SELECT COUNT(*) FROM observation_embeddings WHERE observation_id = ?", - (obs_id,), - ).fetchone()[0] - assert vec_count == 0 - - async def test_create_populates_trigram_index( service: ObservationService, conn: sqlite3.Connection ) -> None: diff --git a/tests/services/test_reflection.py b/tests/services/test_reflection.py index 93bdaca4..2e4b0e8b 100644 --- a/tests/services/test_reflection.py +++ b/tests/services/test_reflection.py @@ -2072,7 +2072,7 @@ def test_db_integrity_error_propagates_and_no_synthesized_at( ) conn.commit() - def boom(self, actions, *, project, embed_tasks=None): + def boom(self, actions, *, project): raise sqlite3.IntegrityError("simulated FK violation") monkeypatch.setattr( diff --git a/tests/services/test_reflection_embedding_write.py b/tests/services/test_reflection_embedding_write.py deleted file mode 100644 index a3496a6d..00000000 --- a/tests/services/test_reflection_embedding_write.py +++ /dev/null @@ -1,473 +0,0 @@ -"""Synthesis writes reflection embeddings, best-effort. - -reflection_embeddings sat at 0 rows from migration 0002 until this change: -the write path simply never embedded. Failures must never block synthesis. -""" -from __future__ import annotations - -from datetime import UTC, datetime -from pathlib import Path - -import pytest - -from better_memory.db.connection import connect -from better_memory.db.schema import apply_migrations -from better_memory.embeddings.sync_embed import SyncEmbedder -from better_memory.services.episode import EpisodeService -from better_memory.services.reflection import ( - AugmentAction, - MergeAction, - NewAction, - ReflectionService, - ReflectionSynthesisService, - SynthesisResponse, - _embedding_source_text, -) -from tests.services._embedding_fakes import FakeEmbedder - - -@pytest.fixture -def conn(tmp_memory_db: Path): - c = connect(tmp_memory_db) - apply_migrations(c) - try: - yield c - finally: - c.close() - - -@pytest.fixture -def fixed_clock(): - fixed = datetime(2026, 4, 22, 10, 0, 0, tzinfo=UTC) - return lambda: fixed - - -def _vec_count(conn): - return conn.execute("SELECT COUNT(*) FROM reflection_embeddings").fetchone()[0] - - -def _insert_obs( - conn, - *, - obs_id: str, - project: str, - episode_id: str, - outcome: str = "success", - content: str = "obs content", - component: str | None = None, - theme: str | None = None, - tech: str | None = None, - created_at: str = "2026-04-22T09:00:00+00:00", - status: str = "active", - status_changed_at: str | None = None, -) -> None: - if status_changed_at is None: - status_changed_at = created_at - conn.execute( - """ - INSERT INTO observations ( - id, content, project, component, theme, outcome, - reinforcement_score, episode_id, tech, created_at, status, - status_changed_at - ) VALUES (?, ?, ?, ?, ?, ?, 0.0, ?, ?, ?, ?, ?) - """, - (obs_id, content, project, component, theme, outcome, - episode_id, tech, created_at, status, status_changed_at), - ) - - -def _insert_reflection( - conn, - *, - refl_id: str, - project: str, - phase: str = "general", - polarity: str = "do", - status: str = "pending_review", - tech: str | None = None, - confidence: float = 0.5, - use_cases: str = "uc", - hints: str = "[]", - title: str = "t", - evidence_count: int = 0, -) -> None: - import json as _json - if not hints.startswith("["): - hints = _json.dumps(hints) - conn.execute( - """ - INSERT INTO reflections ( - id, title, project, tech, phase, polarity, use_cases, hints, - confidence, status, evidence_count, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - (refl_id, title, project, tech, phase, polarity, use_cases, hints, - confidence, status, evidence_count, - "2026-04-22T08:00:00+00:00", "2026-04-22T08:00:00+00:00"), - ) - - -def test_embedding_source_text_joins_fields(): - assert _embedding_source_text("T", "when X", ["h1", "h2"]) == "T\nwhen X\nh1\nh2" - - -class TestApplyNewEmbedding: - def test_embeds_on_new_reflection(self, conn, fixed_clock): - epsvc = EpisodeService(conn, clock=fixed_clock) - ep = epsvc.start_foreground(session_id="s1", project="p", goal="g") - epsvc.close_active( - session_id="s1", outcome="success", close_reason="goal_complete" - ) - _insert_obs(conn, obs_id="obs-1", project="p", episode_id=ep) - conn.commit() - - fake = FakeEmbedder() - sync_embedder = SyncEmbedder(lambda: fake) - svc = ReflectionSynthesisService( - conn, clock=fixed_clock, sync_embedder=sync_embedder - ) - action = NewAction( - title="Always test", phase="general", polarity="do", - use_cases="when writing code", hints=["write tests first"], - tech="python", confidence=0.6, - source_observation_ids=["obs-1"], - ) - svc._apply_new([action], project="p") - conn.commit() - - assert _vec_count(conn) == 1 - assert len(fake.calls) == 1 - assert "Always test" in fake.calls[0] - - def test_no_embed_row_when_embedder_fails(self, conn, fixed_clock): - epsvc = EpisodeService(conn, clock=fixed_clock) - ep = epsvc.start_foreground(session_id="s1", project="p", goal="g") - epsvc.close_active( - session_id="s1", outcome="success", close_reason="goal_complete" - ) - _insert_obs(conn, obs_id="obs-1", project="p", episode_id=ep) - conn.commit() - - sync_embedder = SyncEmbedder(lambda: FakeEmbedder(fail=True)) - svc = ReflectionSynthesisService( - conn, clock=fixed_clock, sync_embedder=sync_embedder - ) - action = NewAction( - title="t", phase="general", polarity="do", - use_cases="uc", hints=[], tech=None, confidence=0.5, - source_observation_ids=["obs-1"], - ) - svc._apply_new([action], project="p") - conn.commit() - - refl = conn.execute( - "SELECT id FROM reflections WHERE title = 't'" - ).fetchone() - assert refl is not None - assert _vec_count(conn) == 0 - - def test_no_embed_when_sync_embedder_not_passed(self, conn, fixed_clock): - epsvc = EpisodeService(conn, clock=fixed_clock) - ep = epsvc.start_foreground(session_id="s1", project="p", goal="g") - epsvc.close_active( - session_id="s1", outcome="success", close_reason="goal_complete" - ) - _insert_obs(conn, obs_id="obs-1", project="p", episode_id=ep) - conn.commit() - - svc = ReflectionSynthesisService(conn, clock=fixed_clock) - action = NewAction( - title="t", phase="general", polarity="do", - use_cases="uc", hints=[], tech=None, confidence=0.5, - source_observation_ids=["obs-1"], - ) - svc._apply_new([action], project="p") - conn.commit() - - refl = conn.execute( - "SELECT id FROM reflections WHERE title = 't'" - ).fetchone() - assert refl is not None - assert _vec_count(conn) == 0 - - -class TestApplyAugmentEmbedding: - def test_reembeds_on_augment(self, conn, fixed_clock): - _insert_reflection( - conn, refl_id="r1", project="p", - hints='["old-hint"]', confidence=0.5, evidence_count=1, - title="Existing", use_cases="old uc", - ) - conn.commit() - - fake = FakeEmbedder() - sync_embedder = SyncEmbedder(lambda: fake) - svc = ReflectionSynthesisService( - conn, clock=fixed_clock, sync_embedder=sync_embedder - ) - action = NewAction( - title="seed", phase="general", polarity="do", - use_cases="uc", hints=[], tech=None, confidence=0.5, - source_observation_ids=[], - ) - # Seed one embedding row directly to prove augment replaces it - # (DELETE+INSERT), not merely inserting a duplicate. - import sqlite_vec - conn.execute( - "INSERT INTO reflection_embeddings (reflection_id, embedding) " - "VALUES (?, ?)", - ("r1", sqlite_vec.serialize_float32([0.0] * 768)), - ) - conn.commit() - assert _vec_count(conn) == 1 - - augment_action = AugmentAction( - reflection_id="r1", - add_hints=["new-hint"], - rewrite_use_cases=None, - confidence_delta=0.0, - add_source_observation_ids=[], - ) - svc._apply_augment([augment_action]) - conn.commit() - - assert _vec_count(conn) == 1 - assert len(fake.calls) == 1 - assert "new-hint" in fake.calls[0] - - -class TestReflectionServiceUpdateTextEmbedding: - """ReflectionService.update_text is the UI-facing edit path. - - Editing use_cases/hints changes the discriminating text a stored - vector indexes (title + use_cases + hints, see - `_embedding_source_text`); without a re-embed here the vector goes - stale the moment the UI edits a reflection. - """ - - def test_update_text_reembeds(self, conn, fixed_clock): - _insert_reflection( - conn, refl_id="r1", project="p", - title="Existing", use_cases="old uc", hints='["old-hint"]', - ) - conn.commit() - - fake = FakeEmbedder() - svc = ReflectionService( - conn, clock=fixed_clock, sync_embedder=SyncEmbedder(lambda: fake), - ) - svc.update_text(reflection_id="r1", use_cases="new uc", hints="new-hint") - - assert _vec_count(conn) == 1 - assert len(fake.calls) == 1 - assert fake.calls[0] == _embedding_source_text( - "Existing", "new uc", ["new-hint"] - ) - - def test_update_text_replaces_not_duplicates_vec_row(self, conn, fixed_clock): - _insert_reflection( - conn, refl_id="r1", project="p", - title="Existing", use_cases="old uc", hints='["old-hint"]', - ) - conn.commit() - import sqlite_vec - conn.execute( - "INSERT INTO reflection_embeddings (reflection_id, embedding) " - "VALUES (?, ?)", - ("r1", sqlite_vec.serialize_float32([0.0] * 768)), - ) - conn.commit() - assert _vec_count(conn) == 1 - - fake = FakeEmbedder() - svc = ReflectionService( - conn, clock=fixed_clock, sync_embedder=SyncEmbedder(lambda: fake), - ) - svc.update_text(reflection_id="r1", use_cases="new uc", hints="new-hint") - - assert _vec_count(conn) == 1 # replaced, not duplicated - - def test_update_text_works_without_sync_embedder(self, conn, fixed_clock): - _insert_reflection( - conn, refl_id="r1", project="p", - title="Existing", use_cases="old uc", hints='["old-hint"]', - ) - conn.commit() - - svc = ReflectionService(conn, clock=fixed_clock) # no sync_embedder - svc.update_text(reflection_id="r1", use_cases="new uc", hints="new-hint") - - row = conn.execute( - "SELECT use_cases FROM reflections WHERE id = 'r1'" - ).fetchone() - assert row["use_cases"] == "new uc" - assert _vec_count(conn) == 0 - - def test_no_embed_row_when_embedder_fails(self, conn, fixed_clock): - _insert_reflection( - conn, refl_id="r1", project="p", - title="Existing", use_cases="old uc", hints='["old-hint"]', - ) - conn.commit() - - svc = ReflectionService( - conn, clock=fixed_clock, - sync_embedder=SyncEmbedder(lambda: FakeEmbedder(fail=True)), - ) - svc.update_text(reflection_id="r1", use_cases="new uc", hints="new-hint") - - row = conn.execute( - "SELECT use_cases FROM reflections WHERE id = 'r1'" - ).fetchone() - assert row["use_cases"] == "new uc" - assert _vec_count(conn) == 0 - - -class TestApplyDecisionDefersEmbeddingOutsideWriteLock: - """apply_decision must not hold the writer lock across Ollama calls. - - #97: the blocking embed is now performed AFTER the SAVEPOINT commits. - These tests exercise the end-to-end flow (apply_decision → embeds) - and prove the deferred path writes vectors correctly. - """ - - def _make_episode(self, conn, fixed_clock): - epsvc = EpisodeService(conn, clock=fixed_clock) - ep = epsvc.start_foreground(session_id="s1", project="p", goal="g") - epsvc.close_active( - session_id="s1", outcome="success", close_reason="goal_complete" - ) - return ep - - def test_apply_decision_writes_embedding_for_new(self, conn, fixed_clock): - ep = self._make_episode(conn, fixed_clock) - _insert_obs(conn, obs_id="obs-1", project="p", episode_id=ep) - conn.commit() - - fake = FakeEmbedder() - svc = ReflectionSynthesisService( - conn, clock=fixed_clock, sync_embedder=SyncEmbedder(lambda: fake), - ) - response = SynthesisResponse( - new=[NewAction( - title="Always test", phase="general", polarity="do", - use_cases="when writing code", hints=["write tests first"], - tech="python", confidence=0.6, - source_observation_ids=["obs-1"], - )], - augment=[], merge=[], ignore=[], - ) - svc.apply_decision(episode_id=ep, response=response, project="p") - - assert _vec_count(conn) == 1 - # FakeEmbedder.embed_batch appends the incoming list to calls; the - # per-row embed_text path would append a bare str instead. Assert - # the one recorded call is the batched form. - assert len(fake.calls) == 1 - assert isinstance(fake.calls[0], list) - assert len(fake.calls[0]) == 1 - assert "Always test" in fake.calls[0][0] - - def test_apply_decision_writes_embedding_for_augment( - self, conn, fixed_clock - ): - ep = self._make_episode(conn, fixed_clock) - _insert_reflection( - conn, refl_id="r1", project="p", - title="Existing", use_cases="old uc", hints='["old-hint"]', - ) - conn.commit() - - fake = FakeEmbedder() - svc = ReflectionSynthesisService( - conn, clock=fixed_clock, sync_embedder=SyncEmbedder(lambda: fake), - ) - response = SynthesisResponse( - new=[], augment=[AugmentAction( - reflection_id="r1", - add_hints=["new-hint"], - rewrite_use_cases=None, - confidence_delta=0.0, - add_source_observation_ids=[], - )], merge=[], ignore=[], - ) - svc.apply_decision(episode_id=ep, response=response, project="p") - - assert _vec_count(conn) == 1 - assert len(fake.calls) == 1 - assert isinstance(fake.calls[0], list) - assert "new-hint" in fake.calls[0][0] - - def test_apply_decision_survives_embedder_failure(self, conn, fixed_clock): - """Embed failure never rolls back the reflection writes. - - This is the pre-existing best-effort contract; the writer-lock fix - must not silently strengthen it into an atomicity guarantee. - """ - ep = self._make_episode(conn, fixed_clock) - _insert_obs(conn, obs_id="obs-1", project="p", episode_id=ep) - conn.commit() - - svc = ReflectionSynthesisService( - conn, clock=fixed_clock, - sync_embedder=SyncEmbedder(lambda: FakeEmbedder(fail=True)), - ) - response = SynthesisResponse( - new=[NewAction( - title="t", phase="general", polarity="do", - use_cases="uc", hints=[], tech=None, confidence=0.5, - source_observation_ids=["obs-1"], - )], - augment=[], merge=[], ignore=[], - ) - svc.apply_decision(episode_id=ep, response=response, project="p") - - refl = conn.execute( - "SELECT id FROM reflections WHERE title = 't'" - ).fetchone() - assert refl is not None - assert _vec_count(conn) == 0 - - -class TestApplyMergeEmbedding: - def test_merge_deletes_source_embedding_no_reembed_of_target( - self, conn, fixed_clock - ): - _insert_reflection(conn, refl_id="src", project="p") - _insert_reflection(conn, refl_id="tgt", project="p") - conn.commit() - - import sqlite_vec - conn.execute( - "INSERT INTO reflection_embeddings (reflection_id, embedding) " - "VALUES (?, ?)", - ("src", sqlite_vec.serialize_float32([0.1] * 768)), - ) - conn.execute( - "INSERT INTO reflection_embeddings (reflection_id, embedding) " - "VALUES (?, ?)", - ("tgt", sqlite_vec.serialize_float32([0.2] * 768)), - ) - conn.commit() - - fake = FakeEmbedder() - sync_embedder = SyncEmbedder(lambda: fake) - svc = ReflectionSynthesisService( - conn, clock=fixed_clock, sync_embedder=sync_embedder - ) - action = MergeAction(source_id="src", target_id="tgt", justification="dupes") - svc._apply_merge([action]) - conn.commit() - - source_count = conn.execute( - "SELECT COUNT(*) FROM reflection_embeddings WHERE reflection_id = ?", - ("src",), - ).fetchone()[0] - assert source_count == 0 - # Target's embedding is untouched; no new embed call was made. - assert fake.calls == [] - target_count = conn.execute( - "SELECT COUNT(*) FROM reflection_embeddings WHERE reflection_id = ?", - ("tgt",), - ).fetchone()[0] - assert target_count == 1 diff --git a/tests/services/test_relevant.py b/tests/services/test_relevant.py index c22a17c8..2df0c9ca 100644 --- a/tests/services/test_relevant.py +++ b/tests/services/test_relevant.py @@ -1,10 +1,11 @@ -"""Contextual relevance: BM25 + vec + Wilson prior behind an evidence gate. +"""Contextual relevance: BM25 + Wilson prior behind an evidence gate. The gate is the point: a memory injects only with positive relevance -evidence (BM25 match on the query, or vec cosine >= floor). The Wilson -prior RANKS qualifiers but can never qualify a memory alone — popularity -must not force irrelevant injections (that failure mode measured 13% useful -as bootstrap). Vectors are unit-norm, so cosine >= c is L2 dist^2 <= 2(1-c). +evidence (BM25 match on the query for reflections; a keyword-hit floor for +semantics, which have no FTS leg of their own). The Wilson prior RANKS +qualifiers but can never qualify a memory alone -- popularity must not +force irrelevant injections (that failure mode measured 13% useful as +bootstrap). """ from __future__ import annotations @@ -12,14 +13,11 @@ from pathlib import Path import pytest -import sqlite_vec from better_memory.db.connection import connect from better_memory.db.schema import apply_migrations -from better_memory.embeddings.sync_embed import SyncEmbedder from better_memory.services.relevant import RelevantMemory, retrieve_relevant from better_memory.storage.sqlite import SqliteBackend -from tests.services._embedding_fakes import DirectedEmbedder, FakeEmbedder FIXED_NOW = datetime(2026, 7, 11, 12, 0, 0, tzinfo=UTC) @@ -34,11 +32,8 @@ def conn(tmp_memory_db: Path): c.close() -def _backend(conn, *, sync_embedder=None, project="p"): - return SqliteBackend( - memory_conn=conn, sync_embedder=sync_embedder, - session_id=None, project=project, - ) +def _backend(conn, *, project="p"): + return SqliteBackend(memory_conn=conn, session_id=None, project=project) def _seed_reflection( @@ -74,22 +69,6 @@ def _seed_semantic( conn.commit() -def _embed_reflection(conn, rid, vector): - conn.execute( - "INSERT INTO reflection_embeddings (reflection_id, embedding) VALUES (?, ?)", - (rid, sqlite_vec.serialize_float32(vector)), - ) - conn.commit() - - -def _embed_semantic(conn, sid, vector): - conn.execute( - "INSERT INTO semantic_embeddings (memory_id, embedding) VALUES (?, ?)", - (sid, sqlite_vec.serialize_float32(vector)), - ) - conn.commit() - - class TestBM25Gate: def test_bm25_match_qualifies(self, conn): _seed_reflection(conn, "r1", title="Retention archives by confidence") @@ -110,68 +89,6 @@ def test_no_evidence_no_injection(self, conn): assert out == [] -class TestVecGate: - def test_vec_match_qualifies_without_token_overlap(self, conn): - # DirectedEmbedder maps EITHER trigger phrase to the same unit - # vector, so the title (matching via "Stdout handling") and the - # query (matching via "console output") land at cosine 1 despite - # sharing zero tokens -- isolating the vec leg from BM25. - title = "Stdout handling on win32 interpreters" - emb = DirectedEmbedder("Stdout handling", "console output") - _seed_reflection(conn, "r1", title=title) - _embed_reflection(conn, "r1", emb._vec(title)) - sync_embedder = SyncEmbedder(lambda: emb) - backend = _backend(conn, sync_embedder=sync_embedder) - out = retrieve_relevant( - backend, query="console output disappears on windows", project="p", - conn=conn, sync_embedder=sync_embedder, now=lambda: FIXED_NOW, - ) - assert [m.id for m in out] == ["r1"] - - def test_vec_below_floor_does_not_qualify(self, conn): - # Orthogonal unit vectors: the title's trigger is absent from the - # query, so DirectedEmbedder's else-branch gives the query the - # noise vector [0,1,...] while the stored embedding is [1,0,...]. - title = "Zebra flamingo unrelated topic" - emb = DirectedEmbedder("only-in-the-title-xyz") - _seed_reflection(conn, "r1", title=title) - _embed_reflection(conn, "r1", emb._vec("only-in-the-title-xyz")) - sync_embedder = SyncEmbedder(lambda: emb) - backend = _backend(conn, sync_embedder=sync_embedder) - out = retrieve_relevant( - backend, query="totally different wording", project="p", - conn=conn, sync_embedder=sync_embedder, now=lambda: FIXED_NOW, - ) - assert out == [] - - def test_orphan_vector_row_does_not_leak_via_vec_gate(self, conn): - # Issue #104: the vec table is global and its rows persist across - # retire (retire only UPDATEs status; the vector stays). An - # "orphan" vector -- one whose metadata row was retired or deleted - # so backend.retrieve does not surface it as a candidate -- must - # not qualify anything through the vec leg. Prior to the fix, - # _vec_qualifiers issued an unfiltered kNN, so the orphan showed - # up in vec_r but happened to be harmless only because the RRF - # loop keys on the caller's candidate list -- this test locks in - # that the candidate scoping is now enforced at the vec-query - # layer too, so orphan ids never appear in vec_r at all. - from better_memory.services.relevant import _vec_qualifiers - emb = DirectedEmbedder("orphan trigger") - # Insert a vec row for a reflection id that has NO metadata row. - _embed_reflection(conn, "r-orphan", emb._vec("orphan trigger")) - qvec = emb._vec("orphan trigger") - # candidate_ids empty -> short-circuit - assert _vec_qualifiers( - conn, "reflection_embeddings", "reflection_id", - qvec, 0.55, [], - ) == {} - # candidate_ids restricts what returns from the kNN scan - assert _vec_qualifiers( - conn, "reflection_embeddings", "reflection_id", - qvec, 0.55, ["r-different"], - ) == {} - - class TestWilsonRanking: def test_wilson_ranks_among_qualifiers(self, conn): _seed_reflection(conn, "r-hi", title="Retention thresholds alpha", @@ -187,19 +104,6 @@ def test_wilson_ranks_among_qualifiers(self, conn): class TestSemantics: - def test_semantic_qualifies_via_vec(self, conn): - content = "Stdout handling on win32 interpreters" - emb = DirectedEmbedder("Stdout handling", "console output") - _seed_semantic(conn, "s1", content=content) - _embed_semantic(conn, "s1", emb._vec(content)) - sync_embedder = SyncEmbedder(lambda: emb) - backend = _backend(conn, sync_embedder=sync_embedder) - out = retrieve_relevant( - backend, query="console output disappears on windows", project="p", - conn=conn, sync_embedder=sync_embedder, now=lambda: FIXED_NOW, - ) - assert [(m.kind, m.id) for m in out] == [("semantic", "s1")] - def test_semantic_fallback_keyword_when_no_embedder(self, conn): _seed_semantic(conn, "s1", content="repo uses uv run pytest on windows") backend = _backend(conn) @@ -209,18 +113,18 @@ def test_semantic_fallback_keyword_when_no_embedder(self, conn): ) assert [(m.kind, m.id) for m in out] == [("semantic", "s1")] - def test_semantic_fallback_keyword_when_conn_none_agentcore(self, conn): - """agentcore mode: conn=None but the embedder is healthy and qvec is - real. The vec candidate set is still structurally empty because vec - queries need the sqlite conn -- so the keyword fallback must fire - here too, not only when qvec itself is None.""" + def test_semantic_fallback_keyword_when_conn_none(self, conn): + """Semantics have no vec/FTS leg at all, so the keyword-hit floor + is their only evidence leg regardless of ``conn`` -- a sqlite + backend called with ``conn=None`` keeps the same keyword-fallback + behaviour as ``conn=conn`` (see services/relevant.py's module + docstring: 'a sqlite backend called with conn=None keeps the + pre-existing keyword-fallback behavior unchanged').""" _seed_semantic(conn, "s1", content="repo uses uv run pytest on windows") - emb = DirectedEmbedder("unrelated trigger phrase") - sync_embedder = SyncEmbedder(lambda: emb) - backend = _backend(conn, sync_embedder=sync_embedder) + backend = _backend(conn) out = retrieve_relevant( backend, query="uv run pytest windows setup", project="p", - conn=None, sync_embedder=sync_embedder, now=lambda: FIXED_NOW, + conn=None, now=lambda: FIXED_NOW, ) assert [(m.kind, m.id) for m in out] == [("semantic", "s1")] @@ -248,34 +152,17 @@ def test_no_conn_falls_back_to_keywords(self, conn): ) assert [m.id for m in out] == ["r-hi", "r-lo"] - def test_embedder_failure_degrades_to_bm25(self, conn): - _seed_reflection(conn, "r1", title="Retention archives by confidence") - sync_embedder = SyncEmbedder(lambda: FakeEmbedder(fail=True)) - backend = _backend(conn, sync_embedder=sync_embedder) - out = retrieve_relevant( - backend, query="how does retention archive things", project="p", - conn=conn, sync_embedder=sync_embedder, now=lambda: FIXED_NOW, - ) - assert [m.id for m in out] == ["r1"] - @pytest.mark.parametrize("blank_query", ["", " ", "\t\n"]) def test_blank_query_returns_empty(conn, blank_query): - # DirectedEmbedder with no trigger args maps EVERY text to the same - # unit vector, so it would match anything if the vec leg were reached -- - # the point is that a contentless query short-circuits before that. title = "Retention archives by confidence" - emb = DirectedEmbedder() _seed_reflection(conn, "r1", title=title) - _embed_reflection(conn, "r1", emb._vec(title)) - sync_embedder = SyncEmbedder(lambda: emb) - backend = _backend(conn, sync_embedder=sync_embedder) + backend = _backend(conn) out = retrieve_relevant( backend, query=blank_query, project="p", - conn=conn, sync_embedder=sync_embedder, now=lambda: FIXED_NOW, + conn=conn, now=lambda: FIXED_NOW, ) assert out == [] - assert emb.calls == [] class _StubAgentCoreBackend: @@ -330,15 +217,14 @@ def _stub_reflection(rid, *, title, use_cases="", useful=0, overlooked=0, ignore class TestAgentCoreRelevanceGate: """conn=None + supports_synthesis=False routes retrieve_relevant - through backend.relevance_ranks instead of the BM25/vec legs (which - are structurally unavailable with no sqlite conn -- see + through backend.relevance_ranks instead of the BM25 leg (which is + structurally unavailable with no sqlite conn -- see services/relevant.py's agentcore_mode gate).""" def test_backend_rank_membership_qualifies_without_token_overlap(self): # Zero shared tokens between title and query -- the old # keyword-hit fallback would never qualify this; only the backend - # rank map's membership does, mirroring the vec-gate's - # DirectedEmbedder scenario but with no embedder involved at all. + # rank map's membership does. reflections = { "do": [_stub_reflection("r1", title="Zebra flamingo unrelated topic")], "dont": [], "neutral": [], diff --git a/tests/services/test_retention.py b/tests/services/test_retention.py index f82d1c71..20078038 100644 --- a/tests/services/test_retention.py +++ b/tests/services/test_retention.py @@ -420,53 +420,17 @@ def test_dry_run_includes_newly_archivable_rows_at_zero_prune_age( assert real.archived_via_no_outcome_episode == 1 -class TestPruneCleansEmbeddings: - def test_prune_deletes_corresponding_embeddings_row( - self, conn, fixed_clock - ): - """When _prune deletes an observation, its row in - observation_embeddings must also go — vec0 has no DELETE - trigger so retention has to clean up explicitly.""" - _seed_episode(conn, ep_id="e1", project="proj-a") - _seed_observation( - conn, obs_id="obs-old", ep_id="e1", status="archived", - status_changed_at="2025-01-01T00:00:00+00:00", - ) - # Insert a fake embedding row keyed to obs-old. - # vec0 syntax: a 768-dim FLOAT vector. Use a literal. - import struct - embedding = struct.pack("768f", *(0.0 for _ in range(768))) - conn.execute( - "INSERT INTO observation_embeddings (observation_id, embedding) " - "VALUES (?, ?)", - ("obs-old", embedding), - ) - conn.commit() - - # Confirm setup. - emb_before = conn.execute( - "SELECT COUNT(*) AS n FROM observation_embeddings " - "WHERE observation_id = 'obs-old'" - ).fetchone()["n"] - assert emb_before == 1 - - report = RetentionService(conn, clock=fixed_clock).run( - retention_days=90, prune=True, prune_age_days=365, - ) - assert report.pruned == 1 - - emb_after = conn.execute( - "SELECT COUNT(*) AS n FROM observation_embeddings " - "WHERE observation_id = 'obs-old'" - ).fetchone()["n"] - assert emb_after == 0 # embeddings row must be gone too - - class TestRetentionAtomicity: - """Multi-statement archive/prune operations must roll back as a - unit on failure. Without a SAVEPOINT, partial UPDATEs sit in the - implicit SQLite transaction and would be persisted by the next + """``run_archive``'s three rule UPDATEs must roll back as a unit on + failure. Without a SAVEPOINT, partial UPDATEs sit in the implicit + SQLite transaction and would be persisted by the next ``conn.commit()`` from any service sharing the connection. + + (Prior to remove-ollama-embeddings Task 9 this class also covered + ``_prune``'s atomicity across its observations + vec0-embeddings + deletes; that second delete went away with the observation_embeddings + table, so ``_prune`` is now a single statement with nothing left to + roll back.) """ def test_archive_rolls_back_partial_state_when_a_rule_raises( @@ -502,48 +466,3 @@ def _boom(*_a, **_k): "SELECT status FROM observations WHERE id = 'obs-A'" ).fetchone() assert row["status"] == "active" - - def test_prune_rolls_back_when_observation_delete_raises( - self, conn, fixed_clock - ): - # Seed an archived obs old enough to prune, plus its embedding row. - _seed_episode(conn, ep_id="e1", project="proj-a") - _seed_observation( - conn, obs_id="obs-old", ep_id="e1", status="archived", - status_changed_at="2025-01-01T00:00:00+00:00", - ) - import struct - embedding = struct.pack("768f", *(0.0 for _ in range(768))) - conn.execute( - "INSERT INTO observation_embeddings (observation_id, embedding) " - "VALUES (?, ?)", ("obs-old", embedding), - ) - conn.commit() - - svc = RetentionService(conn, clock=fixed_clock) - - # Make the observations DELETE raise AFTER the embeddings DELETE - # has already succeeded. - def _boom(_ids): - raise RuntimeError("simulated observation delete failure") - svc._delete_observations = _boom # type: ignore[method-assign] - - with pytest.raises( - RuntimeError, match="simulated observation delete failure" - ): - svc.run(retention_days=90, prune=True, prune_age_days=365) - - # Simulate any other service committing the connection later. - conn.commit() - - # The embeddings DELETE must have been rolled back too. - emb_count = conn.execute( - "SELECT COUNT(*) AS n FROM observation_embeddings " - "WHERE observation_id = 'obs-old'" - ).fetchone()["n"] - obs_count = conn.execute( - "SELECT COUNT(*) AS n FROM observations " - "WHERE id = 'obs-old'" - ).fetchone()["n"] - assert emb_count == 1, "embedding row must survive rollback" - assert obs_count == 1, "observation row must survive rollback" diff --git a/tests/services/test_semantic_embedding_write.py b/tests/services/test_semantic_embedding_write.py deleted file mode 100644 index 8b5b40aa..00000000 --- a/tests/services/test_semantic_embedding_write.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from better_memory.db.connection import connect -from better_memory.db.schema import apply_migrations -from better_memory.embeddings.sync_embed import SyncEmbedder -from better_memory.services.semantic import SemanticMemoryService -from tests.services._embedding_fakes import FakeEmbedder - - -@pytest.fixture -def conn(tmp_memory_db: Path): - c = connect(tmp_memory_db) - apply_migrations(c) - try: - yield c - finally: - c.close() - - -def _vec_count(conn): - return conn.execute("SELECT COUNT(*) FROM semantic_embeddings").fetchone()[0] - - -def _seed_active_observation(conn, *, obs_id="o1", project="p1", - content="bug found", episode_id=None): - if episode_id is None: - episode_id = "ep-default" - conn.execute( - "INSERT OR IGNORE INTO episodes (id, project, started_at) VALUES " - "(?, ?, '2026-04-01T00:00:00+00:00')", - (episode_id, project), - ) - conn.execute( - "INSERT INTO observations (id, content, project, episode_id, status, " - "outcome, created_at, status_changed_at) VALUES " - "(?, ?, ?, ?, 'active', 'success', " - "'2026-05-04T12:00:00+00:00','2026-05-04T12:00:00+00:00')", - (obs_id, content, project, episode_id), - ) - conn.commit() - - -class TestSemanticEmbeddingWrite: - def test_create_embeds_content(self, conn): - fake = FakeEmbedder() - svc = SemanticMemoryService(conn, sync_embedder=SyncEmbedder(lambda: fake)) - svc.create(content="the fact", project="p") - assert _vec_count(conn) == 1 - assert fake.calls == ["the fact"] - - def test_update_text_reembeds(self, conn): - fake = FakeEmbedder() - svc = SemanticMemoryService(conn, sync_embedder=SyncEmbedder(lambda: fake)) - mid = svc.create(content="v1", project="p") - svc.update_text(id=mid, content="v2") - assert _vec_count(conn) == 1 # replaced, not duplicated - assert fake.calls == ["v1", "v2"] - - def test_failure_never_blocks_create(self, conn): - svc = SemanticMemoryService( - conn, sync_embedder=SyncEmbedder(lambda: FakeEmbedder(fail=True))) - mid = svc.create(content="the fact", project="p") - assert mid - assert _vec_count(conn) == 0 - - def test_no_embedder_no_rows(self, conn): - svc = SemanticMemoryService(conn) - svc.create(content="the fact", project="p") - assert _vec_count(conn) == 0 - - def test_promote_from_observation_embeds(self, conn): - _seed_active_observation(conn, obs_id="o1", content="rule text") - fake = FakeEmbedder() - svc = SemanticMemoryService(conn, sync_embedder=SyncEmbedder(lambda: fake)) - svc.create_from_observation(observation_id="o1") - assert _vec_count(conn) == 1 - assert fake.calls == ["rule text"] diff --git a/tests/services/test_vec_fusion.py b/tests/services/test_vec_fusion.py deleted file mode 100644 index de61eb54..00000000 --- a/tests/services/test_vec_fusion.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Query fusion gains a vector leg; missing embeddings self-heal on retrieve. - -Degradation contract: no embedder / breaker open / row unembedded -> exactly -the two-leg (prior + BM25) behaviour that shipped in #81. -""" -from __future__ import annotations - -from pathlib import Path - -import pytest - -from better_memory.db.connection import connect -from better_memory.db.schema import apply_migrations -from better_memory.embeddings.sync_embed import SyncEmbedder -from better_memory.services.reflection import ReflectionSynthesisService -from tests.services._embedding_fakes import FakeEmbedder - - -@pytest.fixture -def conn(tmp_memory_db: Path): - c = connect(tmp_memory_db) - apply_migrations(c) - try: - yield c - finally: - c.close() - - -def _seed(conn, rid, *, title, useful=0, ignored=0): - conn.execute( - """INSERT INTO reflections - (id, title, project, phase, polarity, use_cases, hints, - confidence, created_at, updated_at, useful_count, times_ignored) - VALUES (?, ?, 'p', 'general', 'do', 'uc', '[]', 0.5, - '2026-01-01', '2026-01-01', ?, ?)""", - (rid, title, useful, ignored), - ) - conn.commit() - - -class DirectedEmbedder(FakeEmbedder): - """Maps texts containing any trigger phrase to one vector; noise else. - - Lets a test make the query and one reflection 'semantically identical' - while sharing zero tokens — isolating the vec leg from BM25. - """ - - def __init__(self, *triggers: str): - super().__init__() - self.triggers = triggers - - def _vec(self, text: str) -> list[float]: - if any(t in text for t in self.triggers): - return [1.0] + [0.0] * 767 - return [0.0, 1.0] + [0.0] * 766 - - async def embed(self, text): - self.calls.append(text) - return self._vec(text) - - async def embed_batch(self, texts): - self.calls.append(list(texts)) - return [self._vec(t) for t in texts] - - -def _svc(conn, embedder): - return ReflectionSynthesisService( - conn, sync_embedder=SyncEmbedder(lambda: embedder)) - - -class TestVecFusion: - def test_semantic_match_promoted_without_token_overlap(self, conn): - # Fragility note: r-noise-1 and r-noise-2 both fall through - # DirectedEmbedder's else-branch, so they embed to the EXACT SAME - # noise vector. Against the query vector, r-target sits at distance - # 0 (unambiguous winner) but the two noise rows tie at an equal, - # larger distance. sqlite-vec's ordering among equal-distance rows - # is unspecified/undocumented (not necessarily insertion order — see - # task-9-report.md's "Deviation from the brief" section, which - # walked through the exact tie this creates in the RRF sum: - # r-target's (pop_rank=2, vec_rank=0) is a swap of whichever noise - # row lands at (pop_rank=0, vec_rank=2), and RRF's sum is symmetric, - # so those two rows score EXACTLY equal). The vec_rank secondary - # sort key in _fuse_by_relevance is what breaks that tie toward - # r-target regardless of which noise row sqlite-vec puts first. - # If this test starts failing after a sqlite-vec upgrade, that tie - # order is the first thing to check — the fusion logic is likely - # still correct; a sqlite-vec version bump changing its internal - # equal-distance ordering would not, by itself, indicate a bug here. - _seed(conn, "r-target", title="Stdout handling on win32 interpreters") - _seed(conn, "r-noise-1", title="Unrelated advice alpha", useful=5, ignored=1) - _seed(conn, "r-noise-2", title="Unrelated advice beta", useful=4, ignored=1) - emb = DirectedEmbedder("Stdout handling", "console output") - svc = _svc(conn, emb) - ids = [r["id"] for r in svc.retrieve_reflections( - project="p", query="console output disappears on windows", - )["do"]] - assert ids[0] == "r-target" - - def test_no_embedder_matches_shipped_behaviour(self, conn): - _seed(conn, "r-a", title="Retention thresholds", useful=1) - svc = ReflectionSynthesisService(conn) - ids = [r["id"] for r in svc.retrieve_reflections( - project="p", query="retention")["do"]] - assert ids == ["r-a"] - - def test_embedder_failure_degrades_silently(self, conn): - _seed(conn, "r-a", title="Retention thresholds", useful=1) - svc = _svc(conn, FakeEmbedder(fail=True)) - ids = [r["id"] for r in svc.retrieve_reflections( - project="p", query="retention")["do"]] - assert ids == ["r-a"] - - def test_multi_row_no_vec_leg_matches_two_leg_order(self, conn): - """vr=sys.maxsize as a no-op, proven across 3+ rows, not just one. - - The single-row degrade tests above can't catch a tiebreak bug: with - one row there's no order to get wrong. Here three rows with distinct - popularity and a query that matches all of them via BM25 forces the - scored/sort path (rel_rank is non-empty, so _fuse_by_relevance does - not take its early "nothing matched" bail-out) while the vec leg is - absent — proving the added vec_rank secondary sort key doesn't - reorder anything when there's no vector leg to rank by. - - Comparing against a plain ``sync_embedder=None`` service (the - untouched shipped two-leg path) rather than a hand-computed order - keeps this robust to internal BM25/Wilson scoring details. - """ - _seed(conn, "r-hi", title="Retention thresholds alpha", useful=10) - _seed(conn, "r-mid", title="Retention thresholds beta", useful=5) - _seed(conn, "r-lo", title="Retention thresholds gamma", useful=1) - - no_embedder_svc = ReflectionSynthesisService(conn) - expected = [r["id"] for r in no_embedder_svc.retrieve_reflections( - project="p", query="retention thresholds")["do"]] - - failing_svc = _svc(conn, FakeEmbedder(fail=True)) - ids = [r["id"] for r in failing_svc.retrieve_reflections( - project="p", query="retention thresholds")["do"]] - - assert len(expected) >= 3 - assert ids == expected - - -class TestSelfHeal: - def test_unembedded_candidates_healed_on_query_retrieve(self, conn): - _seed(conn, "r-a", title="Alpha") - _seed(conn, "r-b", title="Beta") - svc = _svc(conn, FakeEmbedder()) - svc.retrieve_reflections(project="p", query="anything at all") - n = conn.execute("SELECT COUNT(*) FROM reflection_embeddings").fetchone()[0] - assert n == 2 - - def test_heal_capped_at_batch_limit(self, conn): - from better_memory.services.reflection import SELF_HEAL_BATCH_CAP - for i in range(SELF_HEAL_BATCH_CAP + 5): - _seed(conn, f"r-{i:03}", title=f"Title {i}") - svc = _svc(conn, FakeEmbedder()) - svc.retrieve_reflections(project="p", query="anything") - n = conn.execute("SELECT COUNT(*) FROM reflection_embeddings").fetchone()[0] - assert n == SELF_HEAL_BATCH_CAP - - def test_no_query_no_heal_and_no_embed_calls(self, conn): - _seed(conn, "r-a", title="Alpha") - fake = FakeEmbedder() - svc = _svc(conn, fake) - svc.retrieve_reflections(project="p") - n = conn.execute("SELECT COUNT(*) FROM reflection_embeddings").fetchone()[0] - assert n == 0 - assert fake.calls == [] - - def test_heal_failure_silent(self, conn): - _seed(conn, "r-a", title="Alpha") - svc = _svc(conn, FakeEmbedder(fail=True)) - rows = svc.retrieve_reflections(project="p", query="anything") - assert rows["do"] diff --git a/tests/setup/test_engine.py b/tests/setup/test_engine.py index a659b213..80c281f2 100644 --- a/tests/setup/test_engine.py +++ b/tests/setup/test_engine.py @@ -74,11 +74,11 @@ def test_patch_mcp_entry_touches_only_our_key(): def test_patch_mcp_entry_preserves_user_env_extras(): existing = {"mcpServers": {"better-memory": { "type": "stdio", "command": "old", "args": [], - "env": {"BETTER_MEMORY_EMBED_LOG": "1"}, + "env": {"SOME_USER_SET_VAR": "1"}, }}} patched = patch_mcp_entry(existing, PARAMS) env = patched["mcpServers"]["better-memory"]["env"] - assert env["BETTER_MEMORY_EMBED_LOG"] == "1" + assert env["SOME_USER_SET_VAR"] == "1" assert env["BETTER_MEMORY_HOME"] == PARAMS.home diff --git a/tests/storage/test_factory.py b/tests/storage/test_factory.py index 20de4d91..de8ba186 100644 --- a/tests/storage/test_factory.py +++ b/tests/storage/test_factory.py @@ -78,7 +78,6 @@ def test_build_backend_returns_sqlite_for_sqlite_config(memory_conn) -> None: backend = build_backend( config=cfg, memory_conn=memory_conn, - embedder=MagicMock(), session_id="s", project="p", ) @@ -86,33 +85,12 @@ def test_build_backend_returns_sqlite_for_sqlite_config(memory_conn) -> None: assert isinstance(backend, StorageBackend) -def test_build_backend_forwards_sync_embedder_to_sqlite_backend(memory_conn) -> None: - """Regression for PR #83: build_backend must pass the caller's - process-wide SyncEmbedder through to SqliteBackend rather than letting - SqliteBackend construct its own — otherwise the circuit breaker splits - into an independent instance per backend build.""" - cfg = _config() - sentinel_sync_embedder = MagicMock(name="sentinel-sync-embedder") - backend = build_backend( - config=cfg, - memory_conn=memory_conn, - embedder=MagicMock(), - sync_embedder=sentinel_sync_embedder, - session_id="s", - project="p", - ) - assert isinstance(backend, SqliteBackend) - assert backend._synthesis._sync_embedder is sentinel_sync_embedder - assert backend._semantic._sync_embedder is sentinel_sync_embedder - - def test_build_backend_raises_for_unknown(memory_conn) -> None: cfg = _config(storage_backend="bogus") with pytest.raises(ValueError, match="unknown storage_backend"): build_backend( config=cfg, memory_conn=memory_conn, - embedder=MagicMock(), session_id="s", project="p", ) @@ -129,7 +107,6 @@ def test_build_backend_returns_agentcore_when_config_loaded(tmp_path, monkeypatc backend = build_backend( config=cfg, memory_conn=None, # not used in agentcore mode - embedder=None, session_id="s", project="p", ) @@ -144,7 +121,6 @@ def test_build_backend_agentcore_raises_when_config_missing(tmp_path, monkeypatc build_backend( config=cfg, memory_conn=None, - embedder=None, session_id="s", project="p", ) @@ -176,7 +152,6 @@ def _fake_client(service_name: str, config: Any = None) -> Any: backend = build_backend( config=_config(storage_backend="agentcore"), memory_conn=None, - embedder=None, session_id="s", project="p", ) @@ -205,7 +180,6 @@ def test_build_backend_agentcore_missing_boto3_raises_install_hint( build_backend( config=_config(storage_backend="agentcore"), memory_conn=None, - embedder=None, session_id="s", project="p", ) diff --git a/tests/storage/test_sqlite_backend.py b/tests/storage/test_sqlite_backend.py index be13dfee..755da4ba 100644 --- a/tests/storage/test_sqlite_backend.py +++ b/tests/storage/test_sqlite_backend.py @@ -7,7 +7,6 @@ from __future__ import annotations import sqlite3 -from unittest.mock import AsyncMock, MagicMock import pytest import sqlite_vec @@ -35,16 +34,15 @@ def memory_conn() -> sqlite3.Connection: @pytest.fixture def backend(memory_conn) -> SqliteBackend: - """A SqliteBackend wired to an in-memory db with a no-op embedder. + """A SqliteBackend wired to an in-memory db. - ObservationService awaits embedder.embed, so we use AsyncMock for that - coroutine while keeping a sync MagicMock for any other attribute access. + ObservationService no longer takes an embedder at all (it indexes via + DB triggers / FTS5 — remove-ollama-embeddings Task 6), and SqliteBackend + no longer accepts an embedder/sync_embedder ctor param either (Task 7 + removed the dead pass-through). """ - embedder = MagicMock() - embedder.embed = AsyncMock(return_value=[0.0] * 768) return SqliteBackend( memory_conn=memory_conn, - embedder=embedder, session_id="test-session", project="testproj", ) @@ -54,33 +52,6 @@ def test_sqlite_backend_satisfies_protocol(backend) -> None: assert isinstance(backend, StorageBackend) -def test_sqlite_backend_shares_caller_sync_embedder(memory_conn) -> None: - """Regression for PR #83: SqliteBackend must NOT build its own - SyncEmbedder for _semantic/_synthesis — it must reuse the caller's - instance so the circuit breaker is process-wide, not split in two.""" - embedder = MagicMock() - embedder.embed = AsyncMock(return_value=[0.0] * 768) - sentinel_sync_embedder = MagicMock(name="sentinel-sync-embedder") - - backend = SqliteBackend( - memory_conn=memory_conn, - embedder=embedder, - sync_embedder=sentinel_sync_embedder, - session_id="test-session", - project="testproj", - ) - - assert backend._synthesis._sync_embedder is sentinel_sync_embedder - assert backend._semantic._sync_embedder is sentinel_sync_embedder - - -def test_sqlite_backend_sync_embedder_defaults_to_none(backend) -> None: - """When the caller passes no sync_embedder (default), the backend must - not silently construct its own — both services stay None.""" - assert backend._synthesis._sync_embedder is None - assert backend._semantic._sync_embedder is None - - def test_sqlite_backend_implements_hot_path_methods(backend) -> None: """Task 3 surface check: every hot-path method is present and callable.""" for name in ("observe", "retrieve", "list_observations", "record_use"): diff --git a/tests/test_async_bridge.py b/tests/test_async_bridge.py deleted file mode 100644 index 7b31778d..00000000 --- a/tests/test_async_bridge.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Unit tests for better_memory.async_bridge.run_async_in_worker.""" - -from __future__ import annotations - -import asyncio - -import pytest - - -def test_returns_coroutine_result() -> None: - """Happy path: bridge returns whatever the coroutine returns.""" - from better_memory.async_bridge import run_async_in_worker - - async def _coro() -> int: - return 42 - - result = run_async_in_worker(lambda: _coro()) - assert result == 42 - - -def test_raises_coroutine_exception() -> None: - """If the coroutine raises, the same exception type+message is - re-raised on the caller's thread.""" - from better_memory.async_bridge import run_async_in_worker - - class CustomError(RuntimeError): - pass - - async def _coro() -> None: - raise CustomError("boom") - - with pytest.raises(CustomError, match="boom"): - run_async_in_worker(lambda: _coro()) - - -def test_captures_factory_setup_error() -> None: - """Regression for PR #11 root cause (memory 4f7afb58): - if the factory itself raises (e.g. simulating sqlite3.connect() - failing) BEFORE the coroutine starts, the bridge must still - propagate the error rather than hanging or returning silently.""" - from better_memory.async_bridge import run_async_in_worker - - class SetupError(RuntimeError): - pass - - def _factory(): - raise SetupError("connect failed") - - with pytest.raises(SetupError, match="connect failed"): - run_async_in_worker(_factory) - - -def test_timeout_raises_WorkerTimeout() -> None: - """If the worker doesn't finish within `timeout`, raises - WorkerTimeout. The worker thread is abandoned and continues - running in the background.""" - from better_memory.async_bridge import WorkerTimeout, run_async_in_worker - - async def _slow() -> None: - await asyncio.sleep(2.0) - - with pytest.raises(WorkerTimeout): - run_async_in_worker(lambda: _slow(), timeout=0.1) - - -def test_each_call_gets_fresh_loop() -> None: - """Sequential calls to run_async_in_worker each create a fresh - event loop, sidestepping the httpx-pool-bound-to-loop bug - (memory 27f77fb6).""" - import gc - - from better_memory.async_bridge import run_async_in_worker - - captured_loop_ids: list[int] = [] - - async def _capture_loop_id() -> None: - captured_loop_ids.append(id(asyncio.get_running_loop())) - - run_async_in_worker(lambda: _capture_loop_id()) - # Force GC so CPython doesn't re-use the first loop's memory address - # for the second loop, which would produce a spurious equality. - gc.collect() - run_async_in_worker(lambda: _capture_loop_id()) - - assert len(captured_loop_ids) == 2 - assert captured_loop_ids[0] != captured_loop_ids[1], ( - "expected each call to get a fresh event loop" - ) - - -async def test_works_inside_pytest_asyncio_running_loop() -> None: - """Sanity: pytest-asyncio's auto mode means this test runs with - a running loop on the main thread. asyncio.run() would raise. - The bridge must work — that's its whole reason for existing - (vs asyncio.run).""" - from better_memory.async_bridge import run_async_in_worker - - # Confirm the test environment really does have a running loop. - # If pytest-asyncio's mode changes and this assertion fails, - # the test is no longer exercising what it claims to. - try: - asyncio.get_running_loop() - loop_present = True - except RuntimeError: - loop_present = False - assert loop_present, ( - "expected a running loop in the test scope (pytest-asyncio auto)" - ) - - async def _coro() -> str: - return "ok" - - assert run_async_in_worker(lambda: _coro()) == "ok" diff --git a/tests/test_config.py b/tests/test_config.py index 430ac1cb..29783eec 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -39,8 +39,6 @@ def test_defaults_resolve_under_home( fake_home = _fake_user_home(monkeypatch, tmp_path) for var in ( "BETTER_MEMORY_HOME", - "OLLAMA_HOST", - "EMBED_MODEL", "AUDIT_LOG_RETRIEVED", ): monkeypatch.delenv(var, raising=False) @@ -56,8 +54,6 @@ def test_defaults_resolve_under_home( assert cfg.knowledge_db == home / "knowledge.db" assert cfg.knowledge_base == home / "knowledge-base" assert cfg.spool_dir == home / "spool" - assert cfg.ollama_host == "http://localhost:11434" - assert cfg.embed_model == "nomic-embed-text" assert cfg.audit_log_retrieved is True @@ -92,17 +88,38 @@ def test_home_expands_tilde( def test_external_service_overrides(monkeypatch: pytest.MonkeyPatch) -> None: """External-service env vars override independently of path layout.""" - monkeypatch.setenv("OLLAMA_HOST", "http://example:9999") - monkeypatch.setenv("EMBED_MODEL", "some-other-model") monkeypatch.setenv("AUDIT_LOG_RETRIEVED", "false") cfg = get_config() - assert cfg.ollama_host == "http://example:9999" - assert cfg.embed_model == "some-other-model" assert cfg.audit_log_retrieved is False +def test_removed_embedding_fields_raise_attribute_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ollama_host/embed_model/embeddings_backend/context_vec_floor were + deleted from Config outright -- accessing them raises, rather than + silently resolving a stale default. Their backing env vars (OLLAMA_HOST, + EMBED_MODEL, BETTER_MEMORY_EMBEDDINGS_BACKEND, + BETTER_MEMORY_CONTEXT_VEC_FLOOR) are set here to prove they're now + silently unread, not just unset.""" + monkeypatch.setenv("OLLAMA_HOST", "http://example:9999") + monkeypatch.setenv("EMBED_MODEL", "some-other-model") + monkeypatch.setenv("BETTER_MEMORY_EMBEDDINGS_BACKEND", "sqlite") + monkeypatch.setenv("BETTER_MEMORY_CONTEXT_VEC_FLOOR", "0.9") + + cfg = get_config() + for attr in ( + "ollama_host", + "embed_model", + "embeddings_backend", + "context_vec_floor", + ): + with pytest.raises(AttributeError): + getattr(cfg, attr) + + def test_paths_are_path_objects( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -320,25 +337,6 @@ def test_project_name_env_var_stripped( assert project_name(tmp_path) == "scoped-name" -def test_embeddings_backend_defaults_to_ollama(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("BETTER_MEMORY_EMBEDDINGS_BACKEND", raising=False) - cfg = get_config() - assert cfg.embeddings_backend == "ollama" - - -def test_embeddings_backend_sqlite_when_env_set(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("BETTER_MEMORY_EMBEDDINGS_BACKEND", "sqlite") - cfg = get_config() - assert cfg.embeddings_backend == "sqlite" - - -def test_embeddings_backend_unknown_value_raises(monkeypatch: pytest.MonkeyPatch) -> None: - # `tfidf` was valid in PR #65, no longer accepted after the rename. - monkeypatch.setenv("BETTER_MEMORY_EMBEDDINGS_BACKEND", "tfidf") - with pytest.raises(ValueError, match="BETTER_MEMORY_EMBEDDINGS_BACKEND"): - get_config() - - def test_storage_backend_defaults_to_sqlite(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("BETTER_MEMORY_STORAGE_BACKEND", raising=False) cfg = get_config() @@ -617,19 +615,3 @@ def test_deferred_selected(self, monkeypatch): def test_unknown_coerces_to_legacy(self, monkeypatch): monkeypatch.setenv("BETTER_MEMORY_INJECT_MODE", "yolo") assert get_config().inject_mode == "legacy" - - -class TestVecFloorConfig: - def test_default(self, monkeypatch): - monkeypatch.delenv("BETTER_MEMORY_CONTEXT_VEC_FLOOR", raising=False) - assert get_config().context_vec_floor == 0.55 - - def test_override_and_clamp(self, monkeypatch): - monkeypatch.setenv("BETTER_MEMORY_CONTEXT_VEC_FLOOR", "0.7") - assert get_config().context_vec_floor == 0.7 - monkeypatch.setenv("BETTER_MEMORY_CONTEXT_VEC_FLOOR", "1.7") - assert get_config().context_vec_floor == 1.0 - - def test_malformed_falls_back(self, monkeypatch): - monkeypatch.setenv("BETTER_MEMORY_CONTEXT_VEC_FLOOR", "high") - assert get_config().context_vec_floor == 0.55 diff --git a/tests/test_diag.py b/tests/test_diag.py index ac9ddaf0..186bb9ae 100644 --- a/tests/test_diag.py +++ b/tests/test_diag.py @@ -18,12 +18,11 @@ def _reset_diag() -> Iterator[None]: def _clear_env(monkeypatch: pytest.MonkeyPatch) -> None: - for var in ("BETTER_MEMORY_EMBED_LOG", "BETTER_MEMORY_DIAG_LOGGING"): - monkeypatch.delenv(var, raising=False) + monkeypatch.delenv("BETTER_MEMORY_DIAG_LOGGING", raising=False) def test_disabled_by_default(monkeypatch: pytest.MonkeyPatch) -> None: - """With neither knob set, diagnostics are off.""" + """With the knob unset, diagnostics are off.""" _clear_env(monkeypatch) assert _diag.enabled() is False @@ -35,26 +34,11 @@ def test_config_true_env_unset_enables(monkeypatch: pytest.MonkeyPatch) -> None: assert _diag.enabled() is True -def test_env_zero_overrides_config_true(monkeypatch: pytest.MonkeyPatch) -> None: - """BETTER_MEMORY_EMBED_LOG=0 disables even when config.diag_logging is true.""" - _clear_env(monkeypatch) - monkeypatch.setenv("BETTER_MEMORY_DIAG_LOGGING", "1") - monkeypatch.setenv("BETTER_MEMORY_EMBED_LOG", "0") - assert _diag.enabled() is False - - -def test_env_truthy_overrides_config_false(monkeypatch: pytest.MonkeyPatch) -> None: - """BETTER_MEMORY_EMBED_LOG truthy enables even when config defaults off.""" - _clear_env(monkeypatch) - monkeypatch.setenv("BETTER_MEMORY_EMBED_LOG", "1") - assert _diag.enabled() is True - - def test_enabled_is_memoized(monkeypatch: pytest.MonkeyPatch) -> None: """enabled() caches its first result; later env changes need a reset.""" _clear_env(monkeypatch) assert _diag.enabled() is False - monkeypatch.setenv("BETTER_MEMORY_EMBED_LOG", "1") + monkeypatch.setenv("BETTER_MEMORY_DIAG_LOGGING", "1") assert _diag.enabled() is False # still the cached value _diag._reset_enabled_cache() assert _diag.enabled() is True diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py index 022b9706..7ed87e16 100644 --- a/tests/ui/conftest.py +++ b/tests/ui/conftest.py @@ -37,16 +37,11 @@ def client( ``TestOriginCheck`` POST-to-/shutdown tests don't fire the real 100 ms timer that calls ``os._exit`` and kills the pytest process. - Pins ``BETTER_MEMORY_EMBEDDINGS_BACKEND=sqlite`` so ``create_app``'s - auto-detected ``sync_embedder`` (see ``_build_sync_embedder`` in - ``better_memory/ui/app.py``) resolves to ``None`` — the default - config backend is ``ollama``, and without this pin every route test - that writes a reflection/semantic memory would silently depend on a - live Ollama instance. Tests exercising the embedding wiring build - their own app via ``create_app(sync_embedder=...)`` instead of this - fixture. + Task 2 (remove-ollama-embeddings) deleted create_app's sync_embedder + auto-detection entirely — every write-path service now receives + ``sync_embedder=None`` unconditionally, so no env pin is needed to + keep route tests from depending on a live Ollama instance. """ - monkeypatch.setenv("BETTER_MEMORY_EMBEDDINGS_BACKEND", "sqlite") app = create_app(start_watchdog=False, db_path=tmp_db) app.config["TESTING"] = True with patch("better_memory.ui.app.threading.Timer"): diff --git a/tests/ui/test_app.py b/tests/ui/test_app.py index ea8b8db0..7ad445a5 100644 --- a/tests/ui/test_app.py +++ b/tests/ui/test_app.py @@ -24,63 +24,6 @@ def test_app_exposes_open_db_connection( assert row[0] == 0 -class TestSyncEmbedderWiring: - """UI-driven writes must embed like MCP-driven writes. - - Before this wiring, better_memory.ui.app constructed ReflectionService - and SemanticMemoryService with no sync_embedder at all, so UI edits/ - creates never populated reflection_embeddings / semantic_embeddings — - only a manual CLI backfill run would fix those rows up. create_app now - auto-builds a shared SyncEmbedder from get_config().embeddings_backend - (mirroring better_memory/mcp/server.py and better_memory/storage/ - sqlite.py), and accepts an explicit sync_embedder= override for tests. - """ - - def test_sync_embedder_none_when_backend_sqlite( - self, tmp_db: Path, monkeypatch: pytest.MonkeyPatch, - ) -> None: - monkeypatch.setenv("BETTER_MEMORY_EMBEDDINGS_BACKEND", "sqlite") - app = create_app(start_watchdog=False, db_path=tmp_db) - assert app.extensions["sync_embedder"] is None - - def test_sync_embedder_built_when_backend_ollama( - self, tmp_db: Path, monkeypatch: pytest.MonkeyPatch, - ) -> None: - from better_memory.embeddings.sync_embed import SyncEmbedder - - monkeypatch.setenv("BETTER_MEMORY_EMBEDDINGS_BACKEND", "ollama") - app = create_app(start_watchdog=False, db_path=tmp_db) - assert isinstance(app.extensions["sync_embedder"], SyncEmbedder) - - def test_explicit_sync_embedder_overrides_config_auto_detect( - self, tmp_db: Path, monkeypatch: pytest.MonkeyPatch, - ) -> None: - from better_memory.embeddings.sync_embed import SyncEmbedder - - monkeypatch.setenv("BETTER_MEMORY_EMBEDDINGS_BACKEND", "sqlite") - fake_sync_embedder = SyncEmbedder(lambda: None) - app = create_app( - start_watchdog=False, db_path=tmp_db, - sync_embedder=fake_sync_embedder, - ) - assert app.extensions["sync_embedder"] is fake_sync_embedder - - def test_reflection_service_receives_the_shared_sync_embedder( - self, tmp_db: Path, monkeypatch: pytest.MonkeyPatch, - ) -> None: - from better_memory.embeddings.sync_embed import SyncEmbedder - - fake_sync_embedder = SyncEmbedder(lambda: None) - app = create_app( - start_watchdog=False, db_path=tmp_db, - sync_embedder=fake_sync_embedder, - ) - assert ( - app.extensions["reflection_service"]._sync_embedder - is fake_sync_embedder - ) - - class TestHealthz: def test_returns_200_with_ok_body(self, client: FlaskClient) -> None: response = client.get("/healthz") @@ -335,7 +278,6 @@ def test_build_backend_called_with_canonical_kwargs( spy.assert_called_once() _, kwargs = spy.call_args assert kwargs["memory_conn"] is app.extensions["db_connection"] - assert kwargs["sync_embedder"] is app.extensions["sync_embedder"] assert kwargs["session_id"] is None assert kwargs["project"] == project_name() assert "config" in kwargs # get_config() forwarded to the factory diff --git a/tests/ui/test_reflections.py b/tests/ui/test_reflections.py index 57f93008..344a9557 100644 --- a/tests/ui/test_reflections.py +++ b/tests/ui/test_reflections.py @@ -380,57 +380,6 @@ def test_post_409_for_retired( assert response.status_code == 409 -class TestReflectionEditEmbeddingWiring: - """UI edit route must re-embed, not just the synthesis write path. - - The shared `client` fixture pins BETTER_MEMORY_EMBEDDINGS_BACKEND=sqlite - (see tests/ui/conftest.py) so ordinary route tests don't depend on a - live Ollama instance. These tests build their own app with an explicit - fake sync_embedder to prove the wiring — app.py passes - app.extensions["sync_embedder"] into ReflectionService's constructor, - and ReflectionService.update_text re-embeds title+use_cases+hints. - """ - - def test_edit_writes_reflection_embedding_row( - self, tmp_db: Path, monkeypatch: pytest.MonkeyPatch, - ) -> None: - from unittest.mock import patch as _patch - - from better_memory.embeddings.sync_embed import SyncEmbedder - from better_memory.ui import app as app_module - from better_memory.ui.app import create_app - from tests.services._embedding_fakes import FakeEmbedder - - monkeypatch.setattr(app_module, "project_name", lambda: "proj-a") - _seed_reflection(tmp_db, rid="r-1", title="Existing") - - fake = FakeEmbedder() - app = create_app( - start_watchdog=False, db_path=tmp_db, - sync_embedder=SyncEmbedder(lambda: fake), - ) - app.config["TESTING"] = True - with _patch("better_memory.ui.app.threading.Timer"): - with app.test_client() as client: - response = client.post( - "/reflections/r-1/edit", - data={"use_cases": "new uc", "hints": "new h"}, - headers={"Origin": "http://localhost"}, - ) - assert response.status_code == 200 - - conn = connect(tmp_db) - try: - count = conn.execute( - "SELECT COUNT(*) FROM reflection_embeddings " - "WHERE reflection_id = 'r-1'" - ).fetchone()[0] - finally: - conn.close() - assert count == 1 - assert fake.calls == ["Existing\nnew uc\nnew h"] - - class TestReflectionPromote: def test_promotes_project_pending( self, client: FlaskClient, tmp_db: Path, monkeypatch: pytest.MonkeyPatch diff --git a/tests/ui/test_semantic.py b/tests/ui/test_semantic.py index 3585edbe..2b3dacec 100644 --- a/tests/ui/test_semantic.py +++ b/tests/ui/test_semantic.py @@ -535,101 +535,6 @@ def test_overlooked_badge_ambered_when_positive( assert "rating-overlooked" in body -class TestSemanticEmbeddingWiring: - """UI create/update routes must embed, not just MCP-driven writes. - - The shared `client` fixture pins BETTER_MEMORY_EMBEDDINGS_BACKEND=sqlite - (see tests/ui/conftest.py) so ordinary route tests don't depend on a - live Ollama instance. These tests build their own app with an explicit - fake sync_embedder to prove the wiring — app.py passes - app.extensions["sync_embedder"] into every SemanticMemoryService(...) - construction site. - """ - - def _make_client(self, tmp_db: Path, fake_embedder): - from unittest.mock import patch as _patch - - from better_memory.embeddings.sync_embed import SyncEmbedder - from better_memory.ui.app import create_app - - app = create_app( - start_watchdog=False, db_path=tmp_db, - sync_embedder=SyncEmbedder(lambda: fake_embedder), - ) - app.config["TESTING"] = True - self._timer_patch = _patch("better_memory.ui.app.threading.Timer") - self._timer_patch.start() - return app.test_client() - - def _vec_count(self, tmp_db: Path) -> int: - # sqlite_vec's vec0 virtual table needs its extension loaded — a - # bare sqlite3.connect() raises "no such module: vec0". Use the - # project's connect() helper, which loads it. - from better_memory.db.connection import connect as _connect - - conn = _connect(tmp_db) - try: - return conn.execute( - "SELECT COUNT(*) FROM semantic_embeddings" - ).fetchone()[0] - finally: - conn.close() - - def test_create_writes_semantic_embedding_row( - self, tmp_db: Path, monkeypatch: pytest.MonkeyPatch, - ) -> None: - from better_memory.ui import app as app_module - from tests.services._embedding_fakes import FakeEmbedder - - monkeypatch.setattr(app_module, "project_name", lambda: "proj-a") - fake = FakeEmbedder() - client = self._make_client(tmp_db, fake) - try: - response = client.post( - "/semantic", - data={"content": "new rule", "scope": "general"}, - headers={"Origin": "http://localhost"}, - ) - assert response.status_code == 200 - finally: - self._timer_patch.stop() - - assert self._vec_count(tmp_db) == 1 - assert fake.calls == ["new rule"] - - def test_update_reembeds_semantic_memory( - self, tmp_db: Path, monkeypatch: pytest.MonkeyPatch, - ) -> None: - import sqlite3 - - from better_memory.ui import app as app_module - from tests.services._embedding_fakes import FakeEmbedder - - monkeypatch.setattr(app_module, "project_name", lambda: "proj-a") - with sqlite3.connect(tmp_db) as seed_conn: - seed_conn.execute( - "INSERT INTO semantic_memories " - "(id, content, project, scope, created_at, updated_at) VALUES " - "('m1','old text','proj-a','project'," - " '2026-05-01T10:00:00+00:00','2026-05-01T10:00:00+00:00')" - ) - seed_conn.commit() - - fake = FakeEmbedder() - client = self._make_client(tmp_db, fake) - try: - response = client.post( - "/semantic/m1/update", data={"content": "new text"}, - headers={"Origin": "http://localhost"}, - ) - assert response.status_code == 200 - finally: - self._timer_patch.stop() - - assert self._vec_count(tmp_db) == 1 # replaced, not duplicated - assert fake.calls == ["new text"] - - class _CapsStub: """Exposes the six capability flags the PR1 caps context-processor reads at render time (all True: PR2 does no gating, values are irrelevant, but diff --git a/website/agentcore-setup.md b/website/agentcore-setup.md index 8fbdb408..c19538d7 100644 --- a/website/agentcore-setup.md +++ b/website/agentcore-setup.md @@ -115,8 +115,8 @@ The self-rating / learning loop is **backend-agnostic**: agentcore mode runs the | Capability | `sqlite` | `agentcore` | |---|---|---| | Exposure ledger, mid-session `memory.credit`, end-of-session sweep, Wilson ranking, exploration slot | Local, entirely in `memory.db` | Same loop. Exposure ledger is the same local `memory.db` table (session-operational state, not memory content); rating counters (`useful_count`, `ignored_count`, `overlooked_count`, `times_misled`) live on the AWS record and are genuinely shared across every teammate's session. | -| Query-conditioned retrieval (`memory.retrieve(query=...)`) | BM25 + vector RRF against the Wilson prior | Server-side semantic search (`RetrieveMemoryRecords`) RRF-fused against the Wilson prior instead (no BM25 leg — semantic search subsumes it); degrades to the Wilson-only order on an AWS error. | -| Contextual-injection evidence gate | BM25 / vector legs | The backend's own `relevance_ranks` (same `RetrieveMemoryRecords` semantic search) — a memory qualifies iff present in that result set; keyword fallback applies only when the AWS lookup itself fails, never merely because it found no matches. | +| Query-conditioned retrieval (`memory.retrieve(query=...)`) | BM25 RRF against the Wilson prior | Server-side semantic search (`RetrieveMemoryRecords`) RRF-fused against the Wilson prior instead (no BM25 leg — semantic search subsumes it); degrades to the Wilson-only order on an AWS error. | +| Contextual-injection evidence gate | BM25 (reflections) / keyword-hit fallback (semantic memories, which have no FTS substrate) | The backend's own `relevance_ranks` (same `RetrieveMemoryRecords` semantic search) — a memory qualifies iff present in that result set; keyword fallback applies only when the AWS lookup itself fails, never merely because it found no matches. | | Rating-evidence browsing | Per-exposure `evidence` column, browsable in the management UI's Reflections/Semantic drawers | **Local-only for now.** The local exposure rows are stamped with evidence the same way, but there's no cross-machine evidence browsing yet — a future PR adds a read path for the ratings-event log below. | | Rating-evidence receipts | N/A (the local row is the record) | Each end-of-session sweep emits one best-effort `CreateEvent` (`extractionMode: "SKIP"`, `metadata.type: "ratings"`) as a durable, team-visible receipt of what was rated. Durable from day one; no read/browse UI for it yet (non-goal of this change, tracked as a follow-up). | | Synthesis (observation → reflection distillation) | Local, Claude-driven (`memory.synthesize_next_*`) | Cloud, built-in strategy on its own ~15-20 minute cadence — unchanged by this parity work. | diff --git a/website/architecture.md b/website/architecture.md index a1f1129c..d123bd32 100644 --- a/website/architecture.md +++ b/website/architecture.md @@ -1,6 +1,6 @@ # Architecture -better-memory is a four-layer epistemic hierarchy backed by a pluggable storage backend — a single SQLite database by default — with embeddings supplied by one of two pluggable backends (Ollama, or an in-SQL trigram-FTS5 fusion). +better-memory is a four-layer epistemic hierarchy backed by a pluggable storage backend — a single SQLite database by default, with all local retrieval running as pure in-SQL FTS5 (word + trigram) — no embedding model, no external service. ## The four layers @@ -24,12 +24,14 @@ better-memory abstracts persistence behind the `StorageBackend` protocol (`bette ```mermaid flowchart LR RESOLVE["env var, else settings.json,
else sqlite"] - RESOLVE -->|sqlite| SQLITE["SqliteBackend
(local memory.db + sqlite-vec)"] + RESOLVE -->|sqlite| SQLITE["SqliteBackend
(local memory.db, FTS5)"] RESOLVE -->|agentcore| AGENTCORE["AgentCoreBackend
(AWS Bedrock AgentCore Memory)"] SQLITE -->|sync I/O| DB[("memory.db")] AGENTCORE -->|boto3| AWS[("region from agentcore.json
bedrock-agentcore")] ``` +The `sqlite-vec` extension is still loaded by every `memory.db` connection (`better_memory/db/connection.py`) — not for search (no code path performs a vector query any more), but because dropping the vec0 virtual tables in migration `0018_drop_vec_tables.sql` requires the module registered first. Removing the dependency outright is an announced follow-up. + | Aspect | `sqlite` | `agentcore` | |---|---|---| | Data location | Local file (`memory.db`) | AWS-managed (region from `agentcore.json`) | @@ -40,12 +42,12 @@ flowchart LR | Multi-machine sync | No | Yes — memory content and rating counters are shared; the exposure ledger and its evidence stay local/session-scoped on every machine | | Closure events | N/A | `CreateEvent(role=OTHER)` from Stop hook; end-of-session rating sweeps additionally emit one best-effort `CreateEvent` (`extractionMode: SKIP`) as a durable, team-visible receipt of what was rated | | Episode tracking | Local `episodes` table | Internal to AgentCore (sessionId) | -| Self-rating loop (exposure ledger, `memory.credit`, sweep, Wilson ranking, exploration slot) | Local, entirely in `memory.db` | Same loop — backend-agnostic. Exposure ledger writes to the SAME local `session_memory_exposure` table (session-operational state, never memory content); rating counters (`useful_count`, `ignored_count`, etc.) live on the AWS record and are genuinely shared across teammates. `query`-conditioned retrieval and the contextual-injection evidence gate use server-side semantic search (`RetrieveMemoryRecords`) in place of sqlite's BM25/vector legs, degrading to Wilson-only / keyword fallback only on an AWS error. | +| Self-rating loop (exposure ledger, `memory.credit`, sweep, Wilson ranking, exploration slot) | Local, entirely in `memory.db` | Same loop — backend-agnostic. Exposure ledger writes to the SAME local `session_memory_exposure` table (session-operational state, never memory content); rating counters (`useful_count`, `ignored_count`, etc.) live on the AWS record and are genuinely shared across teammates. `query`-conditioned retrieval and the contextual-injection evidence gate use server-side semantic search (`RetrieveMemoryRecords`) in place of sqlite's BM25 leg, degrading to Wilson-only / keyword fallback only on an AWS error. | | Bulk import | N/A (clean start) | `better-memory agentcore migrate` copies existing sqlite reflections + semantic memories into AWS (idempotent, ledgered) | Migrating with `better-memory agentcore migrate` is distinct from activating: it only writes records to AWS and never flips `storage_backend`. A migrated reflection carries its rating counters, `status`, and `source_row_id` in the record's JSON **content body** — the built-in episodic strategy owns the metadata schema — whereas cloud-extracted records (and migrated semantic records) keep that state in record **metadata**. See [AgentCore setup > Migrate](agentcore-setup.md#migrate-existing-memory-optional). -The management UI's `create_app` (`better_memory/ui/app.py`) builds a `StorageBackend` via `factory.build_backend`, sharing the same sqlite connection and `sync_embedder` the UI already constructs, and stores it at `app.extensions["backend"]`. The Reflections surfaces (list, detail, promote, retire) and the Semantic surfaces (full CRUD — create, list, get, update text, set scope, delete) reach memory CONTENT exclusively through `app.extensions["backend"]`, never raw SQL in `better_memory/ui/queries.py` or a service constructed directly on the raw connection. `app.extensions["db_connection"]` is retained for OPERATIONAL state — hook errors, rating evidence/counters, retention runs, the audit log — on both backends. A `caps` context processor reads six capability flags off `app.extensions["backend"]` (`supports_episodes`, `supports_observations`, `supports_provenance`, `supports_retention_runs`, `supports_reflection_review`, `supports_reflection_text_edit`) and exposes them to every template as `caps`, rebuilt on every render so swapping the extension flips every gate. All six flags are consumed: each pairs a nav-link/template-block gate (`{% if caps.* %}`, wrapping existing markup, no new classes) with a matching `abort(404)` guard on the route(s) it protects, so an agentcore session can neither see nor navigate directly to a hidden surface. +The management UI's `create_app` (`better_memory/ui/app.py`) builds a `StorageBackend` via `factory.build_backend`, sharing the same sqlite connection the UI already opens, and stores it at `app.extensions["backend"]`. The Reflections surfaces (list, detail, promote, retire) and the Semantic surfaces (full CRUD — create, list, get, update text, set scope, delete) reach memory CONTENT exclusively through `app.extensions["backend"]`, never raw SQL in `better_memory/ui/queries.py` or a service constructed directly on the raw connection. `app.extensions["db_connection"]` is retained for OPERATIONAL state — hook errors, rating evidence/counters, retention runs, the audit log — on both backends. A `caps` context processor reads six capability flags off `app.extensions["backend"]` (`supports_episodes`, `supports_observations`, `supports_provenance`, `supports_retention_runs`, `supports_reflection_review`, `supports_reflection_text_edit`) and exposes them to every template as `caps`, rebuilt on every render so swapping the extension flips every gate. All six flags are consumed: each pairs a nav-link/template-block gate (`{% if caps.* %}`, wrapping existing markup, no new classes) with a matching `abort(404)` guard on the route(s) it protects, so an agentcore session can neither see nor navigate directly to a hidden surface. - `supports_episodes` — hides the Episodes nav link and 404s every `/episodes*` route (episode grouping is internal to AgentCore's `sessionId`, no local table). - `supports_observations` — hides the Observations nav link and 404s every `/observations*` route (no local observation table in agentcore mode). @@ -77,14 +79,16 @@ Two distinct retrieve tools, two distinct ranking mechanisms: - **`memory.retrieve`** returns three reflection buckets — `do`, `dont`, `neutral` — ranked by the Wilson-score hit-rate prior on rated exposures (see [Self-rating loop](#self-rating-loop) for the formula - and the query-driven BM25/vector re-fusion). + and the query-driven BM25 re-fusion). - **`memory.retrieve_observations`** returns raw observations via a - hybrid search: FTS5 lexical match against observation content, plus - a sqlite-vec dense vector match against observation embeddings - (768-dim from `nomic-embed-text`), combined by Reciprocal Rank Fusion - (RRF). Results are filtered by outcome bucket and weighted by - `reinforcement_score` (each `memory.record_use` shifts a memory's - score up on success or down on failure — see [Reinforcement](#reinforcement)). + hybrid search over two BM25 legs -- word-level FTS5 (`observation_fts`) + and trigram-level FTS5 (`observation_trigram_fts`, which catches + substring matches the word tokenizer misses) -- fused by Reciprocal + Rank Fusion (RRF). There is no vector/embedding leg (removed; see + `better_memory/search/hybrid.py`). Results are filtered by outcome + bucket and weighted by `reinforcement_score` (each `memory.record_use` + shifts a memory's score up on success or down on failure — see + [Reinforcement](#reinforcement)). ## Injection strategies @@ -97,79 +101,40 @@ better-memory gets memory in front of Claude two ways: a dump at session start, **CLAUDE.md drift sentinel — retired.** SessionStart no longer runs a standalone sentinel that scanned `~/.claude/CLAUDE.md` for stale tool-parameter references (`better_memory/hooks/_claude_md_sentinel.py`, deleted). See [Self-managing wiring](#self-managing-wiring) above: the retrieve/reinforce/synthesize/record protocol text is now a managed block (`manifest.CLAUDE_MD_BLOCK`) that `better-memory setup` / `doctor --fix` / the session-start autocheck (`better_memory.setup.autocheck.maybe_repair`, wired into `hooks/session_bootstrap.py`) keep byte-identical to the canonical text, so drift is repaired outright rather than merely flagged. `better_memory/skills/CLAUDE.snippet.md` (used for non-Claude-Code MCP integrations) remains behavioural instructions only ("pass a task-describing query when you begin a task", "credit with evidence") and never enumerates parameter names or types, so it can't drift the way the retired sentinel used to detect. -**Contextual injection (UserPromptSubmit / PreToolUse).** The `contextual_inject` hook (`better_memory/hooks/contextual_inject.py`) scores the curated memory set (semantic + reflections) against the current prompt (UserPromptSubmit, fires on every prompt) or tool name + input (PreToolUse, matcher now unscoped -- every tool call, not a fixed allowlist) via `retrieve_relevant` (`better_memory/services/relevant.py`), a three-leg evidence-gated scorer: +**Contextual injection (UserPromptSubmit / PreToolUse).** The `contextual_inject` hook (`better_memory/hooks/contextual_inject.py`) scores the curated memory set (semantic + reflections) against the current prompt (UserPromptSubmit, fires on every prompt) or tool name + input (PreToolUse, matcher now unscoped -- every tool call, not a fixed allowlist) via `retrieve_relevant` (`better_memory/services/relevant.py`), an evidence-gated scorer: -- A memory injects only when it clears an evidence gate: a BM25 match against `reflection_fts` (title / use_cases / hints), or a vector cosine similarity >= `BETTER_MEMORY_CONTEXT_VEC_FLOOR` (default `0.55`) against its embedding. No evidence, no injection -- a memory with neither leg present is silently dropped, however popular it is. In agentcore mode, the BM25/vector legs are replaced by the backend's own `relevance_ranks` (`StorageBackend.relevance_ranks`, backed by server-side `RetrieveMemoryRecords` semantic search): a memory qualifies iff it appears in that search's result set. -- The Wilson lower-bound prior on rated exposures (see [Self-rating loop](#self-rating-loop)) never qualifies a memory by itself; among qualifiers it only ranks, via reciprocal rank fusion together with whichever of the BM25/vector ranks (or, on agentcore, the `relevance_ranks` rank) are present. Popularity forcing irrelevant injections into context was the old failure mode the gate exists to close. -- A keyword-hit fallback (>= 2 distinct hits) applies only where the primary evidence leg is structurally unavailable or has failed: for sqlite-backed reflections, when there is no raw sqlite `conn`; for agentcore-backed reflections and semantic memories, only when `relevance_ranks` itself fails (an AWS error on every namespace, signalled by `None` -- a genuinely empty `{}` result is a legitimate negative and is NOT a fallback trigger); for sqlite-backed semantic memories -- which have no FTS substrate at all -- whenever no query vector could be produced (no embedder configured, or the Ollama embed call is in cooldown). `BETTER_MEMORY_CONTEXT_MIN_HITS` is **deprecated**: `contextual_inject` no longer reads it, superseded by the evidence gate above. -- Because PreToolUse now matches every tool, a per-session latch (`SeenStore.pretool_fired` / `mark_pretool_fired`, `better_memory/services/context_seen.py`) runs the full retrieval path at most once per session for PreToolUse; every later PreToolUse event in the session short-circuits on the latch state before touching the DB or the embedder. UserPromptSubmit is unaffected by the latch and fires on every prompt. +- A memory injects only when it clears an evidence gate. For reflections: a BM25 match against `reflection_fts` (title / use_cases / hints) -- or, only when that leg is structurally unavailable (no sqlite `conn` passed in), a keyword-hit fallback (>= 2 distinct hits). For semantic memories, which have no FTS substrate of their own: the keyword-hit fallback (>= 2 distinct hits) is the only evidence leg, and it is unconditionally active. No evidence, no injection -- a memory with neither leg present is silently dropped, however popular it is. In agentcore mode, both kinds are instead gated by the backend's own `relevance_ranks` (`StorageBackend.relevance_ranks`, backed by server-side `RetrieveMemoryRecords` semantic search): a memory qualifies iff it appears in that search's result set, falling back to the keyword-hit floor only when the lookup itself fails (an AWS error, signalled by `None` -- a genuinely empty `{}` result is a legitimate negative, not a fallback trigger). +- The Wilson lower-bound prior on rated exposures (see [Self-rating loop](#self-rating-loop)) never qualifies a memory by itself; among qualifiers it only ranks, via reciprocal rank fusion together with the BM25 rank (or, on agentcore, the `relevance_ranks` rank) when present. Popularity forcing irrelevant injections into context was the old failure mode the gate exists to close. +- The keyword-hit fallback described above applies only where the primary evidence leg is structurally unavailable or has failed: for sqlite-backed reflections, when there is no raw sqlite `conn`; for agentcore-backed reflections and semantic memories, only when `relevance_ranks` itself fails (an AWS error on every namespace, signalled by `None` -- a genuinely empty `{}` result is a legitimate negative and is NOT a fallback trigger); for sqlite-backed semantic memories it is unconditional, since they have no other evidence leg at all. `BETTER_MEMORY_CONTEXT_MIN_HITS` is **deprecated**: `contextual_inject` no longer reads it, superseded by the evidence gate above. +- Because PreToolUse now matches every tool, a per-session latch (`SeenStore.pretool_fired` / `mark_pretool_fired`, `better_memory/services/context_seen.py`) runs the full retrieval path at most once per session for PreToolUse; every later PreToolUse event in the session short-circuits on the latch state before touching the DB. UserPromptSubmit is unaffected by the latch and fires on every prompt. - Survivors are capped to `BETTER_MEMORY_CONTEXT_MAX_ITEMS`, then filtered through a per-session `SeenStore` (`better_memory/services/context_seen.py`, JSON file at `/state/context_seen_.json`) that deduplicates memories already injected this session. `BETTER_MEMORY_CONTEXT_REINJECT_TURNS=0` (default) means a memory is injected at most once per session; a positive value allows re-injection after that many turns have passed since it was last shown. A "turn" here is one firing of the `contextual_inject` hook, not one user prompt-response cycle: each user prompt is a turn, and each PreToolUse latch-firing is a turn too (subsequent latched-out PreToolUse events do not bump the turn counter). - Survivors render as a `` XML block in `additionalContext`, one entry per item with its kind, id, confidence, `useful_count`, and age, a `dont`-polarity item prefixed `Known pitfall -- do this instead:`, and a footer inviting `memory_credit(kind, id, class, evidence)` — with a one-line evidence statement — when an entry actually helped or misled. - Survivors are logged to `session_memory_exposure` with `source='contextual'` (best-effort; a write failure never blocks injection) and counted in `rating_diagnostics` (`contextual_fired_userprompt`, `contextual_fired_pretool`, `contextual_injected`, `contextual_suppressed_floor`, `contextual_suppressed_dedup`). These counters are per-firing, not per-item: a firing that injects one or several memories still increments `contextual_injected` by exactly 1. `contextual_suppressed_floor` now means "no candidate cleared the evidence gate", not "below the old keyword-hits floor". Gated by `BETTER_MEMORY_CONTEXT_INJECT_MODE` (`userprompt` / `pretool` / `both` / `off`). The hook never raises and always exits 0 — failures are swallowed and logged to `hook_errors`, with no `additionalContext` emitted on that turn. -## Embeddings backends - -better-memory supports two backends behind the -`BETTER_MEMORY_EMBEDDINGS_BACKEND` env var. - -**`ollama` (default).** Observation text is embedded by a local Ollama -server (model: `nomic-embed-text`, 768-dim). Vectors land in the -`observation_embeddings` virtual table; retrieval fuses FTS5 BM25 with -sqlite-vec kNN via Reciprocal Rank Fusion. Same quality as previous -versions; requires Ollama running on `OLLAMA_HOST`. - -**`sqlite`.** A second FTS5 virtual table (`observation_trigram_fts`, -tokenizer=`trigram`) is populated by triggers alongside the existing -`observation_fts` (word tokenizer). Retrieval fuses both via RRF — no -external service, no model downloads, no in-memory state. Lower -recall on long paraphrased queries than Ollama embeddings, but -substring and morphology bridging via trigrams works well for -keyword-dense observations. - -Both FTS5 tables are populated by triggers regardless of which backend -is active, so backend switches require no data migration at runtime — -the trigram table is always ready to serve `sqlite` queries, and the -word-tokenized table always feeds the lexical half of the `ollama` -path. Only the vec0 (sqlite-vec) half degrades for cross-backend rows -that were written while `sqlite` was active (no embedding was computed -for them); a future `memory.reindex` MCP tool can backfill if that -half-state becomes a problem. - -### Reflection and semantic-memory embeddings - -Reflections and semantic memories get their own vectors, written at -write time rather than lazily at query time: `ReflectionSynthesisService` -embeds a reflection when synthesis creates or augments it, `ReflectionService` -re-embeds on `update_text`, and the semantic-memory service does the -same on `memory.semantic_observe` and its update path. All of these go -through `SyncEmbedder` (`better_memory/embeddings/sync_embed.py`), a -synchronous facade over the Ollama embedder that is best-effort by -design: any failure opens a 60-second circuit breaker (`_DEFAULT_COOLDOWN`) -during which every embed call returns `None` immediately instead of -retrying against a dead Ollama. In `contextual_inject`, that cooldown is -additionally persisted to a state file (`/state/embed_down_until`) -so an Ollama outage is paid for once, not once per hook process -- every -`contextual_inject` invocation started while the file's deadline is still -in the future skips the embed call outright instead of re-discovering the -outage itself. A missing vector is never treated as an -error - callers just drop the vector leg from ranking and fall back to -the Wilson + BM25 order above. - -Two mechanisms cover rows left without a vector - historical rows -predating this feature, or rows written during a breaker outage: - -- **Lazy self-heal on retrieve.** Each `memory.retrieve` call embeds up - to 20 reflections missing a vector before ranking (`SELF_HEAL_BATCH_CAP` - in `better_memory/services/reflection.py`) - capped so a cold corpus - can't turn one retrieve call into an unbounded batch of Ollama calls. -- **`python -m better_memory.cli.backfill_embeddings`.** A one-shot, - idempotent CLI that embeds every reflection and semantic memory still - missing a vector, in batches of 50. Intended to run once after - deploying the migration that adds the embedding tables, so the - historical corpus is searchable immediately instead of waiting for - retrieval traffic to heal it row by row. +## Local search (sqlite) + +All local retrieval is pure in-SQL FTS5 — no embedding model, no +external service, no in-memory state. Two FTS5 virtual tables are +populated by triggers on write: `observation_fts` (word tokenizer) and +`observation_trigram_fts` (tokenizer=`trigram`, which catches substring +matches the word tokenizer misses). `better_memory/search/hybrid.py` +fuses both via Reciprocal Rank Fusion into one ranked list for +`memory.retrieve_observations`. Reflections have their own word-level +FTS5 table (`reflection_fts`, title / use_cases / hints) used by the +evidence gate in [Injection strategies](#injection-strategies) and the +query re-fusion in [Self-rating loop](#self-rating-loop). Semantic +memories have no FTS substrate of their own and rely on the keyword-hit +fallback described in those sections. There used to be a second, +Ollama-backed embeddings path (vector search over `nomic-embed-text` +embeddings, selected via a now-removed `BETTER_MEMORY_EMBEDDINGS_BACKEND` +env var); it was deleted, along with the `observation_embeddings` / +`reflection_embeddings` / `semantic_embeddings` vec0 tables (migration +`0018_drop_vec_tables.sql`). The `sqlite-vec` extension is still loaded +by every connection only because dropping those vec0 tables requires +the module registered — see the note under [Storage +backends](#storage-backends). ## Reinforcement @@ -249,11 +214,10 @@ described below: from the headline usefulness metric while still being rated normally through the same self-rating loop. When `memory.retrieve` is called with a `query`, this Wilson-ranked list is re-fused with a BM25 - relevance ranking (title / use_cases / hints) and a vector-kNN - ranking via the same Reciprocal Rank Fusion used for observations -- - a three-leg RRF of Wilson rank, BM25 rank, and vector rank -- so a - query that matches nothing on either extra leg degrades exactly to - the Wilson-only order. + relevance ranking (title / use_cases / hints) via the same + Reciprocal Rank Fusion used for observations -- a two-leg RRF of + Wilson rank and BM25 rank -- so a query that matches nothing on the + BM25 leg degrades exactly to the Wilson-only order. Every non-ignored rating's evidence line is stored on the `session_memory_exposure` row (`evidence` column, migration diff --git a/website/configuration.md b/website/configuration.md index 9b30930b..bb08921f 100644 --- a/website/configuration.md +++ b/website/configuration.md @@ -8,10 +8,7 @@ One environment variable roots the runtime filesystem layout. Everything else ha |---|---|---| | `BETTER_MEMORY_HOME` | `~/.better-memory` | Root directory for `memory.db`, `knowledge.db`, `spool/`, and `knowledge-base/` | | `BETTER_MEMORY_WIRING_AUTOCHECK` | unset (`on`) | Session-start wiring drift autocheck (`better_memory.setup.autocheck.maybe_repair`, wired into the `session_bootstrap` hook): self-repairs `~/.claude.json`, `~/.claude/settings.json` hooks/env, the CLAUDE.md managed block, skill links, and the per-repo `post-commit` hook. Set to `off` (case-insensitive) to disable; any other value leaves it enabled. See [Architecture](architecture.md#self-managing-wiring). | -| `OLLAMA_HOST` | `http://localhost:11434` | Ollama endpoint (embeddings only — see [Architecture](architecture.md#synthesis-pipeline) for why synthesis no longer uses Ollama) | -| `EMBED_MODEL` | `nomic-embed-text` | Embedding model (must produce 768-dim vectors) | | `AUDIT_LOG_RETRIEVED` | `true` | Whether `memory.retrieve` writes per-result audit rows | -| `BETTER_MEMORY_EMBEDDINGS_BACKEND` | `ollama` | `ollama` (default) — local Ollama at `OLLAMA_HOST`; `sqlite` — pure-SQL trigram-FTS5 fusion, no model downloads and no in-memory state. See [Architecture](architecture.md#embeddings-backends). | | `BETTER_MEMORY_AUTO_PRUNE` | unset (`false`) | When `1`, the auto-retention runner that fires on `memory.retrieve` (throttled to once per 24h) ALSO hard-deletes archived observations older than 365 days. **Irreversible.** Default is archive-only (status flip, reversible). Opt in only if you actively want disk space reclaimed. | | `BETTER_MEMORY_PROJECT` | unset | Force the project name for all calls in this process. Highest-priority project-resolution signal — overrides both the `.better-memory` file and the git-derived name. Designed for subprocess scoping (e.g. ralph's executor sets it per-iteration so subagent observations land in the PBI's target_repo regardless of the worktree's cwd). Empty/whitespace-only values are treated as unset. | | `BETTER_MEMORY_STORAGE_BACKEND` | unset | `sqlite` or `agentcore`. Explicit **override** of the storage backend. When unset, the backend is read from `$BETTER_MEMORY_HOME/settings.json` (written by `better-memory agentcore init`), falling back to `sqlite`. The env var always wins over settings.json. `agentcore` requires `pip install 'better-memory[agentcore]'` and a populated `agentcore.json` (see [AgentCore setup](agentcore-setup.md)). | @@ -20,8 +17,7 @@ One environment variable roots the runtime filesystem layout. Everything else ha | `BETTER_MEMORY_CONTEXT_INJECT_MODE` | `both` | Contextual memory-injection hook trigger: `userprompt` (on prompt only), `pretool` (on tool calls only), `both` (default), or `off`. The `contextual_inject` hook surfaces curated memories (semantic + reflections) relevant to the current prompt / tool-input. | | `BETTER_MEMORY_INJECT_MODE` | `legacy` | SessionStart bootstrap strategy: `legacy` (default, byte-identical to pre-deferred-injection behavior -- full bootstrap dump gated only by `BETTER_MEMORY_BOOTSTRAP_TOP_N`) or `deferred` (bootstrap renders only general-scope semantic memories plus a one-line index; project-scoped semantic memories and all reflections surface only through the contextual channel or on-demand `memory.retrieve`). Any value other than `deferred` resolves to `legacy`. See [Architecture](architecture.md#injection-strategies). | | `BETTER_MEMORY_BOOTSTRAP_TOP_N` | `5` | Number of project-scoped semantic memories and reflections the SessionStart bootstrap renders in full (beyond that, a one-line index plus a retrieve affordance). `0` disables slimming and dumps everything in full. Only applies in `BETTER_MEMORY_INJECT_MODE=legacy`. | -| `BETTER_MEMORY_CONTEXT_MIN_HITS` | `2` | **Deprecated, unused.** `contextual_inject` no longer reads this. It was the old minimum-keyword-hits floor, superseded by the three-leg evidence gate (BM25 / vector cosine / keyword fallback) described in [Architecture](architecture.md#injection-strategies). Kept only for back-compat resolution of stray env overrides. | -| `BETTER_MEMORY_CONTEXT_VEC_FLOOR` | `0.55` | Minimum vector cosine similarity a memory's embedding must clear against the query embedding to qualify as vector-leg evidence in `contextual_inject`'s evidence gate. Clamped to `[0.0, 1.0]`; invalid values fall back to the default. See [Architecture](architecture.md#injection-strategies). | +| `BETTER_MEMORY_CONTEXT_MIN_HITS` | `2` | **Deprecated, unused.** `contextual_inject` no longer reads this. It was the old minimum-keyword-hits floor, superseded by the evidence gate (BM25 match, or a keyword-hit fallback when the BM25 leg is unavailable) described in [Architecture](architecture.md#injection-strategies). Kept only for back-compat resolution of stray env overrides. | | `BETTER_MEMORY_CONTEXT_MAX_ITEMS` | `3` | Max number of memories `contextual_inject` injects per firing, after the evidence gate and ranking. | | `BETTER_MEMORY_CONTEXT_REINJECT_TURNS` | `0` | Turns to wait before `contextual_inject` will re-inject a memory already seen this session. `0` means never re-inject (each memory surfaces at most once per session). A "turn" here means one firing of the `contextual_inject` hook, not one user prompt-response cycle: each user prompt counts as a turn, and PreToolUse counts as at most one more turn per session (it's latched to a single real firing -- see [Architecture](architecture.md#injection-strategies)). | @@ -80,7 +76,7 @@ Eight managed hook entries ship with better-memory, across seven hook modules (` - **`better_memory.hooks.observer`** (PostToolUse) — captures tool-call snapshots into `spool/` for later observation creation. - **`better_memory.hooks.session_close`** (Stop) — writes a session-close marker into `spool/`; also emits the rating directive described in [Self-rating loop](architecture.md#self-rating-loop). In agentcore mode (resolved via the env var, else `settings.json`) it additionally fires one `CreateEvent(role=OTHER)` closure event against the current AgentCore session so the episodic strategy extracts within minutes; failure is logged to `hook_errors` and never blocks the marker write. - **`better_memory.hooks.stop_sweep`** (Stop) — a second, independent Stop registration: prints a `systemMessage` reminding Claude to record any non-obvious observations from the session (per the CLAUDE.md mandatory triggers) before stopping. -- **`better_memory.hooks.contextual_inject`** (UserPromptSubmit, every prompt; PreToolUse, now every tool call, latched to one real firing per session) — scores the curated memory set (semantic + reflections) against the current prompt or tool-input through an evidence gate (BM25 match, vector cosine >= `BETTER_MEMORY_CONTEXT_VEC_FLOOR` — replaced on agentcore by the backend's own server-side semantic-search evidence, `relevance_ranks` — or a keyword-hit fallback when those legs are structurally unavailable or an AWS lookup fails; `BETTER_MEMORY_CONTEXT_MIN_HITS` is deprecated and no longer consulted), caps survivors to `BETTER_MEMORY_CONTEXT_MAX_ITEMS`, and injects them as a `` block in `additionalContext`. A per-session seen-file dedups repeats (`BETTER_MEMORY_CONTEXT_REINJECT_TURNS` controls re-injection). Gated by `BETTER_MEMORY_CONTEXT_INJECT_MODE`. See [Architecture](architecture.md#injection-strategies) for detail. +- **`better_memory.hooks.contextual_inject`** (UserPromptSubmit, every prompt; PreToolUse, now every tool call, latched to one real firing per session) — scores the curated memory set (semantic + reflections) against the current prompt or tool-input through an evidence gate (a BM25 match against `reflection_fts` — replaced on agentcore by the backend's own server-side semantic-search evidence, `relevance_ranks` — or a keyword-hit fallback when those legs are structurally unavailable or an AWS lookup fails; `BETTER_MEMORY_CONTEXT_MIN_HITS` is deprecated and no longer consulted), caps survivors to `BETTER_MEMORY_CONTEXT_MAX_ITEMS`, and injects them as a `` block in `additionalContext`. A per-session seen-file dedups repeats (`BETTER_MEMORY_CONTEXT_REINJECT_TURNS` controls re-injection). Gated by `BETTER_MEMORY_CONTEXT_INJECT_MODE`. See [Architecture](architecture.md#injection-strategies) for detail. - **`better_memory.hooks.commit_checkpoint`** (PreToolUse, matcher `Bash`, `if: "Bash(git commit*)"`) — fires only immediately before a `git commit` tool call and injects a reminder that CLAUDE.md's mandatory triggers require a `memory.observe` call first if the commit fixes a non-obvious bug, addresses reviewer feedback, or wraps a phase. - **`better_memory.hooks.pre_compact`** (PreCompact) — injects a reminder to persist the in-flight task, key decisions, open questions, and relevant file paths to better-memory via `memory.observe` before context compaction discards them. diff --git a/website/contributing.md b/website/contributing.md index edb0ec54..4c187463 100644 --- a/website/contributing.md +++ b/website/contributing.md @@ -11,11 +11,11 @@ uv sync --dev ## Running tests ```bash -uv run pytest # full suite (integration tests need Ollama running) +uv run pytest # full suite (integration tests need real AWS / a browser) uv run pytest -m "not integration" # unit tests only — fast, no external deps ``` -The integration marker gates tests that hit Ollama or spawn the MCP server end-to-end. Unit tests cover the bulk of the codebase and run in seconds. +The integration marker gates tests that need real AWS Bedrock AgentCore credentials (`BETTER_MEMORY_TEST_AGENTCORE=1`) or a Playwright browser for the management UI's browser suite. Unit tests cover the bulk of the codebase and run in seconds. ## Linting and type-checking diff --git a/website/index.md b/website/index.md index 1cc741ed..34380c6b 100644 --- a/website/index.md +++ b/website/index.md @@ -22,7 +22,7 @@ hide:
// LOCAL-FIRST · MCP · CLAUDE-CODE

memory that
sticks between sessions.

-

A semantic + episodic memory manager for Claude Code. SQLite + local Ollama embeddings. Synthesis runs in your Claude Code session — no separate cloud dependency.

+

A semantic + episodic memory manager for Claude Code. Local SQLite + FTS5 — no embedding model, no external service. Synthesis runs in your Claude Code session — no separate cloud dependency.

install → read the spec → @@ -203,6 +203,6 @@ cd better-memory diff --git a/website/mcp-tools.md b/website/mcp-tools.md index 13388326..8ba8644c 100644 --- a/website/mcp-tools.md +++ b/website/mcp-tools.md @@ -27,7 +27,7 @@ Get reflections bucketed by polarity, plus drained spool observations. | Parameter | Type | Required | Notes | |---|---|---|---| -| `query` | string | optional (strongly recommended) | Plain-language description of the task at hand. Fuses BM25 + vector search via RRF with the Wilson-score usefulness prior. Omitting it degrades gracefully to the Wilson-prior-only order — the same generic top-ranked lessons every call. | +| `query` | string | optional (strongly recommended) | Plain-language description of the task at hand. Fuses a BM25 relevance ranking via RRF with the Wilson-score usefulness prior. Omitting it degrades gracefully to the Wilson-prior-only order — the same generic top-ranked lessons every call. | | `project` | string | optional | Defaults to current project (cwd-derived). | | `tech` | string | optional | Filter by tech tag. | | `phase` | `planning` / `implementation` / `general` | optional | Filter by reflection phase. | @@ -42,7 +42,7 @@ Drill-down: get raw observations matching filters. Use `memory.retrieve` for the | Parameter | Type | Required | Notes | |---|---|---|---| -| `query` | string | optional | Hybrid FTS5 + vector search. Without it, ordered by `created_at DESC`. | +| `query` | string | optional | Hybrid FTS5 search (word + trigram BM25 legs, RRF-fused). Without it, ordered by `created_at DESC`. | | `component` | string | optional | | | `theme` | string | optional | Ignored in query mode. | | `outcome` | `success` / `failure` / `neutral` | optional | |