The librarian quietly records what each session decided or produced, lets you recall recent work, annotates search results you've engaged before, and measures whether you actually reach for any of it.
All of this is local-first: nothing leaves your machine, the server runs no AI model (your client is the brain), and it only ever writes inside your vault's _librarian/ overlay and the disposable data/ index.
1. Where the librarian keeps what it remembers
Session memory lives in your vault, as plain, human-readable, git-committable markdown:
<vault>/_librarian/sessions/2026-07-24.md
- One file per day, one line per outcome. Each separable thing a session decides or produces adds one curated line to that day's file — not the raw transcript, and not one line per session. A working session usually leaves three or four; the measured average over the first fortnight of real capture was 3.2. Those lines are its steps, and
librarian-recentgroups them back into a single account of that session when you read it. - Typed frontmatter. Each record carries a small typed header (the day, each session's identity and time, the curated summary, and the notes it touched by versioned identity).
- You can read and edit it. It's your markdown, in your vault. Open it in Obsidian, edit it, commit it.
- It is never auto-deleted. Records are only ever appended to. The librarian has no prune or delete path that destroys memory-of-use. The Claude Code Stop hook fires at the end of every assistant turn, so the same session's summary is offered for capture many times — capture is idempotent per directive: an unchanged summary is a no-op (the file is left byte-identical, no duplicate entry), and a changed summary is appended as a revision after the earlier one. Nothing is ever overwritten or deleted.
A note reference is stored as a versioned identity — the vault-relative path plus a content-hash captured as it was read — so the reference still tells you what you saw even after the note changes later.
Which workspace an entry came from
Because a single day can span several efforts, each entry also records where the session happened — automatically, with nothing for you to name or configure:
- id: 20260726T101500123Z
time: 2026-07-26T10:15:00.123Z
summary: Shipped workspace provenance.
refs: []
workspace:
cwd: /Users/you/Development/personal/rutter
project: rutter
repo: https://github.com/you/rutter.git
cwd— the session's working directory, exactly as Claude Code reported it on the Stop event.project— derived from that directory: the name of the enclosing git working tree, or the directory's own name when it isn't in a repo. A session run fromrutter/srcis still projectrutter. It is never something you supply — being asked to name it would make capture non-ambient.repo— theoriginURL, read straight out of.git/config. The librarian never runsgit(no subprocess) and never contacts the remote (no network); the URL is just a string it found in a file.
Everything about this is best-effort and never blocks a capture:
- No working directory in the payload → the whole
workspacefield is omitted and the entry is captured as usual. - Not inside a repository (or no
origin) →repois omitted;cwdandprojectstill land. - A
.gitredirect (linked worktree or submodule) is followed only when its target is itself git metadata; anything else is refused andrepois omitted — the reader can't be steered into arbitrary directories. - Entries captured before this existed stay valid, untouched.
workspaceis an additive-optional field on the same record schema — there is no migration, no rewrite of old records, and a day file can hold a mix of old and new entries. - It never affects duplicate detection. Identity is the directive, so a Stop firing whose directory changed is still an unchanged directive and still a byte-identical no-op. Moving a project does not fork your history.
2. Ambient capture — setting it up
Capture is ambient: it happens at session end with no action from you inside the session. It is wired through a Claude Code Stop hook.
Setup is one step — register the hook (npm run install-hook, or by hand; see Step 6 of getting started). There is nothing to add to your CLAUDE.md. The whole capture contract lives in the server's MCP (Model Context Protocol) instructions — a single source, quoted rather than restated everywhere else — and reaches every client on connect.
How the summary is produced (no AI in the server). The librarian server never summarizes anything — that would be inference, which it does not do. Instead your client writes the one-line summary during the session as a directive, and the hook lifts it out of the transcript verbatim:
<!-- librarian-session {"summary":"Decided to store refs by content-hash; shipped capture.","refs":["Notes/foo.md"]} -->
summary— the one curated line. Required.refs— optional vault-relative paths of notes the session touched.- The last directive in the session wins. If you emit none (or an empty summary), nothing is captured and no empty file is created.
- A summary still wrapped in
<angle brackets>is treated as an unfilled template and captured as nothing — so pasting the syntax without filling it in is safe.
The style contract — how the summary should be written
A summary is authored by a session that is deep in its own context and read weeks later by someone who has none of it. Left alone, that produces build-log lines full of codenames and version tags that were obvious at the time and are opaque now. So the summary carries a style contract:
Write the line for a smart reader in a hurry who was not in this session: lead with what was decided or produced, use common words rather than session shorthand, and expand or avoid codenames, version tags and abbreviations the session invented (terms your vault itself uses are fine). Aim for about 40 words and stop by 60 — one line, not a build log.
The word budget only works because the trigger agrees with it. The contract asks for a line per separable thing, as each thing lands — a client told it gets one directive at the end of a session packs the whole session into one line, which is exactly the overstuffed entry the budget exists to prevent.
The word budget is advisory, and deliberately so. An over-long summary is stored byte-verbatim like any other — the capture path prints its word count so the drift is visible when it happens, and then stores exactly what it was given. Nothing in the server edits a summary to fit; the alternative, silently truncating, would lose the one copy of what the session meant.
Compare:
✗ Landed HK-7/ambient-splice v0.9.3-rc2 behind FLG_SPLICE_V2; idx@4 -> idx@5,
backfill gated on FKS_DUAL_READ, ZQ-1197 still open, cutover ETA W31.
✓ Shipped the ambient capture path behind a feature flag, and started the
session-index upgrade — the data backfill is still switched off.
What the server does about style: nothing at all. It stores the summary byte-verbatim — it never rewrites, shortens, "clarifies", or rejects a line for being dense, and there is no style warning written into your record. That is not an oversight, it is the invariant: judging or rewriting prose is inference, and the server runs no model. A dense summary is a readability problem handled by guidance and by read-time rendering, never a data problem solved by editing your memory.
2a. Position capture — a second, rarer directive
Alongside the session summary, your client can leave a second kind of line: a position directive, emitted only when a session forms, changes, reaffirms, or retires a stance on some topic — expected far less than once per session, against the ~19 session-summary outcomes a day the librarian otherwise sees.
<!-- librarian-position POSITION assert my-topic: I think X because of the meeting notes. -->
assert | revise | reaffirm | retire— the directive's kind. Kind is the only thing that routes it; there is no content inspection.<topic-key>— free-form, client-chosen, kebab-case by convention but never enforced (an off-convention key is reported on stderr, stored as written).<stance>— the rest of the line, stored byte-verbatim. A[[wikilink]]anywhere in the stance is captured as a versioned reference exactly like a session ref; the textrevises: <event-id>anywhere in the stance records an explicit supersession pointer. Neither is stripped out of the stored stance — what you wrote is what is stored, in full.- Position events are appended to a wholly separate stream,
<vault>/_librarian/positions/<YYYY-MM>.md— never to a session record. Emitting one, or many, changes nothing about how session summaries are captured, stored, or read; the reverse holds too. - Same idempotence discipline as a session summary: an unchanged directive re-fired by the same session appends nothing.
- Recall is a separate path with its own guarantees — see the next section.
2b. Position recall — librarian-positions
librarian-positions answers "what do I think about X, and how did that change?" from the stream position capture writes. It is read-only, and it reads a projection, never the stream files themselves.
Ask one of three ways. They are three different questions, so exactly one argument at a time:
topic— an exact topic key. A topic key is unique, so this returns one topic or an explicit not-found (Position not found: <key>), never a list. The miss is an ordinary answer, worded likelibrarian-get-note's own.query— free text matched against your recorded stances, all terms required, aslibrarian-searchdoes for notes. Returns a list.note— a vault-relative note path; returns the positions whose references include that note, in any recorded version of it. Returns a list.
Free-text and note matching scan a topic's entire history, not just its current stance: a topic surfaces if any event in its chain matches, however long since superseded. What comes back is still the current stance — what you searched and what you get are separate knobs. Add chain: true for the whole supersession history, oldest event first.
Every answer says whose it is and when. A recalled stance always carries the date it was formed (its original assert) and, where there is one, the date it was last revised. A reaffirm re-endorses a stance without changing it, so it never moves the revision date — it shows up in the chain instead. The server instructions tell any connected client to report all of this as your recorded position rather than restating it as its own present-tense conclusion.
A retired position is a stub, not a deletion. Where the most recent event for a topic is a retire, the answer is that retire event's own text — typically the reason you withdrew the stance — with retired <date> stated as a retirement, never as a revision. Nothing is removed from the history to produce it: every earlier event is still there under chain: true. And a position asserted again after a retirement is simply live again; a retirement is terminal only while it is the last thing recorded.
Dormant is computed, never stored. A live position nothing has touched for a long while is marked dormant, worked out on every read from the events' own timestamps. Nothing about dormancy is written to disk or to the index, so the rule can change without a migration or a rebuild. A retired position is never marked dormant — withdrawing a stance on purpose is not the same fact as letting one go quiet.
Reindex is the only trigger. The projection tables (position_events, position_refs, positions) are rebuilt wholesale from _librarian/positions/*.md at every npm run reindex, exactly like the note identity projection, and are never patched incrementally. Two consequences, both deliberate:
- A position captured since your last reindex is not recalled until the next one runs. That is a disclosed lag, not a silent gap.
- Because recall never touches the capture path, capture cannot be disturbed by it. Session records, their bytes, and the position write path are unchanged by the fold running or by any query you make.
Calling librarian-positions is logged to _librarian/stateful-use.jsonl under its own kind, and does not count toward the desirability gate.
3. Recall recent work — librarian-recent
Ask "what was I working on lately?" and your client calls the librarian-recent tool. It returns recent session summaries most-recent-first, each with its date, its project, and the versioned provenance of the notes it references:
2026-07-26 10:15:00 [rutter] — Shipped workspace provenance.
refs: Notes/foo.md@sha256:…
2026-07-25 21:40:00 [novel] — Drafted chapter three.
2026-07-24 09:12:00 — An entry captured before provenance existed.
- Project: shown in brackets when the entry recorded one. An entry without provenance shows nothing there — no "unknown project" placeholder, because that would be noise about the record rather than information about the work.
- Project filter: "what have I been doing on the novel?" — matching is on the recorded project name, case-insensitive, whole-name. Entries with no provenance are excluded from a filtered answer — the librarian will not match your filter against a summary's wording or a note path to manufacture a result it cannot actually vouch for.
- Window: limit to a recent span — "what did I do last week?" is the last 7 days.
- Count: cap the number of sessions.
- Empty state: with no records yet, it returns a plain "No recent sessions recorded yet." message — never an error. A filter that matches nothing says so specifically.
Filters only ever remove entries: the order you get is the same order you would have got unfiltered.
Old, dense entries still read clearly
Records written before the style contract existed are exactly as dense as the day they were captured, and they are never migrated, edited, or re-summarized — memory-of-use is append-only, and rewriting your own past record to look tidier would be a worse bug than the density.
Instead the server's instructions ask your client to report recalled summaries in plain language for whoever is asking — and that applies to every record, not just new ones. What is on disk is the record; what you are told is the answer. If you want the exact stored text, open the day file in Obsidian, or run npm run recent, which prints the raw stored line.
npm run recent # everything, most-recent-first
npm run recent -- 3 # the 3 most recent
npm run recent -- --days 7 # just the last week
npm run recent -- --project rutter # just one project
How your client knows to ask
You do not have to tell your client when to use the librarian. The server declares its own MCP instructions, which every client receives on connect: recency questions go to librarian-recent; prior-engagement and content questions go to librarian-search; position questions — "what do I think about X?", "did I change my mind?" — go to librarian-positions; recalled summaries, including old, dense ones, get reported in plain language.
The same instructions carry both directives your client writes, and how a recalled stance must be framed. A session leaves a summary line as each separable thing lands, and leaves a position line as well when it forms, changes, reaffirms, or retires a stance. A stance given back has to be reported as your recorded position, with the date it was formed and, where there is one, the date it was last revised — a retirement named as a retirement, and never restated as the client's own present-tense conclusion.
This matters because guidance in a project's CLAUDE.md only helps in that project. Instructions that ship with the server travel to every client and every directory it's connected from — one install, not one per repo.
The guidance says when the tools are the right answer — it does not tell your client to call them unprompted. Memory stays quiet until it's relevant.
4. Search enrichment — quiet prior-engagement signals
When you run librarian-search and a result is a note a past session referenced, that one result carries a quiet prior-engagement note: what you concluded and when. For example:
1. Orbital telemetry pipeline — reference · evergreen · 2026-05-02
Notes/foo.md
…matching snippet…
↩ prior engagement 2026-07-22: "Decided foo is the canonical source."
- Silence is intentional. Results you've never engaged carry no annotation. The librarian is quiet when unprompted; the absence of a note is not a bug and is never "not seen before" noise.
- It never changes your results. Enrichment is additive metadata only: the set of results and their ranking are byte-identical to the plain search. A prior engagement never promotes, demotes, adds, or drops a result.
5. Measuring the desirability gate
The memory-of-use layer exists behind a kill gate: does ambient memory-of-use actually pull you toward stateful behavior? The gate target is reaching for a stateful behavior unprompted at least three times a week, for two weeks.
Unprompted names intent, not call origin (spec v3.11.0). "What have I been working on?" answered via librarian-recent is unprompted use even though the model executes the call — the assistant is the delivery mechanism, and memory reached through conversation is still memory reached. What doesn't count is a call made only because the server instructions tell clients to prefer these tools, with no human question behind it.
Every time you invoke librarian-recent, or run a search that surfaces at least one prior-engagement signal, the librarian appends one timestamped event to a local, append-only log (_librarian/stateful-use.jsonl). A single search counts as exactly one event no matter how many signals it surfaced.
librarian-positions writes to the same log, under its own kind, and is excluded from the count — the gate measures whether ambient memory-of-use pulls you toward stateful behavior, and folding a later effort's read surface into that number would answer a different question than the one the gate was set up to ask. The log still records the calls, so they remain available to look at; they just do not move the gate figure.
npm run gate # counts across all history
npm run gate 2026-07-13 2026-07-26 # counts within a date range
Output is one line per ISO week, marking weeks that met the target:
stateful-use per ISO week (gate target: >=3):
2026-W29: 2
2026-W30: 4 ✓
Classifying an invocation as unprompted — attributing it to a live human question versus standing server instructions alone — is left to manual review; the log captures every invocation with a timestamp so that review is possible. The project is prepared to conclude that the stateful behavior doesn't get reached for.
6. Note identity — surviving a vault rename
A reference records two things about a note at the moment it was touched: its vault-relative path, and a content hash. Rename the note later and the path stops resolving — but the hash is still there, so the librarian can tell what the reference meant even after where it lives has moved.
Every npm run reindex runs an identity pass over every recorded reference whose path no longer resolves — and "resolves" means exactly what it sounds like: a file exists on disk at that path, inside the vault, of any type. A reference can legitimately point at a non-note artifact (a .gitignore, an exported .html, anything under _librarian/ itself), and such a reference is live for as long as that file exists. Only a path that is actually missing enters the rest of this section:
- Exactly one current note's content hash matches what was recorded — the note was renamed, content untouched. The librarian binds the old path to the new one, deterministically, and appends the binding to a ledger (
_librarian/note-identity.md). No heuristics, no similarity score, no model — a hash either matches or it doesn't. - Zero matches, or more than one — the librarian does not guess. Zero means the note was renamed and edited; more than one means duplicate content exists, and picking one would be inventing an answer the vault doesn't actually give. Either way the reference renders explicitly as unresolved, with every candidate it found — never silently dropped, never silently bound to a guess.
librarian-recent and search enrichment resolve bound references through the ledger at read time: the session record you see still says what you wrote, but the ref line shows the note's current path, or [UNRESOLVED -- candidates: ...] when the librarian genuinely doesn't know. Nothing on this path ever rewrites the stored session entry — resolution happens only when it is displayed.
A reference with no candidates at all (renamed and edited, so no current note matches its recorded hash) is visible on librarian-recent only — that listing is the complete discovery surface for unresolved references; search enrichment is a candidate-anchored extra, not a second complete listing. And a note that is merely a candidate for an old reference is never annotated as if a session had actually engaged it — the two claims are never conflated.
Confirmed bindings are sticky, and disagreements are surfaced, not silently settled. Suppose you confirm Notes/old.md to Notes/keep.md, and later the vault changes so that exact-hash matching would now point somewhere else. The confirmed binding still wins — a later piece of automation never outvotes a human decision — but the disagreement itself is not hidden: both read surfaces render it explicitly: confirmed Notes/keep.md; the hash now matches Notes/stray.md. The only thing that ever moves a confirmed binding is a fresh npm run identity-confirm run.
If a reference stays unresolved, resolve it by hand:
npm run identity-confirm -- Notes/old-name.md Notes/new-name.md
This is a local terminal command only — it is never exposed as an MCP tool, so a connected client can never rewrite what a dead reference means on its own.
The ledger is append-only, same as everything else here: a note renamed more than once gets a fresh binding each time, computed directly against what was originally recorded, with the newest automatic entry winning at read time. Confirmed entries are the one exception to "newest wins": once a pair is confirmed, only a later confirmation moves it. Earlier entries are never rewritten, reordered, or removed. The identity projection tables in the SQLite cache are, like the rest of the index, fully disposable: delete data/librarian.db and reindex, and they rebuild from the vault and the ledger alone.
Guarantees
- Local-first: no network calls, ever. Repository identity is resolved by reading
.git/config— nogitsubprocess, and a remote URL is recorded as text, never fetched. - Store immutability: the librarian only writes under
_librarian/anddata/; it never creates, modifies, or deletes vault notes. - No hard-delete: memory-of-use records are only appended. Old records are never re-summarized or tidied up to match a newer convention.
- Rebuildable:
data/librarian.dbis a disposable cache; delete it andnpm run reindexreconstructs everything from the vault plus_librarian/. - No AI in the server: summaries are your client's; the server stores and serves them verbatim — including summaries that ignore the style contract entirely. Judging or improving prose would be inference, so the server does neither, at capture or at read time.