Skip to content

feat: automatic per-turn recall (UserPromptSubmit) for Claude Code#120

Merged
ccf merged 9 commits into
mainfrom
feat/auto-recall-userpromptsubmit
Jun 29, 2026
Merged

feat: automatic per-turn recall (UserPromptSubmit) for Claude Code#120
ccf merged 9 commits into
mainfrom
feat/auto-recall-userpromptsubmit

Conversation

@ccf

@ccf ccf commented Jun 29, 2026

Copy link
Copy Markdown
Owner

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 recall tool 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 the recall MCP 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

  • UserPromptSubmit hook → a thin shell wrapper (plugin/scripts/user-prompt-submit.sh) that synchronously runs cairn recall-hook, whose stdout is injected as additionalContext for 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). Fires usage.record so the savings ledger moves again.
  • Config (default on; flat keys in ~/.agentcairn/config.toml, env-overridable): auto_recall / auto_recall_k / auto_recall_scopeCAIRN_AUTO_RECALL, CAIRN_AUTO_RECALL_K, CAIRN_AUTO_RECALL_SCOPE.
  • SessionStart pre-warm — a detached, best-effort cairn warm on the warm path so the per-prompt recall stays fast; BM25 cold-fallback if the embedder can't load.
  • The SessionStart recency digest is unchanged — this is additive (recency orientation + per-turn relevance).
  • Plugin bumped to 0.4.0; README + CHANGELOG updated.

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-hook ignored CAIRN_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 new UserPromptSubmit wiring test). New coverage: config resolvers, the gate, format/empty, fail-open (missing index / malformed + non-dict JSON), CAIRN_EMBEDDER honored, BM25 cold-fallback, CLI stdin wiring.

⚠️ Release ordering (post-merge)

The wrapper invokes cairn recall-hook via uvx --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 containing recall-hook is published — cut a release after merge (bump src/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 recall almost never ran. A new UserPromptSubmit hook runs cairn recall-hook synchronously 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 as additionalContext. 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 flat auto_recall, auto_recall_k, and auto_recall_scope (default on). Project boost uses the hook JSON cwd, not the hook process cwd. recall-hook resolves CAIRN_EMBEDDER when --embedder is 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, detached cairn warm on 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.

ccf and others added 8 commits June 29, 2026 00:57
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

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Hook cwd ignored for project
    • Extracted cwd from the hook JSON payload in run() and passed it to _recall, which now calls resolve_current_project(project_from_cwd(cwd)) instead of resolve_current_project(None), ensuring project boost uses the session workspace rather than the process working directory.
  • ✅ Fixed: Search failure breaks recall return
    • Initialized notes: list[dict] = [] before the try block so that if search() raises, _recall returns an empty list instead of raising UnboundLocalError.

Create PR

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.

Comment thread src/cairn/recall_hook.py
Comment thread src/cairn/recall_hook.py
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 29, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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
@ccf

ccf commented Jun 29, 2026

Copy link
Copy Markdown
Owner Author

Addressed Bugbot's review in 4e2bb37:

  • Hook cwd ignored (Medium): recall-hook now resolves the project from the UserPromptSubmit payload's cwd (via project_from_cwd), falling back to the process cwd only when absent — so project boost and auto_recall_scope=project target the session workspace, matching the design and session-end.sh. Added a test asserting the payload cwd drives the project passed to search().
  • Search failure / UnboundLocalError (Low): initialized notes = [] so the return is always bound. Note the literal UnboundLocalError doesn't actually occur — a search() exception propagates to run()'s outer fail-open except → "" before return notes is reached — but the init removes the latent smell.

The earlier format · lint · test failure was an unrelated transient: fastembed couldn't download nomic-embed-text-v1.5 from HuggingFace in CI (retried 3×, gave up), failing the Hermes provider tests, which use the real model. My changes use FakeEmbedder; the full suite passes locally (698/5). The new CI run should clear it.

@ccf
ccf merged commit be2bb16 into main Jun 29, 2026
8 checks passed
@ccf
ccf deleted the feat/auto-recall-userpromptsubmit branch June 29, 2026 14:34
@ccf ccf mentioned this pull request Jun 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant