Skip to content

v0.17.0 — egress tiers, and tests that prove their own guards

Latest

Choose a tag to compare

@cremenescu cremenescu released this 24 Jul 22:31

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 memory

One 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 notice

The 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 status per 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 — reproducible Recall@1, Recall@5, Recall@10 and 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: 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.
  • 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, and consolidate already 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.