Egress tiers
Secret redaction catches what looks like a credential. 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. This release adds the other half: a classification you state rather than one a pattern infers, so 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 "..." --body "..."
mem.py tier <id> private # reclassify an existing memoryOne function decides — emit_for(record, destination) — and every surface that feeds a model goes through it: the SessionStart injection, the MCP tools, and the new MCP resources and prompts. That is the entire point of the design: "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. Implementing it that way immediately found a real hole — memory_get resolved records by id and bypassed the filter completely, which was the one path an agent could use to pull a private memory whose id it had seen.
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 never thought about being handed to a model along with everything else.
A test suite that proves its own guards
python3 tests/test_guards.py # 34 checks, isolated store, stdlib only
python3 tests/test_guards.py --canary # remove each guard in turn; the suite must noticeThe suite covers the rules that must not quietly break: secret redaction (every pattern is fed something it must catch), the injection scan, record-delimiter forgery, the one-live-status invariant, protected, and egress across every surface.
--canary is the part worth stealing for your own project. 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 — a branch below it happened to swallow the record anyway — so the test proved nothing about the rule it was written for.
MCP: 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 instead of hoping the agent calls something.
| Kind | Name | What it gives you |
|---|---|---|
| resource | mem0ry4ai://essentials |
your profile + every critical rule |
| resource | mem0ry4ai://resume |
latest status, open todos, recent knowledge |
| template | mem0ry4ai://resume/{scope} |
the same for one project |
| prompt | recall |
pulls the memories relevant to a question into the conversation |
| prompt | resume |
loads the "where was I" briefing for a project |
For hook-less clients (Cursor, Gemini CLI, OpenCode) this replaces a workaround: the standing rules were being pushed through the instructions field, which a client may truncate or ignore.
Also
- One live
statusper scope. A new status retires the one it continues, with the usual supersede trail rather than a delete — so a project's "where am I" answer is singular again. Measured before shipping: some scopes legitimately carry several concurrent statuses, so it retires the one it directly continues and reports the rest instead of rewriting them all. tools/bench_recall.py— reproducibleRecall@1,Recall@5,Recall@10and MRR over a fixture of paraphrased queries, with no LLM judge, so a ranking change is a number instead of an argument. On the bundled fixture: keywordR@1 0.500 / MRR 0.641, hybrid with bge-m3R@1 0.750 / R@5 0.958 / R@10 1.000 / MRR 0.848.- Per-retriever fusion weights (
MEM_RRF_W_DENSE,MEM_RRF_W_KEYWORD), default unchanged at 1.0. The sweep is the interesting part: on the 14-memory fixture, weighting the dense retriever up looked like a clean monotone win (R@1 0.785 → 0.896) and on a real 799-record store it was completely flat with no trend. A small fixture measures ordering inside a candidate set that already contains the answer, not discrimination. The caveat is in the tool's own docs so nobody tunes production on it. - Opt-in near-duplicate suppression (
--dedup), measured into opt-in rather than shipped on: on a real store the pairs that clear a threshold are stale statuses and adjacent-but-distinct facts, not copies, andconsolidatealready proposes those for review.
Fixed
The documented embedder was not the one that ran. v0.16.0 announced bge-m3 as the default, the settings page said bge-m3, and the CLI error message told you to ollama pull bge-m3 — but llm.py, the file that actually computes the embeddings, still defaulted to all-minilm. A packaging miss in the previous port, now consistent everywhere, with the README corrected too (bge-m3 is ~1.2 GB; the ~45 MB figure belonged to all-minilm).
bge-m3 is the default because it is multilingual: on a mixed Romanian/English store, English-centric all-minilm was effectively blind to non-English paraphrases — median retrieval rank 1 versus 231. English-only stores can set MEM_EMBED_MODEL=all-minilm and get a 40× smaller model; tools/bench_recall.py exists so that stays a measurement rather than a preference.
Acknowledgements
Four of these came from reading mnema by MerlijnW70 (MIT OR Apache-2.0), a local encrypted memory layer for agents written in Rust: the egress tiers resolved at a single choke point, the no-untested-rule discipline, the shape of the recall benchmark, and serving MCP resources and prompts. No code was copied — different language, different store model — but the designs are theirs.
No store-format migration: existing records read unchanged, and tier is additive and absent when open.