recall-wiki compiles your Claude Code sessions into a concept library that does not expire. Every candidate fact has to collide with what the library already says — agree and merge, or refute and supersede. Nothing is simply appended, and that rule is load-bearing: this thing pushes conclusions at you, and an old opinion pushed into context is worse than nothing.
What survives comes back on its own. Describe a matching problem and the page that answers it is already there; the rest of the time — which is most of the time — it says nothing.
Mechanically: a UserPromptSubmit hook scores your prompt against every concept page's declared
keys, injects what clears the threshold, and exits silently when nothing does — about 16 ms, of
which 13 is node starting up. No query,
no MCP call, no "remember to check the wiki".
git clone https://github.com/aaronhg/recall-wiki && cd recall-wiki
node examples/demo.mjs # see a real injection, installs nothing
cp recall-wiki.conf.example recall-wiki.conf # one project to start with
bash .bin/install.sh --no-agent # OR: drop the flag to schedule extraction too
# then paste the hooks it prints into /hooks
node .bin/queue-scan.mjs --requeue 5 # pick 5 sessions you remember
bash .bin/daily-extract.sh # ~10 min, in the foreground, watchableThose last two lines call a model — --no-agent means nothing is scheduled, not that nothing
ever runs. They also read session transcripts, which Claude Code deletes after 30 days unless
cleanupPeriodDays says otherwise, so that number is the hard limit on how far back day one can
reach. queue-scan says so if it is unset.
Pages arrive two ways: extraction reads your sessions, and add.mjs takes
knowledge you already have — --contract prints the shape, your agent turns your notes into it.
Neither is required; a vault fed only by hand works exactly the same.
--no-agent drops the only hook that starts anything on its own, and keeps the local ones:
injection (UserPromptSubmit) and guards (PreToolUse). Neither calls a model or the network.
Pages then come from add.mjs instead of extraction — --contract prints the
shape, your agent turns your existing notes into it.
That is the write half, run by you, in the foreground, once — the model call you supervise rather than schedule. It is what makes day one useful instead of silent (why). Skip them and the vault is empty until the same problem happens to you twice.
Everything below is the reasoning. Skip to Install if you would rather just read the steps, or what this does to your machine before deciding.
The neighbours differ in when they speak, and on what.
Basic Memory is pure pull: the model has to
decide to go and search. mem0 pushes a decision rubric
at session start and leaves the searching to the agent. claude-mem
pushes content at SessionStart, and can also score the prompt you just typed —
CLAUDE_MEM_SEMANTIC_INJECT, off by default — where it asks a semantic endpoint for its top 5.
So scoring the prompt is not the unusual part. Two other things are. This one has a threshold:
below it nothing is injected at all, and that is the normal outcome, not an edge case. And what
does arrive matched on keys each page declares about itself — literal strings, compared
locally — rather than on an embedding, which is why symptoms: carries the design.
The match is the least interesting part. What matters is what the page had to survive to still
be there: every fact that arrived after it either agreed and merged, or refuted it and left a
line in log.md saying what replaced it. Nothing accumulates unchallenged, and no conclusion is
deleted — a refuted one is marked superseded with a pointer to what took its place, so you can
always ask when did this change, and why.
Two things extend that. A page can be retrieved by what the agent is about to do rather than by
what you asked — an acts: pattern over the tool call, because some knowledge has no words in the
prompt to be found by, and a guard: for the rarer case where the right move is to stop and ask
you. And a page can be measured: lint --usage says whether it has ever been retrieved at all,
lint --outcomes whether the thing kept happening anyway.
Why push at all. Pull fails at exactly the moment you need it. When you're chasing a bug, momentum makes every local step look reasonable — "check the log" → "add a probe" → "try again" — and not one of those steps is the step where you go read a knowledge base. That lesson is itself in the library that produced this tool.
aaronhg.github.io/recall-wiki — type a problem and
watch it score, in your browser. It runs the same _score.mjs an install runs, inlined rather
than ported, over the pages in examples/. Nothing is loaded from anywhere: no CDN,
no fonts, no analytics.
Try the silence preset. Nothing happening is the normal outcome and the hardest thing to show in a screenshot — and the near miss scores exactly on the threshold yet still does not fire, because one signal is never enough.
Locally, node examples/demo.mjs runs the real hook against the same pages inside a temp directory
that is deleted afterwards. Nothing installed, no hook registered, no model called.
The top panel is everything you see; the bottom is what the model got — which is the point, and also why there is a visible line at all. Both are bytes from that run, never hand-drawn.
Look at what matched. The page is titled in the vocabulary of the answer — "appearance is
not a guard" — and you typed the vocabulary of the symptom. What bridges them is symptoms:,
retrieval keys each page declares about itself. That is the design most worth stealing here, and
docs/RECALL.md §3 is why titles and embeddings both fail at it.
Status: working, in daily use — on vaults that are private, so take that part on trust. What you can check without me: the pages in
examples/are real ones, de-identified (dimmed-is-not-disabledis the migration finding written up in DEVELOPMENT.md §3); every failure this thing has had, including the round where it detected and wrote up its own silent failure, is recorded there with the diagnosis; the demo above runs the real hook; and CI runs the suite on node 20/22/24, macOS and Linux. Zero runtime dependencies.
The hard part of a knowledge base is not writing it. It is these three:
| Problem | What recall-wiki does |
|---|---|
| It goes stale. An append-only wiki is a pile of old opinions. | Every new fact must collide with the existing pages: add / update / refute. A refuted conclusion is never deleted — it is marked superseded with a pointer to what replaced it, and the change goes in log.md. You can always ask when did this change, and why. |
| The concept layer drowns in detail. | Two layers with a hard 60-line cap on concept pages — apply-ops refuses an operation that would cross it, lint reports a page that has. Detail sinks into cases/, which have no cap. Without that rule the concept layer is buried within a few dozen runs. |
| Retrieval misses. The page is about your problem but the words don't match. | Each page declares how it will be described (symptoms:), not just what it concludes — the mechanism you just saw in the demo. |
| ✅ | ❌ |
|---|---|
| Extracts on a schedule — one background batch a day. Batch, not per-session: cross-session patterns are the point. | Not a memory of your session. It holds distilled conclusions, not what you did yesterday. Want a transcript, keep the transcript. |
| Distills, doesn't archive — pitfalls, mechanisms, decisions and their reasons. Not: which lines changed, which commands ran. | Quiet at first. Concepts need two instances, and only concepts are injected. Seed it from your history to skip the wait. |
| Refutes, and is checked doing it — the collision rules are typed operations now, so a rewrite that changes the conclusion is refused rather than filed as one. | Cannot verify what it extracts. The applier checks that an operation is the KIND it claims to be; nothing checks that the conclusion is true. status exists precisely because some pages are guesses awaiting a second instance or your sign-off. |
| Injects on match, silent otherwise — up to 3 pages, each with a token estimate so context cost is visible. | The collision check is only as good as the model doing it. The prompt hands over an index of the whole library so the check is made against a list rather than left to a search (docs/PROMPT.md) — and an add now has to name the closest page it rejected, so the comparison leaves a record instead of an absence. What is still unchecked is whether it was right: a wrong differs line is accepted. lint --contradictions audits every conclusion afterwards; pages can still drift between audits. |
| Portable — a vault is a conformant OKF bundle: plain markdown any consumer of that spec can read. One vault per domain, and two scopes keep two bodies of knowledge that never mix. | No vector store, deliberately. Literal matching against declared keys: no embedding drift, no index, nothing to load — in exchange for keys you write. |
Tells you why it missed — RECALL_DEBUG=1 prints the ranking and the disqualified terms. That is the loop for tuning symptoms:. |
The n-gram tier is only a fallback. Half-weight, needs two hits. Quality still rests on symptoms: — see docs/RECALL.md. |
Lets the agent answer back — the only party that sees an injected page beside the work it was injected for. report.mjs files a negative report; lint --review puts it in front of you with the moves beside it. |
Negative reports only, and never a verdict. An agent saying a page helped is the agent that just used it; a complaint written mid-session may come good three turns later. Nothing is demoted automatically. |
It reads your coding history and sends it to a model unattended. That deserves stating plainly rather than being found later.
claude -p --allowedTools 'Read,Grep,Glob'That is the extraction half, and its whole permission set. No Write, no Edit, no Bash, no
--permission-mode. The agent reads transcripts and returns a JSON envelope of operations;
.bin/apply-ops.mjs checks each one and performs it. Nothing else in this repo writes wiki/.
That is a recent change and worth being precise about, because the previous line was
--permission-mode acceptEdits --allowedTools 'Read,Write,Edit,Bash,Grep,Glob' — an unattended
agent with edit and shell permission, once a day. The attempt to fence it in with
permissions.deny had already been tried and recorded as not holding: Claude Code rejects
Write(path) rules outright, and Edit(<dir>/**) was measured against a real run that created the
denied file anyway. So the honest note at the time said the arming records in ~/.config were
protected by extraction having no reason to write them, not by anything preventing it. Taking the
tools away is the boundary that rule could not be, and it cost one thing: Bash was needed for
tail -n +N, which Read's offset/limit does.
What is left is still real, and no permission change touches it: your session transcripts go to a model. If that is unacceptable where you work, you do not have to stop:
bash .bin/install.sh --no-agent # or, on a plugin install: RECALL_EXTRACT=0That registers injection and guards — the hooks that respond to something you did — and leaves
out SessionStart, the only one that starts anything. Neither calls a model or the network. You
write the pages (docs/AUTHORING.md), or you run extraction by hand when you
choose to. Everything below in this section describes the half you just declined.
| What it reads | Every session under your configured scope — prompts, files read, command output. In practice that includes whatever was on screen: keys printed by a failing script, client names, internal hostnames, private paths. |
| Where it goes | To the model, as the extraction prompt. What comes back is a JSON envelope of operations, not markdown; this program writes the pages. This tool uploads nothing and has no telemetry — but session content leaves your machine the way any Claude Code request does. |
| Cost | Two model calls per vault per day (extraction + contradiction audit), on whatever model your claude CLI uses. No usage cap in this tool. |
| Only one hook calls anything | recall-hook, guard-hook and the guard outcome pair read markdown and match strings: no network, no model, no cost. install.sh --no-agent registers those and leaves out the one that schedules extraction; on a plugin install, RECALL_EXTRACT=0 does the same job at run time. |
recall-wiki.conf is the blast radius — a vault only ever reads sessions under the directories
you list, so pointing it at one project is a supported way to try this. Also: run
bash .bin/daily-extract.sh by hand instead of registering the hook; RECALL_INJECT=0 disables
injection without unregistering; and since a vault is just markdown, git init it and read the
diffs to see exactly what was written.
The three lines are in Quickstart above.
There are two ways to register the hooks, and they are not equivalent:
bash .bin/install.sh # prints hook JSON for you to paste into /hooks
claude --plugin-dir /path/to/recall-wiki # or: /plugin marketplace add aaronhg/recall-wikiinstall.sh is the one to pick if you want the --no-agent shape, several vaults from one
checkout, or the pre-flight checks — it verifies your scope matches real session directories, seeds
a baseline, and refuses to report itself installed when the PreToolUse matcher has drifted from
the tool list. The plugin is the one to pick if you want it to update itself and to stop editing
settings.json by hand.
Three things the plugin format genuinely cannot do, stated here rather than found later:
No async hooks |
settings.json takes "async": true and install.sh uses it for the three bookkeeping hooks. A plugin has no such field, so those run synchronously, with explicit timeouts. |
| No conditional hooks | Every hook a plugin declares is active whenever the plugin is, so there is no plugin --no-agent. Set RECALL_EXTRACT=0 in <vault>/recall-wiki.env instead: daily-extract.sh reads it before it takes the lock, and it is per vault. install.sh --no-agent now writes it too. |
| No statusline | .bin/statusline.sh is still wired in by hand either way. |
docs/INSTALL.md has the same steps with the reasons attached: what
install.sh checks and why each check exists, the cleanupPeriodDays setting that is not
optional, replacing the shipped vocabulary with your own, seeding from history so the first day is
not silent, what to change afterwards, and running several vaults from one checkout.
Needs node ≥ 20, bash, macOS or Linux, and — for extraction only — the claude CLI.
No other dependencies.
Write — knowledge arrives two ways. By hand, whenever you like:
node .bin/add.mjs --contract → your agent writes JSON → add.mjs validates → wiki/
(from notes, CLAUDE.md rules, anything it can read)
…and on a schedule, from your sessions:
SessionStart hook first Claude Code session of the day → detached background extraction
SessionEnd hook one line of bookkeeping (async, trivial)
↓
daily-extract.sh scan for changed sessions → claude -p, READ-ONLY (schema = CLAUDE.md)
↓ ↳ returns a JSON envelope of operations, and writes nothing
apply-ops.mjs check each operation's preconditions → perform it → lint
↓
wiki/ concepts/ few and sharp · cases/ the evidence
The operations are where the collision rules stopped being advice. rewrite has to carry the
page's current conclusion word for word; change it and the operation is refused as a refutation,
with both lines printed one above the other. update — the "this agrees" row — carries no prose at
all, so it cannot quietly become a rewrite. refute has to quote what it is overturning, and the
## Revisions entry and the log.md line are written for it. Four fields (verified,
stale_after, superseded_by, armed) are not forbidden by instruction; there is no field on any
operation that reaches them.
And add — the one operation that claims something about the whole library — has to name the
closest page it rejected and say how this differs. That does not prove the comparison was right. It
makes it exist: a duplicate opened by a run that never looked used to be byte-identical to an
ordinary new page, and now it is a claim with a subject, checkable by a machine for existence and by
a reader for sense.
One bad operation costs its own session, which stays pending and reruns. The others land.
Read — knowledge comes back:
UserPromptSubmit hook → recall-hook.mjs
score the prompt against every concept page's declared keys
≥1 precise hit and above threshold → push that page's one-line conclusion into context
no match → exit silently (~16 ms, mostly node's own startup; zero noise)
Only concepts/ is scanned. Cases are dense with symbols and would crowd out the distilled
pages; you reach them from a concept's ## Evidence section.
Enforce — some knowledge is a rule, and a rule has to arrive when you act, not when you ask:
PreToolUse hook → guard-hook.mjs
acts: → a regex over the command / path about to run; the page is injected,
exactly as a prompt-side match would inject it. Once per session.
guard: → a permission prompt carrying the rule's reason. Armed by hand, and
never suppressed: the second commit needs the question as much as the first.
Arming is a human act (lint --arm) and is recorded outside the vault, so a rule an unattended
extraction proposes cannot arm itself. See docs/GUARDS.md.
Feed back — recall-hook records which pages fired and which came close, so
lint --usage can answer "has any of this ever been useful?" — the question a knowledge base
normally cannot.
Cull — a fire is not a success; the page appeared and the pitfall may have happened anyway.
lint --outcomes crosses fires against recurrence — an evidence link added after the page was
born means the thing happened again while the knowledge was already there — which git already
records, so no judge is needed:
| recurring | not recurring | |
|---|---|---|
| fires | 🔴 not landing — rewrite it, or give it an acts: key so it arrives at the moment of acting |
✅ working |
| never fires | 🟠 retrieval broken — fix symptoms: |
⚪ dormant, not a verdict |
🔴 and 🟠 are the two cells that say what to do next, and neither is visible any other way.
lint --life <id> tells one page's history instead of reducing it — when it was born, when it
fired, when the thing happened anyway. See docs/RECALL.md §8.
node .bin/lint.mjs --usage # which pages fire, which come close, which never score
node .bin/lint.mjs --propose # …and go FIND the keys that would fix the ones that come close
node .bin/lint.mjs --stats # the counts, uninterpreted — how you read them is your job
node .bin/lint.mjs --review # what is waiting on a decision from you
node .bin/score-ab.mjs # after a scoring change: what did it break, on your real prompts
node .bin/score-ab.mjs --acts # …and what a guard change did, on your real tool calls
node .bin/verify.mjs # after changing anything in .bin/: is the pipeline still intact?--propose is the half --usage was missing. --usage has always been able to say "this page
came within a point, nine times, and here is the wording" — and in this vault's first three days
that advice was acted on zero times, because reading it required knowing to ask. So the daily
run goes looking: candidate keys drawn from the prompts that missed, each replayed through the
same gate --learn runs, and only the survivors recorded. By the time you see a proposal the
replay has already shown it recovers prompts you really typed and costs none you had, so accepting
it cannot fail — that is the difference between this and every "did you mean" ever ignored. It
proposes and never writes: a retrieval key is a claim about how you describe things.
Most proposals are a pair, and that is not a quirk. Two signals are needed to fire, so one new
key crosses the threshold only when the page already had a signal on that prompt. --learn's own
refusal message has said so all along.
verify.mjs runs no checks of its own — it calls the existing ones (the suite, lint, both
selftest canaries, the prompt builder) and says what each failure would mean. Free by default:
no model call, nothing written under wiki/. Those canaries also run inside the daily extraction,
which is the same coverage at a worse time — you found out you had broken retrieval by spending a
real extraction finding out.
--usage distinguishes three things the log used to blur into one: a page that was injected, a
page that qualified and lost its slot to RECALL_MAX_HITS, and a page that came close and did
not qualify. Only the last means the symptoms: need another entry.
Everything above is read back later — after the fact, after a fortnight, at the start of the next session. None of it answers the question you have now: did the library just say anything about that? A statusline can, because it re-renders as you go.
# in your own statusline script, once you have the session id
rc=$(/path/to/recall-wiki/.bin/statusline.sh "$session_id")
[ -n "$rc" ] && out="$out | $rc"recall-wiki (main) | Opus 5 | ctx:42% | $1.23 | rc:3/12 !2 +1 | v2.1.226
└── 3 of the 12 prompts this library had a
chance at got a page pushed into context;
a guard asked twice, and one page was
pushed in from a tool call
12 is not every prompt you typed. It counts the ones the library had a chance at — long
enough to score, and inside a vault's scope. That denominator does not exist in
raw/recall-log.jsonl, which only gets a row when something scored: counting is cheap where
storing rows was not, so the hooks keep four integers per session and this reads them. It spawns no
runtime and scans no log, because a statusline runs on every render. It prints nothing when there
is nothing to say.
docs/INSTALL.md |
Installing it properly: every step with its reason, what to configure afterwards, several vaults from one checkout, and the command reference. |
CLAUDE.md |
The schema — the single authority, including a page's life end to end as one diagram. |
docs/RECALL.md |
How retrieval scores: the three term tiers, thresholds, writing good symptoms:, the fixture canary, RECALL_DEBUG, and the --usage / --outcomes loops that say whether any of it is working. Plus score-ab: what a scoring change breaks, which the log itself cannot say. |
docs/AUTHORING.md |
Adding pages yourself — add.mjs --contract is the whole interface; what it refuses at the door, and why symptoms: is refused there rather than reported later. |
docs/EXTRACTION.md |
The other way pages arrive: the daily batch in detail — the operations extraction returns and the preconditions each one has to survive, queue semantics, baselines, bootstrapping from history (--requeue), defer, and the audit (lint --contradictions). |
docs/PROMPT.md |
The prompt extraction runs on, and why it is assembled rather than written inline: it carries an index of the whole library and the operations contract, both computed, so collision is checked against a list and the contract cannot drift from what the applier enforces. Why that list is not filtered, and what is still an instruction rather than a gate. |
docs/HOOKS.md |
The six hooks and what each costs, --no-agent and RECALL_EXTRACT=0 (which are local and why), installing as a plugin and what that format cannot do, the extraction pipeline, locking, the completion marker, and how each stage fails. |
docs/GUARDS.md |
Standing rules — a PreToolUse gate keyed on what the agent is about to do, why the arming lives outside the vault, and how a rule proves it can still fire. |
docs/VAULTS.md |
Running several vaults from one checkout: the program/vault split, the registry, how each hook behaves, and how a command decides which vault it acts on. |
examples/ |
Three real pages, de-identified — not invented demo data. Plus demo.mjs: run the real hook against them without installing anything, and watch the scoring with RECALL_DEBUG=1. |
DEVELOPMENT.md |
How this was built, the decisions, and — more useful — the wrong turns. |
MIT