Skip to content

Automatic memory

Every tool in the MCP surface still only fires when the model chooses to call it. A memory the model forgets to write is the exact failure a memory store exists to prevent.

What closes that gap is host-side wiring: a hook at session start that recalls relevant context and puts it in front of the model, and a hook at session end that writes back what was learned. None of it is nidus code (it is configuration in your agent host), but nidus is shaped to make it a few lines rather than a project.

This page walks from an empty store to working automatic capture, using Claude Code hooks as the worked example. The shape transfers to any host with equivalent lifecycle events.

The obvious first move (register nidus mcp over stdio and add hooks beside it) does not work, and it is worth knowing why before you build on it.

A stdio session holds the store’s writer lock for its whole lifetime. A hook that tried to write while that session was live would find the store locked. There is also no nidus remember subcommand for a hook to shell out to: the memory surface is reachable through MCP tools or the HTTP routes, and nothing else.

So run one nidus serve, and let both the model and the hooks talk to it:

Terminal window
nidus serve --dir ~/.nidus --dim 1024 \
--addr 127.0.0.1:7700 \
--embed-provider voyage --embed-model voyage-4

Point the agent’s MCP client at that server’s /mcp endpoint rather than spawning its own process:

Terminal window
claude mcp add --transport http nidus http://127.0.0.1:7700/mcp

Now the model has remember/recall as tools, and your hooks have POST /collections/{name}/remember and POST /collections/{name}/recall against the same store, the same embedder, and the same lock.

There is no setup call. The first remember into a collection creates it and declares a default full-text schema over nidus.text, so a client that only ever speaks to the server goes from an empty directory to a working hybrid_search without a CLI invocation or a provisioning request.

That is what makes the hooks below safe to run on a machine where the store does not exist yet: the first session creates it.

The conventions that make later filtering work

Section titled “The conventions that make later filtering work”

Every write stamps a reserved nidus.* namespace. These are ordinary attributes (filterable, projectable, sortable); they simply arrive without you setting them:

AttrTypeWhen
nidus.textStralways, both modes: the raw text exactly as given. This is the field the default full-text schema indexes, so it is what you text_search against.
nidus.created_atDateTimealways; UTC epoch milliseconds. Survives a re-remember of the same id.
nidus.updated_atDateTimealways; moves on every write.
nidus.expires_atDateTimeonly when ttl_seconds was passed.
nidus.summaryStrsummarize mode only: the generated summary that was embedded.

Everything else in attrs is yours, and the conventions you pick at write time are the only ones you can filter on later. A memory written with no dimensions is a memory you can only find by meaning. Stamp what you will want to slice by:

{
"project": "nidus",
"repo": "duckedup/nidus",
"branch": "main",
"kind": "decision",
"session": "0f2c…"
}

kind is the one worth thinking about hardest. A store where everything is note recalls badly, because a decision, a gotcha and a preference all compete on raw cosine. Splitting them lets a recall ask for the kind it actually needs.

Use collections for hard boundaries you would never want mixed (one per project, say) and attrs for everything else: a filter is cheap, and all collections share one embedding space, so a cross-collection search is one ranking rather than a merge.

The session-start hook: recall before the model asks

Section titled “The session-start hook: recall before the model asks”

A SessionStart hook’s stdout is injected into the model’s context. So the hook recalls against the current working directory and prints what it finds:

~/.claude/hooks/nidus-recall.sh
#!/usr/bin/env bash
set -euo pipefail
NIDUS=${NIDUS_URL:-http://127.0.0.1:7700}
PROJECT=$(basename "$PWD")
hits=$(curl -sS --max-time 5 \
"$NIDUS/collections/memories/recall" \
-H 'content-type: application/json' \
-d "$(jq -nc --arg q "context for working on $PROJECT" --arg p "$PROJECT" '{
query: $q,
top_k: 8,
min_score: 0.35,
filter: [ { Eq: ["project", { Str: $p }] } ]
}')" 2>/dev/null) || exit 0
echo "$hits" | jq -r '
if length == 0 then empty else
"## Remembered context\n",
(.[] | "- [\(.attrs["kind"].Str // "note")] \(.attrs["nidus.text"].Str)")
end' || exit 0

Note the response is a bare JSON array, not an object: /recall returns [{ "collection", "id", "score", "attrs" }, …] with no hits wrapper, the same shape as /search and /text-search. Indexing it as .hits is a jq type error, which under set -euo pipefail kills the hook on every run.

Two deliberate choices. It fails open (note the || exit 0 on both the curl and the jq) because a memory store being down should degrade a session, never block one. And it sets a min_score floor, since recall always returns something; without a floor a fresh store pours unrelated text into every session.

Register it:

{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/nidus-recall.sh" }
]
}
]
}
}

The stop hook: write back what was learned

Section titled “The stop hook: write back what was learned”

A Stop hook receives JSON on stdin including transcript_path. The honest version of this hook does not try to summarize the transcript in bash. It stores a durable fact you extract deliberately:

~/.claude/hooks/nidus-remember.sh
#!/usr/bin/env bash
set -euo pipefail
NIDUS=${NIDUS_URL:-http://127.0.0.1:7700}
input=$(cat)
# `|| exit 0` on every jq: under `set -e` a malformed payload would otherwise kill the
# hook here, before the guard below that exists to handle exactly that.
transcript=$(jq -r '.transcript_path // empty' <<<"$input") || exit 0
session=$(jq -r '.session_id // "unknown"' <<<"$input") || exit 0
[ -n "$transcript" ] && [ -f "$transcript" ] || exit 0
# Whatever you choose to persist. Keep it small and factual.
text=$(tail -n 200 "$transcript" | jq -rs 'map(select(.type=="assistant")) | last.message.content[0].text // empty' 2>/dev/null) || exit 0
[ -n "$text" ] || exit 0
curl -sS --max-time 5 -o /dev/null \
"$NIDUS/collections/memories/remember" \
-H 'content-type: application/json' \
-d "$(jq -nc --arg id "session-$session" --arg t "$text" --arg p "$(basename "$PWD")" '{
id: $id,
text: $t,
mode: "raw",
attrs: { project: { Str: $p }, kind: { Str: "session" } },
ttl_seconds: 7776000,
dedupe_threshold: 0.95
}')" || true

mode: "raw" embeds the text as given. "summarize" summarizes first and embeds the summary, which is better for a long transcript, but it requires the server to have been started with --summarize-provider (anthropic or openai, plus that provider’s key). The nidus serve command above configures only an embedder, so a "summarize" write against it fails with “nidus serve was started without a summarizer”. Add the flag before switching the mode.

The last two arguments are what keep the store from rotting:

  • ttl_seconds: here 90 days. An entry past its TTL stops surfacing in recall, text_search, hybrid_search, browse and get.
  • dedupe_threshold: at or above this cosine similarity to an existing entry, the write updates that entry in place instead of inserting a competitor. This is the single most valuable setting for automatic capture, which otherwise stores the same fact under a new id every session until a top-10 is five copies of one thing. Attrs are merged rather than replaced, and the matched entry’s nidus.created_at carries forward, so an entry re-learned ten times keeps its original age.

The response tells you which happened ({"ok":…,"upserted":…,"id":…,"deduped":true}), and id is the record actually written, which on a dedupe match is not the id you sent. Log it if you care which entry moved.

{
"hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/nidus-remember.sh" }] }
]
}
}

With a few sessions captured, ask the store directly what the hook would inject:

Terminal window
curl -sS http://127.0.0.1:7700/collections/memories/recall \
-H 'content-type: application/json' \
-d '{
"query": "why does the release workflow stamp versions instead of hand-editing them?",
"top_k": 3,
"min_score": 0.4,
"filter": [
{ "Eq": ["project", { "Str": "nidus" }] },
{ "Eq": ["kind", { "Str": "decision" }] }
]
}' | jq '.[] | {score, id, text: .attrs["nidus.text"].Str}'
{
"score": 0.71,
"id": "session-0f2c",
"text": "Chart.yaml version and appVersion are stamped from Cargo.toml at release time because a CI assertion that they agree fired on essentially every PR."
}

A filter is a JSON array of predicates, AND-combined, not an object. Nest All/Any/Not inside that list when you need other boolean shapes.

The filter is doing real work here: kind = decision is why a question about why returns a decision rather than the three times someone edited that file. That is the payoff for stamping conventions at write time.

To search the literal words instead of the meaning, text_search against nidus.text, the field the auto-declared schema indexes:

Terminal window
curl -sS http://127.0.0.1:7700/text-search \
-H 'content-type: application/json' \
-d '{"scope":["memories"],"field":"nidus.text","query":"appVersion","top_k":5}'

A hook like the one above never stops writing, and nothing here ever deletes on its own: left alone, the collection grows forever and a stale note about a decision that later got reversed ranks exactly as well as one from this morning. Pass "reinforce": true in the session-start recall so a memory that actually got surfaced and used records that fact:

Terminal window
curl -sS http://127.0.0.1:7700/collections/memories/recall \
-H 'content-type: application/json' \
-d '{"query": "how does the release workflow version things",
"top_k": 3, "reinforce": true}'

Every hit gets nidus.access_count bumped and nidus.last_accessed set to now. Then rank on that count so memories that keep proving useful outrank ones nothing has touched since they were written:

{"query": "...", "rank_by": {"Decay": {
"field": "nidus.updated_at", "origin": 1770000000000, "scale": 604800000,
"count_field": "nidus.access_count"
}}}

A hit with no nidus.access_count at all pays the full count penalty, which is the whole point: a memory nothing has ever recalled sinks, without you having to decide when to delete it. reinforce makes the recall a write, so it queues behind the server’s other writes; a plain recall with no reinforce stays exactly as before. See reinforcement for the full contract, and ranking by reinforcement for the Decay fields above.

A TTL hides an entry; it does not reclaim the row. Expiry is evaluated at read time. nidus runs no background threads, so nothing sweeps expired entries on its own and the bytes stay on disk until you reclaim them deliberately. One call finds every lapsed entry, deletes it, and compacts the store to reclaim the rows:

Terminal window
curl -sS -X POST http://127.0.0.1:7700/compact \
-H 'content-type: application/json' \
-d '{"expired": true}'

Reclaim through the running server, not the CLI, when one is running: nidus compact opens the store read-write and would block on the writer lock nidus serve already holds, for exactly the reason the stdio transport does. nidus compact --expired is the same operation for a store with no server running (and still blocks on the writer lock if one turns out to be running after all).

TTL is enforced on every memory read, not the raw store routes. recall, text_search, hybrid_search, browse and get over MCP filter expired entries for you, and so does POST /recall: the examples above need no extra predicate. The plain /search and /list routes are raw store access and do not, so a hook querying those two directly will see expired memories unless it adds the nidus.expires_at predicate itself. This is the exact predicate the memory reads AND into every query:

[ { "Not": { "Le": ["nidus.expires_at", { "DateTime": 1765200000000 }] } } ]
  • MCP (agent memory): the tool surface the model sees, and the stdio transport for the single-client case.
  • Remember & recall: the same layer from Rust, and what an embedder needs from you. Note the attr table above describes what the server stamps; the in-process Rust Memory::remember does not currently write nidus.text, so text_search against it needs you to set that attr yourself.
  • Search & filters: the filter grammar the hooks above use.