npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

agentic-recall

v2.1.5

Published

Long-term memory for agentic tasks. It tells you when it doesn't know.

Readme

agentic-recall

Long-term memory for agentic tasks. It tells you when it doesn't know.

An MCP server that gives Claude Desktop and Claude Code a persistent memory across sessions — and, unlike a plain vector store, an explicit answer when the corpus does not contain what you asked for.

agentic-recall: a query that hits, and a query that does not

// ~/.claude.json  (Claude Code)  or  claude_desktop_config.json  (Claude Desktop)
"mcpServers": {
  "memory": {
    "command": "npx",
    "args": ["-y", "agentic-recall@2"],
    "env": { "MEMORY_DIR": "/absolute/path/to/your/memory/folder" }
  }
}

That is the whole install. MEMORY_DIR is the one path you supply, and that is deliberate: the server does not go looking through your disk for your notes.

🟥 Note the @2 — pin the major version. npx -y agentic-recall with no version asks npm for whatever is tagged latest every time its cache is cold, so a new major arrives silently and a config that worked yesterday breaks today. That is not hypothetical: 2.0.0 moved five actions to a second tool, and a tester's in-flight session started failing with received 'index' at action mid-task — because npx had re-resolved latest underneath a running workflow. The cache is what makes an unpinned spec feel stable, and a cache is not a pin: it is cleared by npm cache verify, by disk cleanup, and by every new machine.

@2 takes fixes and features, never a breaking major. [email protected] pins exactly.

🟥 A warm npx cache does NOT pick up a fix on its own. Measured 2026-09-22: 2.0.3 had been on the registry for over an hour, and three servers spawned with npx -y agentic-recall@2 still ran the cached 2.0.1. To take a fix, clear the cached copy and re-resolve once — macOS/Linux: rm -rf ~/.npm/_npx && npx -y agentic-recall@2 --version; Windows: the Remove-Item line below — then restart your client. serverVersion in every response tells you which version actually answered.

🟥 Windows users: use this config instead. Bare npx does not work for a Node MCP client on Windows. This is measured, not inferred — CI spawns the published package on windows-latest every push, and reports bare npx -> FAILED: spawn npx ENOENT while cmd /c npx connects:

"memory": {
  "command": "cmd",
  "args": ["/c", "npx", "-y", "agentic-recall@2"],
  "env": { "MEMORY_DIR": "C:/Users/<you>/Documents/memories" }
}

On Windows npx is a batch shim (npx.cmd), and Node's spawn will not resolve it without shell: true. A Node-based MCP client using the config above gets spawn EINVAL and the server silently never connects — it appears configured and does nothing. This is the standard Windows MCP gotcha, not specific to this server (claude-code#58510). npm i -g is not an escape — that installs a .cmd shim too. The only shim-free form is pointing node at the file directly: "command": "node", "args": ["C:/Users/<you>/AppData/Roaming/npm/node_modules/agentic-recall/index.js"].

Whether Claude Desktop itself is affected is still unconfirmed — it may spawn through a shell. The cmd /c form works either way, so it is the one to use on Windows.

🟥 Windows: if npx fails with ECOMPROMISED / Lock compromised, clear the npx cache. Node 24/25 with npm 11 leaves a stale lock in npx's cache directory that it then refuses to refresh (npm/cli#8710). Nothing to do with this server — it hits MCP Inspector and Gemini CLI identically. Measured on a real Windows machine: it reproduced twice, and clearing the cache fixed it (then a 98 s first download):

Remove-Item -Recurse -Force "$env:LOCALAPPDATA\npm-cache\_npx" -ErrorAction SilentlyContinue
npm cache verify
npx -y agentic-recall@2 --version

If it still fails, two alternatives that avoid npx entirely — both verified end to end: npm install -g agentic-recall (then "command": "agentic-recall", "args": []), or Node 22, where npx works normally.

Requires Node 20 or newer. Nothing else — the embedding model downloads on first index and then runs locally. Your memories never leave the machine.

Prefer to read the code first, or run the suite?

git clone https://github.com/dfrancislyondflabc-tech/agentic-recall.git
cd agentic-recall && npm install && npm test    # 268 checks, no corpus of your own needed

Built by Daniel Francis-Lyon — questions, bug reports and criticism all welcome, either as an issue on this repo or at [email protected].

Privacy Policy

Nothing leaves your machine. Memories are plain files in a folder you choose; the embedding model runs locally after a one-time download; there is no telemetry, no analytics and no phone-home. The author receives no data from your installation and has no means of doing so.

Full text: PRIVACY.md.

The part most memory tools skip

Retrieval always returns something. Ranked by similarity, the least-bad match comes back looking like an answer, and an agent acting on it cannot tell the difference between "here is what you wrote" and "here is the closest thing to it".

This server separates those two cases and says which one you got:

// asked about something that isn't in the corpus
{ "results": [],
  "confidence": "low",
  "bestWeak": [{ "name": "wheel-truing", "score": 0.31 }],
  "absenceNote": "no strong match — 'afternoon' appears nowhere in this corpus" }

Nothing was invented, nothing was silently ranked into first place, and the nearest thing is offered as a candidate rather than as an answer. Everything below is about how that verdict is reached and how it is measured.

The same refusal, applied to engineering claims

A memory saying "I fixed that in abc123" is a sentence. Classifying sentences has failed here three times — a correction vocabulary that fired on 76% of exchanges, an unresolved-statement vocabulary that fired on 24%. But a SHA is not a sentence to classify. It either exists or it does not.

Point MEMORY_GIT_REPOS at your repositories and every hex-shaped token in a retrieved memory is looked up. Four commands, and only what they prove:

| command | what it establishes | |---|---| | cat-file | the token is a real commit, not something SHA-shaped | | merge-base | it landed on the mainline, rather than an abandoned branch | | log | its date, author, subject, and how many files it touched | | rev-list | how many commits have landed since the newest memory was written |

Measured over 2,319 ingested exchanges: 707 SHA-shaped candidates collapsed to 355 real commits. Half of what looks like a commit isn't one, which is why every token is checked rather than trusted for its shape. Those rejects are not invented commits: re-measured on the current, larger corpus, 79% of reject occurrences are hex prefixes of conversation session IDs that the deliberately loose 7-10 character pattern sweeps up, so this gap measures how little the shape tells you rather than how often a memory fabricates a SHA.

What this does not tell you, deliberately. It does not check whether the change was later reverted, or whether the thing still exists at HEAD. A commit that landed and was undone the next day still reports as landed. So this is not a claim that the memory is currently true — it is a verified fact about the past, plus rev-list telling you exactly how much has happened since that nothing in the corpus can know about. The gap is measured rather than closed, which is the same discipline as the absence verdict above: never imply knowledge you do not have.

Repos are configured, never inferred — an earlier draft counted commits "in this repo", meaning wherever the process happened to be running, which is a different repository from the one the memories are about.


Two-tier hybrid retrieval over Claude's persistent memory corpus, exposed to Claude Desktop and Claude Code as two MCP tools: memory, which is read-only and never writes to your memory folder, and memory_write, which is the only way anything this server does can change it.

The curated corpus is the folder you point MEMORY_DIR at (see Environment overrides). MEMORY.md, if you have one, is treated as a hand-curated tier-1 index.

Figures quoted throughout this README — "121 .md files, ~2.7 MB", timings, hit rates — are measurements of the author's own corpus, not properties of yours. They are here because a claim with a number behind it can be checked; treat them as the conditions a result was obtained under, not as promises about your data. Three more work corpora sit beside it, each with its own index and its own statistics: other projects' memory folders (projects), auto-ingested conversation exchanges (staging) and the institutional handoff documents (handoff, read-only) — plus the library: imported reference material (books, manuals, policies) in per-category corpora that are searched only when named (scope:'books', or scope:'everything') and can never touch a work score. See Four work corpora + the library, one index each.


Why it exists

Reading the whole memory folder into context costs ~2.7 MB and buries the relevant three lines. This server answers the question "which memories matter for what I'm doing right now?" with three retrievers whose failure modes cancel:

| retriever | field | good at | blind to | |---|---|---|---| | BM25F | title + description + headings + body, each length-normalised separately | slugs, part numbers, file names, jargon, any literal string in the text | paraphrase | | dense cosine | ~200-word body chunks + a per-doc summary vector | "how do I restart the email app server" → a memory that never says restart | exact identifiers | | phrase proximity | the tightest window of body tokens covering the query's terms | telling a quoted sentence apart from a document with the same vocabulary | anything not stated literally |

Scores are normalised, fused 42/42/16, then adjusted by a long-document correction, the hot-tier boost and a mild recency decay. Every result reports its provenance (keyword / semantic / phrase / both) so a surprising rank is diagnosable rather than mysterious — and when nothing matched well enough, search says so instead of guessing (see below).

v1.1 — the three defects the 2026-08-14 benchmark measured

retrieval-features-baselines/memory-systems-benchmark.md scored this server against a pre-loaded MEMORY.md over 32 probes and found three real defects. All three are fixed; the numbers are in that file's v1.1 section.

  1. BM25 was blind to bodies. It indexed title + description + headings only, so a distinctive phrase living in a body paragraph had to be recovered by the dense leg — structurally the wrong tool for a literal string. The body is now a fourth BM25F field (its own length normalisation, weight 0.3), and a phrase-proximity leg reads the body directly. Verbatim recall went from 4/6 found and 1/6 sentence-located to 6/6 and 6/6, every one at rank 1. Snippets are now cut around the matching window rather than the top of the document, so a quote search returns the sentence you quoted.

  2. It could not say "nothing". search always returned limit results; on the four absent probes it handed back confident-looking wrong documents, one at 0.75 — higher than 20 of the 28 correct answers. It now returns noStrongMatch: true with the candidates moved to bestWeak, on 4/4 absent probes with 0/28 false absences. There is no clean score threshold — the distributions overlap across their whole middle — so the verdict is a conjunction of measured weaknesses plus a vocabulary test. the derivation and its margins are recorded in the author's test/ tree.

    🟥 The absence verdict gets less reliable as your corpus gets smaller, and a new corpus is small. orphanShare asks what fraction of your question's distinctive words appear nowhere in the corpus — so on a thin vocabulary, ordinary synonyms are genuinely absent and a question the corpus CAN answer gets refused. Measured here on 122 files: 5 of 20 answerable questions called absent. Measured by an independent reviewer on 13 files: 3 of 4. It fails safe — the right document is in bestWeak, not invented — but on a young corpus read bestWeak before believing a refusal, and expect this to improve as you write more. test/… paths in this README are citations to where a number was measured, not files in this distribution — the suite is not shipped, because it asserts against one private corpus. See CONTRIBUTING.

  3. One enormous document was winning everything. A 616 KB changelog took a top-3 slot on 21 of 32 test questions — questions about deployment, about pricing, about a bug in a scraper. It had no business in most of them.

    The cause is in how a document is scored. Documents are split into chunks, and a document's score is the score of its best chunk. That changelog splits into 517 chunks; the typical memory in the corpus splits into 4. So the long document gets 517 chances to have one paragraph that happens to sit near your question, and the short one gets 4. Ask about anything and something in 616 KB of release notes is vaguely on topic.

    It's the same effect as a library where one book runs to 3,000 pages and everything else is a five-page note. Ask any question and the huge book contains a page that looks relevant — not because it is the best answer, but because it had the most chances to match.

    The giveaway was keywordScore: 0 on almost every one of those 21 hits: none of the words in the question appeared in the document at all. It was winning purely on one chunk out of 517 landing near the question in embedding space.

    The fix is to shrink the semantic score of documents that are far longer than that corpus's own normal length. "Normal" is measured per corpus rather than hard-coded, since a corpus of books and a corpus of notes disagree about what long means. And the shrink is waived in proportion to keyword evidence: if your words really are in the document, the penalty lifts. Long is only suspicious when the document didn't match what you actually asked.

    Result: top-3 appearances 21 → 0. It is de-prioritised, not hidden — ask for that changelog by name and it still comes back at rank 1 with keywordScore: 1.0, because now the words match.

The keyword score is on an absolute scale

Fusing two retrievers only works if both scores mean the same thing on every query. A per-query-max normalisation does not: it hands 1.0 to whatever scored best, so on a paraphrase where nothing really matched, an accidental match still carried half the fused score. (A question about which zip packages to maintain returned an unrelated note about a download link at #1, on the token download alone.)

So the keyword leg is scored against measured reference points instead — a raw noise floor, the score a genuine lexical match earns, and the share of the query the document actually answered — each capped by what the query can possibly achieve, so a short exact query like MEMORY is not punished for having little to match. npm run measure-keyword-scale re-runs the measurement those constants came from (four query populations: title-literal, description-literal, in-domain paraphrase, out-of-domain) and prints where the shipping constants sit against it. The derivation lives next to the numbers in lib/config.js.

The fused path only. In bm25-only mode there is no second score to be comparable with, so the per-query-max form is kept and degraded-mode ranking is unchanged.


Install

Did you get this as a zip? Then none of this section applies: open START-HERE.txt and double-click SETUP-WINDOWS.cmd or SETUP-MACOS.command. A zip carries its dependencies and its search model already — there is nothing to clone and nothing to npm install. (Up to 1.7.1 the zips also shipped a dist/ install guide that said the opposite; it is gone.)

From npm — the short way. Nothing to clone and nothing to build; paste the config block at the top of this README into your Claude config and you are done.

npx -y agentic-recall@2 --version     # optional: fetch it now rather than on first launch

From source, if you would rather read it first or run the suite:

git clone https://github.com/dfrancislyondflabc-tech/agentic-recall.git
cd agentic-recall
npm install

Node 20 or newer. There is nothing to build.

Where it keeps its 35 MB. A clone keeps the model cache, the vector cache and the index beside the code, as it always has. A package install (npx, npm i -g) writes them to ~/.agentic-recall instead, because npm's npx cache is disposable and re-downloading the model on eviction would be miserable. MEMORY_ROOT overrides both. Your memories themselves are never in either place — they stay wherever MEMORY_DIR points.

Not the recall-mcp on npm. That name belongs to a different project (a different self-hosted memory server). This one is published as agentic-recall.

Then tell it where your memories are. It does not search your disk for them — there is no sensible default, so it does not guess. MEMORY_DIR (or memoryDir in local-config.json, copied from local-config.example.json; gitignored, never indexed) is the whole of the discovery logic.

If you already use Claude Code's memory, you are done in one line. Point it at that folder and it indexes those files in place — nothing is copied, nothing is converted, and Claude carries on writing them as it always did:

export MEMORY_DIR=~/.claude/projects/<project-slug>/memory

(The slug is your project's path with the separators replaced — ls ~/.claude/projects to find yours.) Reading Claude's own directory, rather than a copy of it, is the reason this server's corpus cannot silently drift out of date.

If your history lives somewhere else, import it once. A ChatGPT export .zip, a folder of Obsidian/Notion markdown, or a single file:

node scripts/import-memories.js /absolute/path/to/export.zip --dry   # preview
node scripts/import-memories.js /absolute/path/to/export.zip

This one does write files into MEMORY_DIR, converting as it goes. memory_write({action: "import"}) is the same thing from inside a conversation.

Remembering the conversations themselves is controlled by the connector toggle, and there is nothing else to configure. While the connector is on, the server spawns a capture walk every five minutes by itself. The only thing a hook adds is the FINAL exchange of a chat you walk away from, captured as it ends rather than on a later sweep — node packaging/install-hooks.mjs adds scripts/auto-ingest.js on Stop and SessionEnd for this install (it backs up ~/.claude/settings.json first, touches no other hook, does nothing on a second run, and reverses with --uninstall). After that the switch you already use in Claude's UI is the switch: connector on means this server is running, which it advertises by leaving a dated mark on disk; the hook reads that mark and captures the session. Connector off, no mark, nothing captured, silently. Captured conversations land in a separate staging corpus at a lower tier, so they are searchable but never outrank a memory you wrote deliberately.

Two overrides live in local-config.json — and it has to be that file rather than an environment variable, because hooks are spawned without your shell environment:

{
  "memoryDir": "/absolute/path/to/your/memories",
  "captureAlways": true
}

captureAlways: true remembers every session, connector on or off. autoIngest: false remembers none, ever. Set one or neither — the default, with both absent, is "remember the sessions you had the connector on for".

And if you had it switched off and only realised afterwards that the work mattered, nothing is lost — the transcript was on disk the whole time, it simply was not ingested. Ask for it after the fact:

memory_write({action: "capture", sinceMinutes: 60})   // remember the last hour
memory_write({action: "capture"})                     // remember this whole session

Already-captured exchanges are skipped, so running it twice is safe. It answers with two counts, because on a live chat the common outcome is neither "new" nor "nothing": exchangesCaptured is exchanges that were not in the store at all, and exchangesRefreshed is exchanges already there whose text had GROWN — normally the turn you are in the middle of. sinceMinutes is measured against each exchange's last activity, so a turn that has been running for an hour is still inside sinceMinutes: 30.

A memory is just a markdown file with a name: and a description: in its frontmatter:

---
name: freehub-service-log
description: Symptoms and fix for the loaner-wheel freehub pawls disengaging under load
---

The pawls stop engaging when the grease thickens, usually on a climb — the cranks turn and
the wheel does not. Strip and re-grease with a light oil, not the heavy grease in the tub.

Related: [[wheel-build-notes]]

name is how the memory is addressed (get, [[wikilinks]]); description is what a search sees first, so it is worth writing as the sentence you would want back. Everything after the frontmatter is the body. Nothing else is required — no metadata: block, no tier, no id. Then:

npm run index        # first build downloads the embedding model, then embeds: ~3 min
npm test             # self-contained: builds its own fixture corpus, needs none of yours
npm run verify       # the same check under its other name

npm test drives the real server over raw stdio JSON-RPC and exercises all thirteen actions against a temporary corpus it writes itself, so it is meaningful on a machine with no memories at all. The exit code is the verdict. (The author's full suite is not public — it asserts against one particular corpus and would fail for you. See CONTRIBUTING.md.)

Measurement scripts, read-only and re-runnable — the tuning constants in lib/config.js each cite the measurement they came from:

npm run measure-keyword-scale   # the absolute keyword scale
npm run analyse-queries         # what has been asked of it

The tool

One gateway tool, thirteen actions: search, get, neighbors, latest, thread, verify, index, index_status, probe_status, promote, demote, import, capture. The four you will use daily are documented in full below.

memory({action: "search", query, limit?, scope?})

Hybrid retrieval. Returns name, description, tier, score, keywordScore, semanticScore, phraseScore, provenance, snippet, links, path, readOnly, corpus (which of the four indexes answered), the attribution block (account, project, sessionId, sessionTitle, type), plus a top-level mode (hybrid | bm25-only | unavailable) and, when degraded, a degradedReason. Default limit 8.

Every response says when its index was built — see Freshness below:

| field | meaning | |---|---| | indexBuiltAt | ISO time the index these results came from was built | | indexBuiltAtByScope | scope: "all" only — one build time per corpus | | indexStale | the corpus has changed since, and the repair did not happen | | staleFiles | how many files moved | | staleWarning | one sentence saying so, with the files named and why it was not repaired | | indexReindexedInline / indexReindexSeconds | the guard rebuilt it before answering | | indexBuiltInline | that corpus had no index at all and was small enough to build before answering | | corpusNote | for projects / handoff: what this corpus is and that it has its own statistics | | indexCheckedFiles / indexCheckMs | the cost of the check itself (1–2 ms for 122 files) | | newestSourceModified | the newest mtime in the corpus, live | | modifiedFieldNote | states that a result's modified is index-time, not live | | serverVersion / serverStartedAt | which build of this server answered, and when it started |

A result's modified is the file's mtime AT INDEX TIME, not a live read. memory({action: "get"}) returns a live stat, as liveModified. Reading one as the other is what produced a confidently wrong conclusion about project state on 2026-08-19.

Search always covers both tiers. Archived memories are fully searchable; they simply do not get the hot boost.

A single document takes at most one top-N slot (RETRIEVAL.maxSlotsPerDoc).

When nothing matched, it says so. Instead of ranking the least-bad document, search can return:

{
  "noStrongMatch": true,
  "confidence": "low",
  "signals": { "topScore": 0.40, "topPhrase": 0.25,
               "lexicalCoverage": 0.24, "orphanShare": 0.46 },
  "absenceNote": "No strong match: the term(s) that make this question specific
                  appear NOWHERE in the corpus (kubernete — 46% of the query's
                  discriminative weight, floor 40%). …",
  "results": [],
  "bestWeak": [ /* the nearest documents, NOT answers */ ]
}

results is emptied and the candidates move to bestWeak, so a caller that checks results cannot accidentally report a non-answer, while one that wants to overrule the verdict still has the candidates. signals is always present on the fused path, verdict or not, so a surprising call is diagnosable.

Two independent rules produce it (constants and margins: measured, not guessed):

  • vocabulary — the terms that make the question specific exist nowhere in the corpus (orphanShare ≥ 0.40) and nothing holds the remaining words together. Compound forms are forgiven: de-duplication is absent while duplication is present, and the corpus clearly knows the concept.
  • evidence — every word is familiar, but nothing scored (< 0.38), nothing is phrased that way (< 0.40), and most of the question went unanswered (lexical coverage < 0.20). All three, because each alone fires on real questions.

The verdict is advisory and calibrated on this corpus. Never use it to prove a negative that matters — on a held-out set of deliberately vocabulary-free in-domain questions, 5 of 20 are called absent. grep proves a negative; a score does not.

Never claimed in bm25-only mode: the constants are calibrated on the fused three-leg score, so degraded mode reports confidence: "unrated" instead.

What a multi-corpus response is made of

scope: "all" (and an array scope) returns one ranked section per corpus under groups, because the corpora do not share statistics and blending them measurably costs recall. Three rules govern the shape of that envelope; all three came out of one real response, measured on 2026-09-07, which was 56,550 bytes for ten results and told its reader the opposite of the truth.

| | | |---|---| | groups.<corpus>.resultsRef: "results" + count | that section's rows are the top-level results entries carrying corpus: "<corpus>", in that order. They are serialized once — the duplicate copy was 30 % of that response. Everything else in the section is its own. | | groups.<corpus>.empty: true + emptyNote | that corpus holds 0 files and has no index: nothing to rank and nothing to build. An empty corpus is not a stale one, contributes nothing to the top-level indexStale, and makes no claim about the others. A corpus that has files and no index is still stale, still says so, and still sets the top-level verdict — with a warning that names it ([staging] …) and says the rest of the response stands. | | bestWeak capped to names and scores | in a response where another corpus answered, a section that ranked nothing keeps three nearest neighbours as {name, score} and drops its absenceNote; the top-level guidance names every section this happened to and gives the call that returns the whole verdict. When nothing hit anywhere, the fallback is the answer and both stay in full. |

brief: true on search or latest trims the rows — name, corpus, score, snippet, provenance and the timestamp, nothing else. The envelope is untouched: indexStale, staleWarning, recencyVoid, recentUnindexed, uncapturedSessions, captureHealth, configWarning and guidance are all still there. A caller asking for fewer bytes has not asked to be told less about what was never read.

Measured on the reporting caller's own query against the same corpora, read-only, before and after: 56,634 → 26,842 bytes, and 17,123 with brief: true. The ten ranked rows are 12,426 bytes of that, in both runs — they are the answer, and they are what is left.

Query expansion — queries?, expand? (on by default since 2.1.0; can be turned off completely)

A three-position switch: on (default) | shadow | off. latest ignores it.

To turn it off completely, any one of these (the first that is set wins):

  • per call: expand: "off" (or false)
  • for the server: MEMORY_QUERY_EXPANSION=off in the connector's env
  • for the machine: "queryExpansion": "off" in local-config.json

off adds nothing to any response and writes nothing to the log. An unknown value falls back to the default.

How it is meant to be used: search first, rephrase only on a miss. The server never invents synonyms and never re-queries by itself; the caller owns the guessing. A first search is sent with query alone and costs exactly what it did before. If that search is refused, the response invites a second try. If it answered but the rows do not actually answer the question, the caller judges that and tries again the same way:

  • queries: [...] — up to 4 alternative phrasings of the same question, ranked in one call beside query. Each is judged on its own words; a phrasing that refuses adds nothing. results stay the question as asked — same rows, order and scores — each gaining matchedVia, the phrasing(s) that also reached it. Rows only a phrasing reached come back under viaVariants.results with queryScore (what the original question gave them) and alsoBestWeak when the row was already the nearest weak neighbour.
  • The verdict stays on query. If the question as asked has no strong match, noStrongMatch stays true and results stays empty, whatever the phrasings found. Read a viaVariants row before relying on it, and say it came from a rephrasing. On a bm25-only index expansion does nothing and says why.

Why the second try is left to the caller: the server cannot see a miss. In the evaluation below, 15 of 17 wrong answers came back at confidence: high, so a server-side "retry when unsure" rule would have kept only 2 of the 7 recoveries.

Measured on a real 4,700-document corpus (pre-registered; 90 answerable questions written by agents that never searched, 20 absent controls): with 3 phrasings, 7 of the 20 questions the first search missed were recovered into viaVariants, 0 results changed, 0 controls answered because of it. Cost per call with phrasings: ~+50 ms on 500 documents, ~+370 ms on 4,200, ~+1.6K tokens. A first search with no phrasings costs nothing extra.

requeryHint is off by default. On an empty result it lists words the nearest documents use and the query did not. It pointed at the right memory 0 times in 3 real refusals, because on a true absence the nearest documents are the wrong ones. It is still computed and logged; turn it on with MEMORY_REQUERY_HINT=on or "requeryHint": "on" in local-config.json.

shadow computes all of this, records it on the query-log row (expansion, redacted like q), and returns the unexpanded baseline plus an expansion: {mode: "shadow"} marker.

memory({action: "latest", query, limit?, scope?, sessionId?, account?, project?})

For state questions — "did X finish", "what happened after Y", "where did we leave X".

search ranks by relevance, and relevance cannot separate "we are starting X" from "X is finished": both are equally about X. That is not a ranker that needs improving, it is the wrong axis for the question. latest filters on every term (no ranking at all) and orders newest first.

It exists because of a specific failure. A session was asked whether a re-parse had completed. It searched, got the exchange where the work started at score 0.88, saw no completion ranked above it, and reported the answer unknowable. The answer was in the corpus the whole time, one term-filter away.

memory({action: "latest", query: "reparse"})
  -> orderedBy: "ts", totalMentions: 16, scopeHint: {scope: "staging", explicit: false}
     results[0]: x-fb357616-20260903T235959000Z   threadPosition: "650 of 650"   laterInThread: 0

Fields worth reading

| field | why | |---|---| | orderedBy | ts = when the words were said. mtime = file bookkeeping, not chronology. | | scopeHint | which corpus answered, and whether that was your choice or the default. | | threadPosition / threadLast | an exchange is one moment in a conversation. If laterInThread > 0, fetch threadLast before reporting what happened. | | unmatchableTerms / termWarning | this is an AND-filter, so one unknown term takes it to zero — that zero is named, not silent. | | filterWarning | fires when account/project names a label this corpus does not use at all (e.g. project:"this" against staging, where every doc is labelled store). | | indexStale / staleWarning | latest is the action most damaged by staleness: new material is exactly what a stale index lacks. |

Query it with identifiers, not prose. This is a literal string filter. Measured over six real questions with known answers:

| query | result | |---|---| | "pushed commit with failing test semicolon" | nothing — the corpus says ';', never "semicolon" | | "pushed c509e0f" | the exact exchange, immediately | | "high RAM usage cause overnight run" | a coincidental match on "overnight" in a JSON schema | | "max-old-space-size heap 20000 rows" | the exact answer — 8 GB heap ceiling, .all() on 20,000 rows |

Same corpus, which held every answer the whole time. Commit SHAs, file names, flags, function names, error strings and exact numbers work; prose belongs in search, which ranks. When the strict filter finds nothing it relaxes to the best available match and sets relaxed + droppedTerms — a dropped term is often the one that mattered.

Compaction summaries are demoted, not dropped. When a session runs out of context the harness reopens it with a summary of everything so far, and that gets ingested as an exchange (34 of 2,318 here). It restates a whole conversation, so it matches almost any filter while carrying a recent timestamp for old content — one took first place on 5 of 6 test questions, once purely because it restated the question. Excluding them outright measured worse: it fixed one question and broke another whose answer existed only inside a summary. So they are labelled isCompactionSummary, sorted below first-hand exchanges, and removable with includeSummaries: false.

scope: "all" returns one section per corpus, never a merged list. The corpora do not share a clock — staging documents carry ts, curated documents carry none — and merging them by time compares incomparable things. Concretely: the 2026-08-19 account backfill rewrote all 118 curated files in one pass, so in a merged list every one of them would outrank a genuine 08-22 conversation. Each section declares its own orderedBy.

The limit no field can fix. The corpus records what conversations said, never what happened after the newest one. Measured: the newest exchange said "nothing queued, v111 tagged" — 13 commits landed after it. When the answer matters, check the world.

memory({action: "thread", name, forward?, back?})

Read forward from a hit, in sequence. threadLast gives the end of a thread, which is the wrong end of a long one: the resolution to a claim at exchange 200 of a 650-exchange thread is at 201–210. Relevance can't bridge that gap either — the exchange that resolves something often shares almost no vocabulary with the one that raised it ("done", "shipped", "you were right"). Sequence can, and sequence is already in the x-<session>-<ask timestamp> names (e.g. x-fb357616-20260903T054233800Z, which sort as time), so this is arithmetic, not retrieval.

memory({action: "thread", name: "x-fb357616-20260903T054233800Z", forward: 4, back: 1})
  ->  -1  x-fb357616-20260903T053810112Z
      ▶0  x-fb357616-20260903T054233800Z   <- the anchor
      +1  x-fb357616-20260903T060102450Z …          remainingAfter: 30, threadLast: x-fb357616-20260903T235959000Z

offset is relative to the anchor. remainingAfter says how much of the thread the window did not cover, so a long thread stays reachable in one more hop.

memory({action: "verify", name? , text?})

Check a claim against git instead of judging its wording. The corpus records what was said; whether it happened is a question about the world, and for engineering claims the world keeps a record. A cited SHA — the unique fingerprint git gives every commit, 47f71d3 and the like, naming one specific saved change — either exists, landed on the mainline, on a date, touching files, or it does not.

memory({action: "verify", name: "x-df6d25fe-20260818T214812690Z"})
  -> c509e0f [recall-mcp] 2026-08-18  ON MAINLINE  "dream + auto-ingest: a correction signal…"  2 files
     3c1a440 [recall-mcp] 2026-08-18  ON MAINLINE  "auto-ingest: the debounce must run BEFORE the lock"  2 files

latest and thread rows carry verifiedCommits automatically wherever a cited SHA checks out.

  • Measured coverage: 404 of 2,319 exchanges (17%) name a real commit. 707 hex-shaped candidates collapse to 355 actual commits, so shape alone proves nothing and every token is checked against git.
  • Repos are configured, never inferred — set MEMORY_GIT_REPOS to a :-separated list (; on Windows — the code splits on path.delimiter, so use your platform's). Unconfigured, this stays silent rather than guessing: this server lives in a different repo from the codebase the corpus is about, and guessing would answer confidently about the wrong project.
  • onMainline is separate from existence. A commit can sit in the object store after being amended away, or live only on an abandoned branch. "It exists" and "it shipped" are different claims, so both are reported.
  • Absence proves nothing. A row with no verifiedCommits cited no SHA.

The reverse join — git → corpus, on time

Every check above reads a commit SHA out of an exchange. That only works when the conversation wrote one down, and measured on a known day it usually doesn't: of 12 commits made during one session, that session's text named 2. The commits happen inside tool calls, while capture records the prose around them — so the identifier normally never appears in the text at all. Reading the corpus harder cannot recover what was never written.

Time can. A conversation has timestamps and so do commits, so the join needs no SHA, no vocabulary and no judgment:

  • thread returns commitsDuringWindow — what landed in the configured repos while that stretch of conversation was happening. This turns "I'll commit the fix" — a promise, and the hardest thing in a corpus to resolve — into the record of whether anything actually landed.
  • latest returns corpusCurrency — how far behind the world the corpus is, as a count: "N commits have landed since the newest exchange was written." The guidance already says the last word isn't current truth; a sentence is easy to skip and a number isn't.

Evidence, not proof, and labelled that way: a commit inside the window may be unrelated work, and related work can land days later. It narrows "did this ever happen" to "here is what happened at that moment".

Measuring it

npm run analyse-queries   # what callers actually did, and whether retries recovered

eval:state (author's tree only — not in the published package) runs test/state-questions.json, whose answers were written down before the corpus was queried — grading after seeing results produces a test that passes for the wrong reason. Each case also carries a proseControl that is expected to fail; those controls are the measurement behind "query a term filter with identifiers, not prose". Baseline: 6/6 answered, 4 of 6 controls failing as expected. It needs the local store/ corpus, which is gitignored, so it is deliberately not part of npm test.

memory({action: "neighbors", name})

The [[wikilink]] graph, free relevance expansion:

  • outbound — links this memory makes
  • inbound — backlinks from other memories (verify-protocol has 11)
  • unresolvedLinks — [[slugs]] with no matching file
  • semantic — top-3 nearest by cosine, which surfaces relatives nobody linked

memory_write({action: "demote", name}) / memory_write({action: "promote", name})

Two-tier mechanics. demote sets metadata.tier: archive in the file's frontmatter, creating a frontmatter block if the file has none. promote removes the line.

Your body text is never deleted or moved — only that one metadata line changes.

The round trip is byte-for-byte reversible only if the file already had frontmatter. If it had none, demoting creates a frontmatter block (including a description synthesised from the body), and promoting afterwards removes the tier line but leaves that block behind. The body is untouched either way. Files with no frontmatter are common enough that this is worth knowing before you demote one.

Hot tier = everything not archived. Anything MEMORY.md lists is hot by definition, so demoting a memory that MEMORY.md still names is refused rather than silently reverted; the response says which.


memory({action: "get", name, outline?, section?, maxChars?, offset?})

A get of the 103 KB build checklist used to blow the MCP output limit outright — so the tool could not read the documents it exists for, and the caller fell back to cat. Three ways in:

| | | |---|---| | outline: true | headings only, with sizes and offsets. One cheap call to see what is in there. | | section: "## Gate #24" | that heading's whole block, to the next heading of the same or higher level. The primary read path for a large memory. | | maxChars / offset | the bounded fallback. Default 20,000, and the outline rides along so one call is enough to aim the next. | | brief: true | the text and where it came from — name, path, body, and the truncation bookkeeping — without the ~25 provenance and freshness fields. |

Every truncated response carries totalChars, returnedChars and truncated. A slice that looks like a whole document is how a caller concludes something is absent when it is merely past the cut.

Those three fields survive brief: true as well. Brevity may drop provenance; it may never drop the statement of what was left out.

The full response is the right default when you are deciding whether to trust a memory — who wrote it, when, from which account. brief is for when you have already decided to read one and just want the content: most often after a search says the corpus may hold your answer in other words and tells you to open the best weak match. It saves a fixed ~1 KB per call, which is marginal against a long document and most of the response against a one-paragraph note.

Slicing happens after the secrets scrub, so paging cannot reassemble a removed region.

Fenced code is not a heading. This corpus is full of shell snippets whose lines begin # 2. ONLY the intended entries changed…. Read naively those are level-1 headings, and ## MASTER PRE-SHIP GATES returned 426 chars instead of 26,785 — it ended at the first shell comment. The outline went 94 → 50 headings, level-1 count 94 → 1.

memory_write({action: "import", path, dry?, domain?, name?, category?, replace?})

Point it at an absolute path — a file, a folder, or a ChatGPT export — and it brings those memories in. Eighteen formats, no new dependencies:

md markdown txt text log csv tsv json zip rtf rtfd doc docx odt html htm webarchive pdf

textutil (macOS) covers the office and HTML formats and pdftotext covers PDF; archives need nothing — lib/zip.js reads them in-process with node:zlib. A format whose converter is missing is refused by name with the reason — never imported as binary that would poison every search touching it. A ChatGPT export is recognised (conversations.json or its .zip) and its mapping tree is walked in create_time order, because a branched conversation has no single linear list.

converters in the response reports the platform, the probe used (where on Windows, which elsewhere) and what each converter actually is on this machine.

  • Refuses any item containing a credential, and names it. A plaintext secret in a corpus is permanent in a way its author rarely intends.
  • Never overwrites, so re-running is safe — it reports how many were already imported.
  • Skips nothing silently: too-short, refused and unreadable are each counted and named.
  • dry: true writes nothing and says so in the future tense: written: 0, plus wouldWrite and wouldWriteNames for what a real run would file (and wouldReplace in place of replaced). The skip counters are unchanged, so a dry run still tells you what it would pass over.
  • An argument this tool does not know is refused, by name, on any action that writes — import, capture, index, demote, promote. Read actions stay tolerant.
  • It starts the index build itself. An import that wrote at least one file kicks the async index job for the corpus it wrote to and returns indexJobId (+ indexScope); poll it with index_status. Until it finishes the new documents are served from the store rather than ranked. A dry run starts nothing, an import that wrote nothing starts nothing, and a build already running for that corpus is JOINED rather than raced — the response then carries indexAlreadyRunning: true, because that build may have begun before your files landed. MEMORY_IMPORT_AUTOINDEX=0 switches the kick off; the direct read still serves the documents.

Why the import builds the index. Measured over eight import shapes, twice: every imported document was still unindexed 300 s later and would have stayed so indefinitely. Nothing rebuilds a curated index — the 5-minute walker reconciles staging only, and the inline rebuild refuses anything past 8 changed files as a full rebuild in disguise. Retrieval never broke, which is why it went unnoticed: the answers came through the direct store read, a substring scan that answers a token but not a paraphrase and re-reads those files on every query.

Why an archive needs no unzip. The converter probe ran command -v through shell: '/bin/bash', which does not exist on Windows: the spawn failed, every converter read as missing, and .zip import was refused there outright — with the one public check that covers it skipping itself on windows-latest for want of the zip binary to build its fixture. The probe is now where/which spawned with no shell, archives are read in-process, and the fixture is written by writeZipSync, so the check runs on every platform. A stored symlink is still restored and still refused if it points outside the archive, and an entry name that climbs out of the destination is refused by name.

Why a dry run may not say written: 3. It used to. Zero files reached disk and the response said written: 3 with three filenames, because the dry branch reported its intention through the same field a real write uses; only dry: true and one sentence of prose disagreed with the other two signals. A response may not make a claim it cannot back, so the tenses are now separate fields and no field is true of both a dry run and a real import.

Why a write refuses an unknown argument. memory_write({action: 'import', path, dryRun:true}) — a typo for dry — imported for real: the tool is registered with a plain object schema and zod strips unknown keys, so the flag was deleted before the handler ran (measured: written=2, dry=false, curated 19 → 21). The schema now passes unknown keys through so the handler can see them, and a write action refuses one by name, suggesting the argument you meant. A stray key on search still costs nothing and is still tolerated — refusal is for the places where a near-miss costs files.

scripts/import-memories.js is the same thing on the command line, and additionally runs the index and a first dream pass for you.

The corpus knows what kind of corpus it is

This server was built against one software project, and its advice said so: "QUERY WITH IDENTIFIERS, NOT PROSE" was told to every caller. That is measured advice — on a code corpus. Told to someone whose memories are notes for a novel it inverts: they have no SHAs, no flags and no paths, and prose is the only thing they can search with.

Advice now resolves in three layers, most authoritative first:

  1. An explicit domain: — code | writing | business | research | planning | prose | mixed.
  2. The shape of the query — a SHA, path, CONSTANT_CASE or --flag means technical retrieval whatever the corpus is.
  3. The corpus profile — derived once per index by counting structural markers, never by reading a document and classifying it. Reported as corpusProfile with a confidence.

Why both layers. Counting is the floor that needs no cooperation, so a corpus somebody just imported gets sane advice on its first query. But counting can only separate code from not-code: measured across eight corpora, a novel, a business plan, case notes, research notes, recipes and a book about software all score codeScore 0. Only a caller who names the domain can separate those, which is why domain: is first-class rather than a fallback.

The first threshold was codeScore >= 0.35, fitted to this repo's own curated corpus (0.43). It scored 3/8 on pre-registered corpora. Real separation is an order of magnitude lower — prose 0–0.017, code 0.117–0.43. Re-derived on the principle rather than the example: 8/8. test/domain-corpora.json pins all eight.

memory_write({action: "index"}) returns a job

Indexing runs off the request. It used to await buildIndex inline — ~73 s for curated, minutes for staging — so the stale warning told callers to run index and running it returned Error: Request timed out. A tool must never recommend an action it cannot itself complete.

memory_write({action: "index"})                        -> { started: true, jobId }   (~675 ms)
memory({action: "index_status", jobId})          -> { state, indexes, skipped }

One build per index file at a time — a second concurrent index for the same scope reports already being built by job … rather than racing it. wait: true keeps a blocking path for the CLI and tests.

Freshness — an index is a cache of a directory

The incident (2026-08-19). The curated index was last built at 06:18. The corpus files changed at 07:13 and again at 20:46. Every search for the rest of the day answered from the 06:18 snapshot, silently. A session in another chat built a conclusion about the state of the project on top of those snippets — and compounded it by reading each result's modified field, which is the file's mtime at index time, as though it were a live stat.

Two defects: no invalidation rule, and no provenance. Both are fixed.

CHECK. Before answering, search stats the corpus files and compares each one against the mtime the index recorded for it. Exact rather than heuristic: edited, added and deleted files are each detected on their own terms. Measured on this Mac — 122 curated files: 1.00 ms cold, 0.69 ms warm; the 2,104-file staging store: 11.5 ms cold, 8.7 ms warm. Cached for 3 s so a burst pays once.

REPAIR. If anything moved, the existing incremental indexer (lib/index-store.js) runs inline, before the answer. There is one indexer in this repo and the guard calls it; nothing is reimplemented. A one-file edit costs ~3 s end to end (120 files reused, 1 re-embedded).

ADMIT. When the repair cannot be cheap, the query is not blocked. It is answered from the stale index and stamped:

{
  "indexStale": true,
  "indexBuiltAt": "2026-08-20T05:33:03.019Z",
  "staleFiles": 1,
  "staleWarning": "STALE INDEX — these results come from an index built at
     2026-08-20T05:33:03.019Z, and 1 corpus file(s) have changed since:
     1 edited (commit-changes-when-done.md). Not repaired inline because …
     Run memory({action:\"index\"}) before trusting these snippets, and note
     that each result's `modified` is the file's mtime AT INDEX TIME."
}

The repair is refused, by design, when it would not be cheap:

| condition | why | |---|---| | header refused | every vector would have to be recomputed — that is a full build, minutes | | no index on disk and more than FRESHNESS.firstBuildMaxFiles (40) files | a full build. Under the bound it is built inline — the day-2 case, when another project has just written its first memories | | more than FRESHNESS.maxInlineFiles (8) changed | a full rebuild in disguise; 8 ≈ 40 s worst case, 25 ≈ two minutes | | the embedding model will not load | the rebuild would produce a BM25-only index — worse than the stale one | | the last inline rebuild failed < 60 s ago | otherwise a broken model turns every query into a fresh failed build | | MEMORY_INLINE_REINDEX=0 | kill switch; keeps the check and the stamp, drops the rebuild |

One rebuild at a time per index file (a burst of queries does not start a burst of writers over the same 16 MB), and a failure never fails the search.

Staging is checked and stamped the same way — the mtime comparison is just as cheap there — but it is not repaired inline: its rebuild writes 130 MB in ~14 s and its own ingest hook owns it. Its stamp adds lastIngestAt, from store/.last-ingest.json, because "when did material last arrive" is the more useful question for that corpus.

Which build is answering. Node caches every module at spawn, so an MCP process the client started this morning is still running this morning's code no matter how often the repo is edited — and nothing used to say so. The server now logs its git SHA, branch, pid and start time to stderr at startup, and stamps serverVersion / serverStartedAt on every search response. A running server keeps the old code until the client is restarted (Claude Desktop: full ⌘Q and relaunch; Claude Code: a new session).


Four work corpora + the library, one index each

| corpus | roots | index | written by | tier | writable | in 'all' | |---|---|---|---|---|---|---| | curated | the canonical ~/.claude/projects/<this project>/memory | .memory-index.json | Claude, by hand | hot | yes | yes | | projects | every OTHER ~/.claude/projects/<project>/memory | .projects-index.json | Claude, by hand | hot | yes | yes | | staging | store/ | .staging-index.json | scripts/auto-ingest.js | archive | yes | yes | | handoff | any dir named by MEMORY_HANDOFF_DIRS, files matching HANDOFF* / PHASE* / *-HANDOFF* | .handoff-index.json | nobody — read-only | archive | no | yes | | library (one corpus per category) | $MEMORY_LIBRARY_DIR/<category>/ | .lib-<category>-index.json | import with category: — read-only otherwise | archive | no | never |

scope: "all" searches each work corpus against its own statistics and returns them as separate ranked sections under .groups. They are never blended, and that is measured twice:

  • Putting 499 auto-ingested exchanges in the curated index cost three probes their answer (22 → 19) and MRR 0.826 → 0.681.

  • Putting the 14 handoff documents in the curated index cost MRR 0.8194 → 0.7986 and one absence verdict — while taking zero top-3 slots. Nothing was crowded out. The damage was done entirely by referenceChunks, the corpus-derived p90 chunk count the long-document correction normalises against: 14 long documents moved it 16 → 19, which raised the dense score of every curated memory above 16 chunks and pushed partner-email-rules to 0.3842 against an absence floor of 0.38 — a 0.0042 margin, and the server could no longer say "I have no memory of a Postgres migration".

  • Putting 15 other-project memories in the curated index cost MRR 0.8125 → 0.7917 and a rank-1 (measured 2026-08-20; see the next section).

With the handoff documents in their own index, the 32-probe benchmark is bit-identical to the curated-only baseline: MRR 0.8194, absent 4/4, exact 10/10, verbatim 6/6, enum 33/41, and not one rank changed.

The projects corpus — other projects' memory folders

Claude keeps memories per project (~/.claude/projects/<project>/memory), and this server is pointed at one of them. Every other project's folder is discovered automatically, and until 2026-08-20 it was routed into staging by primary: false — so hand-written rules from another project were ranked as though they were raw transcript exchanges (archive tier, no hot boost) and were unreachable at the default scope. Exactly one memory folder exists on this machine, so the defect had never fired. This is the fix before it arms.

They are curated-type content: hot tier, demote/promote allowed, their own account label per file, their project folder carried on every hit. What they do not get is a share of the curated index, and that was measured the same way the handoff corpus was — with a 17-file fixture second project (test/fixtures/projects/…-cli-mcp-server/memory, 15 indexable) pointed at by MEMORY_EXTRA_PROJECT_DIRS:

| metric | curated only | +15 other-project memories inside the curated index | |---|---|---| | MRR (24 ranked probes) | 0.8125 | 0.7917 | | probes in top-3 | 22/24 | 22/24 | | absence verdict | 4/4 | 4/4 | | enum items | 34/41 | 34/41 | | P2 "what do I have to run after editing the huge single-page web file" | rank 1 | rank 2 — a memory from the other project took rank 1 | | every other probe's top score | — | moved, −2.3% to +6.3%, with no content changed |

Two separate damages, worth telling apart:

  • Shared statistics — the one that generalises. Not one curated document changed and every probe's score moved (V2 +5.1%, E9 +4.2%, N2 −2.3%): 15 documents joining 122 move BM25's average document length and every idf, so queryIdealScore moves, so the absolute keyword scale moves, so every fused score moves. That is the same mechanism that cost the handoff experiment an absence verdict on a 0.0042 margin. Here it crossed no floor. There is nothing to say it would not next month.
  • Competition — the specific one. P2 asks about this project's UI-syntax rule and a memory from another project outranked it. Not by being better; by being in the same ranked list.

Kept separate, the curated index built with the fixture present is bit-for-bit the control: corpusHash 56b48c09…, 122 docs, 1,586 chunks, all three unchanged. The fixture's 15 documents are a 33-chunk index of their own with its own referenceChunks (4, against curated's 16).

Reachable without a scope argument. A memory in its own index cannot be found by a default-scope search, and a standing rule that needs an explicit scope is a standing rule nobody finds. So the advisory router widens to scope: "all" whenever a project corpus exists — the same "widen, never narrow" rule the handoff phrasing uses, and free here because all returns each corpus as its own ranked section. With one memory folder on the machine (today) nothing about routing changes at all.

Read the project field. A rule from another project is a rule about another project. Every search row and every get carries project, account and corpus; project: "this" restricts to the canonical folder.

Day 2 — what happens when a real second project appears

Zero configuration. A session run from another project writes ~/.claude/projects/<other>/memory/foo.md, and:

  1. discoverProjectMemoryDirs() finds the folder on the next call — no list to edit, no env var to set. It becomes a projects root, namespaced by the last three dash-segments of its folder name (store/foo.md-style ids, so two projects may hold the same basename).
  2. The first search finds the projects index missing and builds it inline before answering — bounded at FRESHNESS.firstBuildMaxFiles (40) files, ~2 s for a 15-document project. Over that bound the search is answered and stamped indexStale with the sentence saying what to run. (Curated at 122 files and staging at 2,100 are both far over the bound, so their behaviour is unchanged.)
  3. memory_write({action: "index"}) rebuilds it by default (curated + projects + handoff — the three hand-edited corpora; staging stays opt-in).
  4. The router widens, so the new memories are reachable with no scope argument.
  5. scripts/auto-ingest.js rebuilds rootsForCorpus('staging'), which no longer contains project roots — so the new folder is not double-ingested as transcript material.
  6. Its own MEMORY.md acts as its tier-1 index (inMemoryIndex, the larger hot boost). The bare name MEMORY still resolves to the canonical one — every project has a MEMORY.md, and loadCorpus warns about the shadowing — so ask for the other one by its namespaced id: get({name: "cli-mcp-server/MEMORY"}).

One caveat that is not code: a running MCP server keeps the code and the module state it was spawned with. A client started before this change picks it up only after a restart (Claude Desktop: full ⌘Q; Claude Code: a new session).

The library — category-isolated reference corpora

Books, manuals, policies: imported reference material, which is a different thing from a memory. Daniel's rule (2026-08-26): nothing imported may dilute or even touch work retrieval unless a search names it. Both halves are enforced:

  • Isolation by construction. Each immediate subdirectory of each library category is its own corpus with its own .lib-<category>-index.json — own BM25 statistics, own referenceChunks, own profile. The suite's a48 group proves the stronger claim bit-identically: curated corpusHash, every RECALL name and score, and every absence verdict are byte-for-byte the same with library corpora present as with the whole class switched off (MEMORY_LIBRARY=0) — with an absence-probe term planted inside the library fixture the entire time.
  • Reach isolation. 'all' stays the four work corpora, and the router never volunteers a category. A category is searched only when named — scope:'books', scope:['all','books'] — or via scope:'everything' (work + every category). Unknown scope names error, listing what exists.
  • Read-only (doTier refuses; import's own fs path is the sole writer), archive tier, never rebuilt inline (a changed book is a full re-embed; rebuild with memory_write({action: "index", scope:"<category>"})).
  • Import routes and refuses. import with category:'books' files into the category (created on demand). Anything over 200 KB of text, or book-shaped (PDF), without a category is refused before any write — the accident this prevents is a book quietly landing in curated. replace:true supersedes a re-issued document (old version → <category>/archive/, stamped supersededAt, out of the flat scan, never deleted).
  • Structure is recovered at import. PDF form feeds become ## p.N page anchors (and running page headers are stripped); docx/html headings become real ##; a plain-text book's CHAPTER lines are promoted (last occurrence of a duplicated designator — a Gutenberg ToC stays plain text). The existing section splitter then chapters the document, so a manual answer cites ts-x73a-user-guide#p-7 — a page a human can open.
  • Optional memory-library/<category>/.category.json ({domain, description, note}) declares the category's domain for the advice layer — a statute and a novel are statistically identical prose. Starter categories: books