rutter_

Memory-of-use: the mechanics in full

Capture, recall, enrichment, note identity, positions, the trust boundary, and the usage gate — where the record lives, how it accrues, and how it all comes back.

Search alone is something any capable agent can already do. Memory-of-use adds the first thing a stateless assistant can't have: memory that accrues by itself and surfaces later. rutter quietly records what each AI session decided or produced, lets you recall recent work, annotates search results you have engaged with 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/ folder and the disposable data/ index. This page says vault for your notes folder. The _librarian/ folder is rutter's own layer next to your notes. Tool names, environment variables, and that folder keep rutter's earlier name, librarian.

Where rutter keeps what it remembers

Session memory lives in your vault, as plain, human-readable, git-committable markdown:

<vault>/_librarian/sessions/2026-07-24.md

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
  client: claude

Everything about workspace is best-effort and never blocks a capture:

Entries captured before this existed stay valid, untouched. workspace is an optional field added to the same record schema (session-record@1). There is no migration and no rewrite of old records. A day file can hold a mix of old and new entries.

Duplicate detection ignores it. Duplicate detection compares the directive text only. A Stop firing whose directory changed (a rename, a subdirectory, or none at all) is still an unchanged directive and still a byte-identical no-op. Moving a project does not fork your history.

Which client wrote it

client is another optional field: claude, grok, codex, or agy. It names the host, never the model, and it is metadata only. The summary and stance stay byte-verbatim.

The hook compares two things: the raw Stop event payload the host sends (Antigravity's shape, Grok's camelCase lastAssistantMessage) and the identity the installer passes as --client. It writes a label only when the two agree.

No label is written for conflicts, direct payloads, or an unrecognized identity. Codex without an identity gets none either, because nothing in its payload separates it from Claude. Entries from a hook registered by hand before this field existed show no label. Re-run install-hook to add it. A stored value this build does not know is kept and ignored, never an error. The label shows in the record's frontmatter, not the body.

Capturing session summaries

Capture is ambient: it happens after each turn with no action from you inside the session. It is wired through a Stop hook, a hook the host runs when the assistant finishes a turn, in Claude Code, Grok, Codex, and Antigravity.

Setup is the hook. Register it with npm run install-hook, adding --client codex or --client antigravity for those hosts. Getting started, Step 6 has the per-host commands and checks. Two facts change behavior:

The installer merges into existing hook configuration and never touches your other hooks. It refuses to run against an unbuilt repo. The registered script always exits cleanly, so a capture failure can never break your session.

Host differences

All four hosts fire the same hook after each turn and store the same records, in the same notes folder. That shared folder is how a decision captured in one tool is recalled in another. Each record carries a client label naming the host that wrote it (see Which client wrote it). The hosts differ in where the hook looks for the directive:

The directive

How the summary is produced (no AI in the server). The server never summarizes anything, which would be inference. Instead your client writes the one-line summary during the session as a directive, and the hook lifts it out verbatim, from the transcript or from the final reply depending on the host. Emit a directive like this whenever a session is worth remembering:

<!-- librarian-session {"summary":"Decided to store refs by content-hash; shipped capture.","refs":["Notes/foo.md"]} -->

The style contract

A summary is written 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 as each separable thing lands. A client told to write one line at the end packs the whole session into it, which is exactly the over-stuffed entry the budget exists to prevent.

The server does not enforce the style contract. It stores the summary byte-verbatim. It never rewrites, shortens, "clarifies", or rejects a line for being dense, and it writes no style warning into your record. The capture path prints the word count so drift is visible, then stores exactly what it was given. Judging or rewriting prose is inference, and the server runs no model. Silently truncating would lose the only copy of what the session meant. A dense summary is a readability problem, handled by guidance and by how it is read back, never a data problem solved by editing your memory.

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.

Where the contract lives

In one place: the server's MCP instructions (SERVER_INSTRUCTIONS in src/server.ts). The server sends them to every client on connect. The README quotes them verbatim, and a test fails if that copy drifts. They have to fit in 2,048 characters, because Claude Code silently cuts a server's instructions there, and a test holds the line. Guidance that only matters when reporting a result lives in the description of the tool that returns it.

There is nothing to add to your CLAUDE.md. The README keeps a paste-in only as a fallback if captures refuse to land. Antigravity is the exception to "on connect": see Host differences for its rule file.

Recalling recent work

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.

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. 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 tool descriptions ask your client to report recalled summaries in plain language for whoever is asking, and that applies to every record, not just new ones. So when you ask "what was I working on?", your client may answer in cleaner words than the stored line uses. That is the intended behavior: what is on disk is the record, and 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.

From the terminal:

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 rutter. The server's MCP instructions route recency questions to librarian-recent, prior-engagement and content questions to librarian-search (then librarian-get-note to read a note in full), and position questions to librarian-positions. They also tell the client how to write session and position lines.

The reading-back half travels in the tool descriptions. librarian-recent asks the client to report recalled summaries in plain language (see Old, dense entries still read clearly), and librarian-positions asks it to attribute a stance to you rather than adopt it. Neither is enforced by the server. Both reach every client that connects.

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 is connected from: one install, not one per repo. Writing the summary is still your client's job, because writing is inference. The instructions for doing it ship with the server too.

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 is relevant.

Search enrichment

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."

Note identity describes two more things this surface renders when a reference cannot be resolved on its own: a candidate note for it, and a conflict between a confirmed binding and a fresher automatic detection.

Note identity

A reference records two things about a note at the moment it was captured: its vault-relative path and a content hash. Rename the note later and the path stops resolving, but the hash is still there, so rutter can tell what the reference meant even after where it lives has moved.

At every npm run reindex, rutter checks each recorded reference whose path no longer resolves. Resolves means a file exists on disk at that path, inside the vault, of any type. A reference can legitimately point at a non-note file (a .gitignore, an exported .html, a .yaml config, anything under _librarian/ itself), because capture hashes whatever bytes are there. Such a reference is live for as long as the file exists. The identity pass never treats "not an indexed markdown note" as dead. Only a path that is actually missing, deleted or moved with no trace at the old location, is checked further:

librarian-recent and search enrichment resolve bound references through the ledger at read time. The session record you see still says what you wrote. The ref line shows the note's current path, or [UNRESOLVED -- candidates: ...] when rutter genuinely does not know. Nothing on this path ever rewrites the stored session entry. Resolution happens only when it is displayed.

The two surfaces render the unresolved case differently, because they have different things to attach it to:

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. It checks that the target you name exists in the vault right now. It checks existence, not a hash match, because the point is to resolve cases where the hash no longer matches. It then appends a detected: confirmed entry to the same ledger.

A confirmed binding is sticky. Once you confirm a binding, no later automatic detection moves or overrides it, whether that detection is ambiguous or a clean single match pointing somewhere else. Suppose you confirmed Notes/old.md to Notes/keep.md, and later a stray file lands with exactly the bytes last recorded for old.md. Reindex appends nothing new for that reference. Both read surfaces show the disagreement next to the confirmed target: confirmed Notes/keep.md; the hash now matches Notes/stray.md. Only running npm run identity-confirm again changes a confirmed binding.

The ledger is append-only, like everything else here. A note renamed more than once gets a fresh binding each time. Each binding is computed directly against what was originally recorded, never by chaining through an earlier binding, and the newest automatic entry wins at read time. Confirmed entries are the exception, as described above. Earlier entries are never rewritten, reordered, or removed. The identity tables in the SQLite index 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.

Positions: capturing a stance

Alongside the session summary, your client can leave a second kind of line: a position directive. It emits one only when a session forms, changes, reaffirms, or retires a stance on a topic, which should happen in far fewer sessions than not. For scale, session summaries ran at about 19 lines a day in the author's own use when this was written.

<!-- librarian-position POSITION assert my-topic: I think X because of the meeting notes. -->

Recall is a separate path with its own guarantees. See the next section.

Positions: recalling a stance

librarian-positions answers "what do I think about X, and how did that change?" from the stream that position capture writes. It is read-only. It reads a derived table in the index, rebuilt from the position files at each reindex, never the files themselves.

Ask one of three ways. They are three different questions, so pass exactly one argument at a time:

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, so what you searched and what you get are separate knobs. Add chain: true for the topic's full history: every event that replaced or reaffirmed an earlier one, oldest 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 librarian-positions tool description tells 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. 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 that nothing has touched for a long while is marked dormant. That is 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 three index tables that hold positions (position_events, position_refs, positions) are rebuilt wholesale from _librarian/positions/*.md at every reindex, the same way as the identity tables in Note identity. A reindex runs when you call npm run reindex, or when the server starts and finds something changed. The tables are never patched incrementally. Two consequences, both deliberate:

Calling librarian-positions is logged to _librarian/stateful-use.jsonl under its own kind, and does not count toward the usage gate (see Measuring whether it gets used).

What rutter can and cannot establish

A reference is evidence of what a session recorded. It is not proof of what the session read. This section puts the boundary in one place.

What rutter can establish:

What it cannot establish:

What capture depends on. Each item is stated earlier on this page. They are collected here:

Append-only is a rule rutter follows, not a tamper-proof log. The server has no code path that rewrites, reorders, or deletes a stored line. But the records are plain markdown in your notes folder. Anyone with write access, including you in Obsidian, can edit them, and nothing detects it. If you need a history of edits, commit the notes folder to git.

Guarantees

The spec states each of these as an invariant: spec/spec.md.

Measuring whether it gets used

Memory-of-use is on trial. The project set a usage test it can fail: does ambient memory-of-use actually pull you toward reaching for it? The target is reaching for a stateful behavior unprompted at least three times a week, for two weeks. The first reading, in August 2026, passed, and the gate keeps running. It reads one person's usage.

Unprompted names intent, not call origin. "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 does not 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, rutter 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 session memory pulls you toward using it, and counting position queries, a feature added later, would answer a different question. The calls are still logged, so you can inspect them. They just do not move the gate figure.

Read the per-ISO-week count to evaluate the gate:

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  ✓
Honesty note

Deciding whether a call was unprompted, meaning a live human question rather than the standing server instructions alone, is left to manual review of the log. The log captures every invocation with a timestamp so that review is possible. The project is prepared to conclude that the stateful behavior does not get reached for.