A memory-recall hook for Claude Code that injects your past decisions into the prompt — using tuned lexical search that beat embeddings on my vault. No MCP. No vector DB. No API calls. One file, standard library only.
Claude Code forgets everything between sessions. The usual fix is a vector database and an
MCP server doing semantic search over your notes. I built that, measured it, and it lost to
plain keyword search. So this is the opposite: a single Python file, wired as a
UserPromptSubmit hook, that reads your Markdown vault in ~40ms and injects the pages
relevant to this prompt — before Claude re-derives what you already worked out together.
you: "what did we decide about the payments provider?"
▲
│ brain-recall.py fires on the prompt, ranks your vault, injects:
▼
🧠 SECOND BRAIN — pages matching THIS prompt:
• wiki/Payments Service.md — Payments Service
L4: We chose Stripe over Braintree for the lower international fees.
That is the only question that matters, and most notes systems never ask it. Over 25 active days and 564 fires this one injected 390 tokens per prompt and surfaced 94 of 97 pages at least once. A blind sample of ten scored 5 strong, 2 partial and 3 outright misses.
AUDIT.md has the rest, including the parts that went against me: a measurement of my own fix that did not reproduce, a false positive the fix introduced, an admission that I never wrote down what would count as failure, and an earlier audit that called the whole thing "partially worth it."
Measured on my own vault (83 Markdown files, 1.3 MB), graded by held-out queries:
| Retrieval method | Score | Latency |
|---|---|---|
| qmd BM25 (keyword) | 0 / 5 | fast |
| qmd vector (semantic) | 1 / 5 | +1.2s |
| ripgrep + vector RRF (hybrid) | 2 / 5 | slow |
| qmd reranked query | good | ~16s |
| tuned in-process lexical (this repo) | 15 / 15 | ~40ms |
Embeddings were confirmed current — it wasn't a staleness bug. On a personal knowledge vault, semantic similarity kept surfacing plausibly related pages instead of the actually relevant one, and paid a latency tax to do it. Two cheap, boring ideas beat it:
- Length-normalized IDF scoring. Raw match-count ranking let the single biggest file
win 9 of 19 unrelated queries — big files just contain more words. Adding IDF and
dividing by
sqrt(length)fixed 11 held-out misses at once (4/15 → 15/15). - A strict, word-choice-aware gate. A loose gate fired on ~45% of prompts, ~80–97%
of them wrong. The fix wasn't a threshold — it was word choice: a distinctive proper
noun (
tor,nostr,orbstack) can name a page alone; a common word (memory,design,launch) can't unless the prompt is actually asking about your work.
Why strict? A missed recall costs nothing — Claude just reads the repo like normal. A false injection permanently pollutes the context window. So this optimizes precision over recall, hard. It stays quiet unless it's confident.
Full methodology and numbers: BENCHMARK.md.
git clone https://github.com/codeclawd/claude-recall && cd claude-recall
./install.sh # asks for your vault path, wires the hook
# restart Claude CodeOr manually (it's one file):
cp brain-recall.py ~/.claude/hooks/
# then add to ~/.claude/settings.json:
# "hooks": { "UserPromptSubmit": [ { "hooks": [
# { "type": "command",
# "command": "CLAUDE_RECALL_VAULT=\"$HOME/Documents/SecondBrain\" python3 ~/.claude/hooks/brain-recall.py" }
# ] } ] }Requirements: Python 3, standard library only. No pip install, no MCP server, no
embeddings, no network. Your "vault" is any folder of Markdown files — an
Obsidian vault, a docs/ directory, plain notes.
On every prompt, the hook:
- Gates hard. Skips machine/subagent turns, pasted secrets, local file paths, and
prompts over 1200 chars (human prompts run ~92 chars median; a 12k-char subagent
briefing is not someone asking a question). Fires only on a page-name match or an
explicit recall intent (
what did we decide…,remind me…,last time…). - Ranks in-process. Reads the whole vault once (a 1.3 MB vault is ~40ms in Python), scores pages by length-normalized IDF over the salient terms plus tag/title/alias hits.
- Injects the top few. Emits the matching pages (with line-anchored snippets) as
additionalContext, capped to a tight character budget so it never floods the window. Each page carries a freshness stamp — see below.
Every fire is logged to ~/.claude/logs/brain-recall.jsonl so you can audit precision on
your own data and tune the gate.
Retrieval is only as good as the reader's willingness to distrust it. An undated snippet reads as fact.
This bit us. A recalled line said a daemon was "already running via brew services" — true when written, false for months by the time it surfaced, because the service had moved into Docker. Nothing in the injected text marked it as old, so it was believed.
So every injected page now gets a compact stamp:
• wiki/Fresh Page.md [verified 2026-08-16] — Fresh Page
• wiki/Stale Page.md [updated 2026-01-05 · 224d old — STALE? · paused · ⚠has-warning] — Stale Page
• wiki/Undated Page.md [undated] — Undated Page
It reads these optional YAML frontmatter fields. All of them are optional — a vault of
plain Markdown still works, you just get undated, which is itself the useful signal:
| Field | Role |
|---|---|
verified: |
when the claim was last checked against reality — preferred over the rest |
updated: |
when the page was last touched — fallback |
created: |
when the page was written — last resort |
status: |
surfaced whenever it is anything other than active |
Anything older than 30 days also gets an explicit Nd old — STALE?, and a page containing
an Obsidian > [!warning] callout is flagged ⚠has-warning — a page that already
contradicts itself should be read, not skimmed.
The distinction between updated and verified is the whole point: updated only says
when someone touched the file. It cannot tell you whether the claim inside is still true.
Earlier versions returned exactly five pages on every fire, whatever the top score was. That padded misses with confident-looking noise, and a padded miss reads exactly like a hit. It now emits between zero and five, by score. Pages you name in the prompt always come through.
Expect roughly half as many injected pages, and silence on prompts that previously got five irrelevant ones. That is the fix, not a regression. The numbers are in AUDIT.md.
Recall is one stage. Reading is worthless if nothing writes, so these ship alongside it. Each is optional and independently installable.
hooks/session-log.sh |
Stop hook. Writes the session summary into daily-notes/. |
hooks/vault-sanitize.py |
Scrubs secrets and control tags before anything is written. The guard fails closed — no sanitizer, no write. A vault is durable and syncable, so a key landing in one is exfiltrated on the next sync and survives in git history after deletion. |
hooks/writeback-check.py |
Stop hook that refuses to end a substantial session which filed nothing durable. |
maintain/brain-sweep.py |
Read-only. Shortlists duplication, staleness and contradictions. Deletes nothing. |
maintain/gen-lessons-digest.py |
Compiles a lessons file into a short always-loaded digest. |
SCHEMA.md covers the vault layout, and the two conventions that actually change
retrieval quality: aliases and bi-temporal verified_against keys.
Everything is env-driven — no config file:
| Variable | Default | Meaning |
|---|---|---|
CLAUDE_RECALL_VAULT |
~/Documents/SecondBrain |
Folder of Markdown notes to search |
CLAUDE_RECALL_LOG |
~/.claude/logs/brain-recall.jsonl |
Fire log (audit precision here) |
The retrieval knobs live as constants at the top of brain-recall.py (MAX_PAGES,
MAX_CHARS, MAX_PROMPT_CHARS, MIN_TAG_HITS, the INTENT regex, NAME_TOO_COMMON).
They're heavily tuned — change conservatively and watch the fire log.
This is deliberately one dependency-free file so it's trivial to adapt:
- Different note format? It only assumes Markdown with optional YAML frontmatter
(
tags:,aliases:). PointCLAUDE_RECALL_VAULTat any Markdown tree. - Different agent? The hook reads
{"prompt": "..."}on stdin and prints a Claude CodehookSpecificOutputJSON. Swap the I/O shape and the core ranker works anywhere. - Want semantic anyway? Add it as a fallback tier — but measure on your corpus first (see BENCHMARK.md for the harness idea). The point of this repo isn't "never use embeddings," it's "measure before you reach for them."
PRs welcome — especially additional graded query sets from other people's vaults.
Does this need an MCP server? No. It's a hook, not an MCP. It runs a Python file on each prompt and returns text.
Does it use embeddings / a vector database? No. That's the whole point — tuned lexical search outscored semantic on this corpus at ~400× lower latency.
Is this RAG? It's retrieval-augmented, yes — but the retrieval is keyword/IDF, not vector similarity, and it's injected deterministically by a hook rather than decided by the model.
How is it different from Claude Code's built-in memory / CLAUDE.md? CLAUDE.md is
always-on and small; it can't hold months of project knowledge without bloating every
prompt. This injects only the pages relevant to the current prompt, so the vault can be huge
while the context cost stays tiny.
Will it work with plain notes, not Obsidian? Yes. Obsidian is just Markdown files;
so is a docs/ folder.
n=1? The 15/15 is my vault. Your mileage will vary — that's why every fire is logged, so you can check precision on your own data. If semantic wins on your corpus, keep it.
MIT — see LICENSE.
Topics: claude-code · claude · anthropic · ai-memory · second-brain · rag · retrieval · obsidian · llm · developer-tools · hooks · context-engineering