From 2ecf0f4590e44c9e338add8f1ec0df559d2eaa71 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 27 Jul 2026 09:11:39 +0500 Subject: [PATCH] feat(embed): one-variable presets, working local endpoints, honest dim mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embeddings were nominally pluggable and practically not: `OpenAIEmbedder` always sent `dimensions` (Ollama and llama.cpp reject the request outright) and always required OPENAI_API_KEY (local servers have none). So "point it at your own embedder" failed on every local server anyone would actually try. - SESSION_RECALL_EMBED=voyage|ollama|lmstudio|openai sets endpoint, model, dim and reranker as one unit. Those four are not independent: choosing a local model and leaving a cloud reranker configured only fails later, further from the cause. Individual SESSION_RECALL_EMBED_* variables still override, so a preset never blocks a custom endpoint. - With no preset and no Voyage key, probe for a local server that is already listening — a running endpoint beats defaulting to one certain to reject us. A configured key skips the probe entirely, so nothing is added to a working setup. - Store now refuses to open an index whose vector width differs from the configured embedder, naming the fix. Previously the raw sqlite dimension error travelled up into recall_search's degrade path and was reported as "embeddings unavailable" — sending the user to debug a healthy embedder after switching models. Default is Apache-2.0 `nomic-embed-text`, not a stronger CC-BY-NC model: anyone indexing work history with the latter would be in breach without being told. Co-Authored-By: Claude Opus 5 --- README.md | 62 ++++++++++++++++++++- src/session_recall/config.py | 103 +++++++++++++++++++++++++++++++++-- src/session_recall/embed.py | 32 ++++++++--- src/session_recall/store.py | 20 +++++++ tests/test_embed.py | 43 +++++++++++++++ tests/test_presets.py | 68 +++++++++++++++++++++++ tests/test_store.py | 20 +++++++ 7 files changed, 333 insertions(+), 15 deletions(-) create mode 100644 tests/test_presets.py diff --git a/README.md b/README.md index 1bec4dc..18f7cac 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,15 @@ agent. Budget about two minutes plus the first index run. ```bash pipx install git+https://github.com/AbsoluteMode/session-recall -export VOYAGE_API_KEY=... # get one at voyageai.com; add the line to your shell profile -session-recall index # first run walks your whole history; later runs are incremental +session-recall index # first run walks your whole history; later runs are incremental +``` + +That is the whole thing if you have a local embedding server running — see +[Embedding providers](#embedding-providers) for the free local setup. For hosted Voyage +embeddings instead, export a key first: + +```bash +export VOYAGE_API_KEY=... # voyageai.com; put the line in your shell profile ``` `pipx` puts `session-recall` and `session-recall-mcp` on `~/.local/bin` — exactly where the @@ -140,6 +147,57 @@ To register the MCP server by hand instead of using the plugin: claude mcp add session-recall --scope user -- /absolute/path/.venv/bin/session-recall-mcp ``` +## Embedding providers + +Nothing is locked to one vendor. `SESSION_RECALL_EMBED=` sets endpoint, model, +dimension and reranker together, because those four are not independent choices: + +| preset | runs | model | dim | reranker | +|---|---|---|---|---| +| `voyage` | hosted, needs a key | `voyage-4-large` | 1024 | `rerank-2.5` | +| `ollama` | **local, free** | `nomic-embed-text` | 768 | — | +| `lmstudio` | **local, free** | `nomic-embed-text-v1.5` | 768 | — | +| `openai` | hosted, needs a key | `text-embedding-3-large` | 1024 | — | + +With no preset set, session-recall picks Voyage when `VOYAGE_API_KEY` is present, and +otherwise probes for a local server already listening — better than defaulting to a +provider that is guaranteed to reject the request. With a key configured, no probe runs. + +**Free and local, start to finish:** + +```bash +ollama pull nomic-embed-text +export SESSION_RECALL_EMBED=ollama +session-recall index +``` + +**Your own endpoint** — any server speaking `/v1/embeddings` (llama.cpp, vLLM, a +company gateway). Individual variables always beat the preset, so mix freely: + +```bash +export SESSION_RECALL_EMBED_PROVIDER=openai-compatible +export SESSION_RECALL_EMBED_BASE_URL=https://embeddings.internal/v1 +export SESSION_RECALL_EMBED_MODEL=your-model +export SESSION_RECALL_EMBED_DIM=1024 +``` + +Two things worth knowing before you switch: + +- **A different embedder needs its own index.** Vector tables are fixed-width, so + changing the model or dimension means rebuilding: delete + `~/.local/share/session-recall/index.db` and re-run `index`. Attempting to reuse the old + one now fails with a message saying exactly that, rather than looking like a dead + embedder. +- **Local presets ship no reranker**, so ranking is KNN + FTS only — good enough, but + noticeably coarser than the hosted path. + +On model choice: `nomic-embed-text` is the default because it is Apache-2.0 and installs in +one command. Stronger small models exist — `jina-embeddings-v5-text-nano` scores far higher +for its size — but they are **CC BY-NC**, which anyone indexing work history would be +violating without ever being told. If your use is genuinely non-commercial, point the +variables above at one. If you work in more than English, `qwen3-embedding:0.6b` (Apache-2.0) +handles multilingual history far better than `nomic`. + ## Keeping the index fresh If you installed the plugin, this is already handled — skip to the next section. The bundled diff --git a/src/session_recall/config.py b/src/session_recall/config.py index a6ac073..8fb5ffd 100644 --- a/src/session_recall/config.py +++ b/src/session_recall/config.py @@ -1,5 +1,8 @@ import os +import socket +from dataclasses import dataclass from pathlib import Path +from urllib.parse import urlparse DATA_DIR = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) / "session-recall" DB_PATH = DATA_DIR / "index.db" @@ -20,11 +23,101 @@ # NB: provider/model changes are detected via the embed fingerprint baked into # file signatures — the next `index` run re-embeds everything cleanly. Changing # dim still requires a fresh index: the vector table is dim-typed. -EMBED_PROVIDER = os.environ.get("SESSION_RECALL_EMBED_PROVIDER", "voyage") -EMBED_MODEL = os.environ.get("SESSION_RECALL_EMBED_MODEL", "voyage-4-large") -EMBED_DIM = int(os.environ.get("SESSION_RECALL_EMBED_DIM", "1024")) +@dataclass(frozen=True) +class EmbedSettings: + provider: str + model: str + dim: int + base_url: str | None + send_dimensions: bool + rerank_provider: str + rerank_model: str | None + + +# One name per usable setup. A preset fills in endpoint, model, dimension and +# reranker together, because those four are not independent choices — picking a +# local model and leaving a cloud reranker configured just fails later, further away. +PRESETS: dict[str, EmbedSettings] = { + "voyage": EmbedSettings( + provider="voyage", model="voyage-4-large", dim=1024, base_url=None, + send_dimensions=False, rerank_provider="voyage", rerank_model="rerank-2.5"), + "openai": EmbedSettings( + provider="openai", model="text-embedding-3-large", dim=1024, base_url=None, + send_dimensions=True, rerank_provider="none", rerank_model=None), + # Free and local. `nomic-embed-text` is Apache-2.0 and the most-pulled embedding + # model in Ollama; deliberately not a stronger CC-BY-NC model, which would put + # anyone indexing work history in breach without ever telling them. + "ollama": EmbedSettings( + provider="openai-compatible", model="nomic-embed-text", dim=768, + base_url="http://127.0.0.1:11434/v1", + send_dimensions=False, rerank_provider="none", rerank_model=None), + "lmstudio": EmbedSettings( + provider="openai-compatible", model="text-embedding-nomic-embed-text-v1.5", + dim=768, base_url="http://127.0.0.1:1234/v1", + send_dimensions=False, rerank_provider="none", rerank_model=None), +} + +_PROBE_TIMEOUT_S = 0.3 + + +def _probe_local_server() -> str | None: + """Name of a local inference server that is already listening, else None. + A TCP connect, not an HTTP call: this runs at import time when no key is set, + and must cost nothing when nothing is there.""" + for name in ("ollama", "lmstudio"): + url = urlparse(PRESETS[name].base_url) + try: + with socket.create_connection((url.hostname, url.port), _PROBE_TIMEOUT_S): + return name + except OSError: + continue + return None + + +def resolve_embed(env: dict | None = None, probe=None) -> EmbedSettings: + """Turn the environment into one coherent embedding setup. + + `SESSION_RECALL_EMBED=` is the short path. With no preset and no Voyage + key, a local server that is already running beats defaulting to a provider that + is certain to reject us. Individual SESSION_RECALL_EMBED_* variables still win, + so a preset never blocks a custom endpoint. + """ + env = os.environ if env is None else env + probe = probe or _probe_local_server + + name = (env.get("SESSION_RECALL_EMBED") or "").strip().lower() + if name: + if name not in PRESETS: + raise ValueError( + f"unknown embed preset {name!r}; available: {', '.join(sorted(PRESETS))}") + base = PRESETS[name] + elif env.get("VOYAGE_API_KEY"): + base = PRESETS["voyage"] + else: + detected = probe() + base = PRESETS[detected] if detected else PRESETS["voyage"] + + dim = env.get("SESSION_RECALL_EMBED_DIM") + return EmbedSettings( + provider=env.get("SESSION_RECALL_EMBED_PROVIDER", base.provider), + model=env.get("SESSION_RECALL_EMBED_MODEL", base.model), + dim=int(dim) if dim else base.dim, + base_url=env.get("SESSION_RECALL_EMBED_BASE_URL", base.base_url), + send_dimensions=base.send_dimensions, + rerank_provider=env.get("SESSION_RECALL_RERANK_PROVIDER", base.rerank_provider), + rerank_model=env.get("SESSION_RECALL_RERANK_MODEL", base.rerank_model), + ) + + +_EMBED = resolve_embed() + +EMBED_PROVIDER = _EMBED.provider +EMBED_MODEL = _EMBED.model +EMBED_DIM = _EMBED.dim +EMBED_BASE_URL = _EMBED.base_url +EMBED_SEND_DIMENSIONS = _EMBED.send_dimensions # Reranker — OPTIONAL. Voyage rerank-2.5 by default; set provider=none to run on # KNN + FTS only (not every embedding provider ships a reranker). -RERANK_PROVIDER = os.environ.get("SESSION_RECALL_RERANK_PROVIDER", "voyage") -RERANK_MODEL = os.environ.get("SESSION_RECALL_RERANK_MODEL", "rerank-2.5") +RERANK_PROVIDER = _EMBED.rerank_provider +RERANK_MODEL = _EMBED.rerank_model diff --git a/src/session_recall/embed.py b/src/session_recall/embed.py index fdf046a..d928046 100644 --- a/src/session_recall/embed.py +++ b/src/session_recall/embed.py @@ -1,5 +1,6 @@ import hashlib import math +import os from typing import Protocol from . import config @@ -59,28 +60,43 @@ def embed_query(self, text: str) -> list[float]: return self.client.embed([text], model=self.model, input_type="query").embeddings[0] +def _openai_client(**kwargs): + """Constructing the SDK, isolated so tests can stand in for it without a network.""" + from openai import OpenAI + return OpenAI(**kwargs) + + class OpenAIEmbedder: - """OpenAI-compatible embeddings — works with OpenAI and any provider exposing a - /v1/embeddings endpoint (point OPENAI_BASE_URL at it). Reads OPENAI_API_KEY from env. - Lazy client. `dim`, when set, is passed as the `dimensions` parameter (supported by - text-embedding-3-* and compatible models) so the vector matches the index dimension.""" - def __init__(self, model: str | None = None, dim: int | None = None): + """OpenAI-compatible embeddings — OpenAI itself, or any server exposing + /v1/embeddings (Ollama, LM Studio, llama.cpp, vLLM). Lazy client. + + `send_dimensions` exists because the parameter is not universal: OpenAI needs it so + the vector matches the index, while local servers reject the request outright when + it is present. Local servers also have no API key, but the SDK refuses to construct + without one, hence the placeholder.""" + def __init__(self, model: str | None = None, dim: int | None = None, + base_url: str | None = None, send_dimensions: bool | None = None): self.model = model or config.EMBED_MODEL self.dim = dim or config.EMBED_DIM + self.base_url = base_url if base_url is not None else config.EMBED_BASE_URL + self.send_dimensions = (config.EMBED_SEND_DIMENSIONS + if send_dimensions is None else send_dimensions) self._client = None @property def client(self): if self._client is None: - from openai import OpenAI - self._client = OpenAI() + kwargs = {"api_key": os.environ.get("OPENAI_API_KEY") or "local-no-key"} + if self.base_url: + kwargs["base_url"] = self.base_url + self._client = _openai_client(**kwargs) return self._client def _embed(self, texts: list[str]) -> list[list[float]]: out: list[list[float]] = [] for i in range(0, len(texts), 128): kw = {"model": self.model, "input": texts[i:i + 128]} - if self.dim: + if self.dim and self.send_dimensions: kw["dimensions"] = self.dim out.extend(d.embedding for d in self.client.embeddings.create(**kw).data) return out diff --git a/src/session_recall/store.py b/src/session_recall/store.py index d9293a7..b81c588 100644 --- a/src/session_recall/store.py +++ b/src/session_recall/store.py @@ -2,6 +2,7 @@ # sqlite3 is compiled without SQLITE_ENABLE_LOAD_EXTENSION, so enable_load_extension() # and load_extension() are absent. pysqlite3 (wheel) provides them, which sqlite-vec # requires to load vec0. The rest of the API (execute, fetchall, etc.) is identical. +import re import sqlite3 if not hasattr(sqlite3.Connection, "enable_load_extension"): import pysqlite3 as sqlite3 # type: ignore[no-redef] # macOS stdlib lacks extension loading @@ -25,6 +26,25 @@ def __init__(self, db_path: Path): sqlite_vec.load(self.db) self.db.enable_load_extension(False) self._schema() + self._assert_dim_matches(db_path) + + def _assert_dim_matches(self, db_path: Path) -> None: + """vec0 tables are fixed-width, so an index built with another embedder cannot + serve these vectors. Say that here: further down the raw sqlite error is caught + by the search degrade path and reported as an unreachable embedder, which sends + the user to debug something that is not broken.""" + row = self.db.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='vec_chunks'" + ).fetchone() + if not row or not row[0]: + return + found = re.search(r"FLOAT\[(\d+)\]", row[0]) + if found and int(found.group(1)) != EMBED_DIM: + raise RuntimeError( + f"index at {db_path} stores {found.group(1)}-dimension vectors, but the " + f"configured embedder produces {EMBED_DIM}. A different embedding model " + f"needs its own index: delete that file and run `session-recall index` " + f"to rebuild it.") def _schema(self): col_defs = ", ".join( diff --git a/tests/test_embed.py b/tests/test_embed.py index 1430814..4ed8a88 100644 --- a/tests/test_embed.py +++ b/tests/test_embed.py @@ -29,3 +29,46 @@ def test_make_embedder_defaults_to_config(monkeypatch): from session_recall import config, embed monkeypatch.setattr(config, "EMBED_PROVIDER", "fake") assert isinstance(embed.make_embedder(), embed.FakeEmbedder) + + +class _RecordingClient: + """Stands in for the OpenAI SDK: records how it was built and what it was asked.""" + def __init__(self, **kwargs): + self.kwargs = kwargs + self.calls = [] + self.embeddings = self + + def create(self, **kw): + self.calls.append(kw) + return type("R", (), {"data": [type("D", (), {"embedding": [0.0] * 8})() + for _ in kw["input"]]})() + + +def test_openai_embedder_omits_dimensions_for_servers_that_reject_it(monkeypatch): + """Ollama and llama.cpp 400 on `dimensions`. Sending it unconditionally is what + makes 'point it at your own endpoint' fail on every local server.""" + from session_recall import embed + client = _RecordingClient() + monkeypatch.setattr(embed, "_openai_client", lambda **kw: client) + + embed.OpenAIEmbedder(model="nomic-embed-text", dim=768, + send_dimensions=False).embed_query("hi") + assert "dimensions" not in client.calls[0], "local endpoints reject the parameter" + + embed.OpenAIEmbedder(model="text-embedding-3-large", dim=1024, + send_dimensions=True).embed_query("hi") + assert client.calls[1]["dimensions"] == 1024, "OpenAI needs it to match the index" + + +def test_openai_embedder_reaches_a_local_endpoint_without_a_key(monkeypatch): + """A local server has no API key, but the SDK refuses to construct without one — + so an unauthenticated endpoint needs a placeholder, not a crash.""" + from session_recall import embed + seen = {} + monkeypatch.setattr(embed, "_openai_client", + lambda **kw: seen.update(kw) or _RecordingClient()) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + embed.OpenAIEmbedder(base_url="http://127.0.0.1:11434/v1").embed_query("hi") + assert seen["base_url"] == "http://127.0.0.1:11434/v1" + assert seen["api_key"], "SDK requires a non-empty key even when the server ignores it" diff --git a/tests/test_presets.py b/tests/test_presets.py new file mode 100644 index 0000000..062dd4f --- /dev/null +++ b/tests/test_presets.py @@ -0,0 +1,68 @@ +# tests/test_presets.py +import pytest +from session_recall.config import resolve_embed, PRESETS + + +def _never_probed(): + raise AssertionError("probe must not run when the choice is already determined") + + +def test_named_preset_configures_a_local_openai_compatible_endpoint(): + """`SESSION_RECALL_EMBED=ollama` is the whole configuration: one variable has to + produce endpoint, model, dimension and reranker, or 'bring your own embedder' + stays a four-variable puzzle nobody solves.""" + s = resolve_embed({"SESSION_RECALL_EMBED": "ollama"}, probe=_never_probed) + assert s.provider == "openai-compatible" + assert s.base_url == "http://127.0.0.1:11434/v1" + assert s.model == "nomic-embed-text" + assert s.dim == 768 + assert s.rerank_provider == "none", "local presets ship no reranker" + + +def test_local_presets_do_not_send_the_dimensions_parameter(): + """Ollama and llama.cpp reject `dimensions`; OpenAI requires it to match the index. + Sending it unconditionally is why a local endpoint cannot be used today.""" + assert resolve_embed({"SESSION_RECALL_EMBED": "ollama"}, probe=_never_probed).send_dimensions is False + assert resolve_embed({"SESSION_RECALL_EMBED": "openai"}, probe=_never_probed).send_dimensions is True + + +def test_explicit_variables_win_over_the_preset(): + """The preset is a default, not a cage — pointing at your own endpoint or model + must stay possible without abandoning presets entirely.""" + s = resolve_embed({ + "SESSION_RECALL_EMBED": "ollama", + "SESSION_RECALL_EMBED_MODEL": "mxbai-embed-large", + "SESSION_RECALL_EMBED_DIM": "1024", + }, probe=_never_probed) + assert s.model == "mxbai-embed-large" + assert s.dim == 1024 + assert s.base_url == "http://127.0.0.1:11434/v1", "untouched fields keep preset values" + + +def test_unknown_preset_names_the_valid_ones(): + with pytest.raises(ValueError) as exc: + resolve_embed({"SESSION_RECALL_EMBED": "gpt5"}, probe=_never_probed) + for name in PRESETS: + assert name in str(exc.value), "the error must list what IS available" + + +def test_a_running_local_server_is_used_when_there_is_no_api_key(): + """No key and no preset used to mean 'fail on every request'. If a local endpoint + is already up, that is a better default than a guaranteed 403.""" + s = resolve_embed({}, probe=lambda: "ollama") + assert s.provider == "openai-compatible" + assert s.model == "nomic-embed-text" + + +def test_an_api_key_suppresses_the_probe_entirely(): + """Autodetect must never add latency to a configured setup — with a key present + the probe is not merely ignored, it must not run.""" + s = resolve_embed({"VOYAGE_API_KEY": "pa-whatever"}, probe=_never_probed) + assert s.provider == "voyage" + assert s.rerank_provider == "voyage" + + +def test_falls_back_to_voyage_when_nothing_is_running(): + s = resolve_embed({}, probe=lambda: None) + assert s.provider == "voyage" + assert s.model == "voyage-4-large" diff --git a/tests/test_store.py b/tests/test_store.py index 523abe4..59014a9 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -319,3 +319,23 @@ def __getattr__(self, name): "WHERE c.id IS NULL").fetchone()[0] == 0 intruder.close() victim.close() + + +def test_dimension_change_fails_loudly_instead_of_looking_like_a_dead_embedder(tmp_path, monkeypatch): + """Switching embedding preset changes the vector width, and vec0 tables are + fixed-width. Without a check the raw sqlite error travels up into recall_search's + degrade path and is reported as 'embeddings unavailable' — sending the user to + debug a perfectly healthy embedder.""" + import pytest + from session_recall import config, store as store_mod + from session_recall.store import Store + + db = tmp_path / "dim.db" + Store(db).close() + monkeypatch.setattr(store_mod, "EMBED_DIM", config.EMBED_DIM // 2) + + with pytest.raises(RuntimeError) as exc: + Store(db) + msg = str(exc.value).lower() + assert "dimension" in msg, "the error must name the actual problem" + assert "index" in msg, "and must say what to do about it"