Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=<preset>` 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
Expand Down
103 changes: 98 additions & 5 deletions src/session_recall/config.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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=<preset>` 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
32 changes: 24 additions & 8 deletions src/session_recall/embed.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import hashlib
import math
import os
from typing import Protocol
from . import config

Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/session_recall/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
43 changes: 43 additions & 0 deletions tests/test_embed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading