From 74fa35c665409d9a01a786f40a970351fa01f4bb Mon Sep 17 00:00:00 2001 From: "Charles C. Figueiredo" Date: Thu, 20 Aug 2026 10:23:56 -0400 Subject: [PATCH] fix: pin the model cache to a persistent dir so capture stops dying silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fastembed defaults its ONNX cache to tempfile.gettempdir(); macOS purges that directory on a timer. The purge takes the small config/tokenizer blobs first and leaves the snapshot symlinks dangling, so every model load then fails with `Could not find config.json`. Two failure modes, both silent: - `cairn sweep` crashes outright, and the plugin's capture hook redirected its output to /dev/null — so capture can stop for weeks with no error anywhere. - recall keeps "working" because it falls back to BM25 when the embedder fails, quietly losing semantic retrieval. Found on a real install: capture had been dead 3 weeks (vault frozen at Jul 30) while recall fired hourly against a degraded keyword-only path, and `cairn doctor` still reported status: OK. Pin cache_dir to ~/.cache/agentcairn/models for both the embedder and the cross-encoder reranker (an explicit FASTEMBED_CACHE_PATH still wins), make doctor actually load the embedder and fail loudly when it can't, and keep the capture hook's output in ~/.cache/agentcairn/logs/capture.log. Plugin 0.4.2 / Codex plugin 0.1.4 ship the hook change. Release 0.25.3. Tests: 773 passed + 25 plugin static tests; new guards assert both fastembed constructors receive an explicit cache_dir, so the temp default can't return. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 26 +++++++ plugin/.claude-plugin/plugin.json | 2 +- plugin/.codex-plugin/plugin.json | 2 +- plugin/scripts/session-end.sh | 12 ++- src/cairn/__init__.py | 2 +- src/cairn/cli.py | 16 ++++ src/cairn/embed/fastembed_embedder.py | 8 +- src/cairn/paths.py | 20 +++++ src/cairn/search/rerank.py | 8 +- tests/test_models_root.py | 104 ++++++++++++++++++++++++++ 10 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 tests/test_models_root.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 80887e5..f614021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning: [S ## [Unreleased] +## [0.25.3] - 2026-08-20 + +### Fixed +- **Capture no longer dies silently when the OS purges the model cache.** fastembed + defaults its ONNX cache to `tempfile.gettempdir()`; on macOS that directory is + purged on a timer. The purge removes the small config/tokenizer blobs first and + leaves the snapshot's symlinks dangling, so every model load then fails with + `Could not find config.json`. The damage was invisible in two different ways: + `cairn sweep` crashed outright — and the plugin's capture hook sent its output to + `/dev/null`, so weeks of sessions could go uncaptured with no error anywhere — + while recall *appeared* to keep working because it silently fell back to + BM25-only, quietly losing semantic retrieval. agentcairn now pins the cache to a + persistent directory it owns (`~/.cache/agentcairn/models`, beside the index) for + both the embedder and the cross-encoder reranker. An explicit + `FASTEMBED_CACHE_PATH` still wins. + +### Changed +- **`cairn doctor` now verifies the embedder actually loads**, and fails with a + `PROBLEM` when it does not. A healthy index does not imply a healthy model: the + index is a static file, the model is a cache that can rot long after the index was + built. Doctor previously reported `status: OK` while capture was dead and recall + was degraded. +- **The capture hook keeps its output** (`~/.cache/agentcairn/logs/capture.log`, + truncated per run) instead of discarding it, so a failing sweep leaves a trace. + Claude Code plugin `0.4.2`, Codex plugin `0.1.4`. + ## [0.25.2] - 2026-08-17 ### Fixed diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 33d6d45..cd47f9c 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "agentcairn", "displayName": "agentcairn", "description": "Local-first agent memory for Claude Code — recall, remember, and ambient capture into a Markdown vault you own.", - "version": "0.4.1", + "version": "0.4.2", "author": { "name": "Charles C. Figueiredo", "email": "ccf@ccf.io" }, "homepage": "https://agentcairn.dev", "repository": "https://github.com/ccf/agentcairn", diff --git a/plugin/.codex-plugin/plugin.json b/plugin/.codex-plugin/plugin.json index 4affa5b..767f248 100644 --- a/plugin/.codex-plugin/plugin.json +++ b/plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentcairn", - "version": "0.1.3", + "version": "0.1.4", "description": "Local-first agent memory for Codex — recall, remember, and ambient capture into a Markdown vault you own.", "author": { "name": "Charles C. Figueiredo", "email": "ccf@ccf.io" }, "homepage": "https://agentcairn.dev", diff --git a/plugin/scripts/session-end.sh b/plugin/scripts/session-end.sh index 0030b88..fac6ddf 100755 --- a/plugin/scripts/session-end.sh +++ b/plugin/scripts/session-end.sh @@ -17,9 +17,19 @@ INPUT=$(cat 2>/dev/null || true) CWD=$(printf '%s' "$INPUT" | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') [ -d "$VAULT" ] || $CAIRN init "$VAULT" >/dev/null 2>&1 || true + +# Capture runs detached and silent, so a crashing sweep used to leave no trace at +# all — one user lost three weeks of capture to a purged model cache and only +# noticed because the vault stopped growing. Keep the output instead of sending +# it to /dev/null: `cairn doctor` points here, and the log is truncated each run +# so it stays a "last capture" record rather than growing without bound. +LOG_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/agentcairn/logs" +mkdir -p "$LOG_DIR" 2>/dev/null || true +LOG="$LOG_DIR/capture.log" + # Detach: the sweep (and any LLM judge call inside it) must never block session # teardown. nohup + & detaches fine without an inner `sh -c` — which would # re-parse the `>=0.2` pin as a redirection and make $CWD/$VAULT an injection # surface. $CAIRN stays unquoted on purpose (word-splits into argv, no re-parse). -nohup $CAIRN sweep --vault "$VAULT" ${CWD:+--project "$CWD"} >/dev/null 2>&1 & +nohup $CAIRN sweep --vault "$VAULT" ${CWD:+--project "$CWD"} >"$LOG" 2>&1 & exit 0 diff --git a/src/cairn/__init__.py b/src/cairn/__init__.py index e7002ff..de64d6d 100644 --- a/src/cairn/__init__.py +++ b/src/cairn/__init__.py @@ -1,4 +1,4 @@ # SPDX-License-Identifier: Apache-2.0 """agentcairn — local-first agent memory (import package `cairn`).""" -__version__ = "0.25.2" +__version__ = "0.25.3" diff --git a/src/cairn/cli.py b/src/cairn/cli.py index 8a23c47..91b567a 100644 --- a/src/cairn/cli.py +++ b/src/cairn/cli.py @@ -1098,6 +1098,22 @@ def doctor( problems.append(f"chunk/embedding mismatch: {chunks} chunks vs {embs} embeddings") if notes > 0 and chunks == 0: problems.append("notes present but no chunks indexed") + # The embedder is not implied by a healthy index: the index is a static file, + # while the model is a cache that can rot (purged/partial download) long after + # it was built. When it does, `cairn sweep` crashes outright and recall quietly + # falls back to BM25 — both silent. A green doctor must not hide that. + embedder_name = cairn_env().get("CAIRN_EMBEDDER") or "fastembed" + if embedder_name != "none": + try: + get_embedder(embedder_name).embed(["probe"]) + typer.echo(f"embedder: {embedder_name} OK (models: {paths.models_root()})") + except Exception as exc: + typer.echo(f"embedder: {embedder_name} FAILED — {type(exc).__name__}: {exc}") + problems.append( + f"embedder '{embedder_name}' cannot load, so capture will fail and recall " + f"silently degrades to keyword-only. If the model cache is corrupt, delete " + f"{paths.models_root()} and re-run `cairn warm`." + ) if problems: for p in problems: typer.echo(f"PROBLEM: {p}") diff --git a/src/cairn/embed/fastembed_embedder.py b/src/cairn/embed/fastembed_embedder.py index 6cbc169..8eea406 100644 --- a/src/cairn/embed/fastembed_embedder.py +++ b/src/cairn/embed/fastembed_embedder.py @@ -10,8 +10,14 @@ class FastEmbedEmbedder: def __init__(self, model_name: str = "nomic-ai/nomic-embed-text-v1.5") -> None: from fastembed import TextEmbedding + from cairn.paths import models_root + self._name = model_name - self._model = TextEmbedding(model_name=model_name) + # Pin the cache to a persistent dir; fastembed's default is the OS temp + # dir, which macOS purges out from under us (see paths.models_root). + cache_dir = models_root() + cache_dir.mkdir(parents=True, exist_ok=True) + self._model = TextEmbedding(model_name=model_name, cache_dir=str(cache_dir)) # Probe one embedding to learn the dimension rather than hardcoding it. self._dim = len(next(iter(self._model.embed(["probe"]))).tolist()) diff --git a/src/cairn/paths.py b/src/cairn/paths.py index 4f8de57..ec7f678 100644 --- a/src/cairn/paths.py +++ b/src/cairn/paths.py @@ -17,6 +17,26 @@ def cache_root() -> Path: return Path.home() / ".cache" / "agentcairn" +def models_root() -> Path: + """Where downloaded ONNX models live. Must be a PERSISTENT directory. + + fastembed defaults its cache to `tempfile.gettempdir()`, which macOS purges + on a timer. The purge takes the small config/tokenizer blobs first and leaves + the snapshot's symlinks dangling, so every embedder load then dies with + `Could not find config.json` — silently killing capture (`cairn sweep` + crashes) and quietly degrading recall to BM25 through its fail-open + fallback. Keep models beside the index instead, under our own cache root. + + An explicit FASTEMBED_CACHE_PATH still wins: if the user pointed fastembed + somewhere deliberately, respect it rather than silently re-homing (and + re-downloading) their models. + """ + explicit = cairn_env().get("FASTEMBED_CACHE_PATH") + if explicit: + return Path(explicit).expanduser() + return cache_root() / "models" + + def resolve_vault(explicit: Path | str | None = None, env: Mapping[str, str] | None = None) -> Path: """--vault arg → CAIRN_VAULT → ~/agentcairn (matches the `vault` knob default).""" if explicit is not None: diff --git a/src/cairn/search/rerank.py b/src/cairn/search/rerank.py index f969683..f50c381 100644 --- a/src/cairn/search/rerank.py +++ b/src/cairn/search/rerank.py @@ -16,7 +16,13 @@ def _get_reranker(): if _RERANKER is None: from fastembed.rerank.cross_encoder import TextCrossEncoder - _RERANKER = TextCrossEncoder(model_name=_RERANKER_NAME) + from cairn.paths import models_root + + # Same persistent-cache requirement as the embedder (see models_root): + # fastembed's OS-temp default gets purged and breaks model loads. + cache_dir = models_root() + cache_dir.mkdir(parents=True, exist_ok=True) + _RERANKER = TextCrossEncoder(model_name=_RERANKER_NAME, cache_dir=str(cache_dir)) return _RERANKER diff --git a/tests/test_models_root.py b/tests/test_models_root.py new file mode 100644 index 0000000..52c40ba --- /dev/null +++ b/tests/test_models_root.py @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The ONNX model cache must live somewhere persistent, chosen by us. + +Regression guard: fastembed defaults its cache to `tempfile.gettempdir()`, which +macOS purges on a timer. When it does, the small config/tokenizer blobs go first +and the snapshot symlinks dangle, so every model load fails with +`Could not find config.json` — which crashes `cairn sweep` (capture stops +silently) and drops recall to BM25 through its fail-open fallback. + +The load-bearing behaviour is that we ALWAYS pass an explicit `cache_dir`, so +fastembed never gets to choose the temp default. +""" + +import sys +import types +from pathlib import Path + +import pytest + +from cairn import paths + + +def test_models_root_lives_under_the_cairn_cache(): + assert paths.models_root() == paths.cache_root() / "models" + + +def test_explicit_fastembed_cache_path_wins(monkeypatch, tmp_path): + """A user who deliberately pointed fastembed elsewhere keeps that location.""" + monkeypatch.setenv("FASTEMBED_CACHE_PATH", str(tmp_path / "mymodels")) + assert paths.models_root() == tmp_path / "mymodels" + + +def test_explicit_path_is_user_expanded(monkeypatch): + monkeypatch.setenv("FASTEMBED_CACHE_PATH", "~/somewhere/models") + assert paths.models_root() == Path.home() / "somewhere" / "models" + + +def test_production_default_is_not_under_the_os_temp_dir(): + """The shipped default (unpatched by the test-isolation fixture) is $HOME-based. + + conftest repoints `cache_root` into pytest's tmp_path, so read the real + implementation's target rather than the isolated one. + """ + real_default = Path.home() / ".cache" / "agentcairn" / "models" + import tempfile + + assert not real_default.is_relative_to(Path(tempfile.gettempdir()).resolve()) + + +def _stub_fastembed(monkeypatch, captured: dict) -> None: + """Install a fake `fastembed` so we can assert the kwargs we pass it.""" + + class _FakeEmbedding: + def __init__(self, model_name: str, cache_dir: str | None = None, **kw): + captured["embed"] = {"model_name": model_name, "cache_dir": cache_dir} + + def embed(self, texts): + return iter([__import__("numpy").zeros(3) for _ in texts]) + + class _FakeCrossEncoder: + def __init__(self, model_name: str, cache_dir: str | None = None, **kw): + captured["rerank"] = {"model_name": model_name, "cache_dir": cache_dir} + + fastembed = types.ModuleType("fastembed") + fastembed.TextEmbedding = _FakeEmbedding + rerank_mod = types.ModuleType("fastembed.rerank") + ce_mod = types.ModuleType("fastembed.rerank.cross_encoder") + ce_mod.TextCrossEncoder = _FakeCrossEncoder + monkeypatch.setitem(sys.modules, "fastembed", fastembed) + monkeypatch.setitem(sys.modules, "fastembed.rerank", rerank_mod) + monkeypatch.setitem(sys.modules, "fastembed.rerank.cross_encoder", ce_mod) + + +def test_embedder_passes_an_explicit_cache_dir(monkeypatch): + pytest.importorskip("numpy") + captured: dict = {} + _stub_fastembed(monkeypatch, captured) + from cairn.embed.fastembed_embedder import FastEmbedEmbedder + + FastEmbedEmbedder() + assert captured["embed"]["cache_dir"] == str(paths.models_root()), ( + "embedder must pin cache_dir; inheriting fastembed's temp default is the bug" + ) + + +def test_reranker_passes_an_explicit_cache_dir(monkeypatch): + captured: dict = {} + _stub_fastembed(monkeypatch, captured) + from cairn.search import rerank + + monkeypatch.setattr(rerank, "_RERANKER", None) # defeat the module singleton + rerank._get_reranker() + assert captured["rerank"]["cache_dir"] == str(paths.models_root()) + + +def test_model_cache_dir_is_created(monkeypatch): + """The dir must exist before fastembed writes into it.""" + pytest.importorskip("numpy") + captured: dict = {} + _stub_fastembed(monkeypatch, captured) + from cairn.embed.fastembed_embedder import FastEmbedEmbedder + + FastEmbedEmbedder() + assert paths.models_root().is_dir()