mcp-zeromem
Can use Ollama (and other self-hosted OpenAI-compatible embedding servers such as llama.cpp, vLLM or LM Studio) as the embedding endpoint via ZEROMEM_EMBEDDING_URL, with connection testing from the CLI or Settings page and background re-embedding of stored turns.
Supports OpenAI-compatible embeddings APIs as the dense embedder, including the hosted OpenAI API, with configurable base URL, model name, API key, per-request timeout and query/document prefixes. The embedding dimension is learned from the first response, and switching embedders re-embeds the store in the background.
Uses a single SQLite database (WAL mode) as the conversational memory store, holding turns, entity spans, tokens, embeddings, the entity graph, temporal hierarchy and lexical statistics with persistent indexes. The same database file can be written by the server, a host-side CLI, or a separate ingest process, and turns are deduplicated by content or explicit UUID.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-zeromemwhat did we decide about the database setup?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mcp-zeromem
Conversational memory for agents that costs zero tokens to maintain, over MCP.
Every turn an agent sees goes into one SQLite store. Recall over that store — entity graph, lexical and dense search — runs with no LLM in the loop, so remembering is free and the only tokens spent are the ones the model spends reading what came back. The method follows the Zero-Mem paper, arXiv:2607.29377, whose upstream implementation is ptaranat/zeromem; the implementation here is a clean-room rewrite in Rust, loaded in-process by a Node MCP server, with persistent indexes so opening a large store does not mean rebuilding it.
Status: Parts 1 and 2 complete. The engine stores turns with their entity spans, tokens and embeddings in one transaction, keeps the entity graph, temporal hierarchy and lexical statistics on disk stamped with a generation counter, and recalls over four views (lexical, entity, dense, recent) fused by reciprocal rank and calibrated into a primary/supporting evidence set. The Node server exposes six MCP tools over Streamable HTTP and stdio plus a REST API. The admin UI shows the store's structure, not just its counts: an entity graph, a timeline of the temporal hierarchy, a session inspector with entity spans, a retrieval trace for any query, an embedding map, a side-by-side compare, growth and health charts, and the eval history. Quality is gated by a labeled eval set; a 50k-turn store opens in under 300 ms.
Quick start
cp .env.example .env
# Fill in MCP_ZEROMEM_TOKEN: openssl rand -hex 32
docker compose up -d --build
curl -s localhost:3200/api/status | jq .Then point a client at http://localhost:3200/mcp with
Authorization: Bearer $MCP_ZEROMEM_TOKEN. For Claude Code:
claude mcp add --transport http zeromem http://localhost:3200/mcp \
--header "Authorization: Bearer $MCP_ZEROMEM_TOKEN"The admin UI is at http://localhost:3200/; it asks for the same token.
Locally, without Docker (needs a Rust toolchain and Node 26):
npm install
npm run build:native # builds the addon; rerun after any Rust change
SECURE_LOCAL_NET=true npm run devRelated MCP server: identity-storage-mcp
Writing to the store
Three ways in, all landing in the same ./data/zeromem.db:
MCP write tools (
zeromem_remember,zeromem_ingest), for an agent to store what it decides is worth keeping.zminside the container, for hooks and bulk loads with no server hop:printf '{"session_id":"s1","speaker":"user","text":"we chose postgres"}\n' \ | docker compose exec -T mcp-zeromem zm ingestzmon the host, pointed at the bind-mounted directory (ZEROMEM_HOME=./data zm ingest). The store is SQLite in WAL mode, so a host writer and the server coexist; the server notices new turns on its next read.
Turns are deduplicated by content (or by an explicit uuid), so re-ingesting a
transcript is idempotent and reports duplicates rather than double-counting.
Remembering Claude Code sessions automatically
zm hook reads a Claude Code hook event ({"transcript_path", "session_id"})
from stdin and remembers the transcript's user and assistant turns. Wire it to
Stop and SessionEnd in ~/.claude/settings.json. Two ways to run it:
Host
zm(simplest; needscargo install --path crates/zeromem-core):{ "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "ZEROMEM_HOME=$HOME/mcp-zeromem/data zm --embedder none hook" }] }] } }--embedder nonekeeps the hook from loading a model or calling an endpoint: the turns land without vectors and the server embeds them with the store's embedder on its next read (they are searchable lexically in the meantime). Leave the flag off to have the hook embed inline with whatever the store uses, which needsZEROMEM_MODELSpointing atdata/modelsfor the ONNX model — or run it in the container:zmin the container, with the transcripts mounted read-only and the path prefix mapped:# docker-compose.override.yml services: mcp-zeromem: volumes: - ~/.claude:/host-claude:ro environment: ZEROMEM_HOOK_MAP: /home/me/.claude=/host-claude{ "type": "command", "command": "docker compose -f ~/mcp-zeromem/docker-compose.yml exec -T mcp-zeromem zm hook" }ZEROMEM_HOOK_MAPrewrites the host path in the event to where the container sees it; without it the hook fails with "reading transcript".
Set ZEROMEM_SESSION_ID on the server (or pass exclude_session) so recall
does not hand the current conversation back to itself.
Scopes
One store can hold several projects or people. A turn carries one free-form
scope (project:atlas, user:ben); a turn without one is unscoped, which is
every turn written before schema 6. Recall with a scope returns only that
scope, matched exactly and never as a prefix; recall without one searches
everything, as it always did.
zm ingest --scope project:atlas < atlas.jsonl # for the lines that name none
zm query "who owns billing" --scope project:atlas
zm sessions --scope project:atlas
zm hook --scope-from-cwd # scope = the basename of the hook's cwdZEROMEM_SCOPE is the default for all of the above and for zeromem_recall,
zeromem_remember and zeromem_ingest on the server; pass scope: "" on a
call to step outside it. zeromem_stats lists the scopes in the store.
zeromem_read_session never applies the default, so a turn recall returned can
always be expanded, and checks the scope only when asked to.
The scope is not part of a turn's derived uuid: re-ingesting a transcript under
a different scope is a duplicate, and the first write decides. A filter
(scope, session, since, until, exclude_session) is applied inside each
retrieval view, before its candidate cut, so a small scope in a large store is
not crowded out by the turns around it. A curator note takes the scope of its
sources and is refused when they span two.
The standing brief
Everything above costs a recall. A brief is the part that does not: one short paragraph per scope, written by the curator, that a host puts in context when a session starts. It says who and what the scope is about and what currently holds, and nothing the stored turns do not say.
zm brief --scope project:atlas # prints the text; prints nothing, exit 0, when there is none
zm brief --scope-from-cwd # as a hook: scope = the basename of the hook's cwdWire it to SessionStart next to the Stop hook above; Claude Code adds what a
SessionStart command prints to the session's context:
{ "hooks": { "SessionStart": [{ "hooks": [{ "type": "command",
"command": "ZEROMEM_HOME=$HOME/mcp-zeromem/data zm --embedder none brief --scope-from-cwd" }] }] } }An MCP client gets it without a hook: the server's initialize response carries
the brief of ZEROMEM_SCOPE as its instructions, dated, and the resource
zeromem://brief/{scope} serves any other scope (zeromem://brief/_ is the
unscoped store's). The admin UI reads GET /api/brief?scope=. None of this is a
seventh tool.
A brief is a turn (kind: brief) that recall never returns, so writing one moves
no ranking. The brief in force is the newest one of its scope; the ones before it
stay in the store, and undoing the newest brings the previous back. Its length is
capped (Settings → Curator, 1,500 characters by default) because it is paid at the
start of every session.
Tools
Tool | What it does |
|
|
|
|
|
|
| Bulk JSON Lines, inline ( |
| Counts (turns, sessions, entities, edges, windows, episodes, embeddings), the embedder in use and whether it is the fallback; |
| Deletes a session's turns; requires |
Set ZEROMEM_READ_ONLY=true to drop the three write tools from the listing
entirely.
A client holding the curator token also gets five zeromem_curate_* tools and
the zeromem_curate* prompts; see Curation. Every client can read
the zeromem://brief/{scope} resource; see The standing brief.
How recall answers
A hit is one turn, and one turn is rarely the whole answer — the question that
prompted it and the sentence that finished it are the turns either side.
context: N attaches up to N same-session turns on each side of every hit, as
before and after (and as [before] / [after] lines under format: text).
Neighbours are attached to evidence, never ranked with it: evidence is
identical with and without context, so a neighbour never takes a slot from a
real hit, and a turn that is already ranked is not repeated as another hit's
context. Hidden turns stay hidden, leaving a gap rather than being backfilled.
format: text keeps a turn's line breaks — a numbered list stays a list — and
cuts a turn only past max_chars (default ZEROMEM_RECALL_TEXT_LIMIT, 2000
characters). A real cut is marked, and the marker names the call that returns the
rest: … [clipped: 2000 of 6120 characters — zeromem_read_session {around_turn: 418}]. That matters because a model handed a fragment with no marker concludes
its memory is incomplete and goes looking elsewhere; zeromem_read_session
never clips, since it is what the marker points at. format: json has never
truncated.
The same operations are on the REST API the UI uses (/api/status,
/api/sessions, /api/sessions/:id/turns, /api/recall, /api/recall/trace,
/api/ingest), under the same bearer token.
Configuration
Variable | Default | Meaning |
| — | Bearer token for |
| — |
|
|
| Hide the write tools |
|
| Characters per turn before |
| — | Bearer token for |
|
| Stdio server only: serve the curator tools |
|
| Where the store lives. Also exported to the engine as |
|
| The embedder for a new store: |
|
| Force an existing store onto |
| — | Base URL of an OpenAI-compatible embeddings API, e.g. |
| — | Model name sent to that endpoint, e.g. |
| — | Bearer token for the endpoint. Overrides a key saved from the Settings page; never returned by the API |
|
| Prepended to queries and to stored turns, for models that want them ( |
|
| Per-request timeout for the endpoint |
|
| Where the model is downloaded to (≈130 MB, once) |
| — | Session left out of |
| — | Scope read and written when a call names none; see Scopes |
|
| The eval runs the Eval page charts |
|
| Listen port inside the container (published as 3200) |
|
| How long an idle client connection stays open (Node's own default is 5 s). Keep it above the idle timeout of any reverse proxy in front; |
GET /api/status is unauthenticated liveness and is what the healthcheck polls.
It carries embedder, embedder_is_fallback, embedder_active and
embedding_backlog; the UI shows a banner when the fallback is in use, because
hashed vectors make dense recall a spelling match rather than a meaning match,
and a progress line while turns are waiting for a vector.
Choosing an embedder
The store owns the embedder. The first process to open a store writes the
embedder it was started with into the store's meta; from then on every
process that opens it — the server, a stdio MCP session, a host-side zm —
follows the store, and a differing ZEROMEM_EMBEDDER is logged and ignored.
That is what keeps recall coherent when three processes share one file.
Three kinds of embedder are available:
Kind | Name in | Notes |
Built-in BGE-small (ONNX) |
| 384 dims, in-process, ≈130 MB download on first use |
Hash fallback |
| Deterministic bag-of-words, no download; a spelling match, not a meaning match |
OpenAI-compatible endpoint |
| Anything serving |
Change it from the Settings page: pick the kind, fill in the endpoint, Test connection (reports the dimension and latency without touching the store), then Apply. The switch is one transaction — spec written, vectors dropped, generation bumped — and the server's embed worker re-embeds every turn in the background in batches of 256. Recall keeps answering from the lexical and entity views while the backlog drains; the progress bar on Settings, the Overview and the Embeddings page shows how far along it is. Headless hosts do the same with the CLI:
zm embedder show
zm embedder test --kind openai --url http://npu-box:11434/v1 --model nomic-embed-text
zm embedder set --kind openai --url http://npu-box:11434/v1 --model nomic-embed-text --api-key-stdin < key.txt
zm embedder reembed # drop every vector and re-make them with the current embedder
zm embedder drain # re-embed the backlog now instead of waiting for the serverWhen a switch stalled half way, or the model behind an endpoint was replaced
under the same name, Re-embed all turns on the Settings page (or
zm embedder reembed) starts over with the embedder the store already names.
It is tested first, as a switch is, so an endpoint that still fails is
reported and no vector is dropped. The Stored data card below it clears
the store: Clear vectors drops every vector without testing anything
(the worker re-embeds them), and Clear all memory forgets every turn,
session, entity and summary, after you type clear. Both keep the embedder
settings, bump the generation so every other process reloads, and are
refused under ZEROMEM_READ_ONLY (POST /api/settings/embedder/reembed,
POST /api/settings/clear with {"scope": "embeddings" | "memory"}).
An API key entered on the Settings page is saved in the store's meta table
in plain text, so the stdio process and a host zm can use the same
endpoint. ZEROMEM_EMBEDDING_API_KEY in the environment overrides it wherever
it is set; the page says which one is in use and the API never returns either.
Leave the key field blank to keep the stored one.
If a process cannot build the store's embedder — no model files at hand, the
endpoint is down — it runs without a dense view and says so
(embedder_active: false, embedder_warning in the status), storing new turns
vectorless for the worker rather than silently substituting the hash embedder.
A remote endpoint that starts failing trips a breaker for 30 s so a recall
under load never waits out the timeout call after call.
Seeing the store
The admin UI at / (same port as /mcp) has a page per structure the engine
keeps. Each chart has a table twin, so every number on screen can be read as
text.
Page | What it shows | Read behind it |
Overview | Counts, embedder, turns per day and the running total |
|
Sessions → inspect | A session's turns in order with entity spans highlighted; click one to follow it across the session, or jump to it in the graph |
|
Graph | Force-directed entity graph: node size is degree, edge weight is shared turns; focus an entity and expand N hops; click a node for its neighbours and the turns recall finds for it |
|
Timeline | Sessions as lanes, windows as bands, episodes as inner segments; brush a range for the turns beneath it |
|
Recall → Trace | One query through every stage: profile, the views it was routed to with their raw candidates, the fused list, what calibration dropped and why, the evidence |
|
Embeddings | 2-D PCA of the dense vectors, coloured by session; drop a query on the map to ring its nearest turns. Under the hash fallback the structure collapses, which is the point |
|
Compare | The same query with two settings side by side (top-k, session filters); pin side A, change B, and read the diff of the evidence sets |
|
Eval | recall@k / MRR / nDCG per commit, one line per corpus × embedder |
|
Health | Recall latency percentiles and throughput per minute for the last hour, cold-open time, errors, then the raw status payload |
|
Settings | The store's embedder: current name, kind and dimension, the re-embed progress, a form to test and switch to the ONNX model, the hash fallback or an OpenAI-compatible endpoint, re-embedding with the current one; and clearing the vectors or the whole memory |
|
Curation | Curator runs with the actions each took and why; undo one action or a whole run; the aliases and blocklist in force; the standing brief of each scope |
|
Every snapshot endpoint is capped (limit) so a 50k-turn store never lands
whole in a browser tab; the graph page says when it cut at the node cap. The
projection is computed in Rust and cached per generation. Health counters are
in-process and reset with the server.
The eval page reads docs/eval/history.jsonl (override with EVAL_HISTORY).
scripts/record-eval.sh [label] runs the harness and appends one line per
corpus × embedder, stamped with the commit; commit the file with the change
that moved the numbers.
Curation
The store only grows: near-duplicates, pasted tool output and "ok, thanks" compete in recall, one person is split across "Maya" and "Maya Okafor", and an old fact ranks level with the one that replaced it. A curator is any MCP client, most usefully an LLM agent on a schedule, that cleans this up. The engine stays deterministic: it finds candidates cheaply and applies reversible operations. The judgement, the only part that costs tokens, lives in the agent.
Curation never deletes. Turns are immutable; every action is logged with its
reason and can be undone from the Curation page. Purging is left to a human
(zeromem_forget_session, Settings → Stored data).
Op | Effect on recall |
| The turn is left out of recall (still shown, flagged, in session reads) |
| The old turn hands its score to its replacement, which is recalled in its place even when it shares few words with the question; the old turn is halved and folds away when both make the answer. A question about the past ("who owned … before", "… in March 2025") is left alone: the old turn is what it wants, and comes back with |
| Mentions of the alias count as the canonical entity in the graph and entity stats |
| The name is not an entity at all |
| A new turn ( |
| A new turn ( |
| Records the run's summary and advances the cursor the finders start from |
The curator surface
Tool | What it does |
| Past runs, their action counts and summaries, the cursor, and the limits in force |
|
|
|
|
|
|
|
|
The curator prompts
The procedures come from the server too, one MCP prompt per job, each served verbatim from a doc so the two cannot drift:
Prompt | Arguments | The run it describes |
|
| The full sweep: orient, gather candidates since the cursor, judge (when unsure, skip), dry run, apply in batches, finish with |
|
| One conversation once it has ended: read it whole, hide its noise and repeats, supersede what it settled later, note it if it is long |
|
| Consolidation on its own: find long old episodes, read them whole, write a paragraph that says only what the sources say, check it afterwards |
|
| Entity hygiene: fold a name into the entity it belongs to, strike out a name that is not one, each judged from the turns that mention both |
| zeromem_curate_brief (doc) | scope? | The standing brief: find the scopes with none or with one that has fallen behind, read what is current, write one paragraph of what holds now, cite its sources |
The four focused prompts hand the sweep's cursor back to run_end unchanged, so a
session pass at midnight never moves the cursor past turns the nightly sweep has not
read. Under ZEROMEM_READ_ONLY every prompt ends with the same addendum: the write
tools are not there, so report the actions rather than applying them.
Access and limits
Settings → Curator holds all of it:
The curator token. Generate one (shown once) or paste your own. A request to
/mcpbearing it gets the normal tools plus the curator surface; the normal token never sees them, so an ordinary agent keeps its six-tool budget. The token opens/mcponly, not the REST API.MCP_ZEROMEM_CURATOR_TOKENoverrides the stored one, and the page then says so and will not change it.Serve the curator tools to every client. Off by default; on, every
/mcpclient and the stdio server get them, token or not.Limits. Actions per call (default 100), actions per run (300), and a minimum turn age (24 h) so a live conversation is never curated under the user, and the longest standing brief (1,500 characters). A reason is required on every action.
ZEROMEM_READ_ONLY drops the curator's write tools like the others. For the stdio
server, ZEROMEM_CURATOR=true serves the curator tools.
Scheduling a curator
The server runs no agent. Anything that speaks MCP can be the curator, from this
machine or another; it needs the /mcp URL and the curator token, no shared disk.
Notes it writes are embedded by this server.
A Claude Code scheduled task, or claude -p under cron, with an MCP config that
points at the store:
{
"mcpServers": {
"zeromem": {
"type": "http",
"url": "http://memory-host:3200/mcp",
"headers": { "Authorization": "Bearer ${ZEROMEM_CURATOR_TOKEN}" }
}
}
}# Nightly at 03:30: run the playbook the server serves as its zeromem_curate prompt.
30 3 * * * claude -p "/mcp__zeromem__zeromem_curate" --mcp-config /etc/zeromem/curator.json --allowedTools "mcp__zeromem__*"
# Weekly: the jobs the sweep does least well. Both leave the nightly cursor where it was.
0 4 * * 0 claude -p "/mcp__zeromem__zeromem_curate_notes" --mcp-config /etc/zeromem/curator.json --allowedTools "mcp__zeromem__*"
30 4 * * 0 claude -p "/mcp__zeromem__zeromem_curate_entities" --mcp-config /etc/zeromem/curator.json --allowedTools "mcp__zeromem__*"zeromem_curate_session is the one to hand a session id, from the same hook that
ingested the conversation — as long as the turns are older than min_age_ms by then,
which for the default 24 h means a pass the next day, not at the end of the call.
An Agent SDK script does the same from code: fetch the prompt, hand it to the agent as its task, and let it call the tools.
import { query } from '@anthropic-ai/claude-agent-sdk';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
const url = 'http://memory-host:3200/mcp';
const headers = { Authorization: `Bearer ${process.env.ZEROMEM_CURATOR_TOKEN}` };
const client = new Client({ name: 'curator', version: '1.0.0' });
await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers } }));
const { messages } = await client.getPrompt({ name: 'zeromem_curate' });
await client.close();
const playbook = messages.map((m) => (m.content.type === 'text' ? m.content.text : '')).join('\n');
for await (const message of query({
prompt: playbook,
options: {
mcpServers: { zeromem: { type: 'http', url, headers } },
allowedTools: ['mcp__zeromem__*'],
},
})) {
if (message.type === 'result') console.log(message.subtype);
}A local model as the curator
mcp-zeromem-curate is that script for a model you host: an MCP client that hands a
curator prompt to any OpenAI-compatible chat/completions endpoint with tool calls
(Lemonade, llama.cpp, Ollama, vLLM) and relays the model's calls until it stops. The
server still runs no agent, and every rule the model follows is the prompt the server
serves.
ZEROMEM_CURATOR_LLM_URL=http://framework.lan:13305/api/v1 # requests go to {URL}/chat/completions
ZEROMEM_CURATOR_LLM_MODEL=qwen3.6-moe-35b-a3b-FLM
npm run curate # the full sweep
npm run curate -- sweep --focus supersession # one kind of lead
npm run curate -- session --session <id>
npm run curate -- notes
npm run curate -- entities --entity "Kenji"
npm run curate -- brief --scope project:atlas # the standing brief; every scope that needs one without --scope
npm run curate -- sweep --dry-run # store served read-only: it reports, changes nothingWith ZEROMEM_CURATOR_MCP_URL (and MCP_ZEROMEM_CURATOR_TOKEN) it curates a running
server over /mcp; without it, it opens the store under DATA_DIR in its own process,
and a note it writes is embedded by the server's worker or zm embedder drain. Progress
goes to stderr and a JSON report to stdout (finished, closed, steps, tool calls and
errors, tokens). The exit code is 1 when the model ran out of ZEROMEM_CURATOR_MAX_STEPS
(40) before finishing, so cron notices. A tool call the model gets wrong (a missing
field, a string where a boolean belongs) comes back to it as the tool's error and it
can correct itself; a long result is cut with a marker that says so.
A small model is a worse curator than a large one, and nothing here checks its
judgement. Start with --dry-run, then review the first real runs on the Curation page.
Review a run on the Curation page. Undoing a run restores recall to what it was.
How recall works
Ingest writes, in one transaction, the turn, its entity spans (names, dates,
quantities, paths, code symbols and env vars found by shape, no model), its FTS5 tokens and its embedding —
or no embedding, when the writer has no dense view; every read then embeds
one batch of the backlog first, and the server's worker drains the rest.
The entity co-occurrence graph and the temporal hierarchy (sessions → windows
→ episodes, split on silences and on entity drift between windows) are
persisted next to them; appends update them incrementally, forget rebuilds
the aggregates from the per-turn artifacts and bumps a generation stamp so
every other process holding the store reloads. Opening a store is a read,
never a recomputation. The hierarchy feeds the Timeline page, zeromem_stats
and the curator's consolidation finder; it is not yet a recall view.
Recall returns turns, never facts distilled from them. The entity index is a
posting list — which turns mention a key, and which keys occur together — not
a table of assertions, so a hit is always something that was actually said.
That is the paper's position: a generated record costs tokens to write, and a
summary that drops a detail, merges two people or flattens an update loses the
evidence a later question needs. A superseded value stays in the store and
ranks below its replacement instead of being overwritten. The price is paid at
read time, where a turn is longer than a fact would be; top_k, context and
max_chars bound it, and a curator note is the way to condense a topic
without giving up the sources.
A query is profiled (tokens, entities, temporal cues, question form), routed
to the views that can answer it, and each view's ranked candidates are fused
by reciprocal rank. The fused list is calibrated: candidates under 0.35 are
dropped, those at 0.7 or above are primary, the rest supporting. With
detail: full the result carries the route and per-turn sources; the
/api/recall/trace endpoint (and zm query --trace) returns every stage.
A question about the past is read as one, from its words alone. before,
previously, used to, formerly, originally and the like set
profile.history: the value curation knows was replaced ranks first, the most
recently replaced ahead of older ones. A named period sets profile.window —
2025-03-14, 2025-03, March 2025, 14 March 2025, in 2024,
yesterday, last week|month|year, 3 days ago — in UTC calendar units,
relative phrases counted from the newest turn in the store; what was in force
then ranks first: said before the period ended and not replaced before it
began, the latest such statement ahead of earlier ones. A bare month (in March) is not read, since it names an event's date as often as the time
something was said. Both are preferences in the fusion, not filters, so a
store nobody curated still answers from timestamps alone and since/until
remain the way to cut. Every hit that was replaced carries superseded_by and
valid_until, the timestamp of the turn that replaced it; its own ts is when
it started to hold. format: text writes that into the hit's header.
Recall can also say that memory does not hold the answer. The names in a
question (its entities, less dates, quantities and calendar words) are what it
is about, so a turn that mentions none of them is unlikely to answer it: each
candidate's fused score is scaled by 1 − 0.3 × (the share of names its text does not mention), which moves the turn about the right project above one
that only shares the question's other words. When the best hit still misses a
name, or scores under 0.5, the result carries abstained (missing lists the
names nobody mentioned, best the top score), evidence is cut to the closest
two turns, all supporting, and format: text leads with an [abstained]
line. A question that names nothing is never declined, and neither is an empty
result. The check has three limits worth knowing. A name written in lower case
is not seen as a name. A turn that answers without repeating the name ("she
moved it to Friday") loses up to 30% of its score, though the turn a curator
put in its place inherits its standing. And a name check cannot tell a store
that knows the project but not the asked fact from one that knows both, so
those questions are still answered.
Numbers
Hash embedder, one core, npm run bench (cargo run --release --example bench); CI keeps the JSON as an artifact.
turns | ingest | cold open | RSS after open | recall p50 | recall p95 | store |
1k | 19k turns/s | 4 ms | 19 MB | 2.2 ms | 4.1 ms | 2.4 MB |
10k | 14k turns/s | 58 ms | 53 MB | 18 ms | 32 ms | 24 MB |
50k | 9.2k turns/s | 281 ms | 126 MB | 83 ms | 121 ms | 121 MB |
Recall is linear in store size (brute-force cosine over every vector plus a full lexical scan); an ANN index is the follow-up when stores pass 100k turns.
Eval (cargo test -p zeromem-core --test eval, recall@5 / MRR / nDCG@5 over
the labeled fixtures):
corpus | hash-384 | bge-small-en-v1.5 |
small (10 queries) | 1.00 / 0.88 / 0.92 | 1.00 / 0.93 / 0.94 |
large (266 queries) | 0.90 / 0.97 / 0.83 | 0.98 / 1.00 / 0.89 |
transcript (446 queries) | 0.94 / 0.98 / 0.89 | 0.98 / 1.00 / 0.92 |
The floors in tests/eval.rs sit a little under these; raising them is how
retrieval improvements are locked in. With ZEROMEM_EMBEDDING_URL and
ZEROMEM_EMBEDDING_MODEL set the eval also scores that endpoint, recorded but
never gated, so scripts/record-eval.sh gives your model its own line on the
Eval page.
Those queries all ask for the current value of a fact. Each corpus also has
probes.jsonl, the questions they cannot ask, scored by the same test with
the hash embedder and written to target/eval/probes/:
probe | what it asks | large, hash-384 | after an oracle curator |
| the value before the last change | 0.93 / 0.96 / 0.70 | 0.94 / 0.99 / 0.82 |
| the value in force in a named month | 0.81 / 0.91 / 0.71 | 0.81 / 0.91 / 0.71 |
| a fact the corpus never states | answered 19%, withheld 2%, AUC 0.97 | withheld 2% |
All three have floors. For abstain, answered is the share of the 58
unanswerable questions recall did not decline, withheld the share of the 266
answerable ones it declined by mistake, and the AUC says how far the top score
alone tells the two kinds apart (0.5 is chance). The questions still answered
name a project the corpus does talk about and ask for a fact it never states
about it. The same run reports tokens per answer, the evidence text of a top-5
recall at four characters a token: 71 on the large corpus.
To score a benchmark from outside the repo, convert it and name the directory:
cargo run -q -p zeromem-harness -- import longmemeval longmemeval_s.json --out target/longmemeval
ZEROMEM_EVAL_CORPUS=target/longmemeval ZEROMEM_SKIP_ONNX=1 \
cargo test -p zeromem-core --test eval an_external_corpus -- --nocaptureThe importer merges every question's sessions into one store, grades the turns
the file marks as holding the answer 2 (or the whole answer session 1 when
none is marked) and writes unanswerable questions as abstain probes. It
prints what it kept and what it could not place. The result lands in
target/eval/external/; it is never committed and no floor reads it. The
importer is tested against a hand-written sample in the published shape, not
against the dataset itself.
Answering from memory, end to end
The numbers above say the right turn was returned. npm run eval:answers asks
whether a model can use it: it loads a harness corpus into a store of its own,
gives a model the two memory reads (zeromem_recall, zeromem_read_session)
and the labeled questions, and has a judge model compare each answer with the
turns the labels grade 2. For a question the store cannot answer, the right
reply is "I don't know."
ZEROMEM_EVAL_LLM_URL=http://framework.lan:13306/v1 # the model under test; it also grades,
ZEROMEM_EVAL_LLM_MODEL=Qwen3.6-35B-A3B # unless ZEROMEM_EVAL_JUDGE_LLM_* names another
npm run eval:answers -- crates/zeromem-harness/fixtures/small
npm run eval:answers -- crates/zeromem-harness/fixtures/large --limit 60 --out answers.json
npm run eval:answers -- crates/zeromem-harness/fixtures/small --curate # the curator model sweeps firstThe report gives, per kind of question, how many were right, wrong, declined or
cut off at the reply cap (ZEROMEM_EVAL_LLM_MAX_TOKENS, 4096) before the model
answered, with the tool calls and prompt tokens an answer took. Both ends are
models and the grader here is the model under test, so the number moves with the
model and is recorded, never gated. --curate is the only measure there is of a
model curator, as opposed to the oracle one in tests/eval.rs.
One run, Qwen3.6-35B-A3B answering and grading, hash-384, 60 questions spread over the large corpus and its probes:
question | asked | right | wrong value | declined | cut off | prompt tokens / answer |
current value | 27 | 25 | 2 | 0 | 0 | 4,538 |
history ("before") | 11 | 6 | 2 | 0 | 3 | 4,897 |
as of a date | 17 | 8 | 3 | 3 | 3 | 8,877 |
not in the store | 5 | 4 | 0 | 4 | 1 | 23,390 |
Both current-value misses named an older value of a fact that changed four or more times. A history question counts only the value just before the current one, so naming an earlier one is wrong. A question the store cannot answer costs five times the tokens of one it can, because the model keeps searching before it gives up.
Comparing against upstream
The upstream binary is a black box here (see the clean-room policy). To compare rankings, run both over the same fixture turns and score the lists:
scripts/rank.sh large 5 > ours.jsonl # our answers, one {"id","ranked":[uuid…]} per line
# …run the other ranker over crates/zeromem-harness/fixtures/large/turns.jsonl
# and emit the same shape as theirs.jsonl
cargo run -q -p zeromem-harness -- compare large ours.jsonl theirs.jsonlThat prints both sides' metrics against the labels, plus top-k overlap and
Spearman rank agreement. The adapter that drives upstream's binary is not in
this repo yet; CI uploads ours.jsonl for every commit so it can be run
against any version. The comparison is tracked, never gated: the labels are
the gate.
Layout
crates/zeromem-core/ the engine (Rust lib) + the `zm` CLI; tests/ holds the oracle + properties
crates/zeromem-harness/ corpus generator, labeled queries, eval metrics; fixtures/ is generated
crates/zeromem-node/ napi-rs addon → @mcp-zeromem/native
shared/src/ zod DTOs shared by server and app
server/src/ Express 5: /api routes (viz/ for the charts), /mcp gateway, stdio bin, metrics.ts
app/src/ React admin UI; routes/ is one file per page, components/viz/ the chart kit
docs/curator-*.md the curator procedures, served verbatim as the MCP prompts
docs/eval/ history.jsonl, the recorded eval runs the Eval page reads
scripts/ smoke-stdio.sh, rank.sh, record-eval.shDevelopment
npm run lint && npm run typecheck && npm test && npm run test:rust
npm run build # addon → shared → server → app
./scripts/smoke-stdio.sh # JSON-RPC over the stdio bin
npm run bench # cold open / RSS / recall latency at 1k, 10k, 50k turns
scripts/record-eval.sh # append this commit's eval metrics to docs/eval/history.jsonl
npm run routes:gen -w app # regenerate app/src/routeTree.gen.ts after adding a route fileCharts follow the dataviz house rules: a fixed eight-slot categorical
palette (--viz-series-1..8 in app/src/index.css, validated for both
themes) that is never cycled, one axis per chart, thin marks, a legend for
two or more series, a table view for every chart, and the previous render
held at reduced opacity while a refetch is in flight.
npm run test:rust downloads the embedding model into target/models on
first run (set ZEROMEM_SKIP_ONNX=1 to skip the ONNX halves of the
reference-vector and eval tests). UPDATE_GOLDEN=1 rewrites the retrieval
snapshots under crates/zeromem-core/tests/golden/ after an intended ranking
change; review the diff.
The repo is a clean-room rewrite: no code, scripts, fixtures or schema from the
upstream project are used, and none may be added. See the policy in
AGENTS.md.
This server cannot be deployed
Maintenance
Related MCP Connectors
- memnodeOAuthdev.memnode
Persistent, inspectable memory for AI agents with lineage, correction, and a hosted MCP endpoint.
Persistent memory for AI agents to retain, retrieve, and recall conversation context through MCP.
Persistent memory for AI agents across Claude, ChatGPT and any MCP client.
Cross-tool persistent memory and context for AI assistants over MCP.
Related MCP Servers
- AlicenseAqualityDmaintenancePersistent memory for AI agents. Store, recall, and share knowledge across sessions with five MCP tools: remember, recall, context, forget, and share. Includes semantic search and agent/user/org scoping.53Apache 2.0
- AlicenseAqualityDmaintenanceProvides persistent, inspectable memory storage for AI agents using SQLite. Agents can store, recall, and search memories across sessions via three MCP tools.3MIT
- AlicenseNot gradedqualityBmaintenanceEnables MCP-compatible agents to automatically distill conversations into durable local memory and recall relevant context on demand through seven MCP tools, using user-configurable OpenAI-compatible endpoints for extraction and embeddings.MIT
- AlicenseCqualityAmaintenanceLocal-first, governable long-term memory for AI agents. Provides SQLite-backed storage, cross-session recall, and traceable memory corrections through a standard MCP interface.4031 PyPI1MIT