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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugin/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
12 changes: 11 additions & 1 deletion plugin/scripts/session-end.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion src/cairn/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
16 changes: 16 additions & 0 deletions src/cairn/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
8 changes: 7 additions & 1 deletion src/cairn/embed/fastembed_embedder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cache dirs created without private modes

Medium Severity

models_root().mkdir(parents=True) (and the new logs mkdir -p) create ~/.cache/agentcairn with the process umask instead of ensure_private_dir. On first warm/sweep, that runs before index setup, so the cache root stays world-traversable and capture.log is left world-readable. Sweep output can include vault paths and distilled memory text.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 74fa35c. Configure here.

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())

Expand Down
20 changes: 20 additions & 0 deletions src/cairn/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion src/cairn/search/rerank.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
104 changes: 104 additions & 0 deletions tests/test_models_root.py
Original file line number Diff line number Diff line change
@@ -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()