feat: automatic per-turn recall (UserPromptSubmit) for Claude Code#120
Conversation
Close the recall-trigger gap on Claude Code: a thin `cairn recall-hook` command + UserPromptSubmit wrapper runs hybrid recall against each substantive prompt and injects the hits. Covers config (auto/k/scope), a trivial-prompt gate, SessionStart pre-warm, BM25 cold-fallback, and fail-open behavior. Keeps the SessionStart recency digest. Claude Code only; Codex is a verified fast-follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UyeqgET1ZMb59jeZLiREvD
Add the bite-sized TDD implementation plan (5 tasks: config knobs, recall_hook core, CLI command, plugin wiring, docs) with complete code. Reconcile the spec to the codebase: flat config keys (the loader reads no [section] tables) and a simpler latency story (exception-based BM25 fallback + 10s hook-timeout ceiling + SessionStart pre-warm). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UyeqgET1ZMb59jeZLiREvD
Add three new config knobs for auto-recall feature (Claude Code plugin): - auto_recall (env: CAIRN_AUTO_RECALL): boolean, default True - auto_recall_k (env: CAIRN_AUTO_RECALL_K): integer, default 3 - auto_recall_scope (env: CAIRN_AUTO_RECALL_SCOPE): string, default "all" Each has a resolver function that falls back gracefully on unparseable values. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UyeqgET1ZMb59jeZLiREvD
Adds `src/cairn/recall_hook.py` with four pure units (`should_recall`, `format_block`, `build_hook_output`, `_recall`) and the `run()` orchestrator that parses a UserPromptSubmit JSON payload and returns a hook-output envelope (or "" to inject nothing). Every path is fail-open: `run()` never raises. Adds `tests/test_recall_hook.py` (9 tests; hermetic via FakeEmbedder/build_index). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UyeqgET1ZMb59jeZLiREvD
Add `cairn recall-hook [--vault PATH] [--index PATH] [--embedder NAME]` command that reads a UserPromptSubmit hook JSON payload from stdin, delegates to `cairn.recall_hook.run()`, and prints the result (or nothing). Always exits 0 — never blocks or breaks a prompt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UyeqgET1ZMb59jeZLiREvD
…4.0) - Add UserPromptSubmit entry to hooks.json (synchronous, 10s timeout) - Create plugin/scripts/user-prompt-submit.sh (chmod 0755); execs `cairn recall-hook --vault $VAULT` — stdout is the injected context - Add detached `cairn warm` to session-start.sh warm path so the embedder/reranker stays loaded between prompts (steady-state ~1s) - Bump plugin.json version 0.3.1 → 0.4.0 - TDD: test_userpromptsubmit_hook_runs_recall (RED → GREEN); test_hooks_do_not_hardfail_on_unset_vault_path still passes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UyeqgET1ZMb59jeZLiREvD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UyeqgET1ZMb59jeZLiREvD
- recall_hook.run() now resolves embedder via CAIRN_EMBEDDER env when no explicit embedder_name is passed, matching recall/reindex/sweep/warm - Guard valid-but-non-dict JSON (string/list/number) so it returns "" cleanly - recall-hook CLI body wrapped in try/except so stdin decode errors exit 0 - Add tests: CAIRN_EMBEDDER env honored, non-dict JSON silent, BM25 fallback, auto_recall true/1 positive-boolean - README: spell out CAIRN_AUTO_RECALL, CAIRN_AUTO_RECALL_K, CAIRN_AUTO_RECALL_SCOPE Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UyeqgET1ZMb59jeZLiREvD
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Hook cwd ignored for project
- Extracted
cwdfrom the hook JSON payload inrun()and passed it to_recall, which now callsresolve_current_project(project_from_cwd(cwd))instead ofresolve_current_project(None), ensuring project boost uses the session workspace rather than the process working directory.
- Extracted
- ✅ Fixed: Search failure breaks recall return
- Initialized
notes: list[dict] = []before thetryblock so that ifsearch()raises,_recallreturns an empty list instead of raisingUnboundLocalError.
- Initialized
Or push these changes by commenting:
@cursor push 80b825be28
Preview (80b825be28)
diff --git a/src/cairn/recall_hook.py b/src/cairn/recall_hook.py
--- a/src/cairn/recall_hook.py
+++ b/src/cairn/recall_hook.py
@@ -21,6 +21,7 @@
resolve_auto_recall_scope,
)
from cairn.embed import get_embedder
+from cairn.ingest.events import project_from_cwd
from cairn.search import open_search, resolve_current_project, search
_DEFAULT_MIN_CHARS = 12
@@ -69,7 +70,9 @@
}
-def _recall(prompt: str, *, vault, index, embedder_name: str, k: int, scope: str) -> list[dict]:
+def _recall(
+ prompt: str, *, vault, index, embedder_name: str, k: int, scope: str, cwd: str | None = None
+) -> list[dict]:
"""Run one hybrid recall; returns note dicts (possibly empty). Falls back to
BM25-only if the embedder cannot load. Records the savings ledger
best-effort (this is the observability that proves recall fired)."""
@@ -80,8 +83,9 @@
emb = None if embedder_name == "none" else get_embedder(embedder_name)
except Exception:
emb = None # BM25-only fallback when the embedder can't load
- current = resolve_current_project(None)
+ current = resolve_current_project(project_from_cwd(cwd))
con = open_search(str(idx))
+ notes: list[dict] = []
try:
hits = search(con, prompt, embedder=emb, k=k, rerank=False, project=current, scope=scope)
notes = [
@@ -120,8 +124,10 @@
try:
obj = json.loads(stdin_text)
prompt = obj.get("prompt") or "" if isinstance(obj, dict) else ""
+ hook_cwd = obj.get("cwd") if isinstance(obj, dict) else None
except (ValueError, TypeError):
prompt = ""
+ hook_cwd = None
if not should_recall(prompt, env):
return ""
notes = _recall(
@@ -131,6 +137,7 @@
embedder_name=name,
k=resolve_auto_recall_k(env),
scope=resolve_auto_recall_scope(env),
+ cwd=hook_cwd,
)
block = format_block(notes)
return json.dumps(build_hook_output(block)) if block else ""You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 21aa1b5. Configure here.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
agentcairn | 4e2bb37 | Commit Preview URL Branch Preview URL |
Jun 29 2026, 02:06 PM |
Address Cursor Bugbot review on #120: - Medium: recall-hook resolved the current project from os.getcwd() instead of the UserPromptSubmit payload's `cwd`, so project boost / auto_recall_scope could target the wrong project when the hook's pwd differs from the session workspace. Now derive the project via project_from_cwd(payload cwd), falling back to the process cwd when absent. Add a test asserting the payload cwd drives the project passed to search(). - Low: initialize `notes = []` in _recall so the return is always bound. (The reported UnboundLocalError doesn't actually occur — a search() exception propagates to run()'s outer fail-open handler before `return notes` — but the init removes the latent smell.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UyeqgET1ZMb59jeZLiREvD
|
Addressed Bugbot's review in 4e2bb37:
The earlier |


Automatic per-turn recall (Claude Code)
Closes the gap where agentcairn was write-mostly on Claude Code: sessions were captured and the index stayed healthy, but the semantic
recalltool was almost never invoked — the usage ledger sat frozen for days. SessionStart only injected a recency digest (recent -n 5, time-ordered, not relevance-ranked), and therecallMCP tool / skill were LLM-discretion-gated, so targeted recall just never happened. OpenCode and Hermes already do per-turn recall; this brings Claude Code to parity.What this adds
UserPromptSubmithook → a thin shell wrapper (plugin/scripts/user-prompt-submit.sh) that synchronously runscairn recall-hook, whose stdout is injected asadditionalContextfor that turn.cairn recall-hook— a new CLI command: reads the hook JSON from stdin, runs a hybrid recall against the prompt, prints the injection envelope (or nothing). Always exits 0.src/cairn/recall_hook.py— the logic: trivial-prompt gate, config gate, hybrid recall (rerank off on the hot path),## Relevant memories (agentcairn)formatting with[[permalink]]citations, and fail-open behavior (never raises / blocks a prompt). Firesusage.recordso the savings ledger moves again.~/.agentcairn/config.toml, env-overridable):auto_recall/auto_recall_k/auto_recall_scope→CAIRN_AUTO_RECALL,CAIRN_AUTO_RECALL_K,CAIRN_AUTO_RECALL_SCOPE.cairn warmon the warm path so the per-prompt recall stays fast; BM25 cold-fallback if the embedder can't load.Design + process
Built spec-first (
docs/specs/2026-06-29-auto-recall-userpromptsubmit-design.md) → plan (docs/plans/2026-06-29-auto-recall-userpromptsubmit.md) → subagent-driven TDD, one task per commit with spec+quality review between each and a whole-branch review at the end. The final review caught one real bug (now fixed):recall-hookignoredCAIRN_EMBEDDER— non-default-embedder users would have gotten silent fail-open + a cold model download in the synchronous hook; it now resolves the configured embedder like every other command.Tests
Full suite 697 passed / 5 skipped; plugin tests 23/23 (incl. the fresh-install
${user_config}guard and the newUserPromptSubmitwiring test). New coverage: config resolvers, the gate, format/empty, fail-open (missing index / malformed + non-dict JSON),CAIRN_EMBEDDERhonored, BM25 cold-fallback, CLI stdin wiring.The wrapper invokes
cairn recall-hookviauvx --from agentcairn>=0.2, so the subcommand must exist in an installed/cached build. Plugin 0.4.0 only takes effect once a PyPI release containingrecall-hookis published — cut a release after merge (bumpsrc/cairn/__init__.py, move the CHANGELOG[Unreleased]section, tag → PyPI). Fail-open covers the interim: users simply get no auto-recall until the release lands, never a broken prompt.🤖 Generated with Claude Code
Note
Medium Risk
Default-on context injection on every substantive prompt adds latency and could surface wrong memories; mitigated by fail-open hooks, trivial-prompt skip, and no rerank on the hot path. Plugin calls
uvx recall-hook, so auto-recall is inactive until a PyPI release ships the subcommand.Overview
Claude Code gets automatic per-turn memory recall, closing the gap where capture worked but semantic
recallalmost never ran. A newUserPromptSubmithook runscairn recall-hooksynchronously on each substantive user prompt (skips very short text like "yes"/"go"), runs hybrid search with rerank off for latency, and injects a## Relevant memories (agentcairn)block with[[permalink]]citations asadditionalContext. Session-start recency digest is unchanged; this is additive relevance per turn.Core logic lives in
recall_hook.py(config gate, length gate, search, formatting,usage.record). Config adds flatauto_recall,auto_recall_k, andauto_recall_scope(default on). Project boost uses the hook JSONcwd, not the hook process cwd.recall-hookresolvesCAIRN_EMBEDDERwhen--embedderis omitted; embedder load failures fall back to BM25. Everything is fail-open (empty output, exit 0) so hooks never block prompts.The plugin ships
user-prompt-submit.sh, a 10s hook timeout, detachedcairn warmon session start for steady-state speed, and bumps to 0.4.0. README/CHANGELOG and design/plan docs are included; tests cover resolvers, hook behavior, CLI stdin, and hook wiring.Reviewed by Cursor Bugbot for commit 4e2bb37. Bugbot is set up for automated code reviews on this repo. Configure here.