mem0ry4ai
July 26, 2026 · View on GitHub
Persistent, local-first memory for coding agents — built for Claude Code, usable from any MCP client (Gemini CLI, Cursor, OpenCode…).

One session saves a lesson; a later one asks in plain words and gets it back — then picks the project up where it stopped. (Reproduce it: ./demo/seed-demo-store.sh && ./demo/demo.sh.)
Works with: Claude Code · Gemini CLI · Cursor · OpenCode — any MCP client. Landing page: cremenescu.ro/en/mem0ry4ai
Your agent forgets everything between sessions. mem0ry4ai fixes that: it captures durable knowledge (gotchas, decisions, facts, commands, preferences, todos, project status), stores it in plain markdown versioned by git, and delivers the relevant slice two ways — pushed automatically into every Claude Code session via hooks, and pulled on demand by any other agent through a built-in MCP server — always scoped to the project you are working in.
Why another memory tool?
We surveyed the landscape first (claude-mem, basic-memory, mem0, Letta/MemGPT, Graphiti, agentmemory, the official MCP memory server). Recurring failure modes shaped this design:
| Common failure | mem0ry4ai answer |
|---|---|
| Model forgets to call save/recall tools | Deterministic hooks inject at SessionStart; the agent is instructed to write proactively |
| Vector DB fragility (Chroma/Qdrant = #1 source of real bug reports) | Markdown + git is the source of truth; SQLite FTS5 index is derived and disposable |
| Memory rots (stale facts, contradictions) | Supersede, never delete — old records keep history; git keeps everything |
| Auto-extraction noise (small LLMs are over-confident: ~1.0 confidence on everything — we measured) | Trust-gated writes: the in-context agent writes directly; batch LLM extraction goes through a human review queue |
| Tools that vandalize CLAUDE.md / fight native memory | Coexists cleanly — own namespace, never touches your files |
| Nobody remembers "where was I?" on returning to a project | First-class todo and status types, pinned in injection and UI |
| Credentials accumulate in an unencrypted memory file | Secret redaction on every write path (8 patterns: API keys, Bearer/GitHub/OpenAI/Slack tokens, AWS keys, private keys, passwords) + mem.py audit for what is already stored |
| As memory grows, the injection grows — and the harness truncates it blindly, silently dropping rules the agent must follow | Self-budgeted injection: priority: critical rules are always in (first, full body); the rest fills MEM_INJECT_BUDGET; every cut is announced with the command that retrieves it |
Measured impact (author's real setup)
This is not a synthetic benchmark — it is the author's actual monorepo (30 sub-projects), before
and after migrating a monolithic CLAUDE.md into mem0ry4ai (217 active memories):
| Before (one big CLAUDE.md) | After (slim CLAUDE.md + injection) | |
|---|---|---|
| Fixed context loaded at every session start | 242,956 bytes (1,832 lines) ≈ ~61k tokens | repo root: 29,169 bytes ≈ ~7.3k tokens · sub-project: 19,044 bytes ≈ ~4.8k tokens |
| Reduction | — | 88% (root) / 92% (sub-project) |
| Relevance | everything, everywhere (FreeRDP gotchas while editing a weather app) | scoped: global + the current project; status/todo first |
| SessionStart hook overhead | — | 69 ms |
| Live-update poll (no changes) | — | ~1–4 ms |
At the author's measured pace (34 session starts/day) that is roughly 1.8M tokens/day of context that no longer gets loaded — while recall got better, because memories are scoped, ranked-searchable and pinned by relevance instead of buried in a 240KB file.
Honest caveats: tokens estimated at ~4 chars/token; with prompt caching the billed savings are smaller than the raw numbers; this is one user's setup, not a controlled study.
How it works
Claude Code session
├─ SessionStart hook ─► injects relevant memories (global + current project;
│ from a monorepo root: a capped index of ALL projects)
├─ [work] the agent proactively writes durable findings ─► mem.py add
└─ SessionEnd + PreCompact hook ─► transcript pointer to staging/ + auto-commit of the store +
auto-embed of new memories, at EACH boundary (end-of-session and
mid-session before compaction) — nothing is lost when context
compresses without warning; no manual chore
store/*.md ◄── SOURCE OF TRUTH (markdown + git: audit, diff, rollback, supersede)
├─► store/.index.db (FTS5, ranked search — derived, regenerable)
├─► web UI (dashboard, per-project "where was I" page, review queue, live updates)
├─► mem.py (CLI: add/list/search/supersede/ready/resume/link/embed/... — stdlib only)
└─► mem.py mcp (MCP server — ANY agent pulls on demand: Gemini CLI, Cursor, OpenCode, …)
Use from any agent (MCP)
mem0ry4ai works with any MCP-capable agent, not just Claude Code. The Claude Code hooks push memory into the session automatically; every other runtime — Gemini CLI, Cursor, OpenCode, Claude Desktop — pulls it on demand from a built-in MCP server:
python3 mem.py mcp # stdio JSON-RPC server (Windows: py mem.py mcp)
It exposes eight tools — memory_search, memory_get, memory_list, memory_resume, memory_add
(which takes supersedes to retire the memory it revises, and files to anchor it to code),
memory_note / memory_promote (working memory, below), and session_search (past-conversation
search, below). It's a hand-rolled stdio server — still pure stdlib, no SDK, no pip install.
Register it:
It also serves resources and prompts, not only tools. A tool is a pull the model has to decide to make; a resource or a prompt is a surface the client drives, which is the supported way to get context in before the first turn:
| Kind | Name | What it gives you |
|---|---|---|
| resource | mem0ry4ai://essentials | your profile + every critical rule — attach it at the start of a conversation |
| resource | mem0ry4ai://resume | latest status, open todos, recent knowledge (all scopes) |
| template | mem0ry4ai://resume/{scope} | the same for one project, e.g. mem0ry4ai://resume/project:api |
| prompt | recall | pulls the memories relevant to a question into the conversation |
| prompt | resume | loads the "where was I" briefing for a project |
# Claude Code
claude mcp add mem0ry4ai -- python3 /absolute/path/to/mem.py mcp
// or a .mcp.json (Cursor / Claude Desktop / others)
{ "mcpServers": { "mem0ry4ai": { "command": "python3", "args": ["/absolute/path/to/mem.py", "mcp"] } } }
On connect the server sends its usage guide (MEM0RY4AI.md) as the MCP instructions, so the agent
knows to search before answering and how to save — without touching your CLAUDE.md. If you
want a pointer there, add one line yourself (optional): Memory: use the mem0ry4ai MCP tools; see MEM0RY4AI.md.
Push for hook-less agents. For agents that have no SessionStart hook (Gemini/Antigravity, Cursor, …)
the instructions also carry your global essentials — the user profile and every critical rule —
straight from the store, so the must-haves are present before the model's first turn, not left to a
tool call (the same guarantee the Claude Code hook gives). Project context stays a memory_resume /
memory_search pull (at initialize the server doesn't yet know the project). Claude Code already gets
these from its hook, so the push is skipped there — but you can still add the MCP to Claude Code for
the pull tools (fragment refs, working memory, fine-grained search) alongside the hook.
memory_add (write) is on by default; set MEM_MCP_WRITE=0 to make the server read-only. With
several agents writing to one store, have them memory_search before memory_add to avoid
duplicates (writes are serialized by a file lock and secret-redacted regardless).
Working memory. memory_note jots a scratch note (status working): not injected at session
start and hidden from search/recall, so tentative findings during a long task don't pollute the durable
store. memory_promote flips the keepers to a durable memory; review them on the web UI's Working
notes page or with mem.py list --status working. (CLI: mem.py add --working, mem.py promote <id>.)
Session search. memory_search searches the distilled store; session_search searches your raw
conversation history instead — "what did we actually discuss weeks ago?" — over a derived FTS5 index of
the Claude Code transcripts, at zero LLM cost. Secrets are stripped before indexing and the index is
owner-only, gitignored, and never leaves your machine. (CLI: mem.py sessions "<query>", --list,
--reindex, --project <name>.)
Fragment refs. memory_get shows a record's body with 1-based line numbers; pass "<id>:5-9" (or a
lines arg) to retrieve just those lines and cite a precise line of a longer memory. (CLI: mem.py get <id>:5-9.)
Hygiene & housekeeping (v0.16). As the store grows: mem.py consolidate clusters near-duplicate
memories and writes the merge proposals to a mem-consolidation git branch you review with git diff
(never auto-merged); mem_maintenance.py install schedules that plus reindex/re-embed and stale
working-note cleanup as a local launchd job (non-destructive by default). Memories carry a session:
provenance stamp (which conversation produced them) and an optional protected: flag that blocks
in-place edits, and injection at session start honours a per-record body cap. Any non-MCP agent or
script can POST /api/propose JSON to drop a memory into the human review queue.
Quick start
Requirements: Python 3.9+ and git — that's it. No PHP, no Docker, no vector database, no API
keys, no pip install. The CLI, the hooks, and the web UI are all pure-Python stdlib, so it runs
the same on macOS, Linux, and Windows.
Option A — Claude Code plugin (one command)
claude plugin marketplace add cremenescu/mem0ry4ai
claude plugin install mem0ry4ai@mem0ry4ai
Restart Claude Code — hooks (inject at start, capture + git checkpoint at end) register
automatically and the web UI starts with your first session. In plugin installs your data
lives in ~/.mem0ry4ai (its own git repo, created on first write), so plugin updates
never touch your memories. The injected context header shows the exact mem.py path to
use from inside a session.
Option B — git clone (data stays in your clone)
git clone https://github.com/cremenescu/mem0ry4ai.git
cd mem0ry4ai
# 1. CLI works immediately
./mem.py add --type gotcha --scope global \
--summary "openrsync on macOS does not support --chown" \
--body "Use --rsync-path=\"sudo rsync\" and chown separately over ssh."
./mem.py list
./mem.py search "rsync" # FTS5 ranked
./mem.py search "rsync" --since 2026-05-01 # ...only memories created since May
./mem.py audit # report secret-like patterns (read-only)
# 2. Web UI (pure-Python stdlib server — no PHP, no Apache)
./mem.py serve # -> http://127.0.0.1:8841/ (Windows: py mem.py serve)
# 3. Claude Code integration (hooks: inject at start + capture at end)
python3 hooks/install.py --dry-run # preview what would be written
python3 hooks/install.py --target user # ~/.claude/settings.json (all projects)
# restart Claude Code (or /clear) to load the hooks
The SessionStart hook also auto-starts the web server, so the UI is always up while you work.
Windows (native — no WSL, no PHP)
Everything is pure-Python stdlib, so mem0ry4ai runs natively on Windows. There is a graphical
installer that does the whole thing — it installs Python and git through winget if they are
missing (per-user, never as administrator), clones, puts the store outside the clone, registers
the hooks, and sets CLAUDE_CODE_GIT_BASH_PATH, without which the hooks register and then
silently never fire:
powershell -ExecutionPolicy Bypass -File install-windows.ps1
It echoes every command into its log before running it, and its second tab uninstalls. Or do it
by hand with py (or python):
git clone https://github.com/cremenescu/mem0ry4ai.git
cd mem0ry4ai
py mem.py add --type gotcha --scope global --summary "..." --body "..."
py hooks\install.py --target user # registers the hooks with YOUR interpreter
py mem.py serve # web UI at http://127.0.0.1:8841/
hooks\install.py records the hook commands using your Python interpreter (sys.executable), so
the SessionStart/SessionEnd hooks run on Windows without a python3 on PATH, and the web server
launches the same way. Data lives in %USERPROFILE%\.mem0ry4ai. (The plugin-marketplace install
invokes python3; on native Windows the clone + install.py path above is the most reliable. WSL
also works like plain Linux.)
Full Windows walkthrough — real install transcript (username shown as xxxxx)
On a clean Windows 11 box with neither Python nor git, install both with winget, then
close and reopen PowerShell (PATH only refreshes in new windows):
PS C:\WINDOWS\system32> winget install -e --id Python.Python.3.12
Found Python 3.12 [Python.Python.3.12] Version 3.12.10
Successfully installed
PS C:\WINDOWS\system32> winget install -e --id Git.Git
Found Git [Git.Git] Version 2.54.0
Successfully installed
PS C:\WINDOWS\system32> py --version
Python 3.12.10
PS C:\WINDOWS\system32> git --version
git version 2.54.0.windows.1
PS C:\WINDOWS\system32> cd $env:USERPROFILE
PS C:\Users\xxxxx> git clone https://github.com/cremenescu/mem0ry4ai.git
Cloning into 'mem0ry4ai'...
Receiving objects: 100% (368/368), 4.01 MiB | 6.30 MiB/s, done.
Resolving deltas: 100% (211/211), done.
PS C:\Users\xxxxx> cd mem0ry4ai
PS C:\Users\xxxxx\mem0ry4ai> py hooks\install.py --target user
installed in C:\Users\xxxxx/.claude/settings.json
Restart Claude Code (or /clear) so the hooks get loaded.
If winget is unavailable (older Windows), install Python from python.org — tick "Add
python.exe to PATH" — and git from git-scm.com, then continue from the version checks.
If Claude Code then shows "Git is required for local sessions" — it was running before git
was installed, so it doesn't know where bash.exe is. Point it at git-bash and fully restart:
# confirm the path (default install location)
Test-Path "C:\Program Files\Git\bin\bash.exe"
# if it returns True:
[Environment]::SetEnvironmentVariable("CLAUDE_CODE_GIT_BASH_PATH", "C:\Program Files\Git\bin\bash.exe", "User")
# if git is installed elsewhere, resolve bash.exe dynamically:
$bash = Join-Path (Split-Path (Split-Path (Get-Command git).Source)) "bin\bash.exe"
[Environment]::SetEnvironmentVariable("CLAUDE_CODE_GIT_BASH_PATH", $bash, "User")
Then close Claude Code completely (check the system tray and Task Manager — it reads the
variable only at startup) and reopen it. The hooks load on the next session and the web UI comes
up at http://127.0.0.1:8841/.
Server install (Docker)
For a NAS, a home server, a VM — anything always-on that you want to reach from a browser. It is
not the right way to use mem0ry4ai on the machine where you run Claude Code: there the hooks
run inside the Claude Code process, and containerising would turn every SessionStart into a
docker exec, adding a daemon to a tool whose whole claim is that it needs none.
cd docker && docker compose up -d # http://localhost:8841/
The store is a mounted volume, never inside the code clone, so updating the image cannot touch
memories and docker rm is not a data-loss event. The clone's git remote is removed rather than
left unused — a clone of the public repo sitting next to a store full of private memories is one
git push away from publishing them.
Two settings are load-bearing rather than stylistic. MEM_WEB_HOST=0.0.0.0 is required inside the
container: the server binds loopback by default, and publishing a port to a loopback-bound server
gives you a container that looks healthy and answers nothing. And the compose file publishes to
127.0.0.1 only — the web UI has no authentication, so exposing it beyond the host is a
decision to make on purpose, with a reverse proxy in front. Never expose it to the internet.
Uninstalling
python3 uninstall.py # show exactly what would be removed, change nothing
python3 uninstall.py --yes # remove the hooks, the scheduled job, the running web server
It does not delete your memories. That is deliberate: the store is what you spent months
building, the code is a git clone away, and an uninstaller that quietly takes the data with it
is a data-loss bug wearing a helpful face. Removing the store needs --delete-memories and
typing the word DELETE at the prompt.
If your store lives inside the code folder (the git-clone install), the preview says so before you delete anything — that is the one arrangement where "just remove the directory" also removes every memory you have.
On Windows the same operations are the second tab of install-windows.ps1.
Memory types
| Type | What it holds |
|---|---|
gotcha | trap + cause + fix ("X breaks because Y, do Z") |
decision | architecture choice + the why |
fact | stable infrastructure facts (hosts, paths, ports) |
command | a command you would otherwise look up again |
procedural | a reusable multi-step workflow / runbook (release steps, a recovery drill) |
preference | user style/conventions/corrections |
todo | what remains to be done (supersede when finished) |
status | where the project stands / where you left off |
todo + status are pinned first in injection and in the per-project web page — they answer
"where was I?" when you return to a project after weeks. The CLI mirrors this: mem.py resume --scope project:<slug> prints a one-screen briefing (latest status + ready/blocked todos + recent
knowledge); with no scope, a one-line-per-project overview.
Teach your agent to write memories
Add an instruction like this to your CLAUDE.md (this is the behavioral half of the system —
hooks handle recall, the agent handles capture):
When you discover something durable (a gotcha with a non-obvious cause, an architecture decision, an infrastructure fact, a reusable command, a user preference/correction, a change of project status), proactively save it without asking:
echo "body" | <path>/mem.py add --type <T> --scope <global|project:slug> --summary "..." --source claude:liveCheckmem.py searchfirst to avoid duplicates. Never save ephemeral tasks.
mem.py add also warns (never blocks) when a near-duplicate memory of the same type already
exists — printing the closest matches and a ready-to-run supersede command — so overlapping
memories get merged instead of piling up. New memories are auto-embedded at session end, so
search and the Links suggestions stay current without running mem.py embed by hand.
Record fields beyond the basics
A few optional fields make records sharper and history honest:
files— the paths a memory is about. Derived from the memory's own text on write, keeping only paths that resolve to a real file in that project, because optional metadata that has to be typed by hand does not get typed (mem.py backfill-anchors --writefills in records written before). Ask it the other way round with--filesonsearch/list, orfileson thememory_searchtool — what do I already know about the file I am about to edit? Matching is on whole path components, never substrings; a trailing slash matches a whole directory.tier—open(default),redactedorprivate: what this memory is allowed to tell a model. See Egress tiers.protected— refuse in-place edit, delete and supersede, for the handful of standing rules no agent (and no stray click) should rewrite. Opt-in, and since a record is a block in a file you own, unsetting it by hand is always available.session— provenance: which conversation produced this memory, so "where did this claim come from?" is still answerable months later. Pairs withsession_search.- Bi-temporal supersede — superseding keeps the old record and stamps when it stopped
being true (
invalidated) and why (invalid-reason), separately fromcreated(valid-from). You get the full "what did we believe, and when" history instead of a bare tombstone:./mem.py supersede <old-id> --by <new-id> --reason "single Pi-hole was a SPOF; moved to an HA pair"
Relations & ready work
Memories can be linked — deliberately, never auto-guessed by keyword (that only produces noise):
related-to— connect related memories (a gotcha ↔ the decision that caused it, a status ↔ its todos). Shown both ways in the web UI as clickable chips../mem.py link <id> <other-id>...blocked-byon atodo— work that must be done first.mem.py readylists the todos with no open blocker (a blocker is open while it is still an active todo; finishing it = superseding it frees the dependents). The injection annotates blocked todos; the per-project page splits ready vs blocked../mem.py block <todo-id> <blocker-id>... ./mem.py ready --scope project:my-app
The Links page in the web UI shows every edge at a glance — a force-directed graph (nodes
colored by type, sized by degree) over the same related-to / blocked-by data, with a grouped
text list below. No external libraries — a small vanilla-JS + SVG simulation, offline-first.
Search, ranking & suggestions
Search is keyword-first and works with zero dependencies, but degrades up when an embedder is available — it never requires a model:
- Ranked FTS5 + recency —
mem.py searchranks with SQLite bm25, then applies a small recency nudge (MEM_RECENCY_WEIGHT) so that among near-ties the fresher memory wins, without ever overriding a clearly stronger keyword match. - Optional hybrid semantic search — if Ollama is running with an embedding
model (
bge-m3, ~1.2 GB), search fuses keyword scores with cosine similarity over locally-stored vectors, so "auth token expiry" can find a memory that says "JWT TTL" — and it surfaces semantic matches even when nothing keyword-matches. The embedder is retrieval-only: it turns text into vectors to compare, it never decides what is a memory and never writes — so it stays clear of the trust gate. No Ollama → automatic, silent fallback to keyword-only.ollama pull bge-m3 ./mem.py embed # build/refresh vectors (incremental, by content hash) -> store/.embed.db ./mem.py search "auth token expiry" # prints "# search mode: hybrid (FTS + semantic)"; --no-semantic forces keywordbge-m3is the default because it is multilingual: on a mixed Romanian/English store the English-centricall-minilm(~45 MB) was effectively blind to non-English paraphrases — median retrieval rank 1 versus 231. If your memories are English-only,MEM_EMBED_MODEL=all-minilmis 40× smaller and works fine. Measure before you switch:tools/bench_recall.py(below) exists precisely so this is a number rather than an opinion. No toggle to remember: the Memories page auto-detects the embedder and shows a status light — green = the local LLM is up, so search is keyword + semantic; gray = it fell back to classic keyword search (with the reason on hover). The dashboard health panel reports the embedder too. - Link suggestions — on the Links page, the closest unlinked memory pairs (by cosine over
the same vectors) are offered as suggested
related-toedges. You confirm or dismiss each one — nothing is linked automatically. It is computed from the stored vectors (no live model at page load); dismissed pairs stay dismissed.
Vectors live in their own derived file (store/.embed.db), separate from the text index and never
the source of truth — delete it any time and mem.py embed rebuilds it.
Critical rules and the injection budget
A memory system has a failure mode nobody talks about: the more it remembers, the longer the injected context gets — until the harness starts truncating it, blindly. Claude Code persists oversized hook output to a file and shows the model only a small preview; whatever falls past the cut is invisible, and the model cannot follow a rule it cannot see. We hit this in production: a "never add Co-Authored-By to commits" preference fell past the cut and the agent violated it.
The fix is that the injection trims itself, deterministically, before the harness ever has to:
priority: critical— pin the rules that gate the agent's actions ("never X in commits", "never touch production", "always test on one device first"):
Critical rules are injected always, first, with their full body, regardless of budget../mem.py pin <id> # or: ./mem.py add ... --criticalMEM_INJECT_BUDGET(default 8000 bytes) — everything else fills the budget in relevance order (current project's status/todo first, then global knowledge, then recently-touched projects), and every cut is announced in the injection itself:(+12 omitted by budget — mem.py list --scope ...). Nothing disappears silently.- The dashboard health panel shows the real injection size vs the budget, and the "What Claude sees" page renders exactly what the agent gets.
Secret redaction
The store is plain markdown versioned by git — a credential that lands there is hard to remove
retroactively (it survives in git history). So every write path redacts secrets by default,
replacing values with [REDACTED:<label>]:
mem.py add/mem.py propose— the saved memory keeps what kind of secret was used ("the command used a Bearer token"), never the value.consolidate.py— transcripts routinely contain.envreads, curl headers and passwords; they are redacted before the text even reaches the local LLM, and candidates are redacted again before queueing.mem.py audit— read-only report of secret-like patterns already in the store (exit 1 if any found, handy in CI). It never modifies records — you decide what to clean up.
Patterns: generic API/secret/access keys, Bearer tokens, AWS keys, private key blocks,
quoted passwords, GitHub/OpenAI/Slack tokens. Opt out per call with --no-redact, or
globally with MEM_REDACT=0. Infrastructure facts you store on purpose (hosts, paths,
ports, usernames) are not touched — only credential-shaped values are.
For secrets the agent should be able to use across sessions, store a pointer, not the
value: keep the secret in your OS keychain / password manager, and save a fact telling
the agent where it lives and a command that fetches it at use time.
Egress tiers — what a memory may tell a model
Redaction catches what looks like a secret. It cannot know that a client's name, a salary or a home address must not be handed to a model: nothing about those strings is suspicious. A tier is stated rather than inferred, so unlike a pattern it has no false negatives.
| Tier | What reaches an agent | What you still see |
|---|---|---|
open (default) | everything | everything |
redacted | the summary only | everything |
private | nothing — the record is absent | everything: search, mem.py get, the web UI |
./mem.py add --type fact --scope project:acme --tier private \
--summary "who signs off on the migration" --body "..."
./mem.py tier <id> private # reclassify an existing memory
One function decides — emit_for(record, destination) in mem.py — and every surface that
feeds a model goes through it: the SessionStart injection (mem.py list --dest agent), the MCP
tools, and the MCP resources and prompts. That is the point: "a private memory never reaches an
agent" is a property of one function you can read start to finish, not a convention four files have
to remember. The web UI labels anything that is not open, because a classification you cannot see
is one you cannot trust.
Not claimed: this is not encryption, and it does not defend against someone who can read your disk. It defends against the thing that actually happens — a memory you did not think about being handed to a model along with everything else.
Tests
python3 tests/test_guards.py # 43 checks, isolated store, stdlib only
python3 tests/test_guards.py --canary # remove each guard in turn; the suite must notice
tools/crawl_ui.py http://127.0.0.1:8841 # reachability, broken links, URL state — no LLM needed
The suite covers the rules that must not quietly break: secret redaction (each pattern is fed
something it must catch), the injection scan, record-delimiter forgery, the one-live-status
invariant, protected, egress across every surface, and — after that class of bug shipped three
times — that no link in the web UI drops the filters you are looking at.
--canary is the part worth stealing. It deletes each guard from a scratch copy and requires the
suite to go red — because a check that passes while the thing it checks is gone reports green and
protects nothing. It earned its keep on the first run: the egress test for memory_get passed with
the guard removed, because a branch below it happened to swallow the record anyway. The test proved
nothing about the rule it was written for.
Benchmarking recall
Ranking changes are easy to argue and hard to verify. tools/bench_recall.py turns them into a
number: it seeds a throwaway store from a fixture, asks paraphrased questions whose answer is known,
and reports where the intended record landed. No LLM judge — the expected id is written down, so
the score cannot drift with a model's mood.
tools/bench_recall.py # fixture store: keyword vs hybrid
tools/bench_recall.py --no-semantic # keyword only, no embedder needed
tools/bench_recall.py --fixture mine.json --store ~/.mem0ry4ai --min-mrr 0.7
On the bundled fixture (14 memories, 48 queries, English + Romanian): keyword R@1 0.500 / MRR 0.641, hybrid with bge-m3 R@1 0.750 / R@5 0.958 / R@10 1.000 / MRR 0.848.
mem.py drift is the companion for the other direction: memories anchored to files (files:)
whose files have since gone missing, moved, or been committed to many times are reported as worth
re-reading. It reads no code and judges no memory — it produces a reason to look, never an edit.
The web dashboard surfaces the count and links to the list, and each row there says which file
moved and how, because the answer depends on it.
Churn is counted from when the memory was last touched, not when it was written. That is what makes the report clearable: commits only accumulate, so counting from the creation date would report an old memory about an active file forever, with nothing the reader could do to clear it. Re-anchoring is therefore the "I checked, it still holds" gesture the report would otherwise have no way to accept.
There are only three outcomes, and mem.py anchor covers two of them:
mem.py anchor <id> "src/auth/jwt.ts, src/auth/middleware.ts" # the lesson holds, the path moved
mem.py anchor <id> - # it was never about those files
mem.py supersede <id> --reason "..." # the lesson died with the code
Revising a memory rather than retiring it — mem.py add ... --supersedes <id>, or supersedes on
the memory_add tool — carries its related-to and blocked-by edges to the successor. Without
that the graph degrades on every revision, and it degrades invisibly: the edge stays on the
retired record, so a link looks intact while the live memory has none.
Read the caveat before you tune anything with it. A 14-memory fixture measures ordering — the
answer is already in the candidate set — not discrimination. Sweeping the dense retriever's weight
(MEM_RRF_W_DENSE) on it showed a clean monotone win, R@1 0.785 → 0.896, that did not exist
on a real 799-record store: flat at 0.55, no trend from 1× to 10×. That is why the default weight is
still 1.0. Use the fixture to catch regressions; decide ranking parameters with --store against a
store of realistic size.
Web UI
Bilingual (English default, Romanian via the EN/RO switch in the top bar).

The Links page — semantic suggested links (closest unlinked pairs, each confirmed or dismissed by hand) above a force-directed graph of every related-to / blocked-by edge (nodes colored by type, sized by degree; related solid, blocked dashed with an arrow):

Projects — every project at a glance: memory count, open todos, current status:

Per-project "where was I?" page — status and todos (ready vs blocked) pinned first:

The review queue — the over-confident junk candidate (conf 0.95 for "updated the changelog") is exactly why nothing auto-writes:

"What Claude sees" — the exact SessionStart injection, with its cost:

Git history — the memory timeline with per-commit diffs and commit-from-UI:

(Screenshots use demo data.)
- Dashboard (
/): stat cards (each deep-links into the list with a filter), health checks (store/staging/index/queue/hooks/git/injection size), recent activity, live updates via cheap polling (~4 ms when nothing changed). Consistent top nav + breadcrumbs on every page. - Memories list (
/memories): ranked search (same FTS5 index as the CLI), grouped/sortable/filterable table, bulk operations (supersede / re-scope / delete), supersede-chain navigation; related/blocked links shown on each record. - Projects (
/projects): every project at a glance — active count, open todos, current status — each card opening its per-project page (status + ready/blocked todos pinned first). - Links (
/links): semantic suggested links (confirm/dismiss) above a force-directed graph of allrelated-to/blocked-byedges (dependency-free SVG) + a grouped text list — see how memories connect at a glance. - "What Claude sees": renders the exact SessionStart injection, with its size in bytes/tokens.
- Review queue: candidates extracted by the optional local LLM wait here for human approval.
- Git history: the store's timeline — commits touching
store/with colored per-commit diffs and a commit-from-the-UI button (store files only).
Optional: offline extraction with a local LLM
For sessions where the agent could not capture live, consolidate.py digests transcripts with a
local model via Ollama (default qwen2.5:7b-instruct) and proposes
candidates into the review queue. Honest finding from our testing: small models are noisy and
over-confident, so nothing they produce is written without human approval.
ollama pull qwen2.5:7b-instruct
python3 consolidate.py --dry-run # see what it would extract
python3 consolidate.py --write # queue candidates -> review in the web UI
Configuration
Everything is overridable via environment variables — no config file needed:
| Variable | Default | Purpose |
|---|---|---|
MEM_DATA_DIR | next to the code; ~/.mem0ry4ai in plugin installs | where store/, staging/ and .mem-local.env live — point it at a scratch dir and both data and settings are isolated |
MEM_REDACT | 1 | set 0 to disable secret redaction on write paths |
MEM_INJECT_BUDGET | 8000 | max bytes injected at SessionStart (critical rules always fit; cuts are announced) |
MEM_WEB_PORT | 8841 | web UI port (mem.py serve) |
MEM_UI_LANG | en | default web UI language (ro for Romanian; per-user EN/RO switch overrides) |
MEM_RECENCY_WEIGHT | 1.5 | how much fresher memories are nudged up in ranking (0 = pure bm25) |
OLLAMA_URL | http://localhost:11434 | Ollama endpoint for offline extraction + embeddings |
MEM_LLM_MODEL | qwen2.5:7b-instruct | model used by consolidate.py |
MEM_EMBED_MODEL | bge-m3 | embedding model for hybrid search + link suggestions (retrieval only) |
MEM_SUGGEST_THRESHOLD | 0.62 | min cosine similarity for a suggested link to appear |
MEM_DUP_CHECK | 1 | mem.py add warns (never blocks) when a near-duplicate of the same type already exists; 0 to disable |
MEM_DUP_THRESHOLD | 0.62 | cosine similarity above which the add-time duplicate warning fires |
MEM_RRF_W_DENSE / MEM_RRF_W_KEYWORD | 1.0 | per-retriever weight in the hybrid fusion — measure with tools/bench_recall.py --store before changing |
MEM_STATUS_UNIQUE | 1 | a new status retires the previous one for its scope; 0 to let them pile up |
MEM_RECALL_DEDUP | 0.8 | Jaccard threshold for the opt-in --dedup near-duplicate suppression |
Design notes
- One codebase, one parser: the CLI (
mem.py) and the web UI (mem_web.py, a stdlibhttp.server) share a single Python parser and write layer —mem_webimportsmem. No second language, no parser-sync contract to maintain (the earlier PHP UI + conformance test are gone). - Concurrency: atomic writes (tmp + rename), append-mostly files, WAL-free design — the store survives multiple sessions because markdown conflicts are rare and git catches the rest.
- No commit chore: the SessionEnd hook auto-commits
store/(authoredmem0ry4ai hook), so every session leaves a git checkpoint behind; the git page's button is for mid-session checkpoints with a custom message. - Your data is yours: if you fork this repo, do not commit your personal
store/upstream. The store is meant to be versioned in your clone, locally.
Acknowledgements
mnema by MerlijnW70
(MIT OR Apache-2.0) — a local, encrypted memory layer for agents, in Rust. Four things here came
from reading it: the egress tiers resolved at a single choke point, the no-untested-rule
discipline (tests/test_guards.py --canary), the shape of the recall benchmark (in-repo
fixture, Recall@k, no LLM judge), and serving MCP resources and prompts rather than only tools.
No code was copied — that project is Rust and this one is Python — but the designs are theirs, and
implementing the tiers immediately surfaced a hole in ours that had gone unnoticed.
License
GPL-2.0-or-later — see LICENSE. We are giving back to the community.