Mnemosyne
Version control for AI agent memory.
An AI agent builds up memory as it works: facts it learns, decisions it makes.
Frameworks store that as state it overwrites as it goes. Mnemosyne gives agent
memory what Git gives code: commits, branches, merge, blame and bisect. A Rust
core, a mnem CLI, and a Python SDK. Local, deterministic, no network, no model
calls.
This is the reference documentation. For the project overview, the quickstart and the demo, see the README on GitHub.
What is here
- On-disk format: the
.mnem/store, specified in enough detail to write a reader in another language. Final for Era 1 atformat_version1. - Benchmark report: what the substrate delivers, measured. Reconstruction,
bisectprecision,blameaccuracy, merge correctness, and the overhead against a dict and a JSONL log. - Merge chaos report: the merge invariants over a 50,000-case seeded sweep.
- Architecture decisions: every decision that shaped Mnemosyne, and why it went the way it did. Accepted before the code.
- Research: the surveys that fed the decisions.
- Progress notes: what shipped in each release, in plain language.
Use the sidebar.
The three eras
- The substrate (now,
0.0.x): single-agent versioned memory. Local and deterministic. - The collaboration layer: semantic merge that reasons about contradiction, a sync protocol between stores, and a review step. Pull requests, for agent memory.
- The platform (
1.0): the whole agent as one versioned, signed, forkable artefact, with a registry. The GitHub for AI agents.
See ADR-0010.
Why I built this
An agent runs for an hour. It reads a ticket, makes a note, reads a billing record, makes another. Forty tool calls later it tells a customer something wrong. You go looking for why, and there is nothing to look at - just whatever the agent believes right now. Not what it believed an hour ago, not which of those forty calls put the wrong idea in, not why it trusted the source it trusted. The memory is a single mutable blob, and the moment it goes wrong is the moment its own history disappears.
That's the whole problem. Not "agents need memory" - most of them already have some. The problem is that the memory has no past tense.
The frame that already exists
Software had exactly this problem, and solved it decades ago. A codebase is
also just a mutable blob until you put it under version control - then
every change is a commit, you can check out any past state, blame
resolves a line back to whoever wrote it and why, and bisect finds the
exact commit a bug entered without anyone reading the diff by hand. None of
that makes the code better. It makes the code accountable - you can
always answer "when did this happen, and why."
mnem is that idea, pointed at what an agent remembers instead of what a
repo contains. Every write is a commit. branch forks a hypothesis for the
price of a pointer. blame resolves any current belief to the exact commit
and the observation that produced it. bisect binary-searches a whole run
to find the first commit where a wrong belief appears - no reading logs by
hand, no grepping for "Pro" across forty tool calls to find the one that
mattered.
The part that took the longest to get right, and the part I think actually
matters most, is merge. Two lines of memory - two branches, two agents,
two sessions on the same case - can each learn something true, and when
they meet, they can disagree. A support case gets confirmed by phone on one
line while a billing note gets misread on another; both are real commits,
both look confident, and only one is right. merge doesn't pick a winner
for you. It surfaces the conflict as an object - here's what each side
believes, and why - and writes nothing until something, a human or a
policy, actually resolves it. That's the one piece of this that doesn't
exist anywhere else I've found: real memory tools don't have branches to
reconcile in the first place, and the closer prior art I've since found
either doesn't block on the disagreement or resolves it with a plain text
merge marker instead of a typed answer. The comparison is written down,
with the actual source read before any claim was made about it, not just
each project's README: docs/comparisons.md.
What this doesn't claim
Worth being upfront about, because it would be easy to oversell: versioned
memory does not make an agent give better answers. A 2026 wave of research
into roughly this idea - Git4Data, GitOfThoughts, StateFuse, MemTX,
LatticeMind - converges on the same honest finding, and the benchmark here
backs it: mnem doesn't move accuracy. What it gives you is history,
audit, and safe merging. That's the whole pitch, and on reflection I think
it's enough - the actual cost of a wrong belief in production isn't that
the model was wrong once, it's that nobody could tell when it became
wrong, or trust the fix wasn't just another guess. That's a solved
problem for code. It shouldn't still be an unsolved one for memory.
The benchmark is public and adversarial, not a curated demo: every
correctness property - exact reconstruction, precise bisect, accurate
blame, no lost writes on merge - holds at 100% across an 80-seed,
720-run sweep, plus a separate 50,000-case fuzz test on the merge algorithm
specifically, gated in CI on every change. If any of that regresses, the
build fails before it ships.
What's deliberately not here yet
- No shared memory between agents yet. Everything today is one agent, one store, local and offline - no network calls, ever, even to a model. A real sync protocol between stores is the next thing, not this one.
- No semantic merge.
mergetoday is structural: it catches that two branches disagree, it doesn't understand why two claims contradict each other. The seam for a content-aware resolver is already built into the core and proven against the current format - not implemented yet, on purpose, until there's a second store to actually merge with. - MCP is stdio only. The right shape for one agent talking to one local store; a remote/multi-tenant transport is a different, harder problem for whenever shared memory exists to justify it.
- The Python wheels aren't fully cross-platform yet. The
mnembinary itself installs anywhere without a Rust toolchain; the Python package publish pipeline for every platform is mid-rollout as I write this.
None of these are secret gaps. They're the next things, in order, and naming them here is the same discipline the rest of the project runs on - say what's actually true, not what would sound better.
Where this goes
The substrate above - commit, branch, merge, blame, bisect, all of it - is Era 1, and it's what exists today. Era 2 is the collaboration layer: a sync protocol between stores, and a real review step before an update lands in shared memory - a proposal, a diff, an approval, the same shape a pull request has for code. Era 3 is the actual destination: the whole agent - its prompt, tools, memory, policy, evaluations - versioned and signed as one forkable artefact, with a registry to publish, discover, and fork them. GitHub, eventually, but for agents instead of code.
Era 1 had to be right before any of that was worth building. A
collaboration layer over an object model that quietly loses data on merge
isn't worth having. So this is where I started, and where I've been most
careful: the on-disk format, the merge
algorithm, the provenance
index that makes blame possible,
the seam Era 2 will build on.
It's open-source, Apache-2.0, and installable today - pip install mnem-agents, or a one-line binary install with no Rust toolchain required.
If you're running agents in production and any of this sounds like a
problem you actually have, I'd like to hear about it.
One property falls out of the architecture for free, worth stating plainly: there's no garbage collection anywhere in the engine, so every commit is retained for the life of the store, already - see retention.
Why agent memory needs version control
An agent that runs for any length of time accumulates memory: facts it learned, decisions it made, things a user told it. Every framework in common use today stores that memory as state - a row in a database, a key in a dict, a document in a vector store - and updates it in place. That's the right choice for almost everything else an agent does. It's the wrong choice for memory specifically, for one reason: state that's only ever updated in place has no past tense. You can ask it what it believes. You cannot ask it what it believed an hour ago, or why.
That gap doesn't matter until an agent gets something wrong. Then it's the only thing that matters.
The actual question, and why logging doesn't answer it
Say an agent tells a customer they're on the wrong plan. The real question isn't "what does the agent believe now" - you already know that, it's the wrong thing. The question is: when did that belief enter, and what was it based on. That's a different question, and it needs a different kind of data than most memory systems keep.
It's worth being precise about what "different" means here, because the obvious fallback - just log everything - looks like it should be enough, and isn't quite:
| Question you might ask of an agent's memory | a dict | a JSONL log | version control |
|---|---|---|---|
| what does it believe now | yes | yes | yes |
| what did it believe at step t | no | yes | yes |
| when did belief X first go wrong | no | yes, linear scan | yes, binary search |
| which observation set X | no | no | yes |
| what did step t change | no | yes | yes |
| merge two agents' memories, surfacing conflicts | no | no | yes |
A dict answers almost nothing beyond right now. A log gets you the time questions - what did it believe at some past point - but a log is a list of events, not a structure, so it can tell you nothing about why a value is what it is, and it has no idea what to do when two logs need to become one. Finding the moment something went wrong means reading the log by hand or grepping for the right string; there's no structure to search faster than that. This table is not a claim, it's asserted by a CI job on every change - if the "yes" column stops being true, the build fails before it ships.
Version control answers the same questions a JSONL log does, plus the two a log structurally can't: what caused a specific belief, and what happens when two independent lines of memory disagree. Not because version control is a fashionable word to attach to an idea - because a commit graph is a different data structure than a list of events, one built specifically to answer "why" and "which of these two things is right," which is exactly the shape of question an agent's memory eventually forces on you.
A concrete version of the abstract problem
Take a support agent, working a billing dispute over a few sessions. Early on it reads the original ticket and learns the customer is on the Enterprise plan. Later, working the same case, it reads a billing note and misreads it - records that the customer downgraded to Pro. Several steps later, the customer disputes their bill, and the agent's current answer ("Pro") is wrong.
With only current state, there's nothing to do but re-read everything and guess where it went wrong. With a commit history, this is two commands:
mnem bisect --node customer-4821 --equals '"downgraded to Pro last month"'
# -> the exact commit where that belief entered
mnem blame customer-4821
# -> content: "downgraded to Pro last month"
# step: step-31
# source: billing-note-8842
bisect finds the exact commit a wrong belief first appears in - a binary
search over the run, not a linear read. blame resolves the current value
back to the observation that produced it. Neither command is a special
case built for this scenario; they're the same two operations Git gives
code, aimed at belief instead of source lines. The full worked version of
this - including what happens when a second, independent line of memory
disagrees with the first, and how that conflict gets resolved rather than
silently overwritten - is in the project's own README
and demo.
What this doesn't claim
Versioning an agent's memory does not make the agent more accurate. This isn't a hedge - it's a settled finding worth taking seriously: GitOfThoughts (arXiv 2606.14470) tested five memory backends and found none reliably moves an agent's accuracy. Keeping a perfect history of a belief doesn't make the belief more likely to be right in the first place.
What it changes is what happens after the agent is wrong, which every agent eventually is. Instead of nothing to inspect, there's an exact commit and an exact source. Instead of a silent overwrite when two lines of memory disagree, there's a conflict, surfaced, with both sides visible before anything is decided. That's a real, if unglamorous, difference: the distance between "we don't know how this happened" and "here's exactly what happened, and why" - the same distance version control closed for code, a long time before anyone was asking whether Git made programmers write better code. It didn't need to.
There's a real, measured cost to this, too, worth being upfront about
rather than pretending it's free: a commit is a durable write - about 12ms
on the reference hardware, because it's an actual fsync, not a database
row update. Against an LLM call measured in seconds, that's noise. It
wouldn't be, at a much higher write frequency than an agent's own reasoning
steps produce - which is a real, current limit, not a hidden one.
There's a retention property that falls out of this for free, worth stating plainly rather than leaving as an implicit side effect: there is no garbage collection anywhere in the engine, so every commit, once made, is retained for the life of the store. See retention for exactly what that does and doesn't mean.
mnem is one concrete implementation
of this argument - open-source, working, and benchmarked, not a proposal.
Debugging a poisoned agent with bisect
This is a walkthrough, not a demo. Every command and every id below is real
output from examples/claude_agent.py -
run it yourself with python examples/claude_agent.py and you'll see the
same lines, because the ids are content hashes and the script's inputs are
fixed. Nothing here is staged for the page.
The setup
A support agent has been working a billing case for a customer, 4821,
across a few sessions. Before the walkthrough starts, three things have
already happened:
- It opened the case, reading the original ticket: the customer is on the Enterprise plan.
- It noted a primary contact.
- On a side branch,
direct-callback, a second interaction confirmed the Enterprise plan again - this time by phone, independently of the original ticket.
Then, back on the main line, it read a billing note and misread it: the customer had, it recorded, downgraded to Pro. That's the wrong belief. It's now sitting in memory next to a phone-confirmed right one, and nothing has reconciled them yet.
The customer disputes the bill
you ▸ Customer on ticket 4821 says they were never on Pro, but your notes say Pro. Work out what went wrong.
The agent's current belief is wrong, and it knows it - but "wrong" isn't useful on its own. What it needs is when the belief entered, because that's what tells you whether it's a bad source, a bad read, or something else.
claude My current note for customer-4821 is "downgraded to Pro last month".
A dozen updates have touched this case since - grep finds every
mention of Pro, not which one is real. Let me bisect for the exact commit.
● bisect customer-4821 == "downgraded to Pro last month"
d3354bb514df reconcile the plan tier from billing
customer-4821 set here, from step-31, source billing-note-8842
bisect binary-searches the commit history for the first point where a
predicate becomes true - here, "does customer-4821 equal this exact
string." It doesn't matter how long the history is; this is O(log n)
comparisons against the commit graph, not a read through every update
looking for a string match. One call, one answer: commit d3354bb514df,
step step-31.
Tracing it to its source
A commit id tells you when. It doesn't yet tell you why - what the agent was actually looking at when it wrote that belief.
claude Now trace that commit to the observation behind it.
● blame customer-4821
content: "downgraded to Pro last month"
step: step-31
source: billing-note-8842
blame resolves the current value of a node back to the provenance
attached when it was written. Every mnem.agents.remember() call in this
project carries a source - here, billing-note-8842 - so this isn't a
guess or a re-derivation. It's reading back exactly what was recorded at
the time, which is the entire reason provenance is captured on write and
not reconstructed after the fact.
The twist: a second line already had the right answer
This is the part a simple "find the bad commit and fix it" story misses. The agent didn't just misread a note - a second, independent interaction on this same case had already gotten the right answer, on its own branch, at the same time:
claude It came from billing-note-8842 at step-31 - a misread.
A second line on this case ran in parallel: direct-callback, confirmed by phone.
● merge direct-callback
conflict on customer-4821: ours "downgraded to Pro last month", theirs "on the Enterprise plan"
merge doesn't pick a side. Two branches touched the same node with
different values, so it stops and reports both - ours, from the misread
billing note; theirs, from the phone call - as a real conflict object,
not a silently-applied guess. Nothing is written to the store at this
point. That refusal to write anything until the conflict is explicitly
resolved is the whole point of the operation: a merge that quietly picked
one side would just be a second wrong answer with better manners.
Resolving it
The two commits carry enough provenance to make the decision, not just detect that there is one:
claude Ours traces to the misread billing note. Theirs traces to a direct call. Keeping theirs.
● merge direct-callback --resolve customer-4821=theirs
● commit 9d120f5ae3cf merge direct-callback
Resolving a conflict is a normal commit like any other - it has its own
id, 9d120f5ae3cf, and its own message explaining the reasoning, not just
"resolved."
What the history actually looks like afterward
claude Fixed and reconciled. The history keeps every line of it:
● log --oneline
9d120f5ae3cf merge direct-callback: trust the phone confirmation over the misread billing note
d3354bb514df reconcile the plan tier from billing
27be6b7ef0dc confirmed the plan by phone
b62fe5fdede9 note the primary contact
cbbebd81b55e open the case
OK the wrong belief was found, traced and reconciled
Every commit is still there - the misread, the phone confirmation, the merge that reconciled them. Nothing got quietly overwritten or scrubbed out to make the story look cleaner after the fact. If the same thing happens again next month, on this case or another, the same two commands find it the same way.
Run it yourself
git clone https://github.com/Nabzx/mnemosyne.git && cd mnemosyne
python examples/claude_agent.py
The full source is in examples/claude_agent.py -
about 90 lines for the whole scenario above, run for real by CI on every
change so it can't drift from what's shown here. --live replaces the
scripted dialogue with a real Claude tool-use loop over the same store, if
you'd rather watch the reasoning happen than read a fixed transcript of it.
Shared memory across frameworks
A practical guide to using one Mnemosyne store as shared memory for agents
built on different frameworks - when to give each agent its own branch,
how a real conflict surfaces and resolves, and what the concurrency model
means for how to actually structure this. Everything below is checked
against two real worked examples, not written from theory:
examples/cross_framework_merge.py
(two agents, one real conflict) and
examples/multi_agent_incident_retro.py
(four agents, a sequential merge).
Separate branches, or one shared branch?
Give each agent its own branch when its work is provisional until
reviewed, or genuinely independent of the others' until merge time.
Both worked examples do this: CrewAI's own branch in
cross_framework_merge.py, and all four agents' own branches in
multi_agent_incident_retro.py. A branch costs nothing but a pointer
(ADR-0012), so there is no real reason to default to a shared branch just
to save the call to new_branch.
Write straight to a shared branch (usually main) when the agents'
writes are already known not to collide - distinct node ids, no
overlapping scope, and no review step needed before the write counts.
LangGraph's branch in cross_framework_merge.py still uses its own
branch even though its write never collides with CrewAI's, because the
point of that example is to demonstrate branching generally; in a real
deployment where you already know two agents never touch the same ids,
writing directly to a shared branch is a reasonable simplification -
the merge algorithm (ADR-0013) still protects you if that assumption
ever turns out to be wrong, since a real collision would simply surface
as a conflict on the next merge rather than silently overwriting.
How a conflict actually surfaces and resolves
A conflict is only possible when two branches both changed the same
node id since they diverged. This matters directly for the first
question above: if every agent sharing a store writes under a
genuinely distinct id scheme (see each adapter's own "Sharing a store
with other adapters" README section, and the reserved-namespace
finding in #362), two
agents on different frameworks structurally cannot conflict with each
other at all - which is exactly why multi_agent_incident_retro.py's
four-way merge has no conflict to resolve, only four independent
findings landing cleanly in sequence.
When a real conflict does happen - two branches that both touch the
same node, the way cross_framework_merge.py's two CrewAI branches
do - merge() returns a result whose status is "conflicts", not
an exception and not a silent pick of one side:
result = store.merge("their-branch", time_ms=...)
if result.status == "conflicts":
for c in result.conflicts:
print(c.ours.content, c.theirs.content)
# decide, then call again with the resolution:
store.merge(
"their-branch",
resolutions={c.id: "theirs" for c in result.conflicts},
time_ms=...,
)
Nothing is written until every conflict has a resolution - "ours",
"theirs", "base", "delete", or a MemoryNode to set directly
(ADR-0014). Which side should win is a decision for whatever is
driving the merge (a human, a review step, a rule), not something this
guide can answer generically - cross_framework_merge.py picks
"theirs" because the scenario's own later event (an escalation)
should win over an earlier one (a refund), which is a decision specific
to that story, not a default.
What the concurrency model means for a real deployment
The one constraint that actually shapes how you'd structure this for real: redb allows exactly one open handle per store per process. Every agent in both worked examples opens its own backend, writes, and lets the handle drop (the function returns) before the next agent opens its own - never two handles open on the same store path at the same instant in the same process. In practice, this means:
- Separate processes, same machine, same store path: fine, as long as they are not literally open at the identical instant. A short-lived process (open, write, exit) sharing a store with other short-lived processes is the pattern both worked examples model.
- One long-running process holding the store open for its whole lifetime: also fine, as long as no second process (or second in-process handle) ever opens that same path while the first is still open.
- Two agents that need to write at genuinely the same instant, from the same process: not supported by anything in the current substrate. This needs a daemon mediating access, already noted as a backlog item - don't structure a real deployment around an assumption that isn't true yet.
See also
- The concurrency model - the full one-handle constraint this guide's last section summarises.
- Each adapter's own README has a "Sharing a store with other adapters" section naming its real reserved id shape.
- #362 - the research that found which adapter pairs structurally cannot collide, and which pair (the OpenAI Agents SDK and AutoGen) can guarantee one if given the same identifier string.
On-disk format
The layout of a .mnem/ store: the object database, the refs, HEAD, and
config. This is the specification. It is enough to write a reader in another
language, and it is checked against the implementation by the tests in
crates/mnem-store and the golden vectors.
Status: final for Era 1
This describes format_version 1, and as of v0.0.7 it is final for the
Era 1 substrate. Every 0.0.x release reads and writes format_version 1, a
store written by one reads on every other, and the substrate (commit, branch,
merge, blame, bisect, time travel) needs no further field. The benchmark
(docs/benchmark.md) is the evidence that it does what it claims.
The next change is format_version 2 in Era 2, the collaboration layer,
whose pieces are named in ADR-0018: a stored Contradiction object (a kept
record of an incompatible-claims merge), whatever the sync protocol adds to a
Commit or the refs, and the prolly-tree State form if the benchmark's
storage numbers cross the trigger in ADR-0017.
Those are additive where possible (ADR-0002); where not, mnem migrate ships
with the release (ADR-0007). It is never a silent change: the golden-vector
test fails first.
The software version is 0.0.x initial development, where the public API may
change on any release. The format_version integer is the data contract,
and it is the thing to check, not the software version. A release that meets a
format_version it does not support refuses the store with a message naming the
version it would need.
A later format change bumps format_version by one and, if it would make an
older reader misread an existing object, ships mnem migrate (ADR-0007). It is
never a silent change: the golden-vector test fails first.
The prolly-tree state form and the signature table are named below but are not
part of format_version 1.
The store directory
.mnem/
store.redb the object, ref, staging and index tables (see below)
HEAD the current position, one line of text
config key = value lines
.mnem/ sits at the store root. Nothing else in the directory is read.
Discovery
open walks up from the given path to the nearest ancestor that contains a
.mnem/ directory, and uses that. init refuses to create a store at a path
that already has one, or anywhere beneath an existing store: stores do not nest.
Atomicity
config and HEAD are written by writing a sibling temporary file, syncing it,
and renaming it over the target, so a reader never sees a half-written file. A
commit is one redb write transaction (ADR-0008): it lands whole or not at
all.
config
A UTF-8 text file of key = value lines. Whitespace around the key and value is
trimmed. A line whose first non-space character is # is a comment. A blank
line is ignored. An unknown key is ignored, so a later version can add keys
without breaking an older reader's parse.
| Key | Value | Meaning |
|---|---|---|
format_version | integer | the format this store is written in, 1 for this line. A reader that supports up to version N accepts a store with format_version ≤ N and refuses anything higher. |
hash_algo | string | the object hash. blake3 is the only accepted value. |
default_branch | string | the branch HEAD points at in a fresh store, main unless overridden at init. |
All three keys are required. A missing key, or a hash_algo other than
blake3, is a corrupt store.
A fresh store:
# Mnemosyne store config. See docs/format/.
format_version = 1
hash_algo = blake3
default_branch = main
HEAD
One line of UTF-8 text, with an optional trailing newline. Either:
ref: <branch>— attached to a branch.<branch>follows the ref name rules below. The next commit moves that branch.<64 hex characters>— detached, pointing straight at a commit id. A commit from a detachedHEADis refused; check out a branch first.
Anything else is a corrupt store. A fresh store's HEAD is ref: main.
The database
One redb file, .mnem/store.redb. redb is a pure-Rust embedded key-value
store with a single-file, copy-on-write B-tree layout; its own file-format
version travels inside the file and is handled by the redb crate, pinned at
major version 2 (ADR-0008).
Five tables:
| Table | Key | Value | Holds |
|---|---|---|---|
objects | 32 raw bytes, an ObjectId | the object's canonical CBOR | every memory node, state and commit |
refs | branch name, UTF-8 | 32 raw bytes, a commit ObjectId | one row per branch |
staging | node id, UTF-8 | 32 raw bytes, a node ObjectId | the nodes staged for the next commit |
staging_tombstones | node id, UTF-8 | empty | the nodes staged for deletion (ADR-0012) |
commit_nodes | 32 raw bytes, a commit ObjectId | CBOR { node id -> "added" | "modified" | "removed" } | each commit's change set against its first parent (ADR-0015) |
objects is append-only in practice: an id is the hash of its bytes, so writing
the same object twice is a no-op and an entry is never rewritten. staging and
staging_tombstones are local working state and are not part of the portable
history; a fresh clone or a different machine does not carry them. commit_nodes
is a derived index: every entry is recomputable from the objects table
(Store::rebuild_index), it is not part of format_version, and a reader that
finds it missing or stale falls back to recomputing from the two states.
Object identity
An object's id is the BLAKE3 hash of its canonical CBOR bytes (ADR-0005). It is
32 bytes. In text it is 64 lowercase hex characters. In CBOR, anywhere an id
appears as a value, it is a byte string of length 32 (0x58 0x20 then the
bytes), never an array of integers.
There is no framing around an object: the bytes in the objects table are
exactly the canonical CBOR of the object, and the kind field inside that map
is what tells a reader what it is holding.
Canonical encoding
Objects are encoded as CBOR (RFC 8949) restricted to a deterministic profile, so that the same logical object always produces the same bytes and therefore the same id. The profile is RFC 8949 section 4.2 plus three Mnemosyne rules.
From RFC 8949 section 4.2:
- Map keys are sorted by the bytewise comparison of their encoded form. Because CBOR keys are length-prefixed, this is length-first: a shorter key sorts before a longer one, and keys of equal length sort bytewise. Every key in this format is a text string.
- Integers are in shortest form: the smallest of the 0, 1, 2, 4 or 8 byte encodings that holds the value. CBOR has no 3, 5, 6 or 7 byte integer, so a value needing five bytes is written in eight.
- Definite-length only: no indefinite-length strings, arrays or maps.
Mnemosyne's rules:
- Every float is a 64-bit CBOR float (
0xfb), never 16 or 32 bit. This is a legal narrowing of the profile and keeps float encoding unambiguous.contentnumbers follow the JSON data model and must be finite; a non-finite number is rejected before it reaches the store. - No CBOR tags. No value carries a tag.
- String-keyed maps only. Every map, at every level including inside
content, has text-string keys. No integer keys, no positional array standing in for a struct.
The encoder builds a CBOR value, sorts every map by encoded key, then writes it.
The codec tests check idempotence and that the key order inside content does
not affect the bytes.
Object kinds
Every object is a CBOR map with a kind entry. Three kinds. An unknown kind is a
clean decode error, not a silent skip.
memory_node
The versioned unit (ADR-0003). Successive writes with the same id are updates
to one logical node; each write is its own immutable object.
| Field | CBOR type | Required | Notes |
|---|---|---|---|
kind | text string | yes | "memory_node" |
id | text string | yes | the stable logical key |
content | any JSON value | yes | stored verbatim; string-keyed maps, finite numbers |
content_kind | text string | yes | "note" or "claim". 0.0.x only writes and reads "note"; "claim" is defined for a later era |
provenance | map | omitted when empty | see below; an entirely empty provenance is not encoded |
event_time | integer | omitted when absent | when the agent formed the node, Unix milliseconds, may be negative |
provenance (ADR-0003), all fields optional text strings, each omitted when
unset: agent_step, observation, tool_call, source, note.
state
The set of memory nodes visible at a commit (ADR-0003).
| Field | CBOR type | Required | Notes |
|---|---|---|---|
kind | text string | yes | "state" |
nodes | map | yes | node id (text) to that node's ObjectId (32-byte string), one entry per logical node, in canonical key order |
format_version 1 has only this flat form. A prolly-tree form arrives as a
second shape in a later format_version.
commit
A commit (ADR-0005). Its hashed bytes are exactly this object. A signature, if
one exists, lives in a separate table, is not part of the commit and never
changes its id; that table is not in format_version 1.
| Field | CBOR type | Required | Notes |
|---|---|---|---|
kind | text string | yes | "commit" |
parents | array of 32-byte strings | yes | empty for the first commit, one normally, two or more for a merge. Order is significant; parents[0] is the first parent |
state | 32-byte string | yes | the ObjectId of this commit's state |
message | text string | yes | freeform |
author | text string | yes | who or what made the commit, opaque to the core |
time | integer | yes | the record time, Unix milliseconds. Distinct from a node's event_time |
Refs and branches
format_version 1 has branches only, in a flat namespace (ADR-0009). A branch
is a row in the refs table: a name mapping to a commit id. The default is
main. There is no refs/heads/ hierarchy and no tags.
A ref name must be non-empty, must not be HEAD, must not begin or end with /
or ., must not contain .., whitespace or control characters, and may
otherwise contain letters, digits, -, _, . and /.
Ref moves are compare-and-swap: an update names the commit it expects the branch
to be at, and fails if the branch has moved. The first commit on a branch
creates its row. A reflog is reserved but not written in format_version 1.
Golden vectors
golden-vectors.md lists concrete objects with their exact
CBOR bytes and ids. crates/mnem-store/tests/golden_vectors.rs pins them and
checks the doc against the code, so neither can drift without a test failing.
That failure is the signal that a format_version bump is due.
Golden vectors
Canonical objects, their exact CBOR bytes, and their ObjectId. Format version
1, frozen for the 0.0.x line.
These are pinned in crates/mnem-store/tests/golden_vectors.rs, and a test checks
that every hex string below appears there verbatim. If a change to the encoder
moves any byte, that test fails. Such a change is a format_version bump
(ADR-0007, ADR-0010), never a silent one.
A reader in another language is correct for these objects when it produces the
same bytes and the same id. id = BLAKE3(cbor), shown as 64 lowercase hex. The
cbor lines are single lines; scroll to see the whole value.
note_minimal
A memory node with no provenance and no event_time, so both are absent from
the encoding.
object MemoryNode { id: "greeting", content: "hello", content_kind: note }
id 5107a6e4f686efec31950bf3171e7b4affd3c83697e8ab5182dc4a5f867cce10
cbor a4626964686772656574696e67646b696e646b6d656d6f72795f6e6f646567636f6e74656e746568656c6c6f6c636f6e74656e745f6b696e64646e6f7465
Decoded:
a4 map(4)
62 6964 "id"
68 6772656574696e67 "greeting"
64 6b696e64 "kind"
6b 6d656d6f72795f6e6f6465 "memory_node"
67 636f6e74656e74 "content"
65 68656c6c6f "hello"
6c 636f6e74656e745f6b696e64 "content_kind"
64 6e6f7465 "note"
The keys are in canonical order: id, kind, content, content_kind. That
is the length-first order of RFC 8949 section 4.2, since the encoded keys begin
62, 64, 67, 6c.
note_full
Every provenance field set, content a two-key map, event_time present.
object MemoryNode {
id: "customer-4821",
content: { "plan": "enterprise", "seats": 40 },
content_kind: note,
provenance: {
agent_step: "triage", observation: "ticket-4821 body",
tool_call: "read-ticket", source: "ticket-4821", note: "first contact"
},
event_time: 1757000000000
}
id 85ff5bf72e74962be19991ba2f67edeb71212d80fc17a9abc83f0aedf228f8ec
cbor a66269646d637573746f6d65722d34383231646b696e646b6d656d6f72795f6e6f646567636f6e74656e74a264706c616e6a656e746572707269736565736561747318286a6576656e745f74696d651b00000199155c62006a70726f76656e616e6365a5646e6f74656d666972737420636f6e7461637466736f757263656b7469636b65742d3438323169746f6f6c5f63616c6c6b726561642d7469636b65746a6167656e745f73746570667472696167656b6f62736572766174696f6e707469636b65742d3438323120626f64796c636f6e74656e745f6b696e64646e6f7465
Notes:
- Top-level keys in canonical order:
id,kind,content,event_time,provenance,content_kind. event_timeis1b 00000199155c6200, an unsigned 64-bit integer (1757000000000). CBOR has no 5-byte integer, so a value that does not fit in four bytes uses eight. This is shortest form.seatsis18 28, an unsigned integer in one trailing byte (40).- Inside
provenancethe keys arenote,source,tool_call,agent_step,observation: length-first, then bytewise.
state_empty
object State { nodes: {} }
id d6f3970d8ebe9b9ed1a48d4433b2872b324aa84e97bab1cfbfa50a112a620295
cbor a2646b696e64657374617465656e6f646573a0
a2 map(2)
64 6b696e64 "kind"
65 7374617465 "state"
65 6e6f646573 "nodes"
a0 map(0)
state_one
One entry, mapping a node id to the note_minimal object id.
object State { nodes: { "greeting": 5107a6e4...cce10 } }
id d1cdd28f6540e9e7a344554add2bd056bbf3717e7680833582fc463435446991
cbor a2646b696e64657374617465656e6f646573a1686772656574696e6758205107a6e4f686efec31950bf3171e7b4affd3c83697e8ab5182dc4a5f867cce10
The value is 5820 followed by 32 bytes: a CBOR byte string of length 32, not
an array of integers. Every ObjectId in the format is encoded this way.
commit_root
No parents. Its state is state_one.
object Commit {
parents: [], state: d1cdd28f...46991,
message: "first commit", author: "agent", time: 1757000000000
}
id 925570c5e44700e4e36d74cffeaa010cbf3caeecb364a383f955ff835d088814
cbor a6646b696e6466636f6d6d69746474696d651b00000199155c62006573746174655820d1cdd28f6540e9e7a344554add2bd056bbf3717e7680833582fc46343544699166617574686f72656167656e74676d6573736167656c666972737420636f6d6d697467706172656e747380
Keys in canonical order: kind, time, state, author, message,
parents. parents is 80, an empty array. The commit's hashed bytes are
exactly this object; a signature, if one exists, lives in a side table and is
not part of the id (ADR-0005).
commit_child
One parent, commit_root. Its state is state_empty.
object Commit {
parents: [925570c5...88814], state: d6f3970d...20295,
message: "second", author: "agent", time: 1757000060000
}
id fa02c3c4d1d9bdad0adb3f8bddd3ed9f36810e6158ef40d5467e38a579d4d447
cbor a6646b696e6466636f6d6d69746474696d651b00000199155d4c606573746174655820d6f3970d8ebe9b9ed1a48d4433b2872b324aa84e97bab1cfbfa50a112a62029566617574686f72656167656e74676d657373616765667365636f6e6467706172656e7473815820925570c5e44700e4e36d74cffeaa010cbf3caeecb364a383f955ff835d088814
parents is 81 5820 <32 bytes>: an array of one byte string.
The concurrency model
One open handle per store per process. This is a real constraint of
redb (ADR-0008), confirmed directly, not a design choice this project
could lift without changing the storage engine: opening a second
Store (or an adapter wrapping one - MnemosyneStore,
MnemosyneStorageBackend, a Session, a Memory) on a path a first one
in the same process already holds raises
StoreIoError: Database already open. Cannot acquire lock.
What this rules out
Two handles open in the same process, at the same instant, on the same store path. A long-running process that opens a store once and holds it for the process's lifetime is fine; a process that opens, finishes, and lets the handle drop before a second one opens is fine. What doesn't work is holding two live handles side by side - the classic way to hit this by accident is constructing two adapter instances against the same path in one script without ever letting the first go out of scope.
What this does not rule out
Multiple agents, even across different frameworks, sharing one store
- just not at the exact same instant from the same process. The normal
git-like pattern already works today: each agent's process opens the
store, does its work, and either closes (handle dropped) or holds for
its own run's lifetime; merging two agents' divergent work happens the
same way any two branches merge, with no special-casing for which
adapter or framework wrote which side (see
examples/cross_framework_merge.py). Sequential access across processes - one agent's run finishes before another's starts, or they're simply never open on the same path concurrently - is the real, supported pattern.
What would need a daemon
True concurrent access - two processes with the store open on the same
path at the same instant, each reading and writing without stepping
on the other - needs a server process mediating access, already noted
as a backlog item in ROADMAP.md. Nothing in the current substrate
provides this, and this document should not be read as implying it
does.
Related: each adapter's own README documents a "Sharing a store with
other adapters" section stating what id shape it owns
(mnem-crewai,
mnem-langgraph,
mnem-openai-agents,
mnem-autogen);
this document covers when a store can safely be opened at all. See
shared memory across frameworks
for the practical how-to this feeds: branches vs a shared one, and how
a real conflict surfaces and resolves.
Retention
There is no garbage collection anywhere in the engine today. Not a
missing feature with a workaround - no code path exists anywhere in
mnem-store that removes a commit, a state, or a memory node once
written. The closest thing to a deletion primitive, delete_branch,
removes a name-to-commit pointer only; its own doc comment states the
property plainly:
No force variant: with no garbage collection, a deleted branch's commits stay reachable by id.
The same is true of rm (a tombstone commit recording that a node was
removed from working memory, not an erasure of the commits that
created or changed it) and of every adapter's own clear/
clear_session/pop_item method - all tombstone commits, confirmed
directly against each adapter's own implementation, never a hard
delete.
What this means in practice
Every commit, once made, is retained for the life of the store.
This is a real, already-true property of the current architecture -
not a promise about a future feature, not a policy decision someone
made and wrote down, just a consequence of there being no deletion
code at all. log, blame, and bisect can walk back to the first
commit a store ever had, for as long as the store file exists.
What this is not
This document states an existing fact about the engine, not a retention policy. Whether that fact is sufficient on its own to meet a given regulatory retention requirement (for example, the EU AI Act's Article 12 automatic event-recording and Article 26 minimum- retention provisions) is a separate question this document does not answer - it would need the primary legal text read against the current architecture, which is real research, not a documentation task. See map #376 for that open question.
Nor does "no GC exists" mean a store's files can never be deleted - a
caller can always remove the .mnem/ directory itself, or the whole
store, at the filesystem level. What's absent is any in-engine
operation that selectively forgets part of a store's history while
keeping the rest.
Benchmark report (issue #65)
What the substrate delivers, measured. Re-run and updated whenever the core
changes. The design is ADR-0017; the harnesses are
crates/mnem-store/tests/benchmark.rs (correctness) and
benchmarks/overhead.py (overhead and the audit table).
Mnemosyne makes no accuracy claim. The 2026 research is settled on this: GitOfThoughts (arXiv 2606.14470) tested five memory backends and found none reliably moves an agent's accuracy. The value of version control is history, audit, and safe merging, at accuracy parity. So this benchmark measures those.
The one-line version
| Question you might ask of an agent's memory | a dict | a JSONL log | Mnemosyne |
|---|---|---|---|
| what does it believe now | yes | yes | yes |
what did it believe at step t | no | yes | yes |
when did belief X first go wrong | no | yes, O(L) | yes, O(log L) |
which observation set X | no | no | yes |
what did step t change | no | yes | yes |
| merge two agents' memories, surfacing conflicts | no | no | yes |
storage after L steps | O(keys) | O(L x keys) | O(commits) |
A dict answers almost nothing. A JSONL snapshot log answers the time questions
but has no provenance and cannot merge. Mnemosyne answers all seven, and
benchmarks/overhead.py asserts this table on every CI run.
Correctness and precision
Every one of these is deterministic, so the target is exact. The harness fails the build on any miss.
| Metric | What it checks | Result |
|---|---|---|
reconstruction_exact | state_at(commit) equals the working memory recorded at that commit | 100.00% |
golden_bytes_stable | the frozen canonical encoding still hashes identically | true |
bisect_exact | bisect returns the exact commit a planted monotonic fault began | 100.00% |
bisect_error_max | the largest |found - k| seen | 0 |
blame_commit_acc | blame resolves to the correct introducing commit, linear history | 100.00% |
blame_commit_acc_merge | correct origin commit when the value arrived on a merged-in side | 100.00% |
blame_source_acc | correct provenance source string | 100.00% |
merge_invariants | symmetry, no invented ids, no double-listing (the full sweep is merge_chaos.rs) | true |
Sweep: 80 seeds x run lengths {16, 64, 256} x fault positions {10%, 50%,
90%} = 720 synthetic runs, about 80,000 commits reconstructed and blamed, 720
bisects, 720 blame-through-merge checks. The overhead.py run adds a
1024-commit run whose audit assertion is a bisect and a blame at that
length. No failing seed.
A larger sweep (the just bench-sweep target, thousands of seeds up to length
1024) is possible but takes hours: each commit is a durable write (an fsync),
which is the wall-clock floor. Since the metrics are deterministic, more seeds
buy input-shape coverage, not confidence.
Overhead
Against the two baselines an agent author would otherwise write: a dict they
overwrite, and a JSONL file they append the whole memory to each step. Median of
several runs on an idle machine, a 100-key memory, values from a pool of 20.
| Steps | backend | write p50 | write p99 | read (full) | bytes / step |
|---|---|---|---|---|---|
| 256 | dict | ~0 ms | ~0 ms | 0 | |
| 256 | JSONL | 0.03 ms | 0.19 ms | 830 | |
| 256 | Mnemosyne | 12 ms | 24 ms | 1.5 ms | 10,320 |
| 1024 | JSONL | 0.04 ms | 0.65 ms | 955 | |
| 1024 | Mnemosyne | 12 ms | 24 ms | 1.5 ms | 7,470 |
Reading:
- Write latency is ~12 ms, flat with run length, and it is the durable
write: a commit
fsyncs a newState, aCommitand an index entry. A dict is free; a JSONL append is a smallfsync. For an agent making one memory update per reasoning step, 12 ms sits against an LLM call measured in seconds. - Read is ~1.5 ms for the whole working memory, flat with run length. Well
under the ~25 ms mark that would prompt reassessing the flat
State(ADR-0012, ADR-0017). - Storage is single-digit KB per commit and falls as the run lengthens
(10.3 KB/step at 256, 7.5 KB/step at 1024) as redb's fixed overhead amortises.
It is 8 to 12x a flat JSONL snapshot at these lengths: content-addressing
bounds the cost of repeated values, but the per-commit
StateandCommitobjects are the floor. This is the number a prolly-treeStatewould later share down, and the trigger for revisiting it is a real store's.mnemexceeding ~50 MB.
The CI benchmark job fails if write_ms_p50 or bytes_per_step exceeds 2x
benchmarks/baseline.json, so a regression (a double fsync, an O(L^2)) cannot
land quietly.
Reproducing
# the correctness metrics (CI runs a small count on every push)
cargo test -p mnem-store --test benchmark
# the published correctness sweep (~20 min: fsync-bound)
MNEM_BENCH_RUNS=80 MNEM_BENCH_LENGTHS=16,64,256 \
cargo test -p mnem-store --test benchmark --release -- --nocapture
# the overhead comparison and the audit table
python benchmarks/overhead.py # 256 steps, the CI scale
MNEM_BENCH_STEPS=1024 python benchmarks/overhead.py
# both, via just
just benchmark # the CI-scale run
just bench-sweep # the published scale
Sources
- GitOfThoughts (arXiv 2606.14470): five memory backends, no accuracy movement, "the engineering trade-off at accuracy parity"
- ADR-0017: the metrics, the targets, the gating
docs/chaos-report.md: the merge invariants at a 50,000-case sweep, summarised here asmerge_invariants
Merge chaos report (issue #49)
Phase 3's definition of done: the merge is not just implemented, it is hammered. This is the first report. It will be re-run and updated whenever the merge algorithm or the store flow changes.
The harness is crates/mnem-store/tests/merge_chaos.rs. It is seeded (a
hand-rolled LCG, the same approach as tests/time_travel.rs), so every run is
deterministic and a failing seed reproduces exactly. No property-test
dependency is pulled in (ADR-0004: the core stays lean and offline).
What it checks
The invariants are the ones named in the research
(docs/research/issue-42-merge-survey.md, section 4) and locked by ADR-0013 and
ADR-0014.
1. The pure function — merge_state_maps(base, ours, theirs)
Each trial builds a random base map (six possible keys, each present ~2/3 of the
time, values drawn from a pool of four object ids) and diverges it twice into
ours and theirs with a few random set / delete ops. Then:
| Invariant | Assertion |
|---|---|
| Totality | every id across base ∪ ours ∪ theirs is in merged or in conflicts, never both, never neither |
| No lost writes | for every non-conflict id the merged value is exactly what the per-id rule dictates (o if o == t; t if b == o; o if b == t) |
| No invented ids | nothing in merged or conflicts that was not in an input |
| Conflict ordering | conflicts come back in id order, no duplicates, each carrying the true (base, ours, theirs) triple |
| Clean-merge symmetry | swapping ours and theirs gives the identical merged map and the identical conflict id set (the kinds mirror: edit/delete ↔ delete/edit) |
| Idempotence | merge_state_maps(base, ours, ours) is clean and returns ours unchanged |
| Base identity | merge_state_maps(base, ours, base) is clean and returns ours; merge_state_maps(base, base, theirs) is clean and returns theirs |
2. The store flow — Store::merge
Each trial initialises a real store, writes a base commit, branches, and makes one or two random commits on each side. Then:
- an
Ours-strategy merge never surfaces a conflict — the outcome is alwaysMerged,FastForwardedorAlreadyUpToDate; - a real merge commit has exactly two parents, ours first;
- convergence: merging
mainback intofeatureafterwards fast-forwards (or is already up to date) onto exactly the same memory state — the two branches agree node for node; - flow-level idempotence: re-merging the already-merged branch is a no-op
(
AlreadyUpToDate).
Results
CI runs the harness on every push at a small trial count (600 pure, 24 store) —
a few seconds. The counts are overridable with MNEM_CHAOS_ALGEBRA_TRIALS and
MNEM_CHAOS_STORE_TRIALS for a manual sweep.
| Sweep | Trials | Result |
|---|---|---|
| CI (every push) | 600 pure + 24 store | pass |
| Manual, this report | 50,000 pure + 250 store | pass, no failing seed |
MNEM_CHAOS_ALGEBRA_TRIALS=50000 MNEM_CHAOS_STORE_TRIALS=250 \
cargo test -p mnem-store --test merge_chaos --release
# test merge_state_maps_algebra ... ok
# test store_merge_converges ... ok
# test result: ok. 2 passed; 0 failed; finished in 37.85s
Every one of the 50,000 pure cases classified every id and lost no write; every one of the 250 store histories converged when merged both ways. No seed has failed since the harness was written.
Reproducing a failure
If CI ever reports a failing seed N, run it alone:
MNEM_CHAOS_ALGEBRA_TRIALS=<N+1> cargo test -p mnem-store --test merge_chaos -- merge_state_maps_algebra
The assertion message names the seed and the id, and the LCG is pure, so the
case is fully determined by N.
How mnem compares
A written comparison, not a benchmark. docs/benchmark.md
stays scoped to mnem against a plain dict and a JSONL log (ADR-0017),
because most of the tools below solve retrieval or context management, not
versioning - a shared numeric table would measure the wrong axis for at
least half of them. This is the axis that actually matters: does the tool
have a real branch and merge, and does a merge conflict block until
someone resolves it, or does it get decided for you.
Mem0, Zep - memory stores, not version control
Both manage what an agent remembers: extraction, embeddings, retrieval
quality. Neither has anything resembling branch or merge.
- Mem0: the open-source package keeps only per-memory history (one row's own edit trail), not a project-wide event log - stated in Mem0's own docs, which describe a project-wide operations log as a paid-tier feature for dashboards and alerting, not a checkout-able history. No branch, no merge, no conflict object.
- Zep: built on Graphiti, a temporal
knowledge graph that closes a fact's validity window when a new one
supersedes it, rather than deleting the old one - a real form of history,
and the closest of the two to what
mnemdoes. But it's linear: no named branches to diverge and later reconcile, no merge, no conflict to resolve. Its "audit logs" are API access logs, not a versioned memory state.
docs/benchmark.md's audit-query table stays scoped to dict/JSONL/mnem
(ADR-0017) - that page is CI-gated, and Mem0's and Zep's behaviour isn't
mnem's own build to verify. The same seven questions, answered here
against each tool's real source instead
(ADR-0022):
| Question | Mem0 | Zep (Graphiti) |
|---|---|---|
| what does it believe now | yes | yes |
| what did it believe at step t | no built-in call (history is scoped per memory, no global reconstruction) | yes, via SearchFilters' valid_at/invalid_at date filters - but search()'s own convenience wrapper always uses "now" |
| when did belief X first go wrong (bisect) | no | no |
| which observation set X | shallow: actor_id/role per history event | real: EntityEdge.episodes references the originating observation(s) |
| what did step t change | qualified: a real diff per memory, no cross-memory "commit" | no explicit diff API; derivable from two point-in-time queries |
| merge two agents' memories, surfacing conflicts | no | no (a later fact invalidates an earlier one, not a flagged conflict) |
| storage after L steps | O(keys) live state + O(events) append-only history | O(all facts ever asserted), monotonically growing |
Bisect is the one row where mnem, Mem0, and Zep are all behind a plain
JSONL log's linear scan - worth stating plainly rather than only ever
naming where mnem wins.
Letta - the closest of the memory-layer tools, and a real git repo
Letta (formerly MemGPT) has
pivoted since this comparison first looked at it: the block-history/
rollback API it used to ship is retired, sitting only on an archive
branch. The active product, letta-code, is a CLI tool whose agent memory
is stored as a literal git repository - cloned, pulled, committed, and
pushed like any other, confirmed directly in its own source.
That makes it the closest thing to mnem's own idea of any tool compared
here - and also the sharpest contrast. Letta's git usage is sync plumbing,
not a versioning feature: no log, no checkout-at-revision, no branch,
no merge - a push conflict just surfaces as a raw error. Its own
first-party docs recommend driving raw git by hand (git add, git commit, git push) for basic operations like moving memory between
agents. Letta hands you a real git repo and tells you to use real git
yourself. mnem gives you blame, bisect, and a typed merge-conflict
object on the same underlying idea.
Memoria - real conflict detection, but merge doesn't block on it
Memoria (Apache-2.0, backed by
the database company MatrixOne) is the most direct positioning match: its
own tagline is "Snapshot · Branch · Merge · Rollback - for memory, not
code." The claims below are checked against the actual source
(memoria/crates/memoria-git/src/service.rs and the Python SDK's
branches.py, both in the repo linked above), not the README:
- Conflict detection is real.
classify_diff_rowsdistinguishes ADDED / UPDATED / REMOVED / CONFLICTS, tracking both sides' content when a branch and main diverge on the same id. - But the merge operation does not block on an unresolved conflict. The
underlying DDL is literally
... when conflict skip, and the Python SDK'sbranches.merge(name, strategy="accept")defaults to auto-accepting conflicts rather than refusing to complete. Resolving one conflict by hand needs a separatediff_items()call followed byapply(accept_branch_conflicts=[...]), or apick()call with one blanket strategy for the whole operation - not a single cycle where a merge writes nothing until every conflict has an explicit answer, which can differ per item. - The MCP tool surface an agent actually calls doesn't expose branch merging at all, in the version read for this comparison - it's reachable through the HTTP API and the Python SDK, an operator/developer surface, not something the agent invokes mid-run.
ByteRover CLI - real git, wrapped around agent context
ByteRover CLI ships real
branch / checkout / merge / commit verbs, and they're not an
analogy: the CLI is backed by
isomorphic-git under the hood, applied to
a serialised "context tree." A merge either succeeds or returns a
structured conflicts list by file path (both_added / both_modified /
deleted_modified) - closer in spirit to mnem's block-and-report model
than Memoria's.
What's different: those conflicts are literal git text-merge conflicts on
serialised files, resolved with the usual <<<<<<< markers, not a typed
resolution over structured data (ours / theirs / base / a supplied
value). It's also scoped specifically to coding-agent context, not
general-purpose agent memory, and released under the Elastic License 2.0,
not a permissive one.
What this comparison isn't
Not a claim of a clean field. Memoria and ByteRover are real, shipping,
non-trivial projects, and this isn't the first attempt at git-shaped ideas
for agent state. It's an honest account of where each one actually stands
today, checked against source rather than a marketing page - and where
mnem's claim differs from all four: a structured, node-level, typed
three-way merge (ours / theirs / base / set a value) that writes
nothing until every conflict is explicitly resolved, not a database DDL
flag and not a text-merge marker.
mnem init
Create a new memory store in a directory (default: the current one).
Synopsis
mnem init [PATH]
Description
Creates a .mnem/ directory at PATH (or the current directory if
PATH is omitted) holding a fresh, empty store: an object database, no
commits, HEAD pointing at an unborn main branch. Refuses if a store
already exists there. Nothing else in mnem works until a store exists
- every other command looks for
.mnem/starting at the current directory.
Options
| Argument | Description |
|---|---|
[PATH] | Where to create the store. Omit to use the current directory. |
Examples
Create a store in the current directory:
$ mnem init
Initialised an empty Mnemosyne store in ./.mnem
Create one somewhere specific:
$ mnem init ./my-agent
Initialised an empty Mnemosyne store in my-agent/.mnem
See also
add,commit- the next two steps afterinit.- On-disk format - what
.mnem/actually contains.
mnem add
Stage a memory node for the next commit.
Synopsis
mnem add [OPTIONS] <ID> [CONTENT]
Description
Stages a memory node under ID with the given CONTENT, to be
recorded on the next commit. Staging the same ID
again replaces the staged content - it is an update, not a second
node. CONTENT is stored as a plain string by default; --content- json parses it as a JSON value instead (a number, bool, object,
array, or null), and --content-file reads JSON content from a
file rather than the command line. Omitting CONTENT entirely reads
it from stdin as a plain string.
The three provenance flags (--step, --source, --event-time)
record how the node came to exist - which step of an agent's run
produced it, an external source identifier, and when the agent formed
it - queryable later through blame.
Options
| Flag | Description |
|---|---|
<ID> | The node's stable id. Staging the same id again is an update. |
[CONTENT] | The content, as a plain string. Omit to read it from stdin. |
--content-file <FILE> | Read the content from this file, parsed as JSON. |
--content-json <JSON> | The content, parsed as JSON instead of stored as a plain string. |
--step <STEP> | Provenance: which step of the run produced this. |
--source <SOURCE> | Provenance: an external source identifier. |
--event-time <MS> | When the agent formed this, in Unix milliseconds. |
Examples
A plain string fact:
$ mnem add customer-4821 "wants a refund for the delayed order"
Staged customer-4821 as a1b2c3d4e5f6
Structured content, with provenance:
$ mnem add agent:policy --content-json '{"temperature": 0.2}' \
--step "configure" --source "operator"
See also
rm- the reverse: stage a deletion instead of a write.commit- records whataddstaged.blame- reads the provenanceaddrecorded.
mnem rm
Stage the deletion of a node from the next commit.
Synopsis
mnem rm <ID>
Description
Stages the removal of ID from working memory, to take effect on the
next commit. The node's own history is never touched
by this - every earlier commit that wrote it is still reachable, still
blame-able, and still shows up when show is pointed at
an older commit. rm only changes what working memory looks like
going forward.
Options
| Argument | Description |
|---|---|
<ID> | The node id to remove. |
Examples
$ mnem rm customer-4821
$ mnem commit -m "customer-4821 is no longer relevant"
The removed fact's history survives at the commit that still has it -
mnem log names the commit before the removal:
$ mnem show 09a2c3b340af
customer-4821
"wants a refund for the delayed order"
See also
add- the reverse.commit- records whatrmstaged.blame- still resolves a removed node's earlier history.
mnem commit
Record the staged memory state as a commit.
Synopsis
mnem commit [OPTIONS] --message <MESSAGE>
Description
Records everything currently staged by add and
rm as a new commit on the current branch, with --message
as the commit message. --author records who or what made the
commit; it falls back to the $MNEM_AUTHOR environment variable, then
to "unknown" - the same fallback shape --reviewer, registry publish, and every other author-recording flag in mnem use.
A commit's id is the content hash of its own bytes (ADR-0005), so the same staged state, message, author and time always produces the same id - committing is deterministic, not timestamped-and-random the way many systems' ids are.
Options
| Flag | Description |
|---|---|
-m, --message <MESSAGE> | The commit message. |
--author <AUTHOR> | Who or what made the commit. Falls back to $MNEM_AUTHOR, then "unknown". |
Examples
$ mnem add plan "canary rollout, 5% then 50% then 100%"
Staged plan as f1e2d3c4b5a6
$ mnem commit -m "record the rollout plan" --author alice
[a1b2c3d4e5f6] record the rollout plan
Setting $MNEM_AUTHOR once instead of passing --author every time:
$ export MNEM_AUTHOR=my-agent
$ mnem commit -m "update after tool call"
See also
add,rm- what gets committed.log- reads back whatcommitwrote.blame- resolves a node to the commit that introduced it.
mnem status
The current branch, HEAD, and what is staged.
Synopsis
mnem status [OPTIONS]
Description
With no flags, prints the current branch and whatever
add/rm have staged but not yet committed -
nothing you have not already seen echoed back by those commands, just
a single place to check it again.
--all-branches is a different, dashboard-shaped view over data that
already exists: every branch, its tip commit's real author, and how
long ago it last moved ("5s ago", "2h ago", "3d ago") - useful for
spotting a branch nobody has touched in a while, not a replacement for
inbox's own narrower "what's unmerged" question.
Options
| Flag | Description |
|---|---|
--all-branches | A dashboard view: every branch, its tip's author, and how long ago it last moved - instead of just the current branch. |
Examples
$ mnem status
On branch main
be7a8e64a560 record the rollout plan
nothing staged
$ mnem status --all-branches
main be7a8e64a560 alice 1m ago
feature-a f1e2d3c4b5a6 bob 5s ago
See also
inbox- which branches are unmerged, not just when they last moved.branch- list, create or delete the branches--all-branchessurveys.
mnem log
Show the commit history from HEAD, newest first.
Synopsis
mnem log [OPTIONS]
Description
Walks the commit graph from the current branch's tip, newest first.
With no flags, prints each commit in full: its id, author, date, and
message. --oneline compresses that to one line per commit - the id
and the first line of the message, the fastest way to scan recent
history. --first-parent follows only the first parent of each merge
commit, skipping the commits a merge brought in from the other side -
useful once a history has real merges in it.
Options
| Flag | Description |
|---|---|
--first-parent | Follow only the first parent of each commit. |
-n, --max-count <N> | Show at most this many commits. |
--oneline | One line per commit. |
Examples
$ mnem log --oneline
b7a942a11824 housekeeping
d765122d6001 status degraded after a tool error
2f3ddeee0c3e order-1 shipped
9e451eef0c04 seed order and status
The full form, limited to the two most recent commits:
$ mnem log -n 2
commit b7a942a11824e0a4c3ea633e7f5f486d79cb7a1921949a684fb2477d30a67afe
Author: alice
Date: 2026-10-17 18:29:55Z
housekeeping
commit d765122d6001f1caf57b6b86fe256202fce232dba90bb8e7323c518ebe1fef2e
Author: agent
Date: 2026-10-17 18:29:47Z
status degraded after a tool error
See also
show- the full memory at one of these commits.blame,bisect- find a specific commit in this history rather than scanning it by eye.inbox- branches whose own commits never show up here because they have not merged into the current branch yet.
mnem show
Print the full memory at a commit, or working memory with no argument.
Synopsis
mnem show [OPTIONS] [REV]
Description
With no argument, prints working memory - everything
add/rm have staged plus every earlier commit,
materialised as it stands right now. Given a branch name or commit id,
materialises memory exactly as it stood at that point in history
instead - real time travel, not a diff against the present. --stat
swaps the content for just the list of node ids that commit changed.
Options
| Argument / Flag | Description |
|---|---|
[REV] | A branch name or commit id. |
--stat | Instead of the memory, list the node ids this commit changed. |
Examples
Working memory right now:
$ mnem show
note
"unrelated housekeeping"
order-1
"shipped"
status
"degraded"
Memory as it stood at an earlier commit - real time travel, not re-deriving the past from a diff:
$ mnem show 9e451eef0c04
order-1
"pending"
status
"ok"
See also
log- find the commit id to pointshowat.diff- the difference between twoshow-able points, instead of one point in full.
mnem diff
Show what changed between two memory states.
Synopsis
mnem diff [OPTIONS] [FROM] [TO]
Description
Structural diff over two memory states, one line per changed node:
+ added, - removed, ~ modified (shown with both the old and new
content). With no arguments, compares the HEAD commit against working
memory - "what have I staged that isn't committed yet". Given one
side, compares that commit against working memory; given both, a pure
historical comparison with no live state involved at all. --stat
drops the content and shows just the classified node ids; --name- only drops the classification too, just the ids.
Options
| Argument / Flag | Description |
|---|---|
[FROM] | The from side: a branch or commit. Default: the HEAD commit. |
[TO] | The to side: a branch or commit. Default: working memory. |
--stat | One classified line per node, no content. |
--name-only | Just the changed node ids. |
Examples
$ mnem diff 9e451eef0c04 d765122d6001
~ order-1
- "pending"
+ "shipped"
~ status
- "ok"
+ "degraded"
Just the shape of the change, no content:
$ mnem diff 9e451eef0c04 d765122d6001 --stat
~ order-1
~ status
See also
changelog- the same comparison, narrated in plain English instead of a structured diff.show- one point in full, instead of the difference between two.
mnem blame
Resolve a memory node to the commit and provenance that introduced it.
Synopsis
mnem blame <NODE_ID> [REV]
Description
Resolves NODE_ID's current content back to the exact commit that
wrote it - who or what made that commit, when, and the provenance
recorded alongside it (which step of a run produced it, an external
source identifier), if any was given to add at the time.
Defaults to blaming as of HEAD; given REV, blames as of that branch
or commit instead, the same time-travel show does.
Options
| Argument | Description |
|---|---|
<NODE_ID> | The node id to blame. |
[REV] | Blame as of this branch or commit (default: HEAD). |
Examples
$ mnem blame status
d765122d6001 2026-10-17 18:29:47Z
commit: status degraded after a tool error
content: "degraded"
(no provenance recorded)
See also
bisect- find which commit introduced a state, when you do not already know it;blameis for when you do.add- where the provenanceblamereads comes from.
mnem bisect
Find the first commit where a memory node reaches a given state.
Synopsis
mnem bisect [OPTIONS] --node <NODE> [BAD]
Description
Binary-searches the commit history between a known-good and a known-
bad commit to find the exact commit where --node first matches the
given predicate - --equals <JSON> for an exact content match,
--present for the node existing at all, --absent for it not
existing. No reading commits by hand one at a time: bisect does the
search. BAD is a positional argument, not a flag, and defaults to
HEAD if omitted; --good defaults to the history root if omitted.
Options
| Argument / Flag | Description |
|---|---|
[BAD] | A commit where the predicate is true (default: HEAD). |
--node <NODE> | The node id the predicate is about. |
--equals <JSON> | True when the node's content equals this JSON value. |
--absent | True when the node is not in memory. |
--present | True when the node is in memory. |
--good <GOOD> | A commit where the predicate is false (default: the history root). |
Examples
$ mnem bisect --node status --equals '"degraded"' --good 9e451eef0c04 b7a942a11824
d765122d6001 status degraded after a tool error
status set here, no provenance recorded
BAD is positional - a common mistake is passing it as --bad, which
clap rejects outright rather than silently misreading it:
$ mnem bisect --node status --equals '"degraded"' --good 9e451eef0c04 --bad b7a942a11824
error: unexpected argument '--bad' found
See also
blame- resolve a node to its introducing commit once you already know which one;bisectis for when you do not.log- the historybisectsearches.
mnem changelog
Narrate what changed between two memory states in plain English.
Synopsis
mnem changelog [FROM] [TO]
Description
The same comparison diff computes, reworded as one
sentence per changed node instead of a structured +/-/~ dump -
"customer-4821 changed from X to Y" rather than a diff block. diff's
own structured output is unchanged by this existing; changelog is a
rephrasing, not a second computation. Same FROM/TO defaults as
diff: no arguments compares HEAD against working memory.
Options
| Argument | Description |
|---|---|
[FROM] | The from side: a branch or commit. Default: the HEAD commit. |
[TO] | The to side: a branch or commit. Default: working memory. |
Examples
$ mnem changelog 9e451eef0c04 d765122d6001
order-1 changed from "pending" to "shipped"
status changed from "ok" to "degraded"
See also
diff- the same comparison, as a structured dump rather than plain English.
mnem inbox
List every branch not yet merged into main, with its tip commit.
Synopsis
mnem inbox
Description
A read-only view over branches that already exist: every branch whose
tip is not an ancestor of main, with that tip's commit id, author
and message. No new object model behind this - it is branch plus
log plus an ancestry check, read together. Nothing here is a real
pending-proposal object (that is the ADR-gated review model, #419-422,
still unsettled); inbox only answers "what is unmerged right now".
Options
mnem inbox takes no options beyond -h/--help.
Examples
$ mnem inbox
feature-a 4e214d3b688c bob sketch a faster retry backoff
With nothing unmerged:
$ mnem inbox
Nothing pending - every branch is merged into main.
See also
branch- list every branch, merged or not.merge,approve- land one ofinbox's own listed branches.
mnem branch
List, create, or delete branches.
Synopsis
mnem branch [OPTIONS] [NAME] [START]
Description
With no arguments, lists every branch, its tip commit, and that
commit's message, marking the current branch with a *. Given
NAME, creates a new branch there instead of listing - at START (a
branch name or commit id) if given, at HEAD otherwise. Creating a
branch is cheap: a new ref pointing at an existing commit, nothing
copied. --delete <NAME> removes a branch; it refuses to delete the
one currently checked out.
Options
| Argument / Flag | Description |
|---|---|
[NAME] | The branch to create. Omit to list branches. |
[START] | Where the new branch starts: a branch name or commit id (default HEAD). |
-d, --delete <NAME> | Delete this branch. |
Examples
$ mnem branch
escalation fb8e2144d9f5 escalate ticket
main 1d62639c0f62 seed
* resolution ee0e860e1aa9 resolve ticket
Fork a hypothesis at the current commit, and switch to it in one step
with checkout -b:
$ mnem checkout -b feature-b
Switched to branch feature-b
See also
checkout- switch to a branch, or create and switch in one step.status --all-branches- every branch with its tip's author and age, instead of just its message.inbox- which of these branches is unmerged.
mnem checkout
Move HEAD to a branch or commit.
Synopsis
mnem checkout [OPTIONS] [REV]
Description
Switches HEAD to REV - a branch name or commit id - and
materialises working memory to match. Refuses if there is anything
staged and uncommitted, unless --discard says to drop it. -b <NAME> creates a new branch and switches to it in one step, with
REV (or HEAD, if omitted) as that branch's start point - the
quickest way to fork a hypothesis.
Options
| Argument / Flag | Description |
|---|---|
[REV] | A branch or commit to switch to. With -b, the new branch's start point. |
-b, --create <NAME> | Create a branch and switch to it. |
--discard | Drop uncommitted staged changes instead of refusing. |
Examples
$ mnem checkout escalation
Switched to branch escalation
Fork and switch in one step:
$ mnem checkout -b feature-b
Switched to branch feature-b
See also
branch- create a branch without switching to it.merge- bring a branch's own commits back into the one you switched away from.
mnem merge
Merge another branch or commit into the current branch.
Synopsis
mnem merge [OPTIONS] <THEIRS>
Description
A deterministic three-way merge of THEIRS into the current branch,
per node id. A clean merge (no two sides changed the same node
incompatibly) lands as a normal commit, or fast-forwards if the
current branch has no new commits of its own. A real conflict - the
same node edited two different ways since the common ancestor - is
surfaced as a typed conflict object, not silently decided for you:
nothing is written until every conflict has an explicit answer, via
--resolve <id>=<side> (repeatable, one conflict at a time) or
--strategy ours|theirs (every conflict the same way).
--dry-run previews the outcome - clean, fast-forward, or which
nodes would conflict - without writing anything; it does not combine
with --resolve or --strategy, since previewing and resolving are
different questions. --dry-run --html <file> additionally renders
any conflicts as a static two-column HTML page, base/ours/theirs side
by side, for reading outside a terminal.
Options
| Flag | Description |
|---|---|
<THEIRS> | A branch name or commit id to merge in. |
--resolve <ID=SIDE> | Resolve one conflict: <id>=<ours|theirs|base|delete>. Repeatable. |
--strategy <SIDE> | Resolve every conflict one way: ours or theirs. |
-m, --message <MESSAGE> | Override the merge commit message. |
--author <AUTHOR> | Who or what made the merge. Falls back to $MNEM_AUTHOR, then "unknown". |
--dry-run | Show what merging would do, without writing anything. |
--html <FILE> | With --dry-run: if there are conflicts, also render them as an HTML page. |
Examples
Previewing a real conflict before touching anything:
$ mnem merge conflict-a --dry-run
(dry run) would conflict in 1 node(s):
order-10 (edit/edit)
base: "shipped"
ours: "refunded"
theirs: "returned"
Error: resolve with `--resolve <id>=<side>` (or `--strategy ours|theirs`) and merge for real
The same conflict, resolved one node at a time:
$ mnem merge escalation --resolve case-4821=theirs --author alice
[44b432580778] merge escalation into resolution
See also
approve- the same merge, with a named reviewer recorded as its author.inbox- branches waiting to be merged.diff- compare two branches without merging either.
mnem approve
Approve a branch: merge it in with the reviewer recorded as the merge commit's author.
Synopsis
mnem approve --reviewer <REVIEWER> <BRANCH>
Description
A thin wrapper over merge: runs the real merge of
BRANCH into the current branch, with --reviewer recorded as the
resulting merge commit's author - so log/blame show who approved
the change, not just who wrote the branch's own commits. Inherits
merge's own conflict handling unchanged: a conflicting approve fails
exactly the way a plain merge would, with the same instruction to
resolve and merge again. Not the ADR-gated propose/approve object a
future review model may settle on (that would hold a pending change
before anyone reacts to it); this is a thin, immediate wrapper over
what merge already does.
Options
| Argument / Flag | Description |
|---|---|
<BRANCH> | The branch to approve and merge in. |
--reviewer <REVIEWER> | Who is approving. Recorded as the merge commit's author. |
Examples
$ mnem approve feature-b --reviewer alice
[f7f38658a222] merge feature-b into main
$ mnem log -n 1
commit f7f38658a2224ce0764b1d1258929ac4dc973698cc259bd8214dafea7557d6f3
Author: alice
Date: 2026-10-18 17:29:19Z
merge feature-b into main
See also
reject- the alternative: record why a branch is rejected, without touching the store.merge- whatapprovewraps; use it directly when no reviewer needs recording.inbox- branches waiting for an approve or reject.
mnem reject
Reject a branch: record why, without touching the store.
Synopsis
mnem reject --reviewer <REVIEWER> --reason <REASON> <BRANCH>
Description
Prints who rejected BRANCH and why - the store is never opened, so
there is nothing to roll back if a reviewer says no. The companion to
approve: the two give a reviewer a single pair of
commands for the same decision, one of which writes a real commit and
one of which writes nothing at all.
Options
| Argument / Flag | Description |
|---|---|
<BRANCH> | The branch being rejected. |
--reviewer <REVIEWER> | Who is rejecting. |
--reason <REASON> | Why the branch is rejected. |
Examples
$ mnem reject escalation --reviewer alice --reason "resolution already shipped a fix"
Rejected escalation (alice): resolution already shipped a fix
See also
approve- the alternative: merge the branch in with the reviewer recorded.inbox- branches waiting for an approve or reject.
Architecture decisions
Numbered records of the decisions that shape Mnemosyne, and why they went the
way they did. Format: 0000-template.md.
An ADR is Accepted before code is written for anything touching the on-disk format, the object model, a public API surface, the merge algorithm, the Rust and Python boundary, or a new dependency. Accepted ADRs are immutable; a later change is a new ADR that supersedes the old one.
Naming note: ADRs 0001 to 0018 call the core crate
mnem-coreand the CLI cratemnem-cli. Because those names were already taken on crates.io, before the first publish the core crate becamemnem-storeand the CLI cratemnem-git(the binary is stillmnem); the published packages aremnem-store,mnem-git, and on PyPImnem-agents,mnem-mcp,mnem-langgraph.import mnemand the.mnem/store directory are unchanged. See theCHANGELOG.mdentry under Unreleased.
Releasing note: ADR-0007's step 5 sketches one workflow that publishes crates.io, PyPI, and cuts the GitHub release. What shipped (ADR-0019) is two independently-triggered workflows and a couple of steps that stay manual on purpose.
CONTRIBUTING.md's "Releasing" section is the real, current sequence.
| ADR | Title | Status |
|---|---|---|
| 0001 | Record architecture decisions | Accepted |
| 0002 | On-disk object format | Accepted |
| 0003 | The memory node model | Accepted |
| 0004 | The Rust core and Python SDK boundary | Accepted |
| 0005 | Commit identity, hashing and signing | Accepted |
| 0007 | Versioning and release policy | Accepted |
| 0008 | Object encoding and the store engine | Accepted |
| 0009 | The ref model | Accepted |
| 0010 | What 1.0 means, and the 0.0.x roadmap | Accepted |
| 0011 | Conventional commits and the issue lifecycle | Accepted |
| 0012 | The branch and checkout model | Accepted |
| 0013 | The deterministic merge algorithm | Accepted |
| 0014 | The conflict object and the resolution API | Accepted |
| 0015 | The provenance index, blame and bisect | Accepted |
| 0016 | The MCP tools and the adapter contract | Accepted |
| 0017 | The benchmark and its metrics | Accepted |
| 0018 | The Era 2 seam: the semantic merge trait and the sync protocol | Accepted |
| 0019 | Prebuilt mnem binaries via cargo-dist | Accepted |
| 0020 | The OpenAI Agents SDK adapter | Accepted |
| 0021 | The CrewAI adapter | Accepted |
| 0022 | Mem0 and Zep on the audit-query table | Accepted |
| 0023 | The AutoGen adapter | Accepted |
| 0024 | The Mem0 migration path | Accepted |
| 0025 | CrewAI private-filtering is not the adapter's job | Accepted |
| 0026 | The Google ADK adapter | Accepted |
ADR-0001: Record architecture decisions
- Status: Accepted
- Date: 2026-09-06
- Issue: #1
Context
Mnemosyne is being built slowly and deliberately, one phase at a time, with the design settled before the code. The reasoning behind each choice needs to survive, both for the maintainer returning to an area months later and for anyone reading the repo to judge whether the project is serious.
The design space here is unusually live. A wave of 2026 research (Git4Data, GitOfThoughts, StateFuse, MemTX, LatticeMind) is exploring the same ground, and findings from those papers feed directly into decisions. Those inputs need a home.
Decision
Every architecture decision is recorded as a numbered Markdown file in
docs/adr/, following 0000-template.md.
An ADR is written, and reaches Accepted, before code is written for anything that touches: the on-disk format, the object model, a public API surface, the merge algorithm, the Rust and Python boundary, or a new dependency.
ADRs are immutable once Accepted. A later decision that changes an earlier one is a new ADR that marks the old one Superseded.
Consequences
- The repo carries its own reasoning. A reviewer can follow why the format looks the way it does without asking.
- Research is not lost. A
wayfinder:researchticket produces an ADR recommendation, which becomes an ADR. - There is a small tax on every structural change: the ADR comes first.
- The ADR index in
README.mdfor this folder has to be kept current.
Alternatives considered
A design doc that is edited in place. Rejected. It loses the history of what was tried and dropped, which is exactly the part worth keeping here.
Decisions recorded only in issues and pull requests. Rejected. They are hard to find later and get buried under implementation chatter. An ADR is a stable, linkable artefact.
ADR-0002: On-disk object format
- Status: Accepted
- Date: 2026-09-06
- Issue: #4 (grilling), #3 (research)
Context
A .mnem/ store has to hold four kinds of thing: many small memory nodes
(thousands per run), a state per commit (the set of nodes visible then), the
commits that form the graph, and a handful of refs. The store must
support cheap branching, a cheap structural diff between two states (Phase 2), a
three-way merge (Phase 3), and a format that a store written by one v1.x release
can be read by every other v1.x release.
The #3 survey compared Git's loose-object and packfile model, an embedded
key-value object store, a prolly tree for state, the prollytree crate used
wholesale, Git4Data, and DVC or oras. Its recommendation, pressure-tested in
#4:
- Git loose objects hit the many-small-objects failure mode, and building packing and garbage collection to escape it is a storage engine we should not write.
prollytreeas the core data structure would make our frozen format someone else's, resting on their release cadence.- Git4Data needs a database server; DVC and
orasare built for a few large artefacts, not a fine-grained local graph. - An embedded key-value store removes the small-object problem for free, and a prolly tree for state gives cheap diff and node-level merge when those phases need them.
This ADR fixes the store architecture. It does not fix anything byte-level.
Decision
Engine
The store is a single redb file, .mnem/store.redb. redb is pure Rust, has
no C dependency, is crash-safe, uses copy-on-write B+trees, and has an
explicitly stable, documented on-disk format with an upgrade path. It is named
here as the one load-bearing storage dependency: the freeze-for-a-major-version
commitment rests on redb's format stability as well as our own.
Object model
Every object (memory node, state, commit) is self-describing and carries a
kind tag. A reader that meets an unknown kind rejects the store rather than
guessing. Adding a new object kind is an additive, minor change; it does not
break older readers of the objects they do understand.
Store layout
.mnem/
store.redb redb database:
table objects : content hash -> object bytes
table refs : ref name -> commit hash
HEAD plain text, the current pointer, the source of truth
config plain text, holds format_version and later the prolly parameters
HEAD is a plain-text file in the spirit of Git's HEAD: it is the one pointer
a person most often reads or sets by hand, so it stays out of the binary store.
Named refs live in the refs table.
State representation
The state is a plain sorted content-addressed map (kind = flat) in v0.1: an
ordered list of (node key, node hash) pairs, itself content-addressed.
A self-implemented prolly tree (kind = prolly) is added in Phase 2, when
diff first needs it, and pays off again for merge in Phase 3. Because objects
are self-describing this is an additive change, not a break. We implement the
prolly tree ourselves so the format stays ours and frozen; the prollytree
crate is a reference and a spike target, not a dependency.
No packfiles, no bespoke garbage collection
Not in the v0.x line. redb handles its own compaction. A mnem compact
command may be added later if a real store grows unreasonably, but it is not a
launch requirement.
Legibility
The store is not readable with Git tools, which is expected: our objects were never Git objects. In their place:
mnem cat-object <hash>: print one object.mnem verify: check every object's hash, and later its signature.mnem fsck: check graph consistency (no dangling parents, every ref resolves).docs/format/: a written spec complete enough for a third party to build a reader.
redb's own documented format keeps the outer container inspectable.
Migration
config carries a format_version integer. A major version bump ships a
one-shot mnem migrate that reads the old store and writes a new one; we commit
to providing that path for any v1 to v2 change. Within a major version only
additive changes are allowed: new object kinds, new optional fields. This
matches the invariant in AGENTS.md.
Scope
This ADR is architectural. It does not decide:
- the serialisation encoding of an object's bytes (CBOR, bincode, other): #19 and ADR-0008.
- the prolly-tree chunking and encoding parameters: ADR-0008.
- the content hash function and the commit header layout: #15 and ADR-0005.
Those can move without reopening this ADR.
Consequences
- Phase 1 is smaller: a
redbtable and a flat state, no tree, no packing. - The many-small-objects problem is gone from day one.
- Phase 2 and Phase 3 get the prolly tree's cheap diff and node-level merge without a format break, because the object model was built to absorb it.
- We take one deep dependency,
redb, and its format stability is now part of our promise. Ifredbever breaks its format without an upgrade path, that is our problem to absorb in amnem migrate. - Anyone wanting to read a store needs our spec, not just
git cat-file. The inspection commands anddocs/format/are load-bearing, not optional. - A store cannot be inspected or repaired with generic tools while
mnemis unavailable.redb's documented format is the fallback.
Alternatives considered
Git's loose objects and packfiles, via gitoxide. Rejected. Thousands of
tiny loose files per run is the exact case packfiles exist to fix, and building
packing, an index, repack and garbage collection ourselves is a large
distraction from the product. gitoxide would also pull Git's tree and blob
model into ours.
A prolly tree from the start, in Phase 1. Rejected for v0.1. It is real work with a chunking-boundary subtlety, and Phase 1 does not yet need diff or merge. The self-describing object model lets us add it in Phase 2 without a break, so there is no format cost to waiting.
The prollytree crate as the core data structure. Rejected. It is young and
single-maintainer. Depending on it for the core means our frozen format is
really its format, and the freeze-for-years commitment then rests on its release
cadence. It may also not model our commit header, provenance or signing without
a fork. Kept as a reference and a spike target.
A hand-rolled append-only log plus index. Rejected. That is building a
storage engine. redb already solves crash safety, compaction and small-object
locality, with a stable format.
Git4Data (arXiv 2609.02106). Rejected. It needs a database server. Mnemosyne v1 is a local, embeddable, offline library. The "cost proportional to the change" principle is one we keep, through structural sharing in the prolly tree.
DVC or oras style artefact stores. Rejected. Both are built for a small
number of large artefacts with an external remote, not a fine-grained local
graph of thousands of tiny objects.
ADR-0003: The memory node model
- Status: Accepted
- Date: 2026-09-06
- Issue: #6 (grilling), #5 (research)
Context
A memory node is the unit Mnemosyne versions. The #5 survey looked at how mem0, Letta, Zep, LangGraph, Generative Agents, and the 2026 versioned-memory papers (StateFuse, MemTX, LatticeMind) model memory, and at the CogCanvas ablation.
The findings that shaped this decision:
- CogCanvas (arXiv 2601.00821): swapping only the stored representation in a fixed pipeline, verbatim chunks beat LLM-extracted typed artefacts by 15.9 points on LoCoMo and 22.0 on LongMemEval-S. The mechanism is lossy distillation. Structure should augment verbatim text, not replace it.
- LangGraph proves the minimal viable model: a key and an opaque value.
- Zep splits event time (when a fact was true) from ingestion time (when it was learned), and invalidates superseded facts rather than deleting them.
- MemTX keeps provenance, evidence and validity as distinct first-class fields.
- StateFuse keeps contradictions as explicit objects over an immutable history; LatticeMind puts a mutable status on each item. These are in tension.
- Letta's block is a mutable labelled string, which is the wrong shape for version control.
This ADR fixes the node model. It does not build the claim schema, the embedding index, or anything in v2.
Decision
The memory node
A memory node has five fields.
| Field | Type | Required in v0.1 | Notes |
|---|---|---|---|
id | string | yes | a stable logical key |
content | string or any JSON value | yes | freeform, stored verbatim |
content_kind | enum | yes, always note | note or claim |
provenance | object | yes, may be empty | how the node came to exist |
event_time | timestamp or null | no | when the agent formed the node |
id is caller-provided, with a generated fallback. An agent with a natural
key (an entity id, a fact key) supplies it, so successive writes to the same
fact share an id and blame and diff line up. An agent with no key gets a
generated id, and every write is a fresh node. id is never content-derived,
because that would make an update impossible.
Update means: same id, new content, recorded in a new commit. The old
version stays reachable through history. A node is immutable within a commit;
it carries no mutable field.
content is a string or any JSON value, unconstrained. Mnemosyne is a
version-control tool: it stores faithfully what the agent wrote and does not
reshape it. A soft size warning may be added later; there is no hard limit.
content_kind is present from v0.1 with note as the only value the v0.1
reader accepts. claim is named here and in the format spec but is rejected by a
v0.1 reader. Per ADR-0002, adding claim in v2 is then an additive change, not a
format break.
event_time is an optional timestamp: when the agent formed the belief, as
distinct from the commit's record time. If absent, blame falls back to the
commit time. It is one nullable field, and a temporal field added later would be
a semantically meaningful format change, so it goes in now.
Provenance
Provenance answers "where did this record come from". It is read by blame.
Every node has a provenance object, which may be entirely empty.
| Field | Type | Notes |
|---|---|---|
agent_step | string or null | which step of the run produced it |
observation | reference or null | the observation or input it was drawn from |
tool_call | reference or null | the tool call, if one produced it |
source | reference or null | an external source identifier |
note | string or null | free text for anything the fields above do not fit |
An agent loop may only know the step, or nothing. Refusing a memory for lack of
provenance would be worse than storing it bare, so an empty provenance is legal.
bisect operates on node content and presence, not provenance, so it needs
nothing here.
Provenance is not evidence. Evidence (below) is claim-only and says what supports the assertion. MemTX keeps both; so do we.
The claim schema (defined, dormant until v2)
A node with content_kind = claim has content shaped as:
| Field | Type | Notes |
|---|---|---|
subject | reference | the entity the claim is about |
predicate | string | the attribute or relation |
value | any JSON value | the asserted value |
confidence | float 0 to 1 or null | the agent's stated confidence |
evidence | list of references | what supports the assertion. May be empty. |
This ADR fixes the field names and their types so ADR-0002's format can carry a
claim object additively. It defers to a v2 grilling: the predicate
vocabulary, how subject identity is resolved, and how confidence is
calibrated. Nothing is built or validated in v0.1, so a v2 grilling can still
widen a field.
Status and contradiction
A memory node has no status field. Supersession is already visible in the
commit graph: a later commit with a new content for the same id supersedes
the earlier one. Contradiction becomes a separate Contradiction object in
v2, designed by a v2 grilling. This follows StateFuse over LatticeMind, and
keeps nodes consistent with ADR-0002's content-addressed state.
Embeddings
Embeddings are not on the node and not in its content hash. They live in a side
index: a redb table keyed by content hash, with the model_id recorded
alongside so that changing the embedding model forces a rebuild rather than
silent drift. Not built in v0.1, since v1 has no semantic operations. A local
embedding model on an M1 produces roughly 1.5 to 4 KB per node, a few megabytes
for a large store, and the index is rebuildable, so the choice holds.
Retrieval-scoring signals (importance, recency) are not part of the versioned model at all. They are mutable and derived, and belong to a retrieval layer if one is ever built.
Consequences
- v0.1 needs only
id,content,content_kind = note,provenanceand an optionalevent_time. Small. - v2 adds
claim, the Contradiction object, and the embedding index without a format break, because the object model was built to absorb them. - An agent can write arbitrary JSON, so the store faithfully reflects whatever
the agent's memory actually was, garbage included. That is the correct
behaviour for version control, but it means Mnemosyne offers no schema
guarantees about
content. blamequality depends on the agent populating provenance. An agent that passes nothing getsblamethat resolves only to the commit.- Pinning the claim schema shape now carries a small risk of pinning a field we later regret. Mitigated by deferring all claim semantics to v2 and building nothing against the schema until then.
Alternatives considered
Letta-style mutable blocks. Rejected. The unit is designed to be edited in place, which defeats version control.
mem0-style forced fact extraction. Rejected. CogCanvas shows extraction loses 15 to 22 points against verbatim, and it puts an LLM call on the write path, which v1 forbids.
A status field on the node (LatticeMind). Rejected. A mutable field fights
ADR-0002's content-addressed state, and supersession is already in the graph.
Contradiction as a separate object (StateFuse) keeps nodes immutable.
Embeddings on the node. Rejected. It would pull the embedding model's identity into the content hash and force history rewrites when the model changes.
Omit content_kind until claims arrive. Rejected. Adding a discriminator
later is the format break ADR-0002's self-describing objects exist to avoid.
Rely on the commit timestamp instead of event_time. Rejected for v0.1.
A temporal field added later is a meaningful semantic change; one nullable field
now is cheap.
ADR-0004: The Rust core and Python SDK boundary
- Status: Accepted
- Date: 2026-09-06
- Issue: #7 (grilling)
Context
The stack was set at plan time: a Rust core (mnem-core), a mnem command line
tool, and a Python SDK (mnem) bound through pyo3 and maturin. This ADR fixes
the boundary between those pieces, and pre-commits to a fallback so that choice
is not made under pressure later.
The Rust core is the ambitious choice for a solo maintainer. The value is a real CLI binary, a performance story against Git4Data and DoltDB, and the discipline that a typed core imposes. The risk is that the maintainer stalls on Rust and the whole project stalls with it.
Decision
mnem-core
The core holds:
- the object model (ADR-0003)
- the store (ADR-0002)
- the commit graph
- every deterministic operation on them:
commit,branch,checkout,merge,blame,bisect,diff, time travel
The core does not hold, and a CI check enforces the first of these:
- any network-capable dependency
- any model or LLM call
- CLI argument parsing or output formatting
- any Python or FFI types
- any knowledge of an agent framework
- anything semantic: embeddings, retrieval, semantic merge. Semantic work lands
in v2 in a separate
mnem-semanticcrate, behind a trait.
If a behaviour is worth a test, it lives in the core with a Rust test.
The Python SDK
mnem (the Python package) is pure ergonomics over the core: Pythonic names,
exceptions, dataclasses, context managers. It carries no operation semantics.
The one carve-out is Python-idiom composition, such as the
with store.branch("h1"): context manager, which is only "call branch, call
checkout, on exit checkout back or merge". The SDK gets thin smoke tests;
the behaviour it composes is tested in the core.
Consumers of the core
mnem-cli (the mnem binary) and mnem-py (the pyo3 binding) are peer
consumers of mnem-core. Neither depends on the other. The core's consumer
count stays at two.
The MCP server (mnem-mcp) and the framework adapters (mnem-langgraph and
later) are Python packages that depend on the SDK, not on the core. They are the
SDK's customers, the same as any agent author.
Packaging
The Python wheel ships the extension module only. The mnem CLI binary is not
in the wheel; it is installed with cargo install mnem or from a release. This
is revisited at v1.0 if users ask for a bundled CLI.
Errors across the boundary
The core defines one error enum, MnemError, with named variants. The binding
maps each variant to a Python exception in a hierarchy rooted at
MnemError(Exception):
MnemError variant | Python exception |
|---|---|
NotFound | NotFoundError |
InvalidRef | InvalidRefError |
Conflict | ConflictError |
CorruptStore | CorruptStoreError |
FormatVersion | FormatVersionError |
StoreExists | StoreExistsError |
NoStore | NoStoreError |
Io | StoreIoError |
Adding a variant means adding a row here. No stringly-typed errors cross the
boundary. StoreExists and NoStore were added with Store::init/open in
issue #24 and their rows added here per this rule, which the ADR carves out from
the usual immutability. In Python the base class is named MnemError; the
mapping and hierarchy are exercised by the binding's tests (#29).
Versioning
One version number, held in the Cargo workspace [workspace.package] version
and read into the wheel at build time by maturin. A release is one git tag
vX.Y.Z. A release job publishes the crate and the wheel from that tag and
refuses if the two versions disagree. The semver policy, in particular what a
minor versus a patch means for the on-disk format, is ADR-0007's job (#17).
The fallback: a tracer bullet
Phase 1 starts with the minimum vertical slice in Rust: Store::init, write one
object, read it back, one commit, log (issues #23 to #27).
- If that slice works and is pleasant to extend, Rust continues.
- If it hits a genuine wall (a
redborpyo3limitation, or the graph code fighting the borrow checker past reasonable effort), the maintainer switchesmnem-coreto Python for v0.1, keeps the same public API, and records the switch as an ADR that supersedes the "core is Rust" part of this one.
The public API (the CLI surface and the SDK surface) is defined first and is language-neutral, so a switch does not change what users see. The go or no-go is made at the tracer-bullet checkpoint, early and cheap, and it is the maintainer's call alone. No agonising over partial progress below the checkpoint; a clear decision at it.
Consequences
- The core is a deep module with a small, typed surface, testable without any Python or network in the loop.
- The SDK is small enough that most of its cost is documentation and stubs, not logic.
- Two consumers of the core, both thin. Adapters sit a layer further out and cannot reach past the SDK.
- The wheel is simple: no per-platform CLI binary, no entry-point plumbing. Users who want the CLI take one extra step.
- The Rust bet has an explicit, early exit. If it fails it fails in Phase 1 at low cost, not at v0.5.
- A Python fallback core would lose the performance story and the CLI-as-a-single- binary story, and would need its own packaging. That cost is accepted as the price of not stalling.
Alternatives considered
A Python core from the start. Rejected as the default. It gives up the performance story, the single-binary CLI, and the discipline of a typed core. Kept as the named fallback.
The SDK holds orchestration logic. Rejected. Logic in two languages means two test suites for one behaviour and drift between them.
Bundle the CLI in the wheel. Rejected for now. Per-platform wheels and entry-point plumbing for an audience that mostly wants the library. Revisit at v1.0.
Adapters consume mnem-core directly through their own binding. Rejected.
It multiplies bindings and means adapters see different errors and ergonomics
from what agent authors see.
Independent version numbers for the crate, wheel and CLI. Rejected. Three numbers for one release is a source of confusion and mismatched bug reports.
ADR-0005: Commit identity, hashing and signing
- Status: Accepted
- Date: 2026-09-06
- Issue: #16 (grilling), #15 (research)
Context
ADR-0002 fixed that objects are content-addressed and left "assume a 32-byte
content hash" for this ticket. ADR-0003 fixed the memory node model and the
event_time field. This ADR decides the hash function, the signing scheme, and
the fields of a commit.
The #15 survey found: BLAKE3 is 4 to 10 times faster than SHA-256 and parallelises, with the same security profile; git chose SHA-256 for ecosystem interop, which a local-first tool with its own format does not need; SSH-style ed25519 signing has displaced GPG; and a content-addressed store is better served by keeping signatures out of the hashed object.
Decision
Hash function
The content hash is BLAKE3, 32 bytes, displayed as 64 hex characters. mnem
accepts an unambiguous hex prefix, in the spirit of git's short hashes.
config records hash_algo: "blake3". There is no runtime switch. A store is
single-algorithm for its life; moving to another hash is a mnem migrate under
ADR-0002's migration story, not a toggle.
Object identity
An object's id is the BLAKE3 hash of its deterministic canonical serialisation. The requirement is deterministic and canonical: the same logical content produces the same bytes and therefore the same id. The encoding that achieves this (canonical CBOR, or a hand-rolled canonical form) is ADR-0008's decision.
For a commit, the hashed content is exactly:
{ kind, parents, state, message, author, time }
and nothing else. The signature is not in the hashed content.
The commit object
| Field | Type | Notes |
|---|---|---|
kind | string, always "commit" | ADR-0002's self-describing objects |
parents | list of commit ids | [] for the first commit, one normally, two or more for a merge. No sentinel. |
state | state id | the state this commit points at |
message | string | freeform |
author | string | opaque to the core. mnem sets it from config or the caller passes it. Structured agent identity is a v2 and v3 concern and can be added as a new field additively. |
time | integer | Unix milliseconds. The record time, always set by mnem at commit. Distinct from a memory node's event_time, which uses the same representation. |
Not present:
committer: no rebase or patch flow in v1, so oneauthoris enough.- a run reference: grouping commits by agent run is deferred; not in v0.1.
signature: held separately, see below.format_version: lives inconfig, not per commit (ADR-0002).
Signing
Signing uses ed25519. A signing key has the same shape as an SSH
id_ed25519, so a user can reuse an existing one.
Signatures live in a side table, not in the commit object:
signatures : commit id -> { public_key, signature, signed_at }
The signature covers a signable payload, { commit_id, signer_key_id, signed_at }, signed as a whole. This binds "this key attested this commit at
this time". Signing the bare commit id would not bind the signer or the time;
signing the full canonical bytes would be redundant, since the id already
commits to them.
Because signatures are a side table:
- A commit with no signature is valid. An unsigned store is normal.
- A commit can carry several signatures.
- A signature can be added after the fact, or by someone other than the author.
- Signing never changes a commit's id.
The table value can later hold a Sigstore-style certificate bundle in place of a bare signature. Sigstore itself is not adopted in v1, because it needs Fulcio and Rekor, which is heavy infrastructure for a local-first tool.
What ships in v1
mnem verify: check every object's id against its bytes, and check any signatures in the side table. (ADR-0002 already committed this.)mnem sign: attach an ed25519 signature to a commit. Thin: a vetted crate, the side table, and the signable payload.
Signing is never required by any operation.
Consequences
- Hashing is fast and parallel, which matters when a commit hashes a large state.
- Commit identity is purely a function of content. The graph is stable under signing, re-signing, and multi-party signing.
- BLAKE3 is not a NIST standard, so a FIPS-bound user cannot use Mnemosyne until
a SHA-256 mode exists. The
hash_algofield reserves that path; it is not built. - A bare-string
authormeans the core offers no identity guarantees in v1. The v2 agent-identity work fills this in. timeas Unix milliseconds is unambiguous and trivial to serialise, at the cost of not carrying a timezone.mnemrenders local time on display.
Alternatives considered
SHA-256, to match git and Git 3.0. Rejected. Git's reason is interop with a
huge installed base and forge support; Mnemosyne interoperates with nothing
outside a .mnem/ store. BLAKE3 is faster with no security cost.
A configurable hash from day one. Rejected. A store never mixes hashes, so
the abstraction would exist only for a migration that a mnem migrate handles
better.
A signature field in the commit object, like git. Rejected. It ties commit identity to the signature, so re-signing or adding a second signature rewrites the commit and orphans its children. Git accepts this partly for transport reasons we do not have.
Signing the bare commit id. Rejected. It does not bind who signed or when.
Structured author now. Rejected. Agent identity is unsettled and belongs
with the v2 work; a string does not block that and stays forward-compatible.
committer alongside author. Rejected. The split serves git's rebase and
patch flows, which v1 does not have.
ADR-0007: Versioning and release policy
- Status: Accepted
- Date: 2026-09-06
- Issue: #17 (grilling)
Context
ADR-0004 fixed that the crate, the wheel and the CLI carry one version number and
move together. ADR-0002 said the on-disk format is "frozen within a major
version" and that a breaking format change ships mnem migrate. This ADR turns
that into a concrete policy: what the version scheme is, what a bump means, how
the format version relates to the software version, and how a release is cut.
The hard part is the second question. ADR-0002 and ADR-0003 call adding a new
object kind, such as claim in v2, "additive". But a v1.0 reader rejects
unknown kinds, so a store written by a later v1.x is not fully readable by v1.0.
If every format change of any kind forced a major software release, claim
support alone would make Mnemosyne 2.0.
Decision
Version scheme
SemVer 2.0.0. One number for the crate, the wheel and the CLI.
Under 1.0, the Cargo 0.x convention: in 0.MINOR.PATCH, a bump of MINOR may
break the public API or the format, a bump of PATCH may not. This holds
through the whole v0.x roadmap. From v1.0, full SemVer applies.
The format version is a separate track
config carries a format_version integer. It is not the software version.
The software version is an API contract; format_version is a data contract.
Each release declares the format_version range it can read and the single
version it writes. The mapping between a format change and a software bump:
| Format change | format_version | Software bump | Migration |
|---|---|---|---|
| none | unchanged | PATCH or MINOR per the API | none |
| additive (a new object kind, a new optional field) | +1 | MINOR | none. An older release cannot open a newer store, but a newer release opens any older store it declares support for. |
| breaking (an old reader would misread an existing object) | +1 | MAJOR | mnem migrate reads the old store and writes a new one. |
So claim support in v2 is an additive change: format_version goes up by one,
and it ships in a MINOR software release, not a major one. A change that alters
how an existing object is interpreted is major and gets a migration.
An older release that meets a format_version it does not know refuses the
store with a clear message naming the version it would need.
Pre-release tags
SemVer pre-release identifiers: vX.Y.Z-rc.N, vX.Y.Z-beta.N, vX.Y.Z-alpha.N.
A hyphenated tag publishes as a GitHub pre-release, and as a pre-release version
on crates.io and PyPI, which is not installed by default. mnem --version prints
the full string including the identifier.
The release process
A release is gated on, in order:
mainis green.- The version in
Cargo.tomlis bumped to the intendedX.Y.Zand matches the tag about to be pushed. CHANGELOG.mdhas a dated section forX.Y.Z, moved down fromUnreleased.- The tag
vX.Y.Zis pushed. - A
releaseworkflow builds and publishes the crate to crates.io and the wheels to PyPI, and cuts a GitHub release with the changelog section as its notes.
The workflow refuses if the Cargo.toml version and the tag disagree. Building
the workflow is a Phase 6 task (#68); this ADR fixes the policy it enforces.
Changelog
A hand-written CHANGELOG.md in the Keep a Changelog format. Any user-visible
change updates it in the same pull request, under an Unreleased section that is
dated and renamed to the version at release time. Commits are already small and
issue-referenced; a curated changelog reads better than a generated one, and
there is one maintainer to keep it honest.
Deprecation
Post-1.0: a deprecated public API element (a CLI flag, an SDK method) is marked deprecated in a MINOR release, keeps working with a warning for at least one further MINOR, and may be removed only in the next MAJOR.
Pre-1.0: deprecation is best-effort. An element can be removed in a 0.x MINOR
with a changelog note.
Yanking
If a published crate or wheel is broken, cargo yank the crate version and mark
the PyPI release yanked. The version number is never reused. The fix goes out as
the next PATCH. The git tag stays, because history is immutable, and the GitHub
release is marked as broken.
Consequences
- A v2 that adds
claim, the Contradiction object and the embedding index is a MINOR software release, since all three are additive. Mnemosyne 2.0 is reserved for a genuine break. - Two version numbers to keep straight: the software SemVer and
format_version. The mapping table above is the single source of truth, andmnem --versionwill print both. - An older release cannot open a store written by a newer one. This is accepted: a release never promised to read the future, and the error message is explicit.
- The changelog is manual work on every user-visible PR. Accepted for the quality it buys a reader.
Alternatives considered
Any format change is a MAJOR software bump. Rejected. It conflates the data contract with the API contract and turns every additive tweak into a major release, which trains users to ignore major bumps.
Additive format changes are a MINOR bump, with no separate format_version.
Rejected. Without a format_version integer, an old release has no clean way to
detect that a store is too new, and would fail with a parse error rather than a
useful message.
Generate the changelog from commits or PR labels. Rejected. A generated changelog is a list of commits, not a description of what changed for a user. With one maintainer and small commits, hand-writing it is cheap.
Independent version numbers for the crate, the wheel and the CLI. Settled in ADR-0004: rejected there, and this ADR inherits the one-number rule.
ADR-0008: Object encoding and the store engine
- Status: Accepted
- Date: 2026-09-06
- Issue: #20 (grilling), #19 (research)
Context
ADR-0002 fixed redb as the store engine and left the byte encoding of an
object to this ticket. ADR-0005 requires a deterministic canonical serialisation:
the same logical object must produce the same bytes, and therefore the same
BLAKE3 hash. ADR-0002 and ADR-0007 require that objects are self-describing and
that additive changes do not break an older reader.
The #19 survey compared bincode, postcard, MessagePack, CBOR and JSON. bincode and postcard are the fastest and smallest, but have no canonical mode and no schema evolution. JSON is legible but large, slow, and cannot hold bytes natively. CBOR is the only candidate that is self-describing, has a standardised deterministic encoding (RFC 8949 §4.2), and stays legible through diagnostic notation.
Decision
Format
Objects are encoded as CBOR (RFC 8949), restricted to a deterministic profile.
The deterministic profile
The base is RFC 8949 §4.2:
- integers and floats in their shortest form that round-trips (preferred serialisation)
- no indefinite-length items
- map keys sorted in bytewise lexicographic order of their encoded form
Two additions of our own, recorded here and in docs/format/:
content(ADR-0003) is the JSON data model exactly. Finite numbers only. A non-finite float (NaN,±Infinity) is rejected at the SDK boundary and never reaches the core. Finite floats keep their value; they are not reduced to integers (this is not dCBOR).- No CBOR tags are used. Every value is a plain CBOR string, byte string, integer, float, boolean, null, array or map.
CDE and dCBOR were considered and not adopted: both are still drafts, and
dCBOR's float-to-integer reduction would silently rewrite an agent's content.
Struct-to-CBOR mapping
Every object is a string-keyed CBOR map. The kind key is present in every
object and is conventionally written first, though the deterministic profile
sorts keys regardless.
Positional arrays were rejected: legibility and additive evolution are explicit requirements, and an array makes adding a field fragile and the object unreadable. The cost of repeating short field names per object is accepted.
Implementation
ciborium provides the CBOR data model. A thin pass of ours enforces the
deterministic profile on encode: sort map keys, force shortest-form numbers,
forbid indefinite-length. The rule set is small and is tested against the RFC's
own examples.
cbor2 (canonical encoding built in) and a fully hand-rolled serialiser were
considered. cbor2 has a short track record, which is a real risk for a
dependency the frozen format rests on. A hand-rolled serialiser is more than is
needed once ciborium covers the data model.
No per-object framing
An object's on-disk bytes are exactly its canonical CBOR. There is no magic
number, no length prefix, no version byte on the object. kind identifies the
object type; format_version lives in config (ADR-0007); redb frames the
key and value.
The store engine layer
Two tables in the one redb file:
objects : [u8; 32] -> Vec<u8> BLAKE3 hash to canonical CBOR bytes
refs : &str -> [u8; 32] branch name to commit hash
redb = "2" is pinned in Cargo.toml. docs/format/ records the redb
file-format version a store was written with. A mnem commit is a single
durable redb write transaction: fsync on commit, correctness over speed. A
--no-fsync escape hatch for bulk import may be added later.
Verifying the canonical encoding
Two CI checks, from Phase 1:
- A property test:
encode(decode(encode(x))) == encode(x)for generated objects (idempotence), and two semantically equal objects (for instance a map built with keys inserted in different orders) encode to identical bytes. - Golden vectors in
docs/format/: a fixed set of objects and their exact hex encodings, pinned by the test, so an accidental change to the encoder is caught immediately.
Consequences
- The encoder is a small amount of our own code on top of
ciborium, fully testable against a public spec. - Objects are inspectable:
mnem cat-objectdecodes the CBOR and prints diagnostic notation, or JSON with a flag. - Decode is slower than bincode. For a store read a few objects at a time this does not matter, and it buys the three properties bincode lacks.
redbandciboriumare the two dependencies the frozen format rests on. Both are widely used with stable formats. A break in either is absorbed by amnem migrateunder ADR-0007.mnem commitfsyncs, so a commit-heavy workload is bounded by disk sync latency until the--no-fsyncflag exists.
Alternatives considered
bincode or postcard. Rejected. No canonical mode, not self-describing, no schema evolution. Wrong tool for a frozen inspectable format.
MessagePack. Rejected. Its deterministic story is weaker than CBOR's §4.2, and its diagnostic tooling is poorer.
JSON with JCS (RFC 8785). Rejected. Largest and slowest, and no native byte strings, so a BLAKE3 hash inside an object would need base64.
CDE or dCBOR. Rejected for now. Both are drafts; dCBOR alters data.
cbor2 for its built-in canonical encoding. Rejected. Too new to be a
load-bearing frozen dependency; ciborium plus a small pass is safer.
Positional CBOR arrays instead of maps. Rejected. Fights legibility and additive evolution.
ADR-0009: The ref model
- Status: Accepted
- Date: 2026-09-06
- Issue: #21 (grilling)
Context
ADR-0002 and ADR-0005 fixed that HEAD is a plain-text file and the source of
truth for the current pointer, and ADR-0008 fixed that named refs live in a
redb table refs: &str -> [u8; 32]. This ADR fixes the semantics: what a ref
is, how it is named, what HEAD contains, and what happens on a concurrent
update or a branch deletion.
The design space is small and well understood from git and jujutsu. The decisions below take git's model and drop the parts that exist only because git's refs are files on a filesystem.
Decision
A ref is a branch
In v1 a ref is a branch: a mutable name -> commit id pointer. There are no tags
(immovable pointers) in v1. Nothing needs them yet; they are a plausible later
addition.
Flat namespace
Refs are keyed by their bare name in the refs table: main, hypothesis-x.
There is no refs/heads/ hierarchy. Git's hierarchy separates heads from tags
and remotes; v1 has only heads. If tags or remotes arrive (v2 sync, v3), a
prefix convention is an additive change, not a format break.
Naming rules
A branch name is a non-empty string that:
- contains only Unicode letters, digits,
-,_,.,/ - does not start or end with
/or. - contains no
.., no whitespace, and no control characters
Names are case-sensitive. HEAD is reserved and is not a branch name.
HEAD
HEAD is a one-line plain-text file.
- Attached:
ref: <branch-name>.HEADfollows the branch; a commit moves the branch andHEADwith it. - Detached:
<64-hex-commit-id>.HEADpoints straight at a commit; committing from here is refused until a branch is created. - Fresh store, no commits:
ref: main.maindoes not exist inrefsyet. The first commit creates it.
The default branch
main. Overridable by a default_branch key in config for anyone who wants
something else.
Ref updates are compare-and-swap
A mnem commit moves a branch inside the same redb write transaction that
writes the objects (ADR-0008), so the move is atomic. The move is also a
compare-and-swap: the transaction asserts the branch still points where HEAD
resolved it to when the commit started. On a mismatch the commit fails with "the
branch moved under you" and writes nothing.
This costs nothing inside the transaction. Single-agent v1 will almost never hit it. It is the primitive v2's concurrent agents need, so it goes in now.
Branch deletion
mnem branch -d <name> removes the refs row.
- Deleting the branch
HEADpoints at is refused. - Deleting the last branch of a store that has a commit is refused.
- Commits that only the deleted branch reached become unreachable but are not removed (no GC in v0.x, ADR-0008). They are recoverable by hash.
Reflog: reserved, not built
A reflog (a per-ref history of the commits a ref has pointed at) fits the
product and is cheap: append (ref, old, new, time, op) to a reflog table on
every ref move. It is not needed for the Phase 1 definition of done, and it
interacts with the eventual GC design, so it is deferred. It lands as its own
ticket in Phase 2, or alongside blame and bisect in Phase 4.
Consequences
- The ref layer is a thin wrapper over one
redbtable plus a text file. Small. - Compare-and-swap on every commit means the concurrency story is already correct when v2 arrives; no retrofit.
- Without a reflog, recovering a mistakenly deleted branch needs its tip hash. Since there is no GC, the commits are still there; the user just needs to know the hash. The reflog closes that gap when it lands.
- A flat namespace means adding tags later needs a naming convention (a
tags/prefix, or a second table). That is a Phase-7 concern and additive.
Alternatives considered
Lightweight tags in v1. Rejected. Nothing uses them, and they add naming and deletion rules for no v1 benefit.
A git-style refs/heads/ hierarchy. Rejected for v1. It exists to separate
ref categories git has and v1 does not.
No compare-and-swap; last write wins. Rejected. It is free to add now and painful to retrofit once v2 has concurrent writers, and "last write silently wins" is the wrong default for a tool whose job is not losing history.
Build the reflog in v0.1. Rejected. Not on the Phase 1 critical path, and better designed together with GC.
HEAD stored in redb rather than a text file. Settled in ADR-0002:
rejected there, because HEAD is the one pointer a person most often reads or
sets by hand.
ADR-0010: What 1.0 means, and the 0.0.x roadmap
- Status: Accepted
- Date: 2026-09-06
- Issue: #95
Context
ADR-0007 set the version scheme: one SemVer number for the crate, the wheel and
the CLI, with a separate format_version integer for the data contract. It was
written against a roadmap that reached 1.0 at the end of a six-phase substrate
build, and it said the 0.x convention "holds through the whole v0.x roadmap".
Since then the ambition has settled. The end state is not the single-agent substrate; it is the agent as a versioned, signed, forkable artefact with a registry to publish and improve agents, what earlier docs call v3. The substrate and the multi-agent collaboration layer are both groundwork for that. Numbering the substrate's first working build 0.1.0, a sixth of the way to 1.0, oversells how far along the project is.
This ADR fixes what 1.0 means and how versions are numbered until then. It does
not change the format_version track or the release process from ADR-0007.
Decision
1.0 is the platform. Mnemosyne 1.0.0 is the release where an agent, its prompt, tools, memory, policy and evaluations are versioned and signed as one artefact, with a registry to publish, discover and fork them. Nothing smaller is 1.0.
Three eras, all pre-1.0 except the last.
- Era 1, the substrate. Single-agent versioned memory: commit, branch, merge, blame, bisect, time travel. Local and deterministic. This is the current six-phase roadmap.
- Era 2, the collaboration layer. Semantic merge over claims, a sync protocol between stores, and a review step before an update lands in shared memory.
- Era 3, the platform. The agent as a repository, plus the registry. Ships as 1.0.0.
Where older ADRs, research notes and issues say "v1", "v2" or "v3", read Era 1, Era 2 and Era 3. None of them meant a software major version.
The roadmap is 0.0.x. Every milestone from here until the eras are
substantially done is a 0.0.x tag. Phase 1 is v0.0.2, following Phase 0's
v0.0.1; each later phase is the next patch, through v0.0.7. Era 2's work
continues in the same range.
0.0.x is initial development. Per SemVer, anything may change on any bump: the
public API and the on-disk format included. ADR-0007's stricter promise, that a
PATCH bump may not break the API or the format and a MINOR may, takes effect at
0.1.0. When to cut 0.1.0, or whether to move straight from 0.0.x to
1.0.0, is decided when Era 3 is in view and the API is worth stabilising.
The format version track is unchanged. format_version still starts at 1
and still increments by exactly the rules in ADR-0007's table, so an older
release always meets a store it cannot read with a clear message. The only
difference while in 0.0.x is that a format_version increment ships in a
0.0.x bump rather than a MINOR one.
Consequences
- The version number matches the honest state of the project: barely started, on a long road.
- One renumber, once: the six roadmap milestones become
v0.0.2throughv0.0.7. TheROADMAP.mdtable, theREADME.mdarc, the GitHub milestones and the phase label descriptions are updated in the same change as this ADR. - ADR-0007 stays Accepted. This ADR narrows one of its clauses for the
0.0.xperiod and schedules when the rest applies; it supersedes nothing. - Until
0.1.0, theformat_versioninteger, not the software version, is the stability signal. A reader who sees0.0.4knows not to build anything load-bearing on the format yet.
Alternatives considered
Keep 0.MINOR per phase (Phase 1 = 0.1.0 ... Phase 6 = 0.6.0), push 1.0 out in the narrative only. Least churn, but 0.6.0 for a single-agent substrate with no multi-agent story still reads as most of the way to done. Rejected.
Two 0.x eras: 0.0.x for the substrate, 0.1.x onward for collaboration. A cleaner boundary, but it commits now to cutting 0.1.0 at a point that is still far off and better decided later. Rejected for the looser 0.0.x range.
A dated amendment to ADR-0007 instead of a new ADR. ADRs are immutable once Accepted (ADR-0001). A new ADR is the mechanism.
ADR-0011: Conventional commits and the issue lifecycle
- Status: Accepted
- Date: 2026-09-06
- Issue: #100
Context
The project has around fifty commits in a plain student voice, and issues close
the moment a pull request merges to main. Both were fine while there was no
release cadence. As the history grows and releases begin (ADR-0007, ADR-0010),
two gaps show:
- A plain subject line carries no machine-readable signal. A convention lets release notes group changes, lets a check cross-reference the hand-written changelog, and tells a reader the project is run to a standard.
- Closing an issue on merge to
mainconflates "inmain" with "shipped". A store written byv0.0.2is the contract, not whatever is onmain.
This ADR fixes the commit convention and the issue lifecycle. It does not change the hand-written changelog (ADR-0007) or the versioning scheme (ADR-0010).
Decision
Conventional commits
Every commit message and every pull request title follows Conventional Commits 1.0.0:
<type>(<scope>): <description>
<body>
refs #<issue>
- type: one of
feat,fix,docs,test,refactor,perf,build,ci,chore,revert. - scope: optional, lowercase, a crate or area:
core,cli,sdk,format,adr,ci. Use it when it sharpens the subject. - description: lowercase, imperative, British English, concise, no trailing full stop. Aim for 72 characters.
- body: optional, plain student voice, wrapped. Explains why, not what.
- No co-author trailer, ever (unchanged, AGENTS.md).
Earlier commits are left as they are; the history has a small discontinuity at this ADR.
refs #N, not closing keywords
A commit or pull request that relates to an issue puts refs #<n> in the body.
GitHub's closing keywords (closes, fixes, resolves) are not used, so a
merge to main never closes an issue on its own.
Issues close at release
An issue closes when the tag that contains its change is pushed, not when its
pull request merges. Between merge and release the issue carries the
pending-release label. A phase's map issue closes when its milestone tag is
pushed.
Enforcement
scripts/conventional_commits.py validates the subject of every non-merge
commit in a pull request, and the pull request title, against the format above,
and rejects closing keywords. It has a unit test and runs in a CI commits job
on pull_request events.
Consequences
- Release notes and
git log --onelinebecome scannable by type. - One more check to pass on a pull request. It is fast and local via
just ci. - The changelog stays hand-written; a later check can diff it against the commit types in a release range.
- The tracker now distinguishes "merged" from "released", at the cost of a batch issue-close step at each tag.
Alternatives considered
Keep the student voice. Rejected. No machine signal, and a weaker read for anyone judging whether the project is serious.
Conventional pull request titles only, free-form commits. Rejected. The
squash history would be clean but the branch history stays noisy, and the check
would only be half a check. Merges here are merge commits, not squashes, so the
individual commits land on main.
Commitizen or semantic-release. Rejected. Overkill for one maintainer, and ADR-0007 already chose a hand-written changelog over a generated one.
ADR-0012: The branch and checkout model
- Status: Accepted
- Date: 2026-09-06
- Issue: #35 (grilling), research #34
Context
Phase 2 (v0.0.3) adds branching, checkout, reading working memory at any past
commit, a structural diff, and node deletion. It is still Era 1: single-agent,
local, deterministic (ADR-0010).
The research (docs/research/issue-34-branching-survey.md) found that the hard
part is already done. ADR-0009 built the refs table (name -> [u8; 32]) with
compare-and-swap moves and name validation, and Store::commit already creates
and advances a branch. A branch is a 32-byte value in one row, so branch
creation is O(1) and copies nothing. What is missing is the surface and one
decision: what "working memory" is, given that there is no working-tree concept
today, only staging (the index equivalent).
This ADR fixes that surface. It changes no on-disk format: format_version
stays 1.
Decision
Branches are pointer-only
No copy-on-write of state at branch time, because nothing is copied.
Store::branch(name, start: Option<CommitIsh>)inserts arefsrow at the given start point, or whereHEADresolves. Fails if the name already exists (no silent move) or is invalid (ADR-0009 rules).Store::branches()isrefs::list.Store::delete_branch(name)refuses the branchHEADis on and otherwise drops the row. There is no force variant: Phase 2 has no garbage collection, so a deleted branch's commits stay reachable by id.
CLI: mnem branch lists (current marked *, with the tip's 12-hex id and
message); mnem branch <name> [<start-point>] creates; mnem branch -d <name>
deletes.
checkout is one atomic HEAD write
Store::checkout(target: CommitIsh, discard: bool):
- Resolves
target: an exact branch name first, then an unambiguous commit-id hex prefix (minimum four characters). A branch target gives an attachedHEAD; a commit target gives a detachedHEAD. - Refused when
stagingorstaging_tombstonesis non-empty, with a message naming the pending ids, unlessdiscardis set (then both are cleared). - Writes
HEADatomically (temp file plus rename). Nothing else is touched. No lock is taken. - Checkout of the current branch, or of the commit
HEADalready resolves to, is a no-op that succeeds.
CLI: mnem checkout <target>, mnem checkout -b <name> [<start-point>]
(create-and-switch; the name must not exist). On success it prints a short line:
Switched to branch 'h1', Created branch 'h1' at 45ee8a6b, or HEAD is now at 45ee8a6b (detached).
Working memory is a view, never stored
Working memory is defined as:
the
HEADcommit'sState, minus the ids instaging_tombstones, withstagingoverlaid.
It is computed on every read. There is no working table.
Store::working_memory() -> BTreeMap<String, MemoryNode>(decoded).- The SDK returns it as
dict[str, MemoryNode], keyed by node id. mnem showwith no argument prints working memory.
Time travel reads any commit
Store::state_at(commit) -> BTreeMap<String, MemoryNode>loads a commit'sStateand its nodes, decoded.Store::state_map_at(commit) -> BTreeMap<String, ObjectId>is the raw form for callers that do not need content (diffuses it internally).- Any commit that exists in
objectsand is aCommit. No branch, no ancestry requirement. Read-only, so no lock. mnem show <commit>prints the full memory snapshot at that commit.
Node deletion
Store::rm(id)stages a tombstone in a newstaging_tombstonestable (a set of node ids). This table is local working state, likestaging: it is not part of the portable history and does not travel with a store.rmof an id that is neither in theHEADstate nor staged is an error.rmof an id that is only a staged add (not inHEAD) unstages it instead of tombstoning.commitbuilds the newStatefrom the parent'sStateplusstaging, then removes every tombstoned key. A commit with only tombstones staged is valid.unstage(id)clears the id from bothstagingandstaging_tombstones.
CLI: mnem rm <id>.
Structural diff
Store::diff(from: DiffTarget, to: DiffTarget) -> Vec<NodeChange>whereDiffTargetisCommit(ObjectId)orWorking.NodeChangeisAdded { id, new },Removed { id, old }, orModified { id, old, new }, carryingObjectIds only. Computed as a merge-join over the two sortedStatemaps: O(nodes). Any two states; no ancestry required.- The SDK returns
list[NodeChange], a frozen dataclass (id,kind, andold/newasMemoryNode | None).
CLI: mnem diff (HEAD vs working), mnem diff <commit> (vs working), mnem diff <a> <b>. It shows the old and new content of each change by default;
--stat and --name-only give the terse id list.
status
In scope for v0.0.3. mnem status prints the branch line (On branch main,
or HEAD detached at 45ee8a6b), the HEAD commit's short id and message, then
diff(HEAD, Working) rendered as + (new), ~ (update), - (removal) against
the node id. "Clean" when nothing is staged.
The SDK branch context manager
The ADR-0004 carve-out. with store.branch("h1"):
- On enter: create
h1atHEAD, checkouth1. Entering with dirty staging raises, percheckout's rule. - On exit, normal or exception: checkout back to the branch that was current on
enter; leave
h1in place for the caller tomerge(Phase 3) ordelete_branch; re-raise on exception.
No automatic merge. Merge is Phase 3.
Deferred
- The prolly-tree
Stateform. Phase 2 keeps the flatState(a full id-to-ObjectIdmap per commit). Storage grows as commits times nodes, which is fine at the scale an agent runs at. A prolly form (a secondStatekind, aformat_versionbump) is introduced when the first of these fires: Phase 3's three-way merge is judged to need it, or a real store hits the storage ceiling. Whichever comes first opens its own research ticket. - The reflog. ADR-0009 reserved it; Phase 2 does not write it. Detaching prints the commit id so it is not lost.
- Rev-navigation syntax (
<commit>^,<commit>~2). Explicit ids frommnem logfor now.
Consequences
- Branching and checkout cost one small write each. The design adds no on-disk
format structure that a store reader sees;
format_versionstays 1. - Working memory has no materialisation cost and no third place to keep in sync, at the price of an O(nodes) walk per full read. That walk is the true cost of "give me the whole memory" under any design.
staging_tombstonesis a second piece of local-only state.commit,unstageandstatusall have to account for it.- An agent can now forget a stale belief (
rm), fork memory to try an approach (branchplus the context manager), see what it changed (diff,status), and read what it knew at any past point (show <commit>). - The flat-
Stateceiling is real and named. A long-lived agent will hit it before a short one; the trigger and the response are written down rather than discovered.
Alternatives considered
A materialised working table that checkout fills. Closest to Git's
working tree. Rejected: an O(nodes) write on every checkout, and a third place
the node set lives and can drift. The view has none of that cost.
Carry staged changes across a checkout when they do not collide (Git's real
behaviour). Rejected: it doubles the checkout logic for a workflow that does not
need it. Refuse-by-default with --discard is one path.
Defer node deletion to Phase 3. Rejected: it would ship v0.0.3 and
v0.0.4 with no way for an agent to remove a fact, and Phase 3's merge would
have to invent the primitive anyway.
Introduce the prolly tree now, in Phase 2 (ADR-0002 originally placed it here). Rejected for this phase: none of Phase 2's deliverables need it to be correct, and it is real work (a chunker, a node format, a rebalancing story, a format change). Deferred with named triggers.
Start writing a reflog in Phase 2, since checkout is the first operation
that wants one. Rejected: the reflog is its own design (retention, HEAD versus
per-branch, a mnem reflog surface) and nothing in Phase 2's definition of done
needs it.
ADR-0013: The deterministic merge algorithm
- Status: Accepted
- Date: 2026-09-07
- Issue: #43 (grilling), research #42
Context
Phase 3 (v0.0.4) adds merge: combining two lines of memory. It is the last
purely mechanical piece of the substrate. The merge is structural and
deterministic: it works on the State map (node id to object id), one entry
at a time, and never looks inside a node's content. Semantic merge over claim
contents is Era 2 and lives behind a trait in a separate crate.
The research (docs/research/issue-42-merge-survey.md) surveyed the per-id
three-way merge table, the merge base, and the invariants. ADR-0012 named this
phase as a reassessment point for the prolly-tree State form. Issue #45 built
Store::merge_base / merge_bases / ancestors / is_ancestor.
This ADR fixes the algorithm. ADR-0014 fixes the conflict object and the
resolution API. Neither changes format_version: a merge commit is an ordinary
two-parent Commit, and the merged State is an ordinary flat State.
Decision
The merge base
merge requires exactly one merge base (Store::merge_base, #45). If two
commits have several lowest common ancestors (a criss-cross history) or none
(disjoint histories), merge refuses with a message naming them. Recursive
merge over a virtual base (Git's ort strategy) is deferred; the manual path is
to merge one side first. A single-agent substrate with a human in the loop
rarely produces a criss-cross.
Fast-forward
If theirs is a descendant of the current branch tip, there is nothing to
merge. merge fast-forwards: it moves the branch ref to theirs, writes no
merge commit, and reports FastForwarded(theirs). The CLI prints
Fast-forwarded '<branch>' to <short id>. There is no --no-ff; agents do not
want merge commits for trivial merges. If theirs is an ancestor of the tip (or
equal), merge reports "already up to date" and does nothing.
The per-id three-way merge
Let B, O, T be the State maps of the base, ours (the current branch
tip), and theirs. For each node id present in any of them, with b, o, t
its object id in each (or absent):
b | o | t | result |
|---|---|---|---|
| x | x | x | keep x |
| x | y | x | keep y (ours changed) |
| x | x | y | keep y (theirs changed) |
| x | y | y | keep y (convergent edit) |
| x | y | z | conflict EditEdit |
| x | absent | x | absent (ours deleted) |
| x | x | absent | absent (theirs deleted) |
| x | absent | absent | absent (convergent delete) |
| x | absent | z | conflict DeleteEdit |
| x | y | absent | conflict EditDelete |
| absent | y | absent | keep y (ours added) |
| absent | absent | z | keep z (theirs added) |
| absent | y | y | keep y (convergent add) |
| absent | y | z | conflict AddAdd |
The table is total: every combination is an automatic result or a named
conflict. delete/edit and add/add (with differing objects) are conflicts,
not silent resolutions, because auto-resolving loses a side's intent.
The merged map is built by walking the union of ids in sorted order, so a
clean merge produces a bit-identical State object regardless of which side is
"ours", and the conflict list is in node-id order.
The merge call (Model A: stateless)
Store::merge(
theirs: &str, // a commit-ish
resolutions: &BTreeMap<String, Resolution>,
strategy: Option<MergeStrategy>, // Ours | Theirs
message: Option<&str>,
author: &str,
time_ms: i64,
) -> Result<MergeOutcome>
enum MergeOutcome {
AlreadyUpToDate,
FastForwarded(ObjectId),
Merged(ObjectId), // the new two-parent commit
Conflicts(Vec<Conflict>), // nothing was written
}
There is no pending-merge state, no MERGE_HEAD, no local table, no
--continue. merge is a function: run it, and if it returns Conflicts,
inspect them, build a resolution map, and call merge again with it.
- A
resolutionsentry for an id that is not a conflict is an error, so a stale map is caught rather than silently ignored. strategy: Some(Ours)resolves every conflict too;Some(Theirs)tot. An explicitresolutionsentry overrides the strategy for that id.- If, after applying
resolutionsand anystrategy, some conflicts remain unresolved,mergereturnsConflictswith just those.
The merge commit
On a clean merge or a fully resolved one, merge writes the merged State,
then a Commit with:
parents = [ours_tip, theirs_tip](ours first, solog --first-parentfollows the branch we were on)message= the caller's, ormerge <theirs> into <branch>author,timefrom the caller, as withcommit- a compare-and-swap of the branch ref against
ours_tip(ADR-0009), so a concurrent commit makes the merge fail rather than clobber
Preconditions
merge is refused, with a clear message, when:
HEADis detached (merge only from a branch, likecommit)stagingor the tombstone set is non-empty (a dirty index; commit or unstage first, no--discard)
State stays flat; prolly reassessed
The merge is O(distinct node ids across B, O, T); the base walk is O(history
size). Both are trivial at an agent's scale (hundreds of ids, short histories).
The prolly-tree State form's payoff (diff and merge proportional to the change,
not the size) matters at thousands to millions of ids. Flat State is kept for
Phase 3. The trigger for prolly is unchanged: a real store hitting the storage
or latency ceiling, which then opens its own research ticket.
Consequences
mergeis a pure-ish function with a small surface and no extra states to reason about. The SDK reads naturally; the CLI resolves conflicts with flags.- No
format_versionchange: a merge commit and a mergedStateare ordinary objects. - A criss-cross history is a hard error, not a silent guess. Accepted: it is rare here, and the workaround (merge one side first) is clear.
- A merge that hits conflicts writes nothing, so there is never a
half-merged store on disk. The cost is that the caller re-runs
mergewith the full resolution map rather than resolving incrementally. - Persistent, reviewable conflicts (for the Era 2 review model) will need the pending-merge state this ADR omits. That is Era 2's to add.
Alternatives considered
A pending-merge state with merge --continue / --abort (Git's model).
Rejected for v0.0.4: it needs a local table, a MERGE_HEAD equivalent, and
several mid-merge states, for a workflow (interactive incremental conflict
resolution) that a single agent does not do. It returns in Era 2 with the review
model, which genuinely needs conflicts to persist and be reviewed.
Auto-resolving delete/edit and add/add (the edit wins, the newer add
wins). Rejected: it silently discards one side's intent. A conflict makes the
caller choose.
Always writing a merge commit, even for a fast-forward (--no-ff by
default). Rejected: it clutters the history with trivial merges. --no-ff can be
added later if wanted.
Recursive merge over a virtual base for criss-cross histories. Deferred, not rejected. It is correct and Git does it, but it is a body of work for a case that a human-gated single agent rarely produces.
ADR-0014: The conflict object and the resolution API
- Status: Accepted
- Date: 2026-09-07
- Issue: #44 (grilling), research #42
Context
ADR-0013 fixed the merge algorithm: a structural three-way merge over the
State map that produces a merged map plus a set of conflicts. This ADR fixes
what a conflict is, how a caller resolves one, and how that surfaces in the
CLI and the SDK.
The merge is a stateless call (ADR-0013, Model A): merge returns
Conflicts(Vec<Conflict>) and writes nothing; the caller inspects, builds a
resolution map, and calls merge again.
Decision
The conflict, a transient value
A conflict is a plain value returned by merge, not a stored content-addressed
object:
#![allow(unused)] fn main() { pub struct Conflict { pub id: String, pub kind: ConflictKind, pub base: Option<ObjectId>, // None for AddAdd pub ours: Option<ObjectId>, // None for DeleteEdit pub theirs: Option<ObjectId>, // None for EditDelete } pub enum ConflictKind { EditEdit, // both changed the node to different objects DeleteEdit, // ours deleted, theirs changed EditDelete, // ours changed, theirs deleted AddAdd, // both added the id with different objects } }
base, ours and theirs are the node object ids on each side, so the caller
can load and compare their content (Store::node, ADR-0012). The SDK exposes it
as a frozen Conflict dataclass whose base / ours / theirs are
MemoryNode | None.
A stored conflict object, content-addressed and referenced from a persisted merge state, is what the Era 2 review model needs so a reviewer can approve or reject a resolution before it lands in shared memory. That is deferred to Era 2.
Resolutions
Per conflicting id, the caller supplies one:
#![allow(unused)] fn main() { pub enum Resolution { Ours, // take `ours` Theirs, // take `theirs` Base, // take `base`, reverting both edits (invalid for AddAdd) Delete, // the merged state omits the id Set(MemoryNode) // a fresh node as the resolution } }
Set(node) writes the node object to objects (the same path stage uses) and
uses its id. Base on an AddAdd conflict is an error, since there is no base
object.
A resolution map (BTreeMap<String, Resolution>) is passed to merge. An entry
for an id that is not a conflict is an error. Unresolved conflicts after the map
and any strategy are re-returned.
The merge outcome across the boundary
The SDK returns a small result object from store.merge(...):
@dataclass(frozen=True)
class MergeResult:
status: str # "up-to-date" | "fast-forwarded" | "merged" | "conflicts"
commit: str | None # the new commit id, for fast-forwarded / merged
conflicts: list[Conflict] # for "conflicts", else empty
Usage:
result = store.merge("feature")
if result.status == "conflicts":
resolutions = {c.id: pick(c) for c in result.conflicts}
result = store.merge("feature", resolutions=resolutions)
resolutions values are the strings "ours" | "theirs" | "base" | "delete" or a
MemoryNode (for set). strategy="ours" | "theirs" is the whole-merge
shortcut.
The CLI
mnem merge <branch-or-commit>
mnem merge <branch-or-commit> --resolve <id>=<ours|theirs|base|delete> ...
mnem merge <branch-or-commit> --strategy <ours|theirs>
- A clean merge or a fully resolved one prints
[<short id>] merge <theirs> into <branch>and exits 0. - A fast-forward prints
Fast-forwarded '<branch>' to <short id>. - Unresolved conflicts are printed, one block per id (the kind and the content
of each side), and the command exits non-zero. The user re-runs with
--resolveflags or--strategy. --resolvewith aset(a supplied node) is not offered on the CLI inv0.0.4; use the SDK or resolve toours/theirs/base.
mnem status is unchanged: with no pending-merge state, there is nothing
mid-merge to show.
Consequences
- A conflict is cheap: no object written, no id to garbage-collect, no schema.
The trade is that conflicts do not survive the process; a caller that wants to
resolve later re-runs
merge. - The resolution set is small and closed.
Set(node)covers "neither side is right"; the other four cover the common picks. - The CLI conflict-resolution flow is flag-driven rather than an editor loop.
Blunt, but agents drive this far more than humans, and
--strategy oursis the usual agent choice. - Era 2's review model will add a stored conflict object and a persisted merge
state on top of this; the transient
Conflictshape here is the starting point for that object's fields.
Alternatives considered
A stored, content-addressed conflict object now. Rejected for v0.0.4: it
buys "walk away and resolve later" and an audit trail, neither of which a
stateless single-agent merge needs. It is exactly what Era 2 adds, and building
it now would be guessing at the review model's requirements.
A richer resolution language (take-ours-for-these-fields, three-way text merge of content). Rejected: that is semantic merge, which is Era 2 behind a trait. The substrate's resolutions are whole-node.
An interactive mnem mergetool. Rejected for now: flags cover the human
case, and the primary caller is an agent.
ADR-0015: The provenance index, blame and bisect
- Status: Accepted
- Date: 2026-09-07
- Issue: #50 (grilling), research #135
Context
Phase 4 (v0.0.5) is "it explains". Two operations:
blame <node-id>— resolve a memory node to the commit, and the provenance, that introduced its current value.bisect— binary-search a commit range for the first commit where a supplied predicate holds ("this wrong belief is present").
Both are read-only walks over objects that already exist. Nothing here changes
format_version. Provenance lives on the MemoryNode (ADR-0003), not the
Commit, and may be entirely empty; blame therefore always resolves to a
commit and additionally surfaces whatever provenance the node carries.
The research (docs/research/issue-135-provenance-blame-bisect.md) found that at
this scale — hundreds of node ids, short histories — blame is a fast
first-parent walk and bisect is O(log N) state reads, so neither needs an
index. This ADR fixes the two operations and the one small index that is worth
building anyway.
Decision
The commit-nodes index (the reverse map)
A new redb table, commit_nodes, maps a commit id to the node ids it changed
relative to its first parent, each tagged with how it changed:
commit_nodes: commit_id (32 bytes) -> CBOR(BTreeMap<String, ChangeKind>)
enum ChangeKind { Added, Modified, Removed }
- Source.
diff(parents[0], commit)— the sameAdded/Removed/ModifiedclassificationStore::diffalready produces (ADR-0012). The first commit's entry is every node id in its state, allAdded. - Merge commits. Diffed against
parents[0]only, consistent with the first-parent modellogandblameuse. A merge commit legitimately "brought in" whatever the merged-in side added relative to ours. - Write path. Written inside the existing commit write-transaction
(
Store::mergeandStore::commiteach already open one), so the index entry and the commit are always consistent. A merge commit's entry is written in the same txn that writes the merge commit. - Rebuild.
Store::rebuild_index()does a one-pass walk of every commit and rewrites the table. Used on a format upgrade, if the table is missing, or in tests. There is no automatic rebuild onopen: a fresh0.0.5store builds the index forward from its first commit, and there are no older stores in the wild. A missing entry is never wrong — every reader falls back to a direct walk — so a partially built index is safe. - Exposure.
Store::changed_by(commit) -> BTreeMap<String, ChangeKind>in the core and SDK;mnem show <commit> --statprints one classified line per changed node (+ plan,~ owner,- status).
The forward map (node_id -> introducing commit for the current tip) is
deferred. It would give blame an O(1) answer it does not need yet, and it
is the awkward one to maintain: "current" is per-branch, so it must be patched
on every commit and every checkout. It lands when a real store makes the
blame walk too slow, or when the Era 2 review UI needs the reverse lookup at
interactive latency — the same "reassess on a real ceiling" trigger ADR-0012 and
ADR-0013 use for the prolly tree.
blame
#![allow(unused)] fn main() { pub struct Blame { pub commit: ObjectId, // the commit that introduced the current value pub node: MemoryNode, // the node as written at that commit pub time: i64, // node.event_time, or the commit's record time } impl Store { pub fn blame(&self, node_id: &str, at: &str) -> Result<Blame>; } }
atis a commit-ish (branch name or hex prefix,resolve_commitish), defaulting toHEAD. A detachedHEADor an unborn branch is a clean error.- Let
target = state_map_at(at)[node_id]; if the id is absent, error:blame: "<node_id>" is not in memory at <at>. "When was it deleted" is abisectpredicate or a futuremnem log --follow, notblame. - Walk the first-parent chain from
at. At each commitCiwith first parentCp: ifstate_map_at(Ci)[node_id] != state_map_at(Cp)[node_id], thenCichanged the value. The root commit (no parent) always counts. - Through a merge. If
Ciis a merge and its value fornode_iddiffers fromparents[0]but equals someparents[k], continue the walk fromparents[k]instead of stopping. This follows the value to the commit that actually wrote it, rather than reporting "a merge happened". - Index use.
blamemay consultcommit_nodesas a skip filter — if a commit's change set does not containnode_id, its state need not be loaded. The index is an accelerator, never the source of truth. provenanceis read offBlame.node.provenance; an empty provenance is legal and meansblameresolved only to the commit.
bisect
#![allow(unused)] fn main() { impl Store { pub fn bisect( &self, bad: &str, // predicate true; default HEAD good: Option<&str>, // predicate false; default the root predicate: impl Fn(&BTreeMap<String, MemoryNode>) -> bool, ) -> Result<ObjectId>; // the first `bad` commit } }
badandgoodare commit-ishes.gooddefaults to the root commit reached by the first-parent walk frombad.- Up-front checks:
goodis an ancestor ofbad(is_ancestor); the predicate isfalseatgoodandtrueatbad. Otherwise a clear error (the predicate is already true at <good>,<good> is not an ancestor of <bad>). - Monotonicity is assumed, as in
git bisect:falseup to a boundary,truefrom there on. A non-monotonic predicate gives a boundary that is one of possibly several transitions; this is documented, not detected. - Linear history only. Walk the first-parent chain from
badback togood, giving an ordered list, and binary-search it: O(log N)state_atevaluations. Merge commits on the chain are evaluated normally (their state is fully materialised); second parents are not descended into.git bisect's skip / multiple-bad-region handling is not built. - Returns the first commit on the chain (oldest-to-newest) for which the predicate holds — the boundary.
The CLI and the SDK
mnem blame <node-id> [<commit>]
mnem bisect --node <id> --equals <json-value> [--good <commit>] [<bad-commit>]
mnem bisect --node <id> --absent [--good <commit>] [<bad-commit>]
mnem bisect --node <id> --present [--good <commit>] [<bad-commit>]
mnem blame planprints the short commit id, the effective time, the author, and the provenance fields that are set, then the node content.mnem bisectbuilds the predicate from--nodeplus one of--equals(content equals this JSON value),--absent,--present. On success it prints the boundary commit and itsblamefor--node— "the belief entered at<short>, written at<step>from<observation>" — so finding and explaining a bad belief is one command. A general predicate command (git bisect runstyle) is a later ticket.- SDK:
store.blame(node_id, at="HEAD") -> Blame;store.bisect(bad="HEAD", good=None, predicate=...) -> strtaking a Python callable over a{id: MemoryNode}dict;store.changed_by(commit) -> dict.Blameis a frozen dataclass (commit,node,time).
The reflog
ADR-0009 parked the reflog — a small append-only (ref, old, new, time, op)
table — "for Phase 2, or alongside blame and bisect in Phase 4". It is
deferred again: blame and bisect walk the commit graph, not ref history,
so neither needs it, and folding it in widens the phase for no gain to the "it
explains" story. It moves to its own ticket, to be picked up in Phase 5 or 6 or
when GC design starts (the two interact).
Consequences
commit_nodesis cheap and monotonic: one derived write per commit, in a txn that is already open, never invalidated because commits are immutable. It is a real piece of substrate — the basis for--statoutput now and the Era 2 audit view later — not speculative infra.blameandbisectare correct with an empty or absent index; the index only makesblameskip work. A store can alwaysrebuild_index().blamefollows values through merges, so its answer is "who wrote this", which is what a user debugging a belief wants.bisectinheritsgit bisect's monotonicity assumption and its linear-history simplification. Documented; revisited only if a real multi-agent history needs more.blamequality still depends on the agent populating provenance (ADR-0003). An agent that passes nothing getsblamethat resolves to the commit and its time, and no more.
Alternatives considered
Build the forward map (node -> introducing commit) now. Rejected: it is
speculative at this scale, and it is the map that needs per-checkout
maintenance. The reverse map gives most of the value (reverse lookups, --stat)
with none of the invalidation risk.
No index at all this phase; #51 becomes test expansion. Rejected: the reverse map is a dozen lines in a path that is already open, and it is genuinely useful now and structurally needed for Era 2. Deferring it would be deferring work that has no reason to wait.
A predicate DSL or mnem bisect run <command> now. Deferred: the
--node/--equals/--absent helper covers the buggy-belief case, and a general
predicate language (jq paths, external commands) is its own design. The core
closure is fully general for the SDK and tests.
blame reports the merge commit without recursing. Rejected: "a merge
happened here" is not an explanation. Following the contributing parent is a few
lines and gives the origin.
Fold the reflog in while ref-move code is being touched. Rejected: it is not needed by anything in Phase 4, and it interacts with the unstarted GC design. Recorded as deferred so it is not lost.
ADR-0016: The MCP tools and the adapter contract
- Status: Accepted
- Date: 2026-09-07
- Issue: #56 (grilling), research #55
Context
Phase 5 (v0.0.6) is "it plugs in": an MCP server (mnem-mcp) and a LangGraph
adapter (mnem-langgraph), so a real agent uses Mnemosyne as its memory with
branch and blame working. This ADR fixes the MCP tool and resource surface, how
a store binds to a stateless server, and the contract the two adapters share.
ADR-0004 already settled the shape: adapters are Python packages that depend
on the SDK, not the core, and live outside the Cargo workspace. So mnem-core
stays offline and deterministic, cargo deny is not in play, and the network,
the async runtime and the protocol libraries all sit in the adapter packages.
This ADR does not change format_version: the adapters are new consumers of an
unchanged store.
The MCP spec revision assumed here is 2026-07-28: a stateless transport (no
protocol sessions), the tools / resources / prompts primitives, stdio and
Streamable HTTP transports, and ttlMs / cacheScope on list and read results.
Decision
Packaging
Two new packages under packages/ in this repo, each with its own
pyproject.toml, neither a Cargo member:
packages/mnem-mcp/-> PyPImnemosyne-mcp, importmnem_mcp, console scriptmnem-mcp.packages/mnem-langgraph/-> PyPImnemosyne-langgraph, importmnem_langgraph.
Both pin mnemosyne-agents ~= 0.0 and version independently: they are the
SDK's customers, not part of its release train. CI tests them against the built
wheel. They move to their own repositories only if an adapter grows its own
contributors.
The mnem.agents module (the shared contract)
A new module in the SDK, consumed by both adapters and available to the CLI later. It holds the ergonomics that both adapters would otherwise duplicate, keeping ADR-0004's "the SDK holds the ergonomics" true.
# mnem.agents
def remember(store, id, content, *, source=None, step=None, observation=None,
summary=None, author="agent", time_ms=None) -> str # commit id
def remember_many(store, items, *, summary=None, author="agent",
time_ms=None) -> str # one commit
def forget(store, id, *, summary=None, author="agent", time_ms=None) -> str
Each is add (or rm) then commit in one call. summary defaults to a
generated message ("remember {id}", "remember 3 nodes", "forget {id}").
The result dataclasses gain to_dict() for the JSON boundary: Blame,
NodeChange, Commit, MemoryNode, Conflict, MergeResult. The binding
already emits dicts; the SDK dataclasses round-trip to the same shape.
The MCP tools
Nine agent-facing tools, named for how an agent reaches for them:
| Tool | Does | Arguments |
|---|---|---|
remember | record one fact, one commit | id, content, source?, step?, observation?, summary? |
revise | same as remember; the name signals "this changes a belief" | same |
remember_many | record several facts as one commit | items: [{id, content, ...}], summary? |
forget | tombstone one node, one commit | id, summary? |
recall | read one node, or the whole current memory | id? |
recall_at | the memory as of a past commit | commit, id? |
history | recent commits, newest first | limit? |
why | the commit and provenance that set a node | id, at? |
when_did | first commit where a node reaches a value / is absent / is present | id, one of equals / absent / present, good? |
whats_new | what a commit changed | commit |
revise and remember share one implementation and differ only in the
description string that the model reads.
when_did is the --node/--equals/--absent/--present helper form only,
matching the CLI (ADR-0015); the predicate-closure form has no JSON Schema.
Harness tools. branch, switch and merge (with the CLI's
--resolve / --strategy resolution surface) are implemented but hidden from
the model by default. A mnem-mcp --tools=core|all flag (default core)
controls visibility. An agent framework that drives a hypothesis branch around a
sub-task passes --tools=all; a bare model does not see them.
The MCP resources
| URI | Backed by | Cache |
|---|---|---|
mnem://memory | working_memory | short ttlMs, cacheScope per-store, invalidated on any write |
mnem://memory/{node_id} | working_node | as above |
mnem://log | log(limit) | short ttlMs, per-store |
mnem://commit/{id} | state_at + changed_by | long ttlMs (a commit is immutable) |
The resources let a client keep the current memory in context without a tool
call every turn. recall stays as a tool for models that pull rather than get
pushed.
The write contract: no staging over MCP
The SDK's stage-then-commit split does not cross the MCP boundary. A tool call
is a discrete, retryable unit; a server that carried half-staged state between
calls would break the stateless model and leave memory in a limbo the model
cannot see. Every write tool is exactly one commit, atomic (the core's
commit is one write transaction, ADR-0008). remember_many is the batch form:
several stage calls then one commit.
staged, unstage, staged_deletions, init, resolve, add_node,
format_version, root and head are not exposed.
Store binding
MCP 2026-07-28 is stateless. The server binds one store per process: the
path comes from mnem-mcp --store <path>, with a MNEM_STORE environment
fallback. Every tool call does mnem.open (a cheap redb handle), acts, and
returns. An agent runtime launches one mnem-mcp per agent, pointed at that
agent's memory.
Multi-tenant selection (a store argument per call, or a routing header) and
per-identity stores (post-authentication) are deferred to the collaboration
layer.
Concurrency. redb is one writer, many readers. Two concurrent writes to one
store serialise on the write transaction; the loser is retried by the SDK
(bounded, below) and, if it still fails, the tool returns the busy error code.
"Which branch am I on" is the store's HEAD file, not server state, so switch
followed by another call is consistent.
Transport
stdio only for v0.0.6. It is the local, single-agent case the definition
of done describes, needs no authentication, and is what an agent runtime spawns.
The mcp Python SDK's FastMCP API writes the server once; Streamable HTTP
(remote, multi-client, the 2026-07-28 authorization surface) is later
configuration and belongs with the collaboration layer.
The LangGraph adapter
mnem_langgraph.MnemosyneStore implements LangGraph's BaseStore
(cross-thread long-term memory). Not BaseCheckpointSaver: per-superstep graph
state is a different shape and a larger surface, deferred with a trigger
(someone wants a rewindable agent run).
Mapping:
BaseStore | Mnemosyne |
|---|---|
namespace: tuple[str, ...] | an id prefix: the node id is ":".join(namespace) + ":" + key |
key: str | the node id within the namespace |
value: dict | the node content, minus a reserved _meta key |
put(ns, key, value) | agents.remember, with Provenance lifted from value["_meta"] if present |
get(ns, key) | working_node |
delete(ns, key) | agents.forget |
search(ns, query?) | a plain substring filter over node ids and stringified content within the namespace; query omitted lists the namespace |
list_namespaces() | the set of id prefixes present |
- Namespace is a prefix, not a branch. Namespaces scope memory (per user,
per topic); they are not lines of history. Branching stays an explicit
store.branch("hypothesis")call. - Provenance travels in a reserved
_metakey of the opaquevaluedict ({"...": ..., "_meta": {"source": ..., "step": ...}}), which the adapter lifts intoProvenanceand strips from stored content. Absent_metameans empty provenance, which is legal (ADR-0003). searchis a plain filter, not semantic. Documented as such, with a pointer to wrapping a vector store for ranking. Mnemosyne is not a retrieval layer.- Sync and async. The sync methods call the SDK directly; the async methods
(
aget,aput,asearch, ...) wrap them inasyncio.to_threadso the event loop is not blocked. - Extra methods.
store.branch(name),store.switch(name),store.why(namespace, key)andstore.history(limit)are on the adapter class, not the interface, for graph nodes to call directly. This is what "branch and blame working" in the definition of done means.
SDK hardening (#59)
Four items, all in v0.0.6:
mnem.agents(remember/remember_many/forget) and theto_dict()methods, above.- A bounded retry-on-
ConflictErrorwrapper for the atomic write helpers (a few attempts with a short backoff), then a raisedConflictErrorthe caller maps tobusy. to_dict()round-trips onBlame,NodeChange,Commit,MemoryNode,Conflict,MergeResult.Storethread-safety: documented as "one handle per thread"; the adapters open a handle per call, which the stateless MCP design already requires.
Errors across the boundary
One taxonomy, mechanically derived from the core's MnemError hierarchy, as
lowercase string codes on MCP tool errors and adapter exceptions: not_found,
invalid_ref, conflict, corrupt_store, no_store, store_io, plus a
synthetic busy for write-lock contention.
The worked examples (#60)
Three, one per surface, adjustable by #60:
- SDK - a support agent that records beliefs with provenance, gets a plan
tier wrong, and uses
bisect+blameto find the misread observation (the running example, thebuggy_runfixture as a real script). - LangGraph - an agent using
MnemosyneStoreasBaseStoreacross two threads, with a hypothesisbrancharound a sub-task. - MCP - a client script driving
mnem-mcpover stdio throughremember/recall/why.
Consequences
- The core and the SDK are untouched except for one additive module
(
mnem.agents) andto_dict()methods. Noformat_versionchange. mnem-mcpis a thin FastMCP server: nine tools and four resources over the SDK, no state of its own. It is testable by driving it over stdio.- The LangGraph adapter is a
BaseStoresubclass plus a handful of extra methods. An agent author swaps their store backend and gets history, blame and branching, withsearchdegrading to a filter. - The stateless, one-store-per-process design means an agent runtime scales
memory the way it scales agents: one
mnem-mcpeach. - Provenance survives the LangGraph path only if callers populate
_meta. Documented; the MCPremembertool makes it a first-class argument instead. - Two more packages to release. They version independently and pin a loose SDK range, so a patch SDK release does not force an adapter release.
Alternatives considered
Separate repositories for the adapters now. Rejected for v0.0.6: three
repos to keep in sync for a solo maintainer, for no gain while the adapters are
small. The packages/ layout keeps one tracker and lets CI test against the
wheel. Revisited when an adapter gets its own contributors.
Expose the stage/commit split over MCP. Rejected: it makes a tool call stateful, fights the 2026-07-28 stateless transport, and gives a model a way to leave memory half-written that it cannot then see.
Map a BaseStore namespace to a Mnemosyne branch. Rejected: namespaces
scope memory, they are not versions. Branch-per-namespace would multiply
histories and make the common "memories per user" case strange. Branching is an
explicit call.
Implement BaseCheckpointSaver in v0.0.6. Rejected: it is graph
execution state, not beliefs, and a larger surface. It is a compelling second
adapter (a rewindable run) but it waits for a real ask.
A semantic search. Rejected: Mnemosyne is not a retrieval layer (README,
prior work). A plain filter keeps the adapter a drop-in; ranking is the caller's
vector store.
Streamable HTTP transport now. Rejected for v0.0.6: it brings the
authorization surface and multi-client concerns that belong with the
collaboration layer. FastMCP adds it later by configuration, no server rewrite.
ADR-0017: The benchmark and its metrics
- Status: Accepted
- Date: 2026-09-08
- Issue: #62 (grilling), research #61
Context
Phase 6 (v0.0.7) ships the substrate: something you would hand a stranger,
with numbers. This ADR fixes what the benchmark measures, the targets, where the
harnesses live, and how a regression fails CI. Build tickets #64 (the plumbing)
and #65 (the numbers, docs/benchmark.md) follow it.
Mnemosyne makes no accuracy claim. The 2026 research is settled on this: GitOfThoughts (arXiv 2606.14470) tested five memory backends (none, markdown, vector, graph, git) and found none reliably moves an agent's accuracy. Its conclusion is ours: the value of version control is "auditability, history, and the ability to merge two agents' memories, at no cost to accuracy". So the benchmark measures the operational properties and the overhead. Accuracy parity is cited, not re-tested; that needs models and an API budget, and it has been done.
Nothing here changes format_version.
Decision
What is measured
Correctness and precision (deterministic, so the target is exact):
| Metric | Definition | Target |
|---|---|---|
reconstruction_exact | state_at(commit) equals the working memory recorded at commit time, over every commit of every seed | 100.00% |
golden_bytes_stable | the frozen format vectors still hash identically (reuses the golden_vectors assertion) | true |
bisect_exact | bisect returns the exact commit k where a monotonic planted fault began | 100.00% |
bisect_error_max | the largest |found - k| seen | 0 |
blame_commit_acc | blame resolves to the correct introducing commit, linear history | 100.00% |
blame_commit_acc_merge | correct origin commit when the value arrived on the merged-in side | 100.00% |
blame_source_acc | correct provenance source string | 100.00% |
merge_invariants | the #49 chaos invariants (totality, no lost writes, symmetry, convergence) at the bench trial count | true |
Every one of these gates the release. A single failing seed is a bug and blocks
v0.0.7.
Overhead (reported, gated only against regression):
| Metric | Definition |
|---|---|
write_ms_p50, write_ms_p99 | latency of one commit, a 100-key memory |
read_ms_p50 | latency of a full working-memory read |
bytes_per_step | .mnem growth per commit, and the multiple over baseline B |
The workload
A seeded generator, hand-rolled (a linear congruential generator, the style of
tests/time_travel.rs and tests/merge_chaos.rs; no property-test dependency):
- a run is
Lcommits; each stages 1 to 4 operations over a 12-key pool, 75% set / 25% delete; - every set carries provenance:
source = "obs-<n>",agent_step = "step-<c>"; - values are drawn from a small pool, so content-addressed dedup shows up in
bytes_per_step; - a planted fault: at commit
k(at 10%, 50% and 90% of the run) one distinguished key is set to a "wrong" value and left wrong to the end, so thebisectpredicatekey == wrong_valueis genuinely monotonic; - a branched run for
blame_commit_acc_merge: two branches off a base, the blamed key's final value written on the merged-in side, single merge base (no criss-cross, ADR-0013).
The baselines
"No version control" is two things an agent author would otherwise write:
- Baseline A, a dict.
dict[str, Any], overwritten in place. GitOfThoughts's "none". Zero history. - Baseline B, JSONL snapshots. After every step, the whole memory appended as one JSON line. A naive "keep history" approach.
The comparison is an audit-query table, asserted in CI: for each query, which backend can answer it.
| Query | dict | JSONL | Mnemosyne |
|---|---|---|---|
| current memory | yes | yes | yes |
memory as of step t | no | yes | yes |
when did key X first become value V | no | yes, O(L) | yes, O(log L) |
which observation set X's current value | no | no | yes |
what did step t change | no | yes | yes |
| merge two agents' memories, surfacing conflicts | no | no | yes |
benchmarks/overhead.py runs each query against each backend and asserts this
matrix, so a broken bisect or blame fails the benchmark, not just a test.
Where it lives
crates/mnem-core/tests/benchmark.rs: the correctness and precision harness. Runs withcargo testat a small trial count;MNEM_BENCH_RUNS/MNEM_BENCH_LENGTHSenv overrides drive the published sweep.benchmarks/overhead.py: a new top-level directory (mirroringexamples/). Builds a dict, a JSONL file and a.mnemstore over the same synthetic run, times the operations withtime.perf_counter, measures sizes, asserts the audit-query table, and checks the 2x gate.benchmarks/baseline.json:{ "write_ms_p50": ..., "bytes_per_step": ... }. The regression gate reads this;docs/benchmark.mdis the prose. Updated deliberately, in its own commit, when a change to the numbers is intended.docs/benchmark.md: written by #65 from a full sweep, in the shape ofdocs/chaos-report.md: a one-line summary (the audit-query matrix), what was measured, the results table, and the exact reproduction command. Re-run and updated whenever the core changes.
Timing uses std::time::Instant and time.perf_counter; no new dependency,
consistent with the project dropping proptest and time for hand-rolled
equivalents.
CI
- The
rustjob already runsbenchmark.rs(it is a test) at the small trial count:Lin {16, 64}, 40 seeds. - A new
benchmarkjob: Python plus the SDK, runsbenchmarks/overhead.py, asserts the audit-query table, and fails ifwrite_ms_p50orbytes_per_stepexceeds 2x the value inbenchmarks/baseline.json.read_ms_p50is reported, not gated (it is dominated by content decode).
The published sweep
docs/benchmark.md reports:
- correctness:
Lin {16, 64, 256, 1024}, 2000 seeds (a few minutes, like the 50k-case merge-chaos sweep); - overhead:
L = 1024, 100 keys, the median of 5 runs.
The prolly-tree trigger
ADR-0012 and ADR-0013 defer the prolly-tree State form with a "reassess when a
real store hits a ceiling" trigger. The benchmark now produces the number, so
the trigger gets a soft edge: the flat State is reconsidered when
read_ms_p50 for a full working-memory read exceeds roughly 25 ms, or a real
store's .mnem exceeds roughly 50 MB. This is a prompt to revisit, not an
obligation to rewrite.
Consequences
- The correctness metrics are a release gate that runs on every push, so "time travel is exact", "bisect is precise" and "blame is accurate" stop being claims and become CI.
- The overhead numbers are checked in and cannot rot silently; a real regression
(a per-node fsync, an accidental O(L^2)) fails the
benchmarkjob. docs/benchmark.mdis the artefact for the launch: honest, reproducible, and it says plainly what version control does and does not buy.- The
bytes_per_stepandread_ms_p50numbers are the evidence for the prolly-tree decision, without this ADR making it. - Two more files to keep current on a core change (
benchmark.rsis automatic;baseline.jsonanddocs/benchmark.mdneed a deliberate re-run).
Alternatives considered
Benchmark accuracy with real models. Rejected. It needs an API budget, it is not a regression gate (too slow, too noisy), and GitOfThoughts already showed no memory backend reliably helps. Mnemosyne is a substrate, not a retrieval strategy.
A criterion bench harness. Rejected. criterion is a real dependency and
cargo bench is not in CI. A hand-rolled seeded harness in tests/ is the
project's established pattern and runs on every push for free.
Gate the overhead on a fixed absolute (write_ms_p50 < 10). Rejected. CI
runners vary; a fixed ceiling either flaps or is set so loose it catches
nothing. A multiple of the last published number tracks the real trend.
A third baseline (a vector store, SQLite). Rejected. A vector store is a retrieval axis this benchmark does not measure; SQLite is JSONL with indices, the same storage story. Two baselines make the point.
Publish blame_commit_acc_merge below 100% with a caveat. Rejected. The
generator's merges are single-base, so blame's contributing-parent walk is
well-defined and exact. A failure there is a bug, and it blocks the release like
any other correctness miss.
ADR-0018: The Era 2 seam: the semantic merge trait and the sync protocol
- Status: Accepted
- Date: 2026-09-08
- Issue: #63 (grilling)
Context
Era 1 (0.0.x) is the substrate: one agent, local, deterministic. Era 2 is the
collaboration layer: a semantic merge that reasons about contradiction, a sync
protocol between stores, and a review step before a change lands in shared
memory. Pull requests, for agent memory.
Era 2 is not built in v0.0.7. But v0.0.7 freezes the Era 1 format
(docs/format/) and hands the substrate to strangers, so the places Era 2 plugs
in have to be right now, or Era 2 becomes a rewrite of mnem-core rather than a
new crate. This ADR fixes two seams:
- the
SemanticMergetrait: where a content-aware resolver attaches to the structural merge from ADR-0013 and ADR-0014; - the sync protocol shape: the named pieces of store-to-store exchange, without the transport or the wire format.
ADR-0004 keeps mnem-core offline and model-free. The trait lives in the core
(it is part of the merge API); every impl that calls a model lives in a separate
crate. The sync protocol is declared here and built in Era 2 under its own ADR.
Decision
The SemanticMerge trait
The structural merge runs first and unchanged (ADR-0013): it produces a merged
State map plus a list of Conflict values. SemanticMerge is a second pass
over the conflicts the structural merge could not settle:
#![allow(unused)] fn main() { pub trait SemanticMerge { /// Try to settle one structural conflict by reasoning about the nodes on /// each side. Called once per `Conflict` the structural merge produced. fn resolve(&self, store: &Store, conflict: &Conflict) -> Result<Verdict>; } pub enum Verdict { /// The resolver settled it: apply this resolution (ADR-0014). Resolved(Resolution), /// The two sides genuinely disagree and a human or another agent should /// see it. Carries the draft of the `Contradiction` object Era 2 stores. Contradiction(ContradictionDraft), /// The resolver has nothing to add: fall through to the caller's strategy, /// or leave the conflict for the caller (the structural behaviour). Unresolved, } pub struct ContradictionDraft { pub id: String, pub base: Option<ObjectId>, pub ours: Option<ObjectId>, pub theirs: Option<ObjectId>, pub reason: String, } }
resolve takes &Store so a resolver can load the node content on each side
(Store::node) and walk history (provenance, prior commits) to judge. A
narrower context type is a possible Era 2 refinement; &Store is the read
surface that already exists.
Verdict reuses ADR-0014's Resolution unchanged and adds exactly one case,
Contradiction, for "these disagree and the disagreement is worth keeping".
The core ships one impl:
#![allow(unused)] fn main() { pub struct StructuralOnly; // resolve() always returns Verdict::Unresolved }
StructuralOnly is the Era 1 behaviour: no content reasoning, every structural
conflict comes back to the caller.
Where it attaches to Store::merge
Store::merge keeps its exact signature (ADR-0014, Accepted and now frozen). A
new method takes the resolver:
#![allow(unused)] fn main() { pub fn merge_with( &self, theirs: &str, resolver: &dyn SemanticMerge, resolutions: &BTreeMap<String, Resolution>, strategy: Option<MergeStrategy>, message: Option<&str>, author: &str, time_ms: i64, ) -> Result<MergeOutcome>; }
merge is exactly merge_with(theirs, &StructuralOnly, ..). The precedence per
conflicting id is: an explicit resolutions entry from the caller, then the
resolver's Verdict, then strategy, then unresolved. A caller stays in
control, the resolver is the automated middle, and strategy is the blunt
fallback.
merge_with is a Rust-only API in Era 1. The SDK's store.merge(..) is
unchanged; a Python-visible resolver is Era 2's, alongside the crate that
implements one.
Contradiction: a stored object in format_version 2
ADR-0014 kept Conflict transient and deferred a stored form to Era 2's review
model. Contradiction is close to that stored form, and it is different in kind
from a Conflict: a Conflict is "the tool will not choose"; a Contradiction
is "a semantic resolver looked, and the claims are incompatible" (CONTEXT.md).
In Era 2, Contradiction becomes a fourth content-addressed object kind
(format_version 2, after the three in docs/format/), referenced from the
merge commit, so the disagreement is kept in history rather than dropped. Sketch
of the fields: the node id, the object id on each side, the resolver's
reason, and the merge commit it was found in. The exact shape, its encoding
row in docs/format/, and the MergeOutcome arm that carries contradictions
out of merge_with are Era 2's, under the format_version 2 ADR.
ContradictionDraft above is the in-memory precursor: what a resolver returns
before anything is stored.
In v0.0.7, StructuralOnly never returns Verdict::Contradiction, so no
contradiction is produced through merge. A custom resolver that returns one is
treated as Unresolved for now (the conflict comes back to the caller); the
MergeOutcome arm and the stored object land together in Era 2.
The sync protocol shape
Era 2 lets two stores exchange history. This ADR names the pieces and defers the rest:
- A
Remote: a named pointer to another store, the way a branch is a named pointer to a commit.mnem remote add <name> <location>. - Content-addressed exchange: objects are addressed by hash (ADR-0005), so
syncing is a negotiation of "which object ids does each side hold", then a
transfer of only the missing objects and the ref updates. Dedup is free and
the transfer is minimal, the property that makes
git fetchcheap. - A
Proposal: a cross-store change does not write a shared branch directly. It opens aProposal(a source ref, a target ref, the commit range, a status) that a reviewer, human or agent, approves or rejects before it lands. This is the "pull request for memory" step.
What this ADR does not fix, all deferred to a dedicated Era 2 ADR: the
transport (HTTP, SSH, a file path), the wire format, the ref negotiation
protocol, the Proposal object's exact fields, authentication, and the
mnem push / mnem pull / mnem review command surface. Those depend on how
the Era 3 registry wants to serve stores, and picking them now in isolation is a
guess.
The commitment this ADR does make: a cross-store landing goes through a
reviewable Proposal. Era 2 is the review model, or it is just rsync.
What stays out of mnem-core
The trait is in the core. StructuralOnly is in the core. Everything else Era 2
adds is not: a model-calling SemanticMerge impl is a separate crate (ADR-0004);
the sync transport touches the network and is a separate crate; the Proposal
review flow is CLI and orchestration. cargo deny still bans network, TLS and
async crates from the workspace core.
Consequences
- Era 2's merge work is "write a crate that implements one trait", not "reopen
mnem-core's merge flow".merge_with(&StructuralOnly, ..)is exercised by the existing merge tests, so the seam is load-bearing fromv0.0.7. Store::mergeandMergeOutcomeare untouched, so ADR-0014 stays frozen with the rest of the Era 1 surface.format_version2's contents are now named in one place (docs/format/links here): the storedContradiction, whatever sync adds to a commit or the refs, and the prolly-treeStateif ADR-0017's trigger fires.- The sync protocol is still an open design. That is deliberate: its shape depends on the registry, which is Era 3. Naming the pieces stops Era 1 from quietly closing a door, for example a non-content-addressed object id, which would break the cheap-sync property.
- A
SemanticMergeimpl that fails (a model call errors) surfaces asMnemError. Era 2 adds a variant for it;#[non_exhaustive]on the enum already allows that without a break.
Alternatives considered
A whole-State resolver (given the three maps, return merged plus conflicts
plus contradictions). Rejected: it makes every semantic resolver re-implement
the structural walk from ADR-0013. The per-Conflict shape lets the structural
merge always run and the resolver be a clean second pass. StructuralOnly is
then a one-line impl.
merge grows an Option<&dyn SemanticMerge> parameter. Rejected: it breaks
a signature that is Accepted and, at v0.0.7, frozen. merge_with is additive.
ADR-0018 is doc-only; the trait lands in Era 2. Rejected: the point of #63 is that the seam is proven before the format freezes. A trait with a real impl and test coverage now is proof; a paragraph is not.
Fix the sync transport now (say, HTTP with a GET /objects endpoint).
Rejected: the transport should follow the Era 3 registry's needs, which do not
exist yet. Naming the pieces (content-addressed exchange, a Remote, a
Proposal) protects the properties that matter without guessing the wire.
No review step: sync writes straight to a shared branch. Rejected: that is
git push to a shared main with no review, and the whole Era 2 pitch is the
review. The mechanism is deferred; the commitment is not.
ADR-0019: Prebuilt mnem binaries via cargo-dist
- Status: Accepted
- Date: 2026-09-19
- Issue: #173
Context
cargo install mnem-git is the only way to get the mnem CLI without
building from source, and it needs a Rust toolchain. #165 solved the
equivalent problem for the Python SDK: cross-platform wheels, built and
published by a GitHub Actions workflow. This ADR does the same for the
Rust binary: a prebuilt archive and a one-line installer script per
platform, attached to the GitHub Release.
Two things about this needed a decision before writing any workflow,
because both are hard to walk back cheaply: whether to stand up a
Homebrew tap (a second, permanent GitHub repository under the same
account), and whether to change the release process this project has
used since v0.0.2 (tag by hand, then create the GitHub Release by
hand). Both were put to the maintainer directly rather than decided here.
Decision
Tool: cargo-dist (crates.io cargo-dist, binary dist, v0.32.0
pinned in dist-workspace.toml). It builds the archives, generates the
shell and PowerShell installer scripts, and knows how to attach
everything to a GitHub Release. Hand-rolling the same thing (as
release-pypi.yml does for wheels) would mean maintaining installer
scripts by hand for no reason; this is exactly cargo-dist's job.
Targets: x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu,
x86_64-apple-darwin, aarch64-apple-darwin, x86_64-pc-windows-msvc.
The same five platforms #165 covers for the Python wheel, so the CLI and
the SDK have the same reach.
No Homebrew tap for now. The maintainer's call: ship the raw
archives and the shell/PowerShell installers only. A tap is a second
repository (Nabzx/homebrew-tap) to maintain forever after; add it once
there is real usage to justify it. cargo-dist supports adding one later
(the tap setting) without redoing anything else.
The release process stays manual, cargo-dist included. The
maintainer's call, matching how release-pypi.yml already behaves. Note
for anyone reading ADR-0007 alongside this: ADR-0007 sketches a release
workflow that also cuts the GitHub Release from the tag push; what #68
actually built keeps that step manual (the workflow triggers on
release: published, an event only the manual step produces). This ADR
follows the practice that shipped, not ADR-0007's original sketch of it.
dispatch-releases = true: the workflow triggers onworkflow_dispatch(gh workflow run release.yml -f tag=vX.Y.Z, or the Actions tab), never on a tag push by itself.create-release = false: cargo-dist does not create the GitHub Release. It expects one to already exist for the tag, as a draft, and uploads the binaries to that draft, undrafting it once everything is attached.
That second point changes the manual step slightly: today the maintainer
creates the GitHub Release and publishes it in one action. Under this ADR,
the release is created as a draft first; running the dist workflow
(after the tag is pushed) is what makes it visible, once the binaries are
actually attached. Nothing about the release goes live before there is
something to download, which is arguably the better property, not a worse
one, and the maintainer still decides both when to draft it and when to
run the workflow.
Two release workflows, not one. dist generate writes to
.github/workflows/release.yml unconditionally; that name was already
taken by #165's PyPI workflow. Renamed the PyPI one to
release-pypi.yml (and its display name to release (pypi)) rather than
fight cargo-dist's own regeneration model by hand-merging jobs into a
file it expects to own outright. release.yml (cargo-dist's) is
regenerated by editing dist-workspace.toml and running dist generate
again; it is not meant to be hand-edited.
Consequences
cargo install mnem-gitremains the primary path for anyone with a Rust toolchain; the new installers are for everyone else. Neither changes what the crate publishes to crates.io (#165's sibling ADR did not apply here, so there is no ADR change there).- A
[profile.dist]in the rootCargo.toml(added bydist init,inherits = "release",lto = "thin") is the profile the release binaries build with; it does not affectcargo build --release. dist-workspace.tomlis the source of truth;.github/workflows/release.ymlis generated output, checked in for review but not meant to be edited by hand. A futuredistversion bump or a target change means editing the config and re-runningdist generate, the same disciplinerelease-pypi.ymldoes not need because it has no equivalent generator.- PyPI trusted publishing (#165) is scoped to a workflow filename; the
rename to
release-pypi.ymlmeans a trusted publisher must be added under that name, notrelease.yml, when #68's remaining PyPI packages are configured. - No
format_versionchange, no object-model change, no Rust/Python boundary change. This is packaging and CI, not the substrate.
Alternatives considered
Hand-roll the binary builds and installer, matching how
release-pypi.yml was hand-written for Python. Rejected: an installer
script is easy to get subtly wrong (platform detection, checksum
verification, $PATH handling) and cargo-dist's is maintained by people
who do this for a living. Reinventing it buys nothing.
Let cargo-dist create the GitHub Release (create-release = true,
its default). Rejected per the maintainer: it would mean a tag push (or a
workflow_dispatch) starts producing a public release page without the
explicit "publish" action this project has used since v0.0.2.
A Homebrew tap now. Rejected for now, not permanently: cheap to add later, and a second repository to maintain is a real, ongoing cost with no users yet to justify it.
Merge cargo-dist's jobs into the existing release.yml by hand.
Rejected: dist generate regenerates that file from dist-workspace.toml
on every config change or dist version bump; hand-merged jobs would be
silently destroyed the next time it runs, or fought with forever.
ADR-0020: The OpenAI Agents SDK adapter
- Status: Accepted
- Date: 2026-09-23
- Issue: #249 (research + grilling, one combined ticket)
Context
Research (docs/research/issue-249-openai-agents-survey.md, read against the
real openai/openai-agents-python source) found the SDK's one pluggable
memory seam: a Session protocol -
get_items(limit) / add_items(items) / pop_item() / clear_session() - over a
flat, ordered, position-addressed transcript of raw API items (a message,
a tool call, a tool-call output, ...), passed straight into Runner.run(..., session=...). This is a real shape mismatch against Mnemosyne's keyed
BTreeMap<String, MemoryNode> node model: a transcript item has no natural
node id the way a LangGraph (namespace, key) pair or an MCP remember
call's explicit id does.
ADR-0016 already solved a structurally similar problem for LangGraph by not
forcing every foreign concept through the adopted interface: implement
BaseStore narrowly for drop-in compatibility, and put branch / switch /
why / history on the adapter class itself, outside the protocol. This ADR
follows that precedent for Session.
Decision
Packaging
packages/mnem-openai-agents/, PyPI mnem-openai-agents, import
mnem_openai_agents, following the packages actually shipped (not
ADR-0016's now-superseded mnemosyne-* naming - see docs/adr/README.md's
naming note). Pins mnem-agents ~= 0.0, versions independently, tested
against the built wheel, same as mnem-mcp and mnem-langgraph.
Namespacing: a shared store, sessions as an id prefix
One Store serves many sessions. A session's nodes live under the prefix
{session_id}:, mirroring the LangGraph adapter's
":".join(namespace) + ":" + key scheme exactly, rather than inventing a
second convention. MnemosyneSession(store, session_id) takes an already-open
Store (the caller owns the lifecycle, same as the LangGraph adapter's
MnemosyneStore), not a path - unlike mnem-mcp's one-store-per-process
rule, which fits a stateless server, not an in-process SDK object serving
many concurrent conversations.
Storage granularity: one node per transcript item
Every message, tool call, and tool-call output becomes its own node, not one
node per whole transcript. This is the one choice that makes the adapter
worth adopting over the SDK's own SQLiteSession: blame and bisect
resolve to the message that introduced a belief, not just "the commit that
touched the transcript." An add_items([item1, item2, item3]) call (the SDK
batches a turn's message plus its tool call plus the tool's output in one
call) becomes one commit adding three nodes, mirroring
mnem.agents.remember_many's existing batch form (ADR-0016) rather than
three separate commits - one write, however many nodes it touches.
Item content is the raw TResponseInputItem dict, stored as-is (Mnemosyne's
MemoryNode content is already opaque JSON, ADR-0003); nothing is
reinterpreted or restructured.
Node id scheme: a reserved per-session sequence counter
Each item's id is {session_id}:{seq:010d}, seq a monotonically
increasing integer. Zero-padding keeps BTreeMap lexicographic order equal
to insertion order within a session, so get_items and pop_item are plain
keyed range reads, no full-namespace scan. The counter itself lives in a
reserved node, {session_id}:_seq, updated in the same commit as every
write - reusing this codebase's own precedent for an out-of-band reserved
key (LangGraph's _meta, ADR-0016) rather than a new idiom. _seq is
excluded from get_items, pop_item, and any content iteration; it is
adapter-internal state, not a transcript item.
get_items, pop_item, clear_session
get_items(limit): read the keyed range under{session_id}:(excluding_seq), in id order;limit(orsession_settings.limitif unset) takes the tail.SessionSettings.limitpasses straight through - no Mnemosyne- side equivalent is needed, it is already a bounded keyed read.pop_item():mnem.agents.forget(store, highest_seq_id)- a tombstone commit, not a hard delete. History survives; the item is gone fromworking_memory.clear_session(): one batched tombstone commit forgetting every node under{session_id}:(including resetting_seq), mirroringremember_many's batch shape. Not a hard delete for the same reason:SQLiteSessioncallers call this as routine cleanup, not as a rare "actually erase this" operation, and a real delete would silently defeat Mnemosyne's whole point the first time an ordinary caller cleared a session.
Provenance: on_tool_end auto-capture, on by default
RunHooksBase.on_tool_end receives the tool's name and output directly - a
carrier MCP (remember's source argument) and LangGraph (_meta) each had
to invent by hand. MnemosyneSession installs an internal hook by default
that populates Provenance.source (the tool name) and observation (the
tool output) for any node written as a result of that call, no caller
action required. This is a deliberate departure from LangGraph's opt-in
_meta precedent: unlike _meta, which asks the caller to shape their own
data, this is purely additive metadata the adapter is already positioned to
fill for free, and defaulting it off would mean most callers never see it.
Documented plainly in the package README, not silent.
Extra methods, outside Session
branch(name), switch(name), merge(...), why(item_id), bisect(...),
history(limit) on the MnemosyneSession class directly, for code that
wants Mnemosyne's actual differentiators rather than just drop-in
compatibility - exactly ADR-0016's LangGraph pattern, not exposed through
Session's four methods (which have no way to express a branch or a
conflict at all).
Errors
The same taxonomy as every other adapter (ADR-0016): not_found,
invalid_ref, conflict, corrupt_store, no_store, store_io, busy
for write-lock contention, with the bounded retry-on-ConflictError wrapper
the SDK hardening work already added.
The worked example
One script in examples/, alongside the SDK, LangGraph, and MCP examples
(ADR-0016): an agent using Runner.run(agent, prompt, session=session)
across a couple of turns including a tool call, then using blame/bisect
on the resulting history - demonstrating that the adapter is a drop-in
Session first, with Mnemosyne's differentiators one call away.
Consequences
- A fourth adapter package, versioned and released independently, same
operational shape as
mnem-mcpandmnem-langgraph. blameandbisectstay meaningful per message, not just per API call - the reason to choose this adapter overSQLiteSessionat all.- A session's history carries a real per-item audit trail automatically via
on_tool_end, with no caller code required, at the cost of the adapter making one behavioural choice (installing a hook) the caller did not explicitly request - documented, not hidden. clear_sessionis cheap only in the sense that it is one commit; the tombstoned nodes and the session's full prior history remain in the store, by design, and a caller who genuinely wants storage reclaimed has no path to that yet (same open question every other adapter already carries, not new here).- The
_seqreserved-key idiom is now used twice (LangGraph's_metashares the spirit, not the exact mechanism) - worth a shared helper if a third adapter needs an analogous piece of internal bookkeeping.
Alternatives considered
One node per whole transcript, overwritten on every add_items call.
Rejected: cheapest to build, but blame/bisect degrade to "this commit
changed the transcript," which quietly defeats the adapter's entire reason
to exist for the one framework where per-message provenance would be most
visible.
Ordering via Store::log instead of a sortable node id. Rejected: a
batched add_items call becomes one commit touching several nodes, and
Store::log gives commit order, not the relative order of nodes touched
within one commit - not a contract any current ADR guarantees. A sortable id
under a reserved counter is a few more lines and sidesteps the question
entirely.
One Mnemosyne store per session (mirroring mnem-mcp's one-store-per-
process rule). Rejected: fits a stateless server spawned once per agent,
not an in-process SDK object a host application constructs many of, one per
conversation, often concurrently. The LangGraph adapter's namespace-as-prefix
precedent fits an in-process object far better.
on_tool_end provenance capture opt-in, matching LangGraph's _meta.
Rejected: _meta is opt-in because it asks the caller to shape their own
payload; here the adapter can populate real provenance with zero caller
effort, and opt-in would mean most integrations never turn it on and lose
the value for no real cost saved.
A hard delete for pop_item/clear_session. Rejected outright, not
seriously considered: it defeats Mnemosyne's core premise the moment an
ordinary SQLiteSession-shaped caller calls clear_session() as routine
cleanup, which is exactly when the audit trail matters most.
ADR-0021: The CrewAI adapter
- Status: Accepted
- Date: 2026-09-23
- Issue: #250 (research + grilling, one combined ticket)
Context
Research (docs/research/issue-250-crewai-survey.md, read against the real
crewAIInc/crewAI source) found CrewAI's pluggable memory primitive,
StorageBackend, is far richer than the OpenAI Agents SDK's Session
(ADR-0020) and is vector-search-first: search(query_embedding: list[float], ...) takes only a pre-computed embedding, no query string to
fall back on. The finding that makes this tractable: CrewAI's own Memory
class always computes embeddings itself (via its own configured embedder)
before handing a record to storage.save() or a query to
storage.search(). A backend never needs its own embedder dependency, only
to persist and compare vectors it is handed - which keeps this adapter
inside ADR-0004's boundary (adapters may take on network/runtime
dependencies MCP and LangGraph already do; an embedding-model dependency
would have been a materially different kind of commitment).
MemoryRecord already carries first-class source and private fields -
provenance and access scoping map straight through, unlike LangGraph's
invented _meta carrier (ADR-0016). Scope is a hierarchical /-delimited
path, not a flat namespace tuple, with real one-level child-walk operations
(list_scopes, get_scope_info) that no adapter built or designed so far
has needed.
Decision
Packaging
packages/mnem-crewai/, PyPI mnem-crewai, import mnem_crewai, following
the same shipped-naming convention as mnem-mcp, mnem-langgraph, and
mnem-openai-agents (ADR-0020). Pins mnem-agents ~= 0.0, versions
independently.
Storage granularity: one node per memory record
Every MemoryRecord (content, scope, categories, metadata, importance,
source, private, and its embedding) becomes one Mnemosyne node, mirroring
ADR-0020's per-item granularity for the same reason: blame and bisect
should resolve to which memory, not just which save call. The full
record, embedding included, is the node's opaque JSON content (ADR-0003).
Node id scheme: CrewAI's own /-delimited scope path, honored literally
A node id is {scope}/{record_id} (record_id CrewAI's own generated
MemoryRecord.id), rather than normalizing into the :-prefix convention
ADR-0016 and ADR-0020 established for LangGraph and the OpenAI Agents SDK.
This is a deliberate departure from adapter-to-adapter consistency: each
prior adapter already maps its own host framework's model onto Mnemosyne on
its own terms (ADR-0016 chose namespace-as-prefix because it matched
LangGraph specifically, not from a project-wide id-format rule), and
list_scopes/get_scope_info are genuine one-level tree-walk operations -
implementing them correctly needs the id to actually reflect /-nesting,
not just contain slashes incidentally.
Search: real, unindexed, brute-force cosine similarity - with a stated ceiling
search(query_embedding, scope_prefix, categories, metadata_filter, limit, min_score) is implemented as exact cosine similarity over every record's
stored embedding within the scanned scope - the same math LanceDB or Qdrant
would run against the same vectors, just without a sublinear index.
Correct, not approximated or faked, at a stated cost: O(records in
scope) per query. The package README documents a recommended ceiling
(on the order of a few thousand records per scope) and points to CrewAI's
own LanceDB/Qdrant backends beyond it - mirroring the LangGraph adapter's
already-accepted precedent of degrading search to something honestly
weaker than a purpose-built store, documented rather than silently
approximated (ADR-0016).
Provenance and privacy: pass straight through
MemoryRecord.source maps directly to Mnemosyne's Provenance.source - no
invented carrier, unlike LangGraph's _meta key or the OpenAI Agents
adapter's on_tool_end hook (ADR-0020). private is stored and
re-surfaced as-is; enforcing it (filtering by source unless
include_private=True) happens in the adapter's search/list_records
implementations, exactly as CrewAI's own Memory.recall() already
documents the semantics.
importance and last_accessed: round-trip only
The adapter never recomputes or independently updates these fields. It
stores exactly what CrewAI hands it in save()/update(), and returns
exactly that on read. CrewAI's own Memory/RecallFlow code is the only
thing that should decide what these values mean; an adapter maintaining its
own view risks silently disagreeing with what CrewAI's own ranking would
have produced - the same principle ADR-0020 applied to why on_tool_end
provenance capture is documented rather than silent, just in the opposite
direction here (do less, not more).
Registration
crewai.memory.storage.factory.set_memory_storage_factory() is a one-time,
process-wide setter, not a per-Memory-instance argument. The adapter
documents this plainly (call it once at application startup) rather than
pretending it is scoped like Session's per-Runner.run argument
(ADR-0020) or BaseStore's per-graph argument (ADR-0016) - it is not, and
saying otherwise would mislead an integrator.
Async
Sync methods call the SDK directly; async (a-prefixed) methods wrap the
sync path in asyncio.to_thread, mirroring the LangGraph adapter's existing
precedent (ADR-0016) rather than a native async re-implementation.
Errors
The same taxonomy as every other adapter (ADR-0016): not_found,
invalid_ref, conflict, corrupt_store, no_store, store_io, busy.
Consequences
- A fifth adapter package (after
mnem-mcp,mnem-langgraph, and the designed-but-unbuiltmnem-openai-agents), same independent-versioning shape. blame/bisectwork per memory record, and CrewAI's own semantic recall keeps working exactly as before - the adapter is a realStorageBackend, not a degraded one, up to its stated scale ceiling.- The one real limitation this ADR accepts and documents: no sublinear
search index. A crew with memory scopes well beyond a few thousand
records per scope should use LanceDB/Qdrant instead, or wrap Mnemosyne's
history/blame value alongside one of them rather than through this
adapter's own
search. - A fourth id-delimiter convention now exists across adapters (
:prefix for LangGraph and OpenAI Agents SDK,/path for CrewAI) - not unified, deliberately, since each honors its own host framework rather than a project-wide scheme. Worth revisiting only if a real reason to unify appears, not for its own sake.
Alternatives considered
A fake or degraded search() (e.g. keyword matching over content).
Rejected: unlike LangGraph's search(ns, query?), CrewAI's search() gets
no query string, only a vector - there is nothing to keyword-match against.
A degraded implementation here would not just be weaker, it would be
answering a different question than the one asked.
The adapter computes its own embeddings. Rejected: unnecessary given
CrewAI's own Memory class already computes and hands over every embedding
involved, and it would have pulled a real embedding-model dependency into
an adapter package for no benefit.
Normalizing node ids to the :-prefix convention for adapter-to-adapter
consistency. Rejected: list_scopes/get_scope_info are real one-level
tree-walks that need the id to reflect actual /-nesting; fighting
CrewAI's own hierarchy to match a convention invented for a flat namespace
would make the one adapter with real tree operations harder to get right,
for a consistency benefit no contract actually depends on.
The adapter maintains its own importance/last_accessed values (e.g.
bump on every search hit). Rejected: these exist for CrewAI's own ranking
to use as it sees fit; an adapter second-guessing them is scope creep that
risks disagreeing with what CrewAI's own code would have computed.
Block on building an indexed search variant before shipping. Rejected:
blocks the adapter on infrastructure this project has no reason to own, for
a scale problem documented and stated rather than hidden - the same call
already made for LangGraph's search (ADR-0016).
ADR-0022: Mem0 and Zep on the audit-query table
- Status: Accepted
- Date: 2026-09-23
- Issue: #282 (research + grilling, one combined ticket)
Context
ADR-0017 scopes docs/benchmark.md's audit-query table to a plain dict, a
JSONL log, and mnem, reasoning (via docs/comparisons.md) that "a shared
numeric table would measure the wrong axis" for tools like Mem0 and Zep that
solve retrieval, not versioning. That reasoning holds fully for an
accuracy-style benchmark - Mem0/Zep's actual home turf, where mnem has
nothing to submit, since it does no semantic retrieval at all.
It does not hold for the audit-query table itself. Those seven questions
("which observation set this belief", "does a merge conflict block until
resolved", ...) are mnem's own axis, not an accuracy axis, and research
(docs/research/issue-282-mem0-zep-audit-table.md, read against the real
mem0ai/mem0 and getzep/graphiti source) found real, verifiable answers for
Mem0 and Zep on every row - several of them genuinely qualified rather than a
clean yes or no.
Decision
This narrows ADR-0017's scope, it does not reverse it
ADR-0017's actual point - no numeric accuracy/relevance benchmark against
Mem0/Zep - stands, fully, unchanged. This ADR carves out one specific,
narrower thing: the audit-query table's seven structural questions may be
answered for Mem0 and Zep, sourced and qualified, outside docs/benchmark.md.
docs/benchmark.md's own CI-gated numbers remain scoped exactly as ADR-0017
states - dict, JSONL, mnem, nothing else, still asserted on every build.
The table lives in docs/comparisons.md, not docs/benchmark.md
docs/benchmark.md is terse, boolean-shaped, and CI-gated - claims the build
itself verifies every run. Mem0's and Zep's behaviour isn't mnem's to
assert that way: it's a claim about someone else's software that needs
re-checking by hand if they ever change it. docs/comparisons.md (#237) is
already the written, sourced, qualifiable home for exactly this kind of
claim, and already carries the prose version of these same findings.
Qualified answers are written inline, not pushed to footnotes
At least three of the seven rows need a qualification for at least one tool
(e.g. Mem0: "no built-in call, but the schema permits it manually"; Zep: "yes,
via the filter API, but search()'s own convenience wrapper always uses
now"). The nuance is the finding - a reader comparing tools should see it at
the point of the claim, not chase a footnote to learn the "yes" they just read
has a real caveat.
Letta is not added to this table
Scoped to Mem0/Zep, matching how #282 was filed. Letta's comparison stays as
existing prose in docs/comparisons.md; adding a third column here is scope
this ticket didn't ask for and can be a separate decision later if there's a
real reason to numerically compare Letta specifically.
The seven rows
| Question | Mem0 | Zep (Graphiti) |
|---|---|---|
| what does it believe now | yes | yes |
| what did it believe at step t | no built-in call (the history table is scoped per memory_id, no global reconstruction; the schema permits a manual per-memory replay, nothing does it for you) | yes, via SearchFilters' valid_at/invalid_at date filters - but search()'s own convenience wrapper always uses "now" |
| when did belief X first go wrong (bisect) | no | no |
| which observation set X | shallow: actor_id/role per history event | real: EntityEdge.episodes references the originating observation(s) |
| what did step t change | qualified: a real diff (old_memory/new_memory), but scoped to one memory, no cross-memory "commit" | no explicit diff API; derivable by comparing two point-in-time filtered queries |
| merge two agents' memories, surfacing conflicts | no | no (a later fact invalidates an earlier one; not a flagged, resolvable conflict) |
| storage after L steps | O(keys) live state (vector store) + O(events) append-only history | O(all facts ever asserted), monotonically growing |
Consequences
docs/comparisons.mdgains a concrete, sourced table alongside its existing prose - strengthens the same claims with specifics rather than duplicating them.docs/benchmark.mdand its CI assertion are completely untouched; nothing about ADR-0017's actual mechanism changes.- The qualified-answer format means this table needs re-verification by hand
if Mem0 or Zep materially change their history/temporal APIs - an accepted,
stated cost of comparing against software this project doesn't control,
same as
docs/comparisons.md's existing Memoria/ByteRover sections already carry. - Bisect is now the one row where the written record plainly states
mnem, Mem0, and Zep are all behind a plain JSONL log's linear scan for two of the three real competitors - worth being honest about rather than only ever stating wheremnemwins.
Alternatives considered
Fold this into docs/benchmark.md as a new subsection. Rejected: that
page's whole point is CI-gated numbers the build itself verifies; Mem0/Zep's
behaviour is not something mnem's test suite can check, and putting it there
would misrepresent it as build-verified the way the dict/JSONL/mnem rows
genuinely are.
Force every cell to a strict yes/no. Rejected: collapses real information. Mem0's "no, but the schema permits it manually" and Zep's "yes, but only via the low-level filter API" are different claims than a flat no/yes, and a reader comparing tools loses exactly the detail that makes the table useful.
Footnote the qualifications instead of writing them inline. Rejected: keeps the table's surface scannable at the cost of hiding the nuance that is the actual finding - a reader skimming the table would come away with the wrong impression of at least three rows.
Add Letta as a third column. Rejected for now: out of #282's filed scope,
and Letta's existing prose comparison in docs/comparisons.md already covers
it; a separate decision if there's a real reason to want it numerically.
Treat this as reversing ADR-0017 rather than narrowing it. Rejected: ADR-0017's actual claim (no accuracy benchmark against Mem0/Zep) is not challenged anywhere in this research or this ADR - it remains correct and unchanged. Framing this as a reversal would overstate what changed.
ADR-0023: The AutoGen adapter
- Status: Accepted
- Date: 2026-09-23
- Issue: #251 (research + grilling, one combined ticket)
Context
Research (docs/research/issue-251-autogen-survey.md, read against the real
microsoft/autogen source) found AutoGen's pluggable memory primitive,
Memory, is the narrowest contract of the three adapters designed so far:
MemoryContent has no id field at all, and the bundled reference
implementation, ListMemory, ignores its own query() argument entirely
and returns every stored item chronologically - its own docstring says so
plainly. That is not a limitation to work around; it means a Mnemosyne-backed
Memory doing the same thing is the SDK's own real default behaviour, not a
degraded implementation, sidestepping the embedder question ADR-0021 had to
resolve carefully for CrewAI.
update_context(model_context) is a genuinely new integration shape: called
automatically before every model inference, it actively injects a message
into the conversation - push, not pull, unlike every adapter designed before
it. add() has no automatic call site anywhere in the codebase; every
example calls it directly from application code.
Decision
Packaging
packages/mnem-autogen/, PyPI mnem-autogen, import mnem_autogen,
following the same convention as the other three adapters. Pins
mnem-agents ~= 0.0, versions independently.
Node id scheme: reuse ADR-0020's sequence-counter scheme directly
{name:_seq}-backed, zero-padded ids ({name}:{seq:010d}), name being the
Memory instance's own identifier (ListMemory's existing name field).
No new id convention: nothing about Memory's own model argues for a
different shape the way CrewAI's hierarchical scope paths did (ADR-0021).
Reusing an established pattern here is consistency with a real reason behind
it, not just tidiness for its own sake.
Namespacing: one Mnemosyne store per Memory instance
Unlike LangGraph's BaseStore (one store, many namespaces, ADR-0016) or
CrewAI's globally-registered StorageBackend (ADR-0021), a Memory
instance in every AutoGen example is constructed directly and handed to one
agent - there is no established pattern of one Memory serving multiple
logical scopes. Closer in spirit to the OpenAI Agents SDK's Session
(ADR-0020) than to either of the shared-store adapters.
query(): ignore the argument, return everything chronologically
Matches ListMemory's own real behaviour exactly - not a degraded
implementation, the SDK's own actual default. No ranking, no filtering, no
embedder dependency anywhere in this adapter.
update_context(): match ListMemory's exact output shape
One SystemMessage, a numbered list of memory contents in chronological
order - byte-for-byte the same format ListMemory produces, so a caller
already familiar with the reference implementation's output sees no
surprise switching backends.
metadata: fully opaque, no reserved-key convention
Stored and returned exactly as given, no interpretation, no _meta-style
carrier the adapter tries to lift into Provenance. Unlike the OpenAI
Agents SDK (on_tool_end, ADR-0020) or LangGraph (_meta, ADR-0016), there
is no automatic call site here that would make a reserved-key convention pay
off - add() is always explicit application code, so inventing a rule
nobody is forced to use just adds surface without a mechanism behind it.
clear(): a tombstone commit, never a hard delete
mnem.agents.forget on every node under the instance's namespace, one
batched commit - the same reasoning ADR-0020 already settled for
clear_session: a routine clear() call must not silently defeat the whole
point of using Mnemosyne.
Errors
The same taxonomy as every other adapter (ADR-0016): not_found,
invalid_ref, conflict, corrupt_store, no_store, store_io, busy.
The worked example
One script in examples/, alongside the other three: an AssistantAgent
constructed with a MnemosyneMemory instance, a couple of turns adding
content via add(), update_context demonstrably injecting it before
inference, then blame/bisect on the resulting history.
Consequences
- A fourth adapter package (alongside
mnem-mcp,mnem-langgraph, and the designed-but-unbuiltmnem-openai-agents/mnem-crewai), independently versioned. - The simplest adapter built so far in terms of what it has to reconcile -
no embedder question, no tree-shaped scoping, no invented provenance
carrier - because
Memory's own contract is the narrowest one studied. blame/bisectresolve per memory item, same reasoning as every prior adapter.metadata's opaqueness means provenance is entirely the calling application's responsibility if it wants any - a real, stated limitation, not a silent gap.
Alternatives considered
Implement real ranking in query() (an adapter-side embedder, or a
keyword match over content). Rejected: the reference implementation
itself does neither, so there is no real bar to clear here the way CrewAI's
search() set one. Inventing ranking capability nothing in the ecosystem
expects is scope this adapter doesn't need.
A reserved-key convention for metadata, mirroring LangGraph's _meta.
Rejected: _meta pays off because LangGraph callers are already shaping a
value dict for put(); here add() has no equivalent structure and no
automatic call site to make a convention discoverable or worth adopting.
A shared store with name as a prefix, mirroring LangGraph/OpenAI Agents
SDK. Rejected: no example or established pattern in AutoGen's own codebase
constructs one Memory to serve multiple logical scopes - one store per
instance matches how the SDK is actually used.
A hard delete for clear(). Rejected outright, not seriously
considered - same reasoning as every prior adapter's tombstone decision
(ADR-0020, ADR-0021): a routine SDK call must not be able to silently erase
history.
ADR-0024: The Mem0 migration path
- Status: Accepted
- Date: 2026-09-25
- Issue: #253 (research + grilling, one combined ticket)
Context
Mem0 is a memory store, not an agent framework - "an adapter for Mem0"
doesn't mean the same thing as an adapter for CrewAI or AutoGen. There is no
Session/Memory/StorageBackend-shaped object to implement the way
ADR-0020/0021/0023 had. What makes sense instead is a migration path for
someone moving off Mem0, or running both side by side.
Research (docs/research/issue-253-mem0-migration-survey.md, extending
#282's already-verified mem0ai/mem0 source reading) found two real
constraints Mem0's own get_all API imposes: it requires an explicit scope
(user_id/agent_id/run_id) up front, with no list-everything call, and
top_k is a cap with no cursor. It also produced a concrete, sourced answer
to a real external contributor's question on this same issue
(samvallad33): mem.payload.get('data', '') confirms Mem0's own code
silently defaults to an empty string when a record's content is missing,
not an error - the concern was real.
Decision
Both a tool and a guide
A mnem.agents.import_mem0(...) SDK function, plus a short written
migration guide. They answer different questions: the function does the
work, the guide states the one limitation that matters honestly up front -
this imports Mem0's current state, not a ported-over timeline, because
Mem0's own history table (verified in #282) is scoped per memory_id,
with no project-wide event log to port in the first place.
Location: the SDK, not a script or a new package
mnem.agents.import_mem0(store, mem0_memory, *, filters, ...) lives
alongside the module's existing remember / remember_many / forget
(ADR-0016) - the same "the SDK holds the ergonomics" precedent, not
scripts/ (repo tooling, not user-facing functionality) and not a new
packages/ adapter (this isn't an ongoing integration surface anyone
depends on continuously; it's a function called once).
Empty content: skip and log, never import-as-empty or fail the batch
A migration tool handling someone else's real data is conservative about
what it writes. A record whose memory field is empty is skipped, logged
by its Mem0 id so the caller can inspect the source data themselves, and
the import continues - not silently written as an empty mnem node, and
not a reason to abort an otherwise-good migration.
Node id scheme: mem0:{memory_id}, namespaced
Mem0's own memory_id (a UUID) becomes mem0:{memory_id}, not a bare id.
A bare id risks colliding with existing content in a target store that
already holds other memories (this is explicitly a "moving into an existing
store, possibly alongside other sources" case, unlike a fresh adapter's own
namespace); the prefix costs nothing and keeps the import's origin visible
in the id itself, permanently.
Pagination: a real loop, not a single large default
import_mem0 calls get_all with an initial top_k, and keeps raising it
(or otherwise re-querying) until a call returns fewer results than
requested, rather than trusting one fixed cap. A migration tool that
silently drops memories past an arbitrary count is a worse failure mode
than a slower import.
Provenance mapping
Mem0's promoted payload fields - user_id, agent_id, run_id,
actor_id, role - map into Provenance the same way every adapter's
mapping has worked: actor_id (or role where actor_id is absent) as
source, created_at preserved, everything else Mem0 attaches folded into
metadata rather than dropped.
Errors
The same taxonomy as every adapter (ADR-0016), plus one migration-specific outcome: a per-record skip (empty content) is reported in the function's return value (a count and a list of skipped ids), not raised as an error - it's an expected, handled case, not a failure.
Consequences
- A migration function, not a package - no new PyPI release train, ships
in the next
mnem-agentsrelease. - Provenance survives the move (Mem0's
actor_id/role/created_atare real fields, not invented), but Mem0's own per-memory edit history does not - stated honestly in the guide, not silently dropped without mention. - The
mem0:id prefix means a store that has both nativemnemcontent and imported Mem0 content keeps them visibly distinct - a deliberate, permanent marker, not a one-time migration artifact that disappears. - Answers a real, publicly-asked question (the external comment on #253) with a concrete decision, not just a private research finding.
Alternatives considered
A standalone scripts/import_mem0.py. Rejected: this project's
scripts/ directory is repo tooling (changelog/ADR-index checks), not
user-facing functionality - a real migration tool belongs where the SDK's
other ergonomic helpers already live.
A dedicated packages/mnem-mem0-import package. Rejected: this isn't
an ongoing integration surface anyone depends on continuously the way the
adapters are; a new independently-versioned package is disproportionate to
"a function someone calls once."
Bare memory_id as the node id. Rejected: real collision risk in a
store that already holds other content, for no benefit over a namespaced
id - the prefix is free and permanently useful context.
Import-as-empty or fail-the-batch for missing content. Rejected: writing an empty node silently is worse than not writing anything, and failing an entire migration over one bad record punishes the rest of a otherwise-good import.
A single large default top_k. Rejected: silently caps how much gets
migrated with no signal to the caller that anything was left behind - the
paging loop costs a few extra API calls, not correctness.
ADR-0025: CrewAI private-filtering is not the adapter's job
- Status: Accepted
- Date: 2026-09-30
- Issue: #314
Context
ADR-0021's "Provenance and privacy" section states that enforcing
MemoryRecord.private ("filtering by source unless include_private=True")
"happens in the adapter's search/list_records implementations."
Building #314's search against the real, installed crewai package found
this isn't buildable as written: StorageBackend.search's actual signature
(crewai.memory.storage.backend, a Protocol) is (query_embedding, scope_prefix=None, categories=None, metadata_filter=None, limit=10, min_score=0.0) - no source, include_private, or any other requester
identity is ever passed in. An adapter cannot filter by "who's asking" when
it is never told who's asking.
Reading CrewAI's own reference backends confirms this isn't an oversight in
the protocol: neither LanceDBStorage.search nor QdrantEdgeStorage.search
filters on private at all. The real filtering happens one layer up, in
crewai.memory.recall_flow.RecallFlow._do_search, strictly after calling
storage.search(...):
if not self.state.include_private and raw:
raw = [
(r, s) for r, s in raw
if not r.private or r.source == self.state.source
]
RecallFlow holds include_private and source on its own state, which
storage.search() never sees. private is a signal the storage layer
carries and the caller enforces - the same shape as importance and
last_accessed, which ADR-0021 already correctly treats as round-trip-only.
Decision
This narrows ADR-0021, it does not reverse it
Every other decision in ADR-0021 - packaging, node id scheme, storage
granularity, search's own cosine-similarity semantics, importance/
last_accessed round-tripping - stands unchanged. This corrects one
specific claim: private-enforcement is not, and cannot be, the adapter's
job, because the protocol never gives it the information to do that job.
private is round-tripped only, exactly like importance and last_accessed
MnemosyneStorageBackend.search and list_records never filter on
private. A private record scores and returns like any other; the field
is stored and surfaced exactly as CrewAI handed it over. This matches both
reference backends' real behaviour, not a degraded version of it.
Consequences
search/list_recordsare simpler than ADR-0021 described - one fewer piece of state to track correctly, since there is nothing to enforce.- An integrator calling
MnemosyneStorageBackend.search/list_recordsdirectly (outside CrewAI's ownRecallFlow) gets every record back, private ones included - matching what LanceDB/Qdrant's own backends would do if called the same way.RecallFlowremains the only real enforcement point CrewAI itself ships, and this adapter does not invent a second one. #312's already-shippedlist_recordsnever filtered onprivateeither; this ADR confirms that was the correct behaviour, not an oversight to fix.
Alternatives considered
Add an adapter-only include_private/source keyword argument beyond the
protocol's own signature. Rejected: RecallFlow, the only real caller
CrewAI ships, always calls the plain protocol signature - it would never
pass the extra argument, so this would only help a caller using the adapter
directly outside CrewAI's own recall path, at the cost of a search/
list_records signature that diverges from every other real StorageBackend
implementation's actual shape.
Leave ADR-0021's claim as written and skip filtering silently, undocumented. Rejected: ADR-0021 is Accepted; a later correction to a specific decision needs its own record with the real reasoning, the same precedent ADR-0022 already set narrowing ADR-0017 - not a silent divergence between what the ADR says and what the code does.
ADR-0026: The Google ADK adapter
- Status: Accepted
- Date: 2026-10-20
- Issue: #568 (map); #571, #572, #573, #574, #576, #578 (research and decisions)
Context
Research against ADK's own real v2.12.0 source
(docs/research/issue-569-adk-memory-seam-survey.md,
issue-570-adk-memory-call-patterns.md) found Google's Agent
Development Kit's one pluggable memory seam,
google.adk.memory.BaseMemoryService, is wider than any adapter
built so far: an abstract add_session_to_memory(session), optional
add_events_to_memory(*, app_name, user_id, events, session_id=None, custom_metadata=None) and add_memory(*, app_name, user_id, memories, custom_metadata=None), and an abstract search_memory(*, app_name, user_id, query) -> SearchMemoryResponse. delete_memory does not
exist in the released 2.12.0 package - confirmed real only on an
unreleased commit well ahead of the v2.12.0 tag.
Two real call patterns exist in practice (#570): a manual batch call
after several turns, and a callback-driven per-turn auto-save. Both
need the caller to re-fetch the session first, since an in-memory
Session object can go stale. Memory itself is a corpus, not a
dictionary: MemoryEntry.id is optional and never caller-assigned in
any real sample.
search_memory is scoped by (app_name, user_id) only - it takes no
session_id (#571) - and the reference InMemoryMemoryService ranks
by real keyword overlap, not an unranked default (#573). Event
objects already carry everything needed for provenance directly on
the object (author, invocation_id, structured function_call/
function_response parts), unlike the OpenAI Agents SDK's need for an
on_tool_end capture hook (#572, ADR-0020). google-adk requires
Python >=3.11, one version above every other adapter package's own
>=3.10 floor, confirmed by a real failed install on a 3.10 venv
(#576); its mcp extra resolves cleanly against mnem-mcp's own pin.
Decision
Packaging
packages/mnem-adk/, PyPI mnem-adk, import mnem_adk, following
the same convention as the other three adapters. Pins mnem-agents ~= 0.0, versions independently. requires-python = ">=3.11" - a real,
stated exception to this project's otherwise-uniform >=3.10 floor,
forced by google-adk itself (#576).
Scope and node id scheme: a flat prefix, Event.id honoured literally (#571)
Scoped by {app_name}:{user_id}:, not a /-path: search_memory's
own real signature takes no session_id, and ADK's own guide states
the scoping rule directly. CrewAI's own /-path scheme (ADR-0021)
does not fit here - it earns its syntax from tree-walk operations ADK
has none of.
- An ingested event:
{app_name}:{user_id}:{session_id}:{event.id}. - A direct
add_memorywrite with its own id:{app_name}:{user_id}:_memory:{memory_entry.id}. - A direct
add_memorywrite with none:{app_name}:{user_id}:_memory:{seq:010d}, backed by a reserved{app_name}:{user_id}:_memory:_seqcounter node, excluded from search and content iteration - the same idiom ADR-0020 and ADR-0023 already use for items with no natural id.
Storage granularity: one node per event (#578)
Matches the id scheme above directly - a per-session or per-extracted-fact granularity would have needed a different key shape from the start. Matches every other adapter's own discipline (ADR-0020, ADR-0021, ADR-0023): pass storage items through literally, one node per item, no semantic extraction step anywhere in this ecosystem to point to.
Provenance mapping: a pure field read, no capture hook (#572)
Unlike the OpenAI Agents SDK, which needed an on_tool_end hook
installed to capture tool provenance (ADR-0020), ADK's own Event
already carries everything needed directly on the object handed to
add_events_to_memory/add_session_to_memory:
Provenance field | Event source |
|---|---|
source | Event.author |
tool_call | a function_call part's name/args |
observation | a function_response part's response |
agent_step | Event.invocation_id |
note | left empty, an escape hatch |
A real naming collision to flag plainly, not map around quietly:
ADK's own Event.branch is a dotted sub-agent visibility path
("agent_1.agent_2.agent_3"), unrelated to mnem's own Branch. Both
this adapter's code and its README always write event.branch
explicitly, never bare "branch" in a context that could read as
mnem's.
Search ranking: match InMemoryMemoryService exactly (#573)
Confirmed directly against the v2.12.0 source, not assumed:
NFC-normalised \w+ tokenising, Latin/non-Latin script-run widening,
a non-ASCII query token also matching by substring over the lowered
event text (CJK carries no word boundaries for token-set membership
alone to catch), zero-score events dropped, stable descending sort by
score, top 10 (_MAX_SEARCH_RESULTS). Neither prior precedent
transplants whole: AutoGen's ListMemory ignores its query argument
because it has no real ranking to match (ADR-0023); CrewAI's
search() takes a vector with no keyword to match against at all
(ADR-0021). ADK's own reference service has a real, specific ranking
algorithm, so matching it is the right call here, not degrading it.
delete_memory: mnem-adk's own extra method, built now (#574)
google-adk 2.12.0 has no delete_memory to match or defer to -
confirmed real only on an unreleased commit (#569). Rather than wait
on an upstream shape that may still change, mnem-adk exposes its own
delete_memory-shaped extra method now, the same way every other
adapter already exposes capability beyond its base contract (why,
history, branch, switch, merge, bisect). It is a tombstone
commit over the node's own id, never a hard delete - mnem's
cross-adapter principle regardless of what ADK eventually calls it.
If ADK later ships its own delete_memory with a different real
signature, this method can satisfy that interface too without
rework.
Extra methods, outside BaseMemoryService
why, history, branch, switch, merge, bisect and
delete_memory (above) all live on the adapter class itself, outside
the ADK protocol - the same precedent ADR-0016 set for LangGraph and
every adapter since has followed.
Errors
The same taxonomy as every other adapter (ADR-0016): not_found,
invalid_ref, conflict, corrupt_store, no_store, store_io,
busy.
add_session_to_memory: idempotent, one commit per call
Re-ingesting the same Session must not duplicate nodes: Event.id
is the node id, so a repeat call lands on the same keys and a second
add_events_to_memory dedupes against existing ids the same way
InMemoryMemoryService already does. Retries on ConflictError, one
commit per call.
The worked example
One script in examples/, alongside the other three: an ADK Runner
wired to a MnemosyneMemoryService, a scripted model in replay mode
(no key needed, offline in CI, the same discipline
examples/claude_agent.py already uses), demonstrating
load_memory/preload_memory composing on one agent, then
blame/bisect on the resulting history. A --live mode runs the
same script against the real demo model.
Consequences
- A fourth-generation adapter package, independently versioned,
alongside
mnem-mcp,mnem-langgraph,mnem-openai-agents,mnem-crewaiandmnem-autogen. - The widest
BaseMemoryServiceimplementation that exists today:add_session_to_memory,add_events_to_memory,add_memoryandsearch_memoryall real, where only the managedVertexAiMemoryBankServicecurrently implements all four. packages/mnem-adk/pyproject.tomlcarries a real, statedrequires-python = ">=3.11"exception to this project's otherwise uniform>=3.10floor, and the adapters CI job installs it in the same sequence as the other adapter packages.blame/bisectresolve per event, same reasoning as every prior adapter.- Callers get a real
delete_memoryahead of ADK's own upstream method; if that upstream method's eventual real signature differs, this adapter's method may need a second, compatible overload later.
Alternatives considered
A /-path node id scheme, mirroring CrewAI (ADR-0021). Rejected:
CrewAI's own scheme earns its syntax from real list_scopes/
get_scope_info tree-walk operations; ADK's search_memory has
nothing equivalent, so a flat :-joined prefix matching three of the
four existing adapters is the right default here, not CrewAI's
exception.
Defer delete_memory until ADK ships its own. Considered
seriously, rejected: mnem's own store already has the tombstone
machinery to support this now at little cost, and waiting ships the
adapter with a real, visible capability gap other adapters do not
have, for an upstream timeline outside this project's control.
A degraded or invented search_memory ranking. Rejected: unlike
AutoGen's ListMemory, which has no real ranking to match, ADK's
reference service has a specific, real one, so degrading it would
answer a narrower question than the one the ecosystem's own default
already asks.
One node per session, or per extracted fact. Rejected: per-session loses the per-turn granularity blame/bisect exist to provide; per-extracted-fact needs an LLM extraction step this adapter would have to own itself, with no existing adapter or research finding to ground it.
Research
Outputs of wayfinder:research tickets: prior art reviews, format comparisons,
API surveys. Each file is named for its ticket, for example
issue-2-object-format-survey.md, and ends in a recommendation that feeds an
ADR.
Research: agent memory data model survey (issue #5)
Feeds ADR-0003. Question: what exactly does Mnemosyne version, and what fields does a memory node carry?
CONTEXT.md already sketches this: a memory node is content plus provenance
plus timestamps plus an optional embedding, and a claim is a node whose content
follows a schema. This survey pressure-tests that sketch against how other
systems model memory, and fills in the specifics for the grilling in #6.
What other systems store
mem0
add() extracts discrete facts from input text, checks each against
existing memories for semantic overlap, and emits a resolution event: ADD,
UPDATE, DELETE or NONE. A memory is a fact string plus a metadata dict (user_id
and arbitrary keys) plus extracted entities kept in a parallel collection.
Storage is hybrid: a vector store, a graph of entities and relations, and a
key-value store.
Takeaway: fact-oriented, with an extraction step, and update or delete happens in place.
Letta (MemGPT)
Memory is organised into blocks: a block is a labelled, persistent string
with a size limit, edited by the agent with core_memory_append and
core_memory_replace. Tiered into core (in context), recall (searchable
history) and archival (large store).
Takeaway: block-oriented, not fact-oriented. The unit is a mutable labelled string the agent rewrites. Wrong shape for version control, since the whole point of a block is that it is edited in place.
Zep and Graphiti
A temporal knowledge graph. Every fact edge carries bi-temporal metadata:
valid_from and valid_to (event time, when the fact was true in the world)
and an ingestion time (when Zep learned it). When a fact is superseded the old
edge is invalidated, not deleted.
Takeaway: Zep already does "keep history, mark superseded" at the application level, with a clean split between event time and record time. Mnemosyne should do the same thing one layer down, in the store.
LangGraph store
An item is {namespace: text[], key: text, value: JSON, created_at, updated_at}
with a primary key of (namespace, key). That is the whole schema. The value is
an opaque JSON blob.
Takeaway: the minimal viable model is a keyed opaque value. Anything Mnemosyne adds beyond this needs to earn its place.
Generative Agents
A memory object is {description, creation_time, last_access_time, importance_score, embedding}. Retrieval scores each object on recency,
importance and relevance.
Takeaway: importance and recency are retrieval-scoring signals, mutable and derived, not facts about the memory. They do not belong in a versioned model.
The 2026 versioned-memory papers
- StateFuse (arXiv 2607.05844): immutable history, explicit conflict
objects,
claim_idandclaim_refas exact and semantic correction handles, deterministic predicate contracts, and projection-time resolution that never rewrites replicated state. - MemTX (arXiv 2607.23929): each record carries evidence, permissions, provenance, and validity. Writes are staged in snapshot-isolated transactions and admitted by a validate-and-commit pipeline. Irreversible tool calls are gated on in-flight belief state.
- LatticeMind (arXiv 2608.08236): explicit status tracking on items, symbolic conflict checks, and selective LLM reconciliation in one update loop.
Takeaway: provenance, evidence and validity are consistently first-class. Contradiction is either an explicit object (StateFuse) or a status on the item (LatticeMind); these two are in tension.
CogCanvas ablation (arXiv 2601.00821)
A controlled ablation swapped only the stored representation in a fixed retrieval pipeline: LLM-extracted typed artefacts versus verbatim conversation chunks. Verbatim won by 15.9 points on LoCoMo and 22.0 on LongMemEval-S. The mechanism is lossy distillation, not structure. Recommendation: structured memory should augment verbatim text, not replace it.
Takeaway: do not force extraction or a schema. Store what the agent gives you. Structure is optional and additive.
Answers to the four questions
1. Freeform node, structured claim, or both?
Both, with freeform as the default. The unit is a memory node whose
content is freeform: text or an arbitrary JSON value, like LangGraph's opaque
value. A node may declare content_kind = claim, in which case its content
follows the claim schema. v0.1 ships only note.
This is the CogCanvas finding applied directly: verbatim by default, structure as an optional overlay. It also avoids Letta's mistake of a mutable unit.
2. What does the claim schema need for semantic merge in v2?
subject: the entity the claim is about.predicate: the attribute or relation.value: the asserted value.confidence: a number in 0 to 1, the agent's stated confidence.evidence: a list of references that support the assertion (distinct from provenance, see below). Optional.
subject and predicate together are what a v2 merge uses to detect that two
claims are about the same thing and might contradict. This mirrors StateFuse's
predicate contracts and MemTX's per-record validity and evidence.
3. How is provenance attached, and what does it point at?
Every node carries provenance. It is a structured record of how this node came to exist:
agent_step: which step of the run produced it.observation: a reference to the observation or input it was drawn from.tool_call: a reference to the tool call, if one produced it.source: an external source identifier, if any.
All fields are optional individually, since an agent loop may only know the
step. This is what blame reads.
Provenance is not evidence. Provenance answers "where did this record come
from" (a blame concern, every node). Evidence answers "what supports this
assertion" (a claim concern, optional). MemTX keeps both; so should we.
4. Do embeddings belong on the node, or in a side index?
A side index. A redb table in the store, keyed by content hash,
rebuildable, never part of the versioned object and never part of its content
hash. Reasons:
- Embeddings are a v2 concern (semantic merge, retrieval). v1 does not use them.
- A float array on every node bloats the object store and would pull the embedding model's identity into the content hash.
- The embedding model changes over time; history must not have to be rewritten when it does.
- mem0 and Generative Agents both keep vectors in a separate store.
Recommendation for ADR-0003
The memory node. Fields:
| Field | v0.1 | Notes |
|---|---|---|
id | yes | a stable logical key, so updates across commits target the same node. Caller-provided, or generated if absent. Not content-derived. |
content | yes | freeform: a string or an arbitrary JSON value |
content_kind | yes, always note | note or claim. claim follows the claim schema. |
provenance | yes | {agent_step, observation, tool_call, source}, all optional individually |
event_time | yes | when the agent formed the node. The commit carries the record time. Zep's bi-temporal split. |
The claim schema (content of a claim node): {subject, predicate, value, confidence, evidence[]}. Not built in v0.1, but defined now so ADR-0002's
self-describing objects can carry it additively at v2.
Not on the node. Embeddings (side index, v2). Importance and recency (retrieval-scoring signals, mutable, out of the versioned model). Status (asserted, superseded, contradicted): computed by the v2 merge layer, which emits Contradiction objects rather than mutating nodes. Nodes stay immutable per commit, matching StateFuse over LatticeMind on this point.
Rationale. The model is deliberately close to LangGraph's minimal item at
the core (id plus opaque content), with the two additions that version
control genuinely needs: provenance for blame, and event_time for an
honest history. The claim schema is defined but dormant until v2, so Phase 1
stays small and the format does not need a break to add it later.
Open questions for the grilling (#6)
id: caller-provided with a generated fallback, as recommended, or always generated. A content-derived id is out, since it would make "update a node" impossible.content: accept any JSON value plus text, or constrain it. This survey says accept anything; it is the agent's memory, not ours to shape.event_timein v0.1: include it now, as recommended, or rely on the commit timestamp until v2. One field, andblamereads better with it.- Contradiction as an object (StateFuse) versus a status on the node (LatticeMind). This survey picks the object, to keep nodes immutable. Confirm, since it constrains the v2 design.
- How much provenance structure to mandate. This survey makes every field optional. Confirm that a node with an empty provenance is legal.
Sources
- State of AI Agent Memory 2026 (mem0) and mem0 update operations
- Letta memory blocks
- Zep temporal knowledge graph and Graphiti
- LangGraph stores
- Generative Agents (arXiv 2304.03442) and the memory stream pattern
- StateFuse (arXiv 2607.05844)
- MemTX (arXiv 2607.23929)
- LatticeMind (arXiv 2608.08236)
- CogCanvas: verbatim beats extracted artefacts (arXiv 2601.00821)
Research: on-disk object format survey (issue #3)
Feeds ADR-0002. Question: what on-disk shape should a .mnem/ store take?
What the store has to hold
- Memory nodes. Many small objects. A run can produce thousands, each a short piece of text or JSON plus provenance. This is the dominant object by count.
- State. The set of memory nodes visible at a commit. Needs to be content-addressed so an unchanged state is free to reference, and needs a cheap structural diff between two states (Phase 2) and a three-way merge (Phase 3).
- Commits. A small object: parent hashes, a state hash, author, timestamp, message, signature. Forms the graph.
- Refs. Branch names and HEAD. Few, small, frequently updated.
Constraints from AGENTS.md: the format is frozen within a major version, the
core is pure and offline, and a store written by one v1.x release is readable by
every other v1.x release. From the plan: the moat is our format, so the core
data structures should be ours, not borrowed wholesale.
Options
1. Git's model: loose objects, packfiles, refs as files
Content-addressed objects written as individual zlib-compressed files under
objects/ab/cdef..., consolidated into packfiles over time, refs as plain files
under refs/.
- Many small objects. Poor. This is the exact failure mode Git added packfiles to escape: directory traversal, file-open overhead, metadata churn, weak locality. Thousands of loose files per run is the bad case.
- Branch cost. O(1). A branch is one small ref file.
- Merge. Nothing built in for our state shape. Git's merge is line and tree oriented; our state is a set of nodes.
- Format stability. Excellent and battle-tested.
gitoxidegives a mature Rust reader and writer. - Build effort. High. To avoid the loose-object problem we would have to
build packing, an index,
repack, and garbage collection. That is a storage engine, and a large distraction from the product.
2. Content-addressed objects in an embedded key-value store
Objects serialised and stored by hash as keys in an embedded store such as
redb. Refs in a small table.
- Many small objects. Good. The store is one file; there is no per-object
filesystem cost.
redbuses copy-on-write B+trees and has lmdb-class performance. - Branch cost. O(1). A branch is one row in a refs table.
- Merge. Not provided, but the state can be given a structure that supports it (see option 3).
- Format stability.
redb's on-disk format is explicitly stable with a documented upgrade path. It becomes our one load-bearing storage dependency. - Build effort. Low to moderate. We build the object model, the graph walk, and the operations. We do not build a storage engine.
Candidate engines: redb (pure Rust, single file, crash-safe, stable
format, B+tree, mature), fjall (LSM, write-optimised, younger), sled
(perpetually beta, effectively unmaintained), rocksdb (C++ dependency, heavy).
redb is the clear pick.
3. Prolly tree for state, key-value object store for the rest
The state at each commit is a prolly tree: a content-addressed,
history-independent probabilistic B-tree mapping a node key to a node hash.
Commits, memory nodes and refs live in a redb object store as in option 2.
This is DoltDB's storage model, and it exists as Rust crates (prollytree,
prolly-map).
- Many small objects. Good, same as option 2 for the blob store. The prolly tree adds internal nodes but chunking keeps the count reasonable.
- Branch cost. O(1), and structural sharing means a large state costs only its delta when it changes.
- Diff. Cheap and structural. An in-order walk of two content-addressed trees emits exactly the changed keys, skipping identical subtrees by hash.
- Merge. Three-way merge works at the tree-node level without enumerating every node. Non-overlapping key changes merge with no conflict.
- Format stability. Ours to fix, if we implement the tree rather than depend on a crate. Chunk parameters go in the store config.
- Build effort. Moderate to high. A minimal prolly tree is a few hundred lines, but it is real work and needs care around the chunking boundary.
4. Depend on the prollytree crate wholesale
prollytree already offers a content-addressed key-value store with branching,
three-way merge, cryptographic proofs, and Git-backed storage. It could cover
much of Phases 1 to 3.
- Upside. A large head start.
- Downside. It is young and single-maintainer. Making it the core data structure means our frozen format is really its format, and the freeze-for-years invariant then rests on someone else's release cadence. It also may not model our commit header, provenance, or signing without forking it.
Useful as a reference and for a spike, not as the core dependency.
5. Git4Data / a relational database with branching
Git4Data (arXiv 2609.02106, MatrixOrigin and Purdue) treats a table as a versioned object and exposes snapshot, branch, diff and merge through SQL, with cost proportional to the change size via immutable object storage and MVCC. It is implemented in MatrixOne.
- Wrong deployment shape. It needs a database server. Mnemosyne v1 is a local, embeddable, offline library. Rejected on that alone, though the "cost proportional to the change" principle is one we want.
6. DVC or oras style content-addressed artifact stores
DVC tracks large files with metadata in Git and data in a cache keyed by hash,
synced to an external remote. oras stores artefacts in an OCI registry.
- Wrong granularity. Both are built for a small number of large artefacts with a remote, not a fine-grained local graph of thousands of tiny objects. Rejected.
Comparison
| many small objects | branch cost | structural diff | merge | format stability | build effort | |
|---|---|---|---|---|---|---|
| 1 Git loose + pack | poor without packing | O(1) | no | no | excellent | high (build packing + GC) |
| 2 KV object store | good | O(1) | no | no | good (redb stable) | low to moderate |
| 3 prolly state + KV | good | O(1), delta-cost | yes, cheap | yes, node-level | ours to fix | moderate to high |
4 prollytree crate | good | O(1) | yes | yes | not ours | low, but risky |
| 5 Git4Data | n/a | n/a | yes | yes | n/a | n/a, wrong shape |
| 6 DVC / oras | n/a | n/a | no | no | n/a | n/a, wrong shape |
Recommendation for ADR-0002
Option 2 now, option 3 by Phase 3. Concretely:
.mnem/is oneredbfile (store.redb), plus a plain-textHEADand a plain-textconfigfor legibility and easy inspection.- An object database: a
redbtableobjects: hash -> bytesholding memory nodes, states and commits. The hash function and commit header are ADR-0005's call; assume a 32-byte content hash for now. - Refs: a
redbtablerefs: name -> hash, withHEADmirrored to the text file. - State: a plain sorted content-addressed map in v0.1 (option 2). The
prolly tree lands in Phase 2, when
difffirst needs it, and pays off again for merge in Phase 3 (option 3). We implement a minimal prolly tree ourselves so the format stays ours and frozen;prollytreeis a reference and a spike target, not a core dependency. - No packfiles, no bespoke garbage collection in v0.x.
redbhandles compaction. Amnem compactcommand can come later if a store grows unreasonably. - Legibility is recovered with
mnem cat-object,mnem verifyandmnem fsckstyle commands and a written format spec, so anyone can build a reader.
Rationale. The workload is thousands of tiny objects per run, which is
exactly where Git's loose objects fall over, and building packing and GC to fix
that is a storage engine we should not write. redb removes the small-object
problem for free, is pure Rust, and has a stable documented format. Deferring the
prolly tree to Phase 2 keeps Phase 1 small and de-risked while still reaching the
structure DoltDB has proven for this shape before diff and merge need it.
Open questions for the grilling (#4, ADR-0002)
- State in v0.1: a plain sorted map, or the prolly tree from the start. This survey recommends the plain map and a Phase 2 upgrade; the grilling should confirm the format can absorb that change without a version bump, or decide to pay the prolly-tree cost up front.
- Is
redbacceptable as the one storage dependency to freeze on for years. Its format is documented and stable, but name it explicitly as load-bearing. - One
redbfile, orredbfor refs and index with separate object files. This survey recommends one file. - The object encoding (CBOR, bincode, other) and the exact prolly tree parameters are #19 and ADR-0008's job, not this ADR. Confirm that split.
Sources
- Git4Data (arXiv 2609.02106)
- Git's database internals: the packed object store (GitHub blog)
- Jujutsu architecture and Jujutsu on LWN
- Efficient diff on prolly trees (DoltHub) and three-way merge in a SQL database (DoltHub) and fast merge on prolly trees (DoltHub)
- Dolt storage engine docs
prollytreecrate andprolly(crabbuild)redbgitoxide
Research: commit identity survey (issue #15)
Feeds ADR-0005. Three questions: the hash function for content addressing, the signing scheme, and what belongs in a commit header.
ADR-0002 already fixed that objects are content-addressed and self-describing
with a kind tag, and left "assume a 32-byte content hash" for this ticket to
settle. ADR-0003 fixed that a memory node carries event_time (when the agent
formed a belief), which is separate from a commit's record time.
Hash function
The options
| speed vs SHA-256 | security | standard | Rust | |
|---|---|---|---|---|
| SHA-1 | ~2x | broken (SHAttered) | legacy | yes |
| SHA-256 | baseline (~3 GB/s) | strong | NIST, FIPS | yes |
| SHA-3-256 | ~0.3x (slower) | strong | NIST, FIPS | yes |
| BLAKE2b | ~3x | strong | RFC 7693 | yes |
| BLAKE3 | 4 to 10x, parallel (tens of GB/s on many cores) | strong, same profile as SHA-2 and SHA-3 | not a NIST standard | reference impl in Rust |
What git did, and why it does not bind us
Git is moving from SHA-1 to SHA-256 for Git 3.0 (targeted late 2026). It chose SHA-256, not BLAKE3, and the reason was ecosystem interop: TLS and X.509 already use SHA-256, and the transition's main blocker is forge support (GitHub still does not accept SHA-256 repositories). Git needs to interoperate with a huge installed base.
Mnemosyne has no such constraint. It is a local-first tool with its own format
(ADR-0002). Nothing outside a .mnem/ store needs to recompute its hashes.
Recommendation: BLAKE3
- 4 to 10 times faster than SHA-256, and it parallelises, which matters when hashing a large state or many objects in one commit.
- Same security profile as SHA-2 and SHA-3.
- The Rust community default for a greenfield project with no external standard mandate, with a first-class reference implementation.
- 32-byte output, displayed as 64 hex characters, with
mnemaccepting an unambiguous prefix in the spirit of git's short hashes.
The one real cost is FIPS. BLAKE3 is not a NIST standard, so a regulated or
government user could not use it. Mitigation: config records hash_algo
(ADR-0002's format is self-describing), so a future SHA-256 mode is possible
without a format break. It is not built now.
Signing
The options
- GPG: git's historical default. Heavy key management, a separate keyring.
- SSH signatures (ed25519): native to git since 2.34. Reuses an existing
id_ed25519key, no separate keypair. The modern default. - Sigstore gitsign: keyless. Signs against a short-lived certificate minted from an OIDC identity, logs the signature in a public transparency log, and discards the key. Strong for CI and supply-chain, but it depends on Fulcio and Rekor, which is heavy infrastructure for a local-first tool.
What gets signed
Git signs all commit data except the signature header itself: strip the signature, and the remaining canonical buffer is what was signed. The commit hash therefore covers the signature, so an unsigned commit and its later-signed version have different hashes.
The AT Protocol repository model is similar: the issuer signs the root hash plus metadata, and stores a signed manifest alongside.
Recommendation: ed25519, optional, in a side table
- ed25519 keys, the same shape as an SSH
id_ed25519. A user can reuse an existing SSH key. - Optional. A commit with no signature is valid. A single-agent local store often does not need signatures; they matter for v2 (shared memory, agent identity) and v3 (a registry).
- In a side table, not a commit field: a
redbtablesignatures: commit hash -> signature. The commit object has no signature field, so signing does not change a commit's hash.mnem verifychecks the side table.
This is a deliberate departure from git. In a content-addressed store, keeping commit identity purely about content, and signing as a separate optional attestation, is cleaner: you can sign after the fact, sign someone else's commit, or hold several signatures for one commit, without rewriting anything.
Sigstore is not chosen for v1 because of its infrastructure weight, but the side-table design does not preclude a Sigstore-style entry later (the value can be a bare ed25519 signature or a certificate bundle).
The glossary currently calls a commit "signed"; that becomes "optionally signed", with the signature held separately.
Commit header
What git and others carry
Git: tree hash, parent hashes, author (name, email, time), committer (name, email, time), message, optional signature. The author and committer split exists because of patches and rebases.
AT Protocol: CID, root hash, signature, parameters, issuer certificates, metadata.
Recommendation for the Mnemosyne commit object
| Field | Type | Notes |
|---|---|---|
kind | string, always commit | ADR-0002's self-describing objects |
parents | list of commit hashes | 0 for the first commit, 1 normally, 2 or more for a merge |
state | state hash | the state this commit points at |
message | string | freeform |
author | string | who or what made the commit. One field, not author plus committer: an agent commit is made in one step by one identity. Structured agent identity is a v2 and v3 concern. |
time | timestamp | the record time, always set by mnem at commit. Distinct from a memory node's event_time. |
Not in the header:
- signature: side table, as above.
- committer: no rebase or patch flow in v1, so one
authoris enough. - format_version: lives in
config, not per commit (ADR-0002). - a run reference: grouping commits by agent run is plausibly useful, but it is deferred; not in v0.1.
Canonical serialisation
The commit object, and every object, must serialise deterministically: the same logical content produces the same bytes and therefore the same hash. That means sorted keys, a fixed field order, and canonical number and string encoding.
This ADR fixes the requirement (deterministic canonical serialisation) and the field set. The encoding (canonical CBOR, or a hand-rolled canonical form) is ADR-0008's job (#20).
Recommendation for ADR-0005, in one place
- Hash: BLAKE3, 32 bytes, hex display, prefix matching.
hash_algorecorded inconfigfor a future switch, not built. - Signing: ed25519, optional, held in a side table keyed by commit hash. The
commit object has no signature field.
mnem verifychecks signatures where present. Sigstore deferred. - Commit header:
{kind, parents, state, message, author, time}. Oneauthor, nocommitter. Record time only; per-belief time is on the node. - Serialisation: deterministic and canonical is a requirement; the encoding is ADR-0008.
Open questions for the grilling (#16)
- BLAKE3 only, or a configurable
hash_algobuilt from day one. This survey says BLAKE3 only, with the config field reserved. - Signature in a side table, as recommended, or as a commit field like git. The side table changes the "signed" wording in the glossary.
- Does a minimal
mnem signandmnem verifyship in v1, or is signing purely reserved (design only, nothing built)? author: a bare string in v1, as recommended, or a small structured identity now.- Should a commit reference its agent run? Deferred here; confirm.
Sources
- BLAKE3 vs SHA-256 (2026) and BLAKE2 and BLAKE3 alternatives to SHA-256 and Rust hashing FAQ
- Git hash function transition and Git 3.0 and SHA-256 as default
- Signing git commits with SSH keys and ditching GnuPG for SSH signing
- Keyless commit signing with Sigstore gitsign
- Signing commits in Git, explained (GitButler)
- AT Protocol repository spec
- IPFS Merkle-DAG spec and Merkle-CRDTs (arXiv 2004.00107)
Research: object encoding and the store engine (issue #19)
Feeds ADR-0008. Two questions: which storage engine, and which byte encoding for an object.
ADR-0002 already settled most of the engine question: one redb file, objects
and refs as tables, self-describing objects with a kind tag, state flat in
v0.1. ADR-0005 requires a deterministic canonical serialisation, and left the
encoding to this ticket. So the engine part here is a confirmation and a layer
sketch; the encoding part is the real work.
Storage engine
redb, confirmed
redb is a pure-Rust ACID key-value store: copy-on-write B+trees, a stable and
documented file format, and per-transaction durability. Nothing found in a 2026
scan changes the ADR-0002 conclusion. fjall (LSM) and sled (perpetually
beta) remain the also-rans; rocksdb is a C++ dependency.
One property worth stating that ADR-0002 did not: a mnem commit is a single
redb write transaction. Writing the memory-node objects, the state object,
the commit object, and the ref update all happen in one atomic transaction. A
store is therefore never half-committed, and a crash mid-commit rolls back to the
previous commit. Crash-atomicity is free.
The object-database layer
Two tables in the one redb file:
objects : [u8; 32] -> Vec<u8> key is the BLAKE3 hash, value is the encoded object
refs : &str -> [u8; 32] branch name to commit hash; HEAD lives in the text file
redb supports &[u8] and &str keys and values with zero-copy reads, so no
richer typing is needed. Object lookup is a point query on objects. Writing an
object is idempotent: compute the hash, insert if absent.
Object encoding
The requirements, from earlier ADRs
- Deterministic and canonical (ADR-0005): the same logical object must produce the same bytes, so the same hash.
- Self-describing and evolvable (ADR-0002, ADR-0007): objects carry a
kindtag, and additive changes (a new field, a new kind) must not break an older reader of the objects it does understand. - Legible (ADR-0002):
mnem cat-objectmust produce something a person can read. - Compact and quick (ADR-0002): many small objects per commit.
The candidates
| Format | Deterministic mode | Self-describing | Schema evolution | Legibility | Size and speed |
|---|---|---|---|---|---|
| bincode | none | no | no (field order is declaration order) | none | fastest, small |
| postcard | none | no | no | none | ~1.5x bincode, ~70% its size |
MessagePack (rmp-serde) | weak, no standard | partly (type tags) | tolerable | poor | smallest on the wire, slower to decode |
CBOR (ciborium, cbor2) | RFC 8949 §4.2, plus the CDE and dCBOR profiles | yes | yes (unknown fields skippable or capturable) | good (CBOR diagnostic notation) | mid size, fast encode, slower decode in ciborium |
| JSON / JCS (RFC 8785) | JCS is a full standard | yes | yes | best | largest, slowest, no native bytes |
Why the binary schema-coupled formats are out
bincode and postcard are the fastest and smallest, but they fail requirements 1, 2 and 3 outright. They have no canonical mode, they are not self-describing, and adding a field is a silent break unless every object is manually versioned. For a project whose whole thesis is a stable, inspectable, frozen format, they are the wrong tool.
Why CBOR wins
CBOR is the only candidate that meets all four requirements:
- Determinism is standardised. RFC 8949 §4.2 defines deterministic encoding: preferred (shortest-form) integers and floats, no indefinite-length items, and map keys in bytewise sorted order. The stricter CDE and dCBOR profiles go further. This is exactly ADR-0005's requirement, already specified and tested by others, rather than rules we invent.
- It is self-describing. The
kindtag is just the first entry of the CBOR map. An older reader meeting an unknown field can skip it; a newer object kind is refused cleanly by itskind. This matches ADR-0002 and ADR-0007 with no extra machinery. - It is legible. CBOR diagnostic notation renders an object as readable text,
and
mnem cat-objectcan emit that or JSON. - It carries bytes natively, which JSON cannot without base64, and it is far more compact.
The cost is decode speed: ciborium decodes CBOR more slowly than it encodes,
and slower than bincode. For a store of small objects read a few at a time this
is not a real constraint, and it is the correct trade for the three requirements
bincode fails.
The memory-node content field
ADR-0003 says content is a string or any JSON value. CBOR's data model is a
superset of JSON's, so content maps directly: a CBOR string, or a CBOR
map/array/number/bool/null. The one care point is floats: deterministic CBOR has
specific float rules (shortest form that round-trips), and an agent's JSON
content can contain floats. The encoder must apply those rules to content too,
not just to our own fields.
Recommendation for ADR-0008
- Engine:
redb, one file, tablesobjectsandrefsas above. A commit is one write transaction. - Encoding: CBOR, restricted to a deterministic profile. Start from RFC 8949 §4.2; the grilling decides whether to adopt the stricter CDE or dCBOR profile.
- Crate:
ciboriumfor the CBOR data model (mature, ubiquitous), with a thin deterministic-ordering and preferred-form pass on top, since the §4.2 rule set is small and well specified.cbor2is the alternative, with §4.2 canonical encoding built in but a shorter track record. The grilling picks. - Framing: none beyond CBOR itself.
kindis the first map entry.format_versionstays inconfig(ADR-0007), not per object.redbframes the key and value. mnem cat-object: renders CBOR diagnostic notation by default, JSON with a flag.- The exact profile, the struct-to-CBOR mapping, and the float rules go in
docs/format/.
Open questions for the grilling (#20)
ciboriumplus a hand-rolled §4.2 pass, versuscbor2's built-in canonical encoding, versus a fully hand-rolled canonical binary form. This survey leansciboriumplus a thin pass.- Which determinism profile exactly: RFC 8949 §4.2 core, or CDE, or dCBOR.
- Confirm
contentmaps straight to the CBOR data model, and how floats insidecontentare canonicalised. - Confirm no per-object framing header:
kindas the first CBOR map entry is enough.
Sources
- redb design doc and redb 1.0 release
- RFC 8949 (CBOR), Section 4.2 deterministic encoding and CBOR: On Deterministic Encoding (draft-bormann-cbor-det)
- dCBOR deterministic profile and the CBOR determinism chapter
cbor2crate (RFC 8949, canonical encoding)- Rust serialization benchmark and json vs bin sizes
- RFC 8785 JSON Canonicalization Scheme
Research: cheap branching and time travel (issue #34)
Feeds ADR-0012 (the branch and checkout model). Phase 2 delivers branch,
checkout, reading working memory at any past commit, and a structural diff,
all still Era 1: single-agent, local, deterministic.
Three questions:
- How do Git and Jujutsu keep branch creation constant time, and does Mnemosyne already have that?
- What does
checkouthave to touch, and what is "working memory" here? - Is the
flatstate form (ADR-0002) enough for time travel and diff, or is the prolly-tree form needed now?
1. Branching is already cheap
How Git and Jujutsu do it
A Git branch is a 40-byte file under .git/refs/heads/ (or one line in
packed-refs) holding a single commit id. Creating a branch writes those bytes
and touches nothing else, so it is O(1) regardless of repository or working-tree
size. HEAD names the current branch; a commit moves whatever branch HEAD
points at.
Jujutsu calls them bookmarks and they are also just named pointers to revisions. Two differences from Git: there is no "current" bookmark, so a new commit does not move any bookmark on its own; and a bookmark automatically follows its target if that commit is later rewritten. Branch creation is the same constant-time pointer write.
Mnemosyne already has the pointer model
ADR-0009 built the refs table: name -> [u8; 32], one row per branch, with
list / get / set / compare_and_set / delete and name validation.
Store::commit already creates the branch row on the first commit and advances
it with a compare-and-swap after. HEAD is the one-line text file, attached
(ref: <branch>) or detached (a bare commit id), and a commit from a detached
HEAD is already refused.
So "make branching cheap" is not open work. What is missing is only the surface:
Store::branch(name)— insert arefsrow pointing at whereHEADresolves.Store::branches()—refs::list, already there.Store::delete_branch(name)—refs::delete, plus a guard so the branchHEADis on cannot be deleted.Store::checkout(target)— see below.
Recommendation: branches stay pointer-only. No copy-on-write of state at branch time, because nothing is copied: a branch is a 32-byte value in one row.
2. What checkout touches, and what "working memory" is
The gap today
CONTEXT.md defines working memory as "the current, mutable memory state
the agent reads and writes, materialised from a commit ... the equivalent of a
working tree". Right now there is no such thing. There is staging (the index
equivalent): mnem add writes a node straight into the staging table, and
commit overlays staging on the parent commit's state. There is no step that
takes a commit and presents its full node set as something to read.
Three ways to model working memory
(a) A materialised working table. checkout walks the target commit's
state and writes every (node_id -> object_id) into a working table. Reads
hit that table. This is closest to Git's working tree. Cost: O(nodes) writes on
every checkout, and a third place where the node set lives.
(b) Working memory is a view, never stored. "Working memory at HEAD" is
defined as the HEAD commit's state, with staging overlaid. checkout only
moves HEAD. Reading working memory resolves HEAD, loads its state, applies
staging on top. Cost: O(nodes) to read, which is inherent, and zero to check
out. No new table.
(c) staging holds the full state after checkout. checkout clears
staging and refills it with the target state, so staging stops being a diff
and becomes the whole working set. This collapses two concepts into one but
breaks the "staging is what changed" meaning that commit and a future mnem status rely on.
Recommendation: (b), a view
checkout <branch-or-commit> is one atomic HEAD write and nothing else. It is
refused if staging is non-empty, the same way Git refuses a checkout that
would discard uncommitted changes; a --force / discard=True escape hatch can
come later. Checking out a commit id rather than a branch gives a detached
HEAD, which already works.
Reading working memory is then a pure function: resolve(HEAD).state
overlaid with staging::list. Add Store::working_memory() -> Vec<(String, MemoryNode)> and Store::working_node(id) for the single-node case.
Time travel is the same walk without touching HEAD: Store::state_at(commit) -> Vec<(String, MemoryNode)> loads any commit's state and its nodes. This is
what issue #38 asks for, and it is read-only, so it needs no locking and no
branch. mnem show <commit> and the SDK expose it.
What checkout must guard
- Empty
staging, or refuse (with a clear message naming the staged ids). - A branch name must exist in
refs; a commit id must exist inobjectsand be aCommit. - The
HEADwrite is atomic (temp file plus rename), already the case. - A reflog entry would belong here. ADR-0009 reserved the reflog and does not write it; Phase 2 keeps it reserved.
3. Flat state is enough for Phase 2
The flat form and its ceiling
State today is { nodes: BTreeMap<String, ObjectId> } and every commit stores
a full State object listing a pointer for every visible node. A run that
makes 500 commits over 500 nodes stores 500 State objects of ~500 entries
each. At ~40 bytes per entry that is ~10 MB of state objects for a store whose
actual memory content might be a few hundred KB. Storage grows as commits ×
nodes, not as changes.
For Phase 2's deliverables this does not matter:
- Time travel is a point read of one
Stateplus its nodes: O(nodes), which is the true cost of "give me the whole memory at commit C" under any design. - Structural diff between commit A and B walks both sorted
Statemaps once in lockstep and classifies each id as added / removed / modified (differentObjectId) / unchanged: O(nodes).BTreeMapmakes this a merge-join. - Agent runs are hundreds, not millions, of nodes and commits. 10 MB is fine.
Prolly trees, and when they earn their place
A prolly tree (probabilistic B-tree; the storage engine behind Noms and Dolt) is a content-addressed ordered map: each node is referenced by the hash of its contents, not a file offset, and node boundaries are set by content-defined chunking so the same logical data always chunks the same way. Two consequences:
- Structural sharing. Any subtree whose hash is unchanged between two versions is stored once. State storage becomes O(total distinct subtrees), not O(commits × nodes).
- Diff and merge proportional to the change. Comparing two trees skips every subtree pair with equal hashes, so a diff costs O(differences), not O(size). This is how Dolt diffs a table in time proportional to the diff.
The cost is real: a chunker, a tree node format, a rebalancing story, and a
second State kind in the on-disk format (a format_version bump under
ADR-0007). None of it is needed for Phase 2 to be correct.
Recommendation: keep flat, add prolly when merge or scale forces it
Phase 2 ships flat State. docs/format/ already says a prolly form arrives as
a second State shape in a later format_version; this survey narrows when:
- Phase 3 (merge) needs an efficient three-way diff against a common base. Flat diff is O(nodes) per pair, which is acceptable for the merge algorithm itself, so this is a "reassess", not an automatic trigger.
- A real store hitting the storage ceiling (a long-lived agent, or many branches) is the hard trigger.
Whichever comes first opens the prolly research ticket. Until then, flat is the honest choice for the scale the substrate runs at.
Sketch of the Phase 2 core surface
impl Store {
fn branch(&self, name: &str) -> Result<()>; // ref at HEAD
fn branches(&self) -> Result<Vec<(String, ObjectId)>>;
fn delete_branch(&self, name: &str) -> Result<bool>; // not the current one
fn checkout(&self, target: &str) -> Result<()>; // branch name or commit id; empty staging
fn working_memory(&self) -> Result<Vec<(String, MemoryNode)>>; // HEAD state + staging
fn state_at(&self, commit: ObjectId) -> Result<Vec<(String, MemoryNode)>>; // time travel
fn diff(&self, from: ObjectId, to: ObjectId) -> Result<Vec<NodeChange>>;
}
enum NodeChange {
Added { id: String, new: ObjectId },
Removed { id: String, old: ObjectId },
Modified{ id: String, old: ObjectId, new: ObjectId },
}
CLI: mnem branch [name] [-d name], mnem checkout <target>, mnem show [<commit>], mnem diff <from> [<to>]. SDK: store.branch("h1") plus the
with store.branch("h1"): context manager (create, checkout, on exit checkout
back; no automatic merge, that is Phase 3).
Open questions for the grilling (#35)
- View versus materialised working memory: confirm (b), the view. Any real need
for a
workingtable now? checkoutwith a dirtystaging: refuse always, or allow when the staged ids do not collide with the target state (Git's "carry your changes across")?- Does the branch context manager delete its branch on exit, or leave it for the
user to
mergeor drop later? mnem showwith no argument: working memory, or theHEADcommit's state?- Diff output shape: the
NodeChangelist above, or a richer object that also carries the decoded old and new content for rendering? - Confirm flat
Statefor Phase 2 and the prolly triggers above. checkoutand the reflog: still reserved, or does Phase 2 start writing it?
Sources
- Git branches are refs (Pro Git, "Git Branches in a Nutshell") and Git References (Pro Git)
- Jujutsu bookmarks and jj/docs/bookmarks.md
- Prolly Trees: a content-addressed B-tree with structural sharing (Lobsters discussion)
- A Study in Structural Sharing in a Dolt Prolly Tree (DoltHub) and Prolly Tree (Dolt docs)
- How Dolt Scales to Millions of Versions, Branches, and Rows (DoltHub)
- How to Chunk Your Database into a Merkle Tree (DoltHub)
crabbuild/prolly: content-addressed ordered map on prolly trees
Research: merge algorithms, conflicts and invariants (issue #42)
Feeds ADR-0013 (the deterministic merge algorithm) and ADR-0014 (the conflict
object and resolution API). Phase 3 (v0.0.4) is the last purely mechanical
piece: a three-way merge over the flat State map, conflict objects, a
resolution API, and a property and chaos harness. Still Era 1: structural and
deterministic, no semantics. Semantic merge over claim contents is Era 2.
Four questions:
- What does a three-way merge of two
Statemaps do, per node id? - How is the merge base found, and what happens on a criss-cross history?
- How are conflicts represented, and what does the resolution API look like?
- What invariants must the chaos harness check, and is flat
Statestill enough?
1. The per-id merge rules
State is { nodes: BTreeMap<String, ObjectId> }. A merge takes a base B
(the merge base's state), ours O, and theirs T, and produces a
merged map plus a set of conflicts. This is a tree merge, one entry per node id,
not a line merge; there is no content-level merge of a node (that is Era 2).
For each node id present in any of B, O, T, let b, o, t be the
ObjectId in each (or absent):
b | o | t | result |
|---|---|---|---|
| x | x | x | keep x (unchanged) |
| x | y | x | keep y (ours changed, theirs did not) |
| x | x | y | keep y (theirs changed, ours did not) |
| x | y | y | keep y (both changed the same way, convergent) |
| x | y | z | conflict: edit/edit |
| x | absent | x | absent (ours deleted, theirs unchanged) |
| x | x | absent | absent (theirs deleted, ours unchanged) |
| x | absent | absent | absent (both deleted, convergent) |
| x | absent | z | conflict: delete/edit |
| x | y | absent | conflict: edit/delete |
| absent | y | absent | keep y (ours added) |
| absent | absent | z | keep z (theirs added) |
| absent | y | y | keep y (both added the same, convergent) |
| absent | y | z | conflict: add/add |
This is the classic filesystem three-way merge (Git's per-path logic, minus renames, which do not exist here: a node id is stable, ADR-0003). The table is total: every combination is either an automatic result or a named conflict.
Determinism. The result map is built by iterating the union of keys in
sorted order (a BTreeSet), so a clean merge is bit-identical regardless of
argument order, and the conflict list is in node-id order.
2. The merge base
The merge base of commits O and T is a lowest common ancestor in the
commit DAG: an ancestor of both that has no descendant which is also an ancestor
of both. Issue #45 computes it.
- The common case has one LCA. Walk both ancestor sets (BFS over
parents), intersect, then take the elements with no other intersection element among their descendants.Stateis small and histories are short, so an O(nodes in history) walk is fine; no generation numbers or bloom filters needed yet. - A criss-cross history (two branches merged into each other in the past)
can have more than one LCA and no unique base. Git's
ortstrategy merges the LCAs into a virtual base and three-way-merges against that. That is correct but is a recursive merge and its own body of work.
Recommendation for ADR-0013. Phase 3 requires a single merge base. When
there are multiple LCAs, merge refuses with a clear error naming them, rather
than silently picking one or doing a recursive merge. Recursive merge over a
virtual base is deferred; the trigger is a real criss-cross showing up, which a
single-agent substrate with a human in the loop rarely produces. If it does, the
manual path is to merge one side first.
- The first commit on each branch shares the root; two branches off the same commit have that commit as the base. A branch never merged has exactly one base with any other branch descended from a shared point.
- If
Ois an ancestor ofT(or vice versa), the merge is a fast-forward: the result isT, no merge commit.mergeshould detect this.
3. Conflicts and resolution
The conflict object
When a merge has conflicts, it does not write a merge commit. Instead it
returns the conflict set to the caller, and (option A) leaves the store
untouched, or (option B) records a pending merge state that mnem status shows
and mnem merge --continue finishes.
A conflict names one node id and the three sides:
Conflict {
id: String,
kind: EditEdit | DeleteEdit | EditDelete | AddAdd,
base: Option<ObjectId>, // None for AddAdd
ours: Option<ObjectId>, // None for DeleteEdit
theirs: Option<ObjectId>, // None for EditDelete
}
Whether the conflict is a first-class stored object (like a memory node,
content-addressed, referenced from a MergeState) or a transient value
returned by the merge call is the ADR-0014 question. A stored conflict object
supports "walk away and come back", a mnem status that survives a restart, and
an audit trail; a transient value is simpler and matches "merge is one call".
For a single agent, the transient value is likely enough for v0.0.4, with the
stored form arriving when the review model (Era 2) needs conflicts to persist
and be reviewed.
The resolution API
Per conflicting id, the caller picks one of:
- ours — take
O's object id - theirs — take
T's object id - base — take
B's object id (revert both edits) - delete — the merged state omits the id
- set(node) — stage a new node object as the resolution
Once every conflict has a resolution, merge (or merge --continue) writes the
merged State and a two-parent Commit. The resolution API is:
store.merge(theirs: CommitIsh) -> MergeOutcome // clean, ff, or conflicts
store.merge_resolve(id, Resolution) -> () // record one resolution
store.merge_continue(message, author, time) -> ObjectId // finish
store.merge_abort() -> () // drop the pending merge
CLI: mnem merge <branch>, mnem merge --continue, mnem merge --abort, and
mnem status shows the unresolved ids. SDK: store.merge("other") returns a
result object; a context-manager form is possible but not required.
Interaction with staging
merge requires a clean index (no staged adds or tombstones), the same rule as
checkout (ADR-0012). The pending-merge resolutions live in their own local
table, not staging.
4. Invariants and the chaos harness (#49)
The harness generates random histories (two branches off a base, each with a random sequence of adds, updates and deletes) and checks:
- Clean-merge symmetry. When
merge(O, T)is clean,merge(T, O)is clean and produces the sameStateobject id. Conflict sets are equal; the merge commit differs only in parent order and message. - Idempotence.
merge(O, O)is a fast-forward toO; merging an ancestor is a no-op. - Base identity.
merge(O, B)whereBis the base is a fast-forward or a no-op (Oalready contains the base). - Convergence. After resolving conflicts,
merge(O, T)thenmerge(T, O')(whereO'is the first merge result) converges: no new conflicts, same final state. - The table is total. A generated case never produces an unclassified outcome: every id is in the merged map or in the conflict set, never both, never neither.
- No lost writes. Every node id that both sides agree on (or only one side touched) appears in the result with the agreed object id.
This is the spirit of the ephor exactly-once tests: hammer the operation with
random interleavings and assert the algebra holds. Deterministic seeds, like
tests/time_travel.rs (#41).
Is flat State still enough?
ADR-0012 named Phase 3 merge as a reassess point for the prolly tree, not an
automatic trigger. The merge algorithm above is O(total distinct node ids across
B, O, T), which for an agent's memory (hundreds of ids) is trivial. The
merge base walk is O(history size). Neither needs structural sharing to be
correct or fast at this scale.
Recommendation. Keep flat State for Phase 3. The prolly tree's payoff is
diff and merge proportional to the change rather than to the size, which
matters at thousands to millions of ids, not hundreds. The trigger stays: a real
store hitting the storage or latency ceiling. Record this reassessment in
ADR-0013.
Recommendations, in one place
- ADR-0013 (algorithm): the per-id table above; union-of-keys in sorted
order for determinism; a single required merge base, refuse on multiple LCAs;
fast-forward detection; a clean index required; flat
Statekept, prolly deferred again. - ADR-0014 (conflict object + API):
Conflict { id, kind, base, ours, theirs }; transient (returned) rather than stored forv0.0.4, stored form deferred to the Era 2 review model; resolutions areours | theirs | base | delete | set(node);merge/merge_resolve/merge_continue/merge_abort; pending resolutions in a local table;mnem merge+--continue/--abort,mnem statusshows unresolved ids.
Open questions for the grillings (#43, #44)
- #43: single-base-or-refuse versus recursive virtual base. Fast-forward:
silent, or announced? Does
mergeever touchHEADother than the new merge commit? Confirm the per-id table, especially delete/edit and add-add as conflicts rather than "take the non-deleting side". - #44: transient versus stored conflict object. Where do pending resolutions
live. Is there a
mnem merge --continueflow, or must a merge be resolved in one process. Whatmnem statusshows mid-merge. Theset(node)resolution: does it go throughadd(staging) or a dedicated path.
Sources
- git-merge(1) and git merge-strategies (modify/delete, add/add, stages 1/2/3, criss-cross)
- git-merge-tree (a merge can conflict without any single entry conflicting)
- Criss-Cross Merge (revctrl.org) and the
ortvirtual-merge-base approach - Three-Way Merge in a SQL Database (DoltHub) (per-row three-way merge, prolly-tree diff proportional to the change)
- Evaluation of Version Control Merge Tools (arXiv 2410.09934) and Analyzing Git Diff and Merge (arXiv 2507.22071)
Research: the provenance index, blame and bisect (issue #135)
Feeds ADR-0015 (the provenance index) and the Phase 4 build tickets
#51–#54. Phase 4 (v0.0.5) is "it explains": given a memory node, say where it
came from (blame); given a wrong belief, find the commit it entered
(bisect). Still Era 1: deterministic, no network, no model calls. Nothing here
changes format_version — blame and bisect are read-only walks over
objects that already exist.
Four questions:
- What does
blameresolve, and how, given that provenance lives on the node? - Is a provenance index needed at Phase 4 scale, and if so what is in it, where does it live, and how is it kept current?
- What does
bisectsearch, and how is the range given? - What is the buggy-run fixture (#54), and does the reflog land here?
1. blame
What it resolves
From CONTEXT.md and ADR-0003: blame resolves a memory node to the commit
and the provenance that introduced its current value. Provenance is on the
MemoryNode (object.rs), not the Commit, and may be entirely empty — so
blame always resolves to a commit, and additionally surfaces whatever
provenance the node carries (agent_step / observation / tool_call /
source / note) and its event_time (falling back to the commit's record
time when absent, per ADR-0003).
The algorithm
State at a commit is { node_id -> ObjectId } (state_map_at). A node id's
"value" at a commit is that ObjectId (or absent). The value changed at
commit C iff state_map_at(C)[id] != state_map_at(parent)[id] — this covers
add (absent → present), edit (id → different id) and, if we want it, delete
(present → absent).
Blame walk. Start at the target commit-ish C (default HEAD). Let
target = state_map_at(C)[id]; error if the id is absent at C ("nothing to
blame: C
towards the root. For each commit Ci with first parent Cp:
- if
state_map_at(Ci)[id]differs fromstate_map_at(Cp)[id], thenCiis the introducing commit for the value as ofC. Return it. - the root commit (no parent) always counts as a change (absent → present).
Return { commit: Ci, node: <the MemoryNode at Ci>, provenance, event_time, time: Ci.time }.
Cost: O(history length) point reads, each cheap. For an agent's memory
(hundreds of ids, short histories) this is microseconds. No index required for
blame to be correct or fast — see §2.
The merge subtlety
If the current value of id arrived on the theirs side of a merge, the
first-parent walk sees the merge commit as the point of change (the first
parent, "ours", did not have that value). Git's blame follows the parent that
actually contributed the line. Options for Era 1:
- (a) Report the merge commit, with a note that the value came in via a merge and pointing at the other parent. Simple, honest, one walk.
- (b) Recurse: at a merge commit where the value differs from
parents[0]but equalsparents[k], continue the walk fromparents[k]. This gives the true origin commit but is a multi-parent walk.
Recommendation: (b), but only the minimal form — pick the first parent whose
state_map_atvalue equals the value at the merge, and continue from there. It
is a few extra lines and gives the answer a user actually wants ("who first
wrote this"), not "a merge happened". A single agent with a person in the loop
rarely has deep merge nesting, so the walk stays short.
blame on a deleted node
If id is absent at C, blame <id> errors. A separate question is "when was
id deleted" — that is really a bisect predicate (|s| !s.contains_key(id))
or a future mnem log --follow <id>. Keep blame to "explain a value that
exists". Do not overload it.
2. The provenance index
Is one needed?
At Phase 4 scale, no — for the two operations this phase ships:
| Operation | Without an index | With an index |
|---|---|---|
blame <id> | O(history) first-parent walk, ~µs | O(1) lookup of the introducing commit |
bisect | O(log n) state_at reads | unchanged — bisect is about content predicates, not provenance |
bisect gets nothing from a provenance index (ADR-0003 already says "bisect
operates on node content and presence, not provenance"). blame is already
fast. So an index bought now is speculative infrastructure — exactly the
kind of thing the project has twice deferred for the prolly tree (ADR-0012,
ADR-0013) with a named "reassess when a real store hits a ceiling" trigger.
What an index would hold, when it is built
The useful shape, when a real store makes the walk too slow, or when the Era 2 review/audit UI needs reverse lookups:
node_id -> introducing commitfor the current tip (answersblamein O(1)). Must be recomputed or patched on every commit and on every branch/checkout, because "current" is per-branch.commit -> [node_ids it introduced or changed](the reverse: "what did this commit teach the agent"). Append-only, never rewritten, since a commit is immutable. This one is cheap and monotonic.
The reverse map is the safer first index: it is append-only, derived purely from
diff(parent, commit) at commit time, and never invalidated. The forward map is
the one that needs care (per-branch, mutated on checkout).
Where it would live
A new redb table in the same database (objects / refs / staging live
there already). Not a separate file — one database keeps the write-transaction
story simple (a commit already opens one write txn; the index write joins it).
Key/value: commit_id -> CBOR([node_id]) for the reverse map.
Incremental vs rebuild
- Reverse map: built incrementally at commit time inside the existing write
transaction (
diffthe new commit againstparents[0], write the id list). A full rebuild is a one-pass walk of all commits, used once on upgrade or if the table is missing. - Forward map: not proposed for Phase 4.
Recommendation
Defer the index. Phase 4 ships blame and bisect as plain graph walks.
Add ADR-0015 as a short ADR that:
- records that
blame/bisectwalk the graph directly and why that is fine at this scale (the numbers above); - specifies the reverse map (
commit -> introduced node ids) as the index that will be built, its on-disk home, and its trigger — the same pattern as the prolly-tree deferral: a real store whereblamelatency or an Era 2 UI needs it; - keeps #51 in the phase, but reframed: #51 builds the reverse map (cheap,
append-only, immediately useful for
mnem show --stat-style output and a foundation for Era 2), andblamereads it opportunistically with a walk fallback. Or #51 becomes "defer the index, expand the blame/bisect tests".
The grilling (#50) picks between "#51 builds the reverse map now" and "#51 is a deferral ADR + more tests". Both are defensible; the reverse map is the more productive use of the ticket and carries no invalidation risk.
3. bisect
What it searches
Binary search a commit range for the first commit where a supplied predicate
holds (CONTEXT.md). The predicate is a function of the reconstructed memory
at that commit:
#![allow(unused)] fn main() { fn bisect( &self, good: &str, // a commit-ish where the predicate is false bad: &str, // a commit-ish where the predicate is true (default HEAD) predicate: impl Fn(&BTreeMap<String, MemoryNode>) -> bool, ) -> Result<ObjectId> }
The predicate takes the full state_at map so it can express "node plan says
enterprise" or "node x is absent" or "any node mentions 'refund'". The
common case — "this one node has this wrong value" — is a helper that builds the
closure.
The range and the monotonicity assumption
Like git bisect, bisect assumes the predicate is monotonic over the
range: false up to some commit, true from there on. good must be an ancestor
of bad, and the predicate must be false at good and true at bad — all
three checked up front, with a clear error otherwise ("the predicate is already
true at
Linear history only, for now
Walk the first-parent chain from bad back to good, giving an ordered
list of N commits, and binary-search that list: O(log N) state_at
evaluations. Merge commits on the chain are evaluated like any other (their
state is fully materialised). Second parents are not descended into — a
single-agent history is essentially linear, and git bisect's merge handling
(skip, multiple bad regions) is complexity Era 1 does not need. Document the
limitation.
Result
Return the first bad commit (the boundary), plus optionally its blame for
whichever node the predicate helper targeted, so mnem bisect can print "the
belief entered at
4. The buggy-run fixture (#54) and the reflog
The fixture
A synthetic agent run, built in a test (seeded, deterministic), where:
- commits 1..k build up plausible memory (a support agent learning about an account: plan tier, contacts, open tickets);
- at a known commit
k, a wrong belief enters — e.g.planflips fromenterprisetoprooff a misread observation, with provenance pointing at that observation; - commits k+1..n continue, some of them touching other nodes, one or two even
reading the wrong
plan(so the error "spreads"); - the test then: (a)
bisectwith the predicateplan == "pro"and asserts the boundary is exactly commitk; (b)blame planatHEADand asserts it resolves to commitkand the planted provenance.
This is the Phase 4 definition of done and the demo GIF material.
The reflog
ADR-0009 explicitly parked the reflog: "It lands as its own ticket in Phase 2,
or alongside blame and bisect in Phase 4." It is a small append-only
(ref, old, new, time, op) table written on every ref move. It is not needed
for blame or bisect (those walk the commit graph, not ref history), and
Phase 4 has no child ticket for it.
Recommendation: defer again, deliberately. Note in ADR-0015 (or a one-line ADR-0009 amendment) that the reflog moves to Phase 5/6 or its own ticket, so the deferral is on the record rather than forgotten. Folding it in now widens Phase 4 for no gain to the "it explains" story.
Recommendations, in one place
blame: first-parent walk from a commit-ish (defaultHEAD) for the commit that changed the target node id's value; follow the contributing parent through merges; return commit + node + provenance + effective time. Errors if the id is absent at the target commit.- The index: defer the forward (
node -> introducing commit) map. ADR-0015 is short: it records the direct-walk approach and its scale justification, and specifies the reverse map (commit -> introduced node ids, append-only, in a newredbtable, built in the commit write txn) as either what #51 builds now or what is deferred with a trigger. bisect: binary search the first-parent chain betweengood(predicate false, ancestor) andbad(predicate true, defaultHEAD); predicate isFn(&state_at map) -> bool; assumes monotonic; linear history only; O(log N)state_atreads. Helper for the "one node, one value" case.- Fixture (#54): seeded synthetic run, wrong belief at a known commit,
tests that
bisectfinds it andblameexplains it. - Reflog: deferred again, on the record.
Open questions for the grilling (#50)
- The index. Build the reverse map in #51 now, or make ADR-0015 a deferral + test-expansion ADR? If built: reverse map only, or also the forward map?
blamethrough merges. Report the merge commit (simple) or recurse to the true origin (a few more lines, better answer)?blameoutput. Just the introducing commit, or also the chain of every commit that touched the node (amnem log --follow <id>in disguise)?bisectpredicate. A Rust closure only (SDK/tests), or also a CLI form — and if CLI, what language (node == value, a jq-ish path, an external command likegit bisect run)?bisectrange. Requiregoodexplicitly, or default it to the root commit? Error messages when the predicate is non-monotonic (git just gives a possibly-wrong answer; we could detect some cases).- Deleted / re-added nodes. Does
blamecare about history before the most recent add, or only since the current value's introduction? - Reflog. Confirm deferral, or fold the small table in now while ref-move code is being touched anyway.
Sources
- git-blame(1) — per-line last-change,
-C/-M, following through merges - git-bisect(1) and
git bisect run— good/bad boundary, monotonicity,runwith a predicate command - How
git bisectworks (Julia Evans) — the binary-search framing - ADR-0003 (the memory node model) — provenance fields,
event_time, "bisectoperates on content and presence" - ADR-0009 (the ref model) — the reflog deferral
- DoltHub
dolt blame— row-level blame over a table, the closest prior art to node-level blame
Research: the MCP tool surface for memory (issue #55)
Feeds ADR-0016 (the MCP tools and the adapter contract) and the Phase 5
build tickets #57 to #60. Phase 5 (v0.0.6) is "it plugs in": an MCP server and
a LangGraph adapter, so a real agent uses Mnemosyne as its memory with branch
and blame working, plus SDK hardening and three worked examples.
The frame is already set by ADR-0004. Adapters, meaning the MCP server
(mnem-mcp) and the framework adapters (mnem-langgraph), are Python packages
that depend on the SDK, not the core, and live outside the Cargo
workspace. So mnem-core stays offline and deterministic; cargo deny is not
in play here; the network, the async runtime and the protocol libraries all sit
in separate Python packages.
Six questions:
- Which SDK operations become MCP tools, and which become MCP resources?
- What is the write contract: one commit per call, or a stage/commit split?
- How is a store bound to a server, given MCP 2026-07-28 is stateless?
- What does the LangGraph adapter implement (
BaseStore,BaseCheckpointSaver, or both), and how does its namespace/key model map onto Mnemosyne? - What is the shared adapter contract, and does the SDK need anything new?
- Where do the adapter packages live, and how are they named and versioned?
1. Tools vs resources
MCP (spec revision 2026-07-28) gives a server three primitives:
- Tools: model-invoked functions with a name, description and JSON Schema. Side effects allowed. This is where writes and searches go.
- Resources: readable context addressed by URI, pulled in by the client
(app-controlled, not model-invoked). List and read results now carry
ttlMsandcacheScope. This is where "the current memory" goes. - Prompts: user-invoked templates. Not needed for
v0.0.6.
The tools
The agent-facing verbs, mapped from the SDK. Names are chosen for how an agent would reach for them, not to mirror Git:
| Tool | SDK call(s) | Notes |
|---|---|---|
remember | add + commit | the common case: record one fact with provenance, in one commit. { id, content, source?, step?, observation? } |
revise | add + commit | same shape; a separate name so the model signals "this changes an existing belief" |
forget | rm + commit | tombstone one node in a commit |
recall | working_node / working_memory | read one node or the whole current memory. Also a resource (below); the tool form is for when the model decides it needs it |
recall_at | state_at | the memory as of a past commit (time travel) |
history | log | recent commits, newest first, { limit? } |
why | blame | resolve a node to the commit and provenance that set it |
when_did | bisect | first commit where a node reaches a value, is absent, or is present |
whats_new | changed_by | what a commit changed |
Branch, checkout and merge are orchestration, not in-loop reasoning. They
are exposed as tools too (branch, switch, merge, with the same resolution
surface as the CLI), but the ADR should mark them "for the harness, not the
model": an agent framework drives a hypothesis branch around a sub-task, and the
model rarely asks for one itself. A server flag (--tools=core|all) or MCP tool
annotations can hide them from a model that does not need them.
bisect on MCP is the --node/--equals/--absent/--present helper form only
(the predicate-closure form has no JSON-Schema representation), matching the CLI
(ADR-0015).
The resources
| URI | Backed by | cacheScope |
|---|---|---|
mnem://memory | working_memory | per-store; short ttlMs, invalidated on any write |
mnem://memory/{node_id} | working_node | per-store |
mnem://log | log(limit=N) | per-store |
mnem://commit/{id} | state_at + changed_by | immutable, long ttlMs, since a commit never changes |
Putting the current memory behind a resource means a client can keep it in
context for free, without spending a tool call every turn. The recall tool
stays for models that pull rather than get pushed.
Not exposed
init (a deployment step, not an agent action), format_version / root /
head (introspection), staged / unstage / staged_deletions (there is no
staging on MCP, see §2), resolve (internal), add_node (the dataclass form).
2. The write contract: no staging over MCP
The SDK has a stage-then-commit split (add writes to staging, commit seals
it). Over MCP that split is a liability: a tool call is a discrete, retryable
unit, and a server that carries half-staged state between calls breaks the
stateless model (§3) and leaves an agent's memory in a limbo the model cannot
see.
Every write tool is one commit. remember / revise / forget each do
add or rm then commit in a single SDK call sequence, atomically (the
core's commit is one write transaction, ADR-0008). The commit message is the
tool's summary argument, or a generated one ("remember {id}"); the author is
the server's configured agent name.
Batch writes, meaning "record these five facts as one commit", are a real case
(an agent finishing a step). One option is a remember_many tool taking a list,
one commit. The ADR should decide whether that lands in v0.0.6 or waits.
3. Binding a store to a server
MCP 2026-07-28 is stateless: no protocol sessions, no Mcp-Session-Id, any
request answerable by any server instance. So the server cannot "hold" a store
handle across a session the way a CLI process does.
Options, cheapest first:
- (a) One store per server process. The store path is a launch argument
(
mnem-mcp --store ./agent-memory) or env var. Every tool call opens the store fresh (mnem.openis cheap, a redb handle), acts, closes. Stateless, trivial, correct. This is thev0.0.6recommendation: an agent runtime launches onemnem-mcpper agent, pointed at that agent's memory. - (b) Store selected per call. A
storeargument on every tool, or theMcp-Name-style header carrying it. Needed only for a multi-tenant server hosting many agents' memories behind one process. Defer. - (c) Store per MCP client identity (post-auth). Era 2, the collaboration layer, where a store is shared and access-controlled.
Concurrency. redb gives one writer, many readers (ADR-0008). Two concurrent
remember calls to the same store serialise on the write transaction; the loser
retries. The server should surface a clean "busy, retry" rather than block
indefinitely, which is a small SDK hardening item (#59).
Branch state. mnem.open resolves HEAD from the store's HEAD file, so
"which branch am I on" is store state, not server state, which is consistent
with statelessness. A switch tool writes HEAD; the next call sees it.
4. The LangGraph adapter
LangGraph persistence has two interfaces:
BaseCheckpointSaver: saves the full graph state after every node, keyed by thread.put/get_tuple/list. This is execution state (the channels, the pending writes), not beliefs.BaseStore: cross-thread, long-lived memory. Namespaced by a tuple of strings,keya string,valuean arbitrary dict.get/put/search/delete/list_namespaces, plus async forms.
The Phase 5 DoD, "a LangGraph agent uses Mnemosyne as its memory with branch
and blame working", is BaseStore. Recommendation: implement BaseStore
first.
The mapping is close to 1:1:
BaseStore | Mnemosyne |
|---|---|
namespace: tuple[str, ...] | a prefix on the node id (":".join(namespace)), or a branch, per the ADR |
key: str | the node id (within the namespace) |
value: dict | MemoryNode.content (JSON) |
put(ns, key, value) | remember: add + commit, provenance from value["_meta"] if present |
get(ns, key) | working_node |
search(ns, query) | working_memory filtered. Mnemosyne has no vector search; the ADR decides whether search is prefix/substring only or out of scope for v0.0.6 |
delete(ns, key) | forget |
list_namespaces() | the set of id prefixes seen |
branch and blame are not on the BaseStore interface. They are exposed as
extra methods on the adapter class (store.branch("hypothesis"),
store.why(key)), which a graph node calls directly. That satisfies "branch and
blame working" without fighting the interface.
BaseCheckpointSaver (every superstep is a commit) is a compelling second
adapter, since it makes a whole agent run rewindable, but it is a different
shape and a bigger surface. Name it in the ADR as deferred, with a trigger.
5. The adapter contract, and SDK gaps
The MCP server and the LangGraph adapter should share one thin contract module so #57 and #58 do not diverge:
remember(id, content, *, source=None, step=None, observation=None, summary=None) -> commit_id: the atomic add+commit. Both adapters call this.forget(id, *, summary=None) -> commit_id.- A stable mapping from
Blame/NodeChange/Committo plain JSON dicts (the MCP tool results and the adapter return values). - One error taxonomy surfaced as JSON (
not_found,invalid_ref,conflict,busy).
Where this lives: a small mnem SDK addition (mnem.agents or top-level
helpers), not in each adapter. That keeps "the SDK holds the ergonomics"
(ADR-0004) true, and means the CLI could use the same remember helper later.
SDK hardening (#59) that this surfaces:
remember/forgetatomic helpers (above).- A retry-on-write-conflict wrapper, or a clear
ConflictErrorthe caller can catch (compare_and_setalready raises one in the core). to_dict()onBlame,NodeChange,Commit,MemoryNodefor the JSON boundary (the binding already emits dicts; the SDK dataclasses should round-trip).- Confirm thread-safety of a
Storehandle, or document "one handle per thread" and have the adapters open per-call.
6. Packaging
ADR-0004 says adapters live outside the workspace. Two readings:
- A
packages/(oradapters/) directory in this repo, each its ownpyproject.toml, not a Cargo member, published to PyPI separately. One repo, one issue tracker, and CI can test them against the built wheel. Recommended forv0.0.6: a separate repo per adapter is overhead the project does not need yet. - Separate repos (
Nabzx/mnem-mcp,Nabzx/mnem-langgraph). Cleaner dependency story, but three repos to keep in sync for a solo maintainer. Revisit if an adapter grows its own contributors.
Names: PyPI mnemosyne-mcp and mnemosyne-langgraph (the SDK is
mnemosyne-agents); import names mnem_mcp / mnem_langgraph. The mnem-mcp
console script is the server entry point.
Versioning: the adapters pin mnemosyne-agents ~= 0.0 and version
independently. They are the SDK's customers, not part of its release train.
Transport and auth
- stdio only for
v0.0.6. It is the local, single-agent case the DoD describes, needs no auth, and is what an agent runtime spawns. Streamable HTTP (remote, multi-client) brings the 2026-07-28 authorization story and belongs with the collaboration layer. - The
mcpPython SDK's FastMCP API covers both; the server is written once and gains HTTP later by configuration.
Recommendations, in one place
- Tools:
remember/revise/forget/recall/recall_at/history/why/when_did/whats_new;branch/switch/mergebehind a "harness, not model" annotation. - Resources:
mnem://memory,mnem://memory/{id},mnem://log,mnem://commit/{id}, so the current memory is pull-free context. - Writes: no staging over MCP; every write tool is one atomic commit.
- Store binding: one store per server process, path from a launch argument; stateless, open-per-call. Multi-tenant selection deferred.
- LangGraph: implement
BaseStorefirst;branch/whyas extra adapter methods;BaseCheckpointSaverdeferred with a trigger. - Contract: a shared
remember/forget/to_dicthelper set in the SDK, consumed by both adapters. - Packaging: a
packages/directory in this repo, PyPImnemosyne-mcp/mnemosyne-langgraph, versioned independently. - Transport: stdio only for
v0.0.6.
Open questions for the grilling (#56)
- Write granularity. Is
remember_many(batch, one commit) inv0.0.6, or is one-fact-one-commit enough to ship? - The
_metachannel. How does provenance travel throughBaseStore.put's opaquevaluedict: a reserved_metakey, a separateputargument the adapter adds, or dropped (provenance only via the MCPremembertool)? search. Prefix/substring filter overworking_memory, or "not inv0.0.6, raiseNotImplementedError"? Mnemosyne is explicitly not a retrieval layer (README, prior work).- Namespace. Does a
BaseStorenamespace map to an id prefix, or to a Mnemosyne branch? (Prefix is simpler; branch is more powerful and matches "branch working".) - Harness tools. Hide
branch/mergefrom the model by default, or expose everything and trust the framework? - One repo or many.
packages/here, orNabzx/mnem-mcp+mnem-langgraphfrom the start? - Checkpointer. Confirm
BaseCheckpointSaveris deferred, and name its trigger (someone wants a rewindable run).
Sources
- MCP 2026-07-28 specification: stateless transport, tools/resources/prompts,
ttlMs/cacheScope, header routing - modelcontextprotocol.io: the primitives, stdio vs Streamable HTTP
- modelcontextprotocol/python-sdk and
mcpon PyPI: FastMCP,mcp run/mcp dev - LangGraph persistence: checkpointers and BaseStore and BaseStore for long-term memory: the two interfaces, namespace/key/value model
- langgraph-checkpoint-aws: a worked custom-backend example (
BaseCheckpointSaversubclass) - ADR-0004 (the core / SDK boundary): adapters depend on the SDK, live outside the workspace
- ADR-0015 (blame and bisect): the
--node/--equals/--absent/--presentpredicate helpers the MCPwhen_didtool reuses
Research: the v1 benchmark design (issue #61)
Feeds ADR-0017 (the benchmark and its metrics) and the Phase 6 build
tickets #64 (harness and baseline) and #65 (run it, publish docs/benchmark.md).
Phase 6 (v0.0.7) ships the substrate: something you would hand a stranger,
with numbers.
The honest frame. Mnemosyne does not claim an agent answers better because its memory is versioned. The 2026 research says the same: GitOfThoughts tested five memory backends (none, markdown, vector, graph, git) and found none reliably moves accuracy. Its conclusion, which is also ours: "git's value is the engineering trade-off at accuracy parity. It gives auditability, history, and the ability to merge two agents' memories, at no cost to accuracy."
So the benchmark measures the operational properties and the overhead, not answer quality. Accuracy parity is assumed and cited, not re-tested (that needs models, an API budget, and it has been done).
Six questions:
- What does the benchmark measure?
- What synthetic workload produces the runs?
- What are the baselines to compare against?
- What is the metric for each property, and its expected value?
- Where does the harness live, and does CI run it?
- What is out of scope, and how is the v2 seam (#63) kept separate?
1. What the benchmark measures
Four operational properties, each turned from "it works" into a number, plus overhead:
| Property | The claim | Measured as |
|---|---|---|
| Reconstruction | state_at(commit) is the exact memory of that moment | over N seeded runs, the fraction of commits where state_at equals the working memory recorded at commit time; also the golden-vector bytes are still bit-identical |
| Bisect precision | bisect finds the exact commit a belief went wrong | plant a fault at a known commit k in a run of length L; report the exact-hit rate and the distribution of found - k |
| Blame accuracy | blame resolves a belief to the commit and observation that set it | plant provenance on every write; blame every node at HEAD; report the fraction resolving to the correct introducing commit and the correct source |
| Merge correctness | a structural merge never loses a write or leaves a node in limbo | re-run the chaos harness (#49) at a large trial count and report totality, no-lost-writes, symmetry and convergence |
| Overhead | version control has a bounded, small cost | write latency per commit, read latency for the working memory, and on-disk bytes after L steps, each as a multiple of the baseline |
The first three are deterministic (the operations are), so the expected number
is 100.00%. The benchmark's job is to demonstrate that at scale and publish
it, the way docs/chaos-report.md does for merge. A single failing seed would
be a real bug and would block the release.
2. The synthetic workload
A seeded generator, in the style of tests/time_travel.rs and
tests/merge_chaos.rs (a hand-rolled LCG, no property-test dependency):
- A run is
Lcommits. Each commit stages 1 to 4 operations over a small key pool: set a key to a new value, or delete one. Every set carries provenance (source = "obs-<n>",agent_step = "step-<c>"). - A planted fault: at a chosen commit
k, one key is set to a distinguished "wrong" value and left wrong for the rest of the run. This is thebuggy_run.rsfixture, parameterised. - A branched run: for the merge and blame-through-merge numbers, two branches off a base, each with a random op sequence, merged with a strategy.
Parameters swept: L in {16, 64, 256, 1024}; k at 10%, 50%, 90% of the run;
seeds 0..S. CI uses small L and S; the published sweep uses large ones.
3. The baselines
"No version control" is not one thing. Two baselines, both what an agent author would otherwise write:
- Baseline A: a dict. The agent keeps
dict[str, Any]and overwrites it. This is GitOfThoughts's "none". Zero history. - Baseline B: JSONL snapshots. After every step the agent appends the whole memory as one JSON line to a file. A naive "keep history" approach.
The comparison is a table: for a set of audit queries, which backend can answer it, and at what storage cost.
| Audit query | dict | JSONL | Mnemosyne |
|---|---|---|---|
| current memory | yes | yes (last line) | yes |
memory as of step t | no | yes (line t) | yes (state_at) |
when did key X first become value V | no | yes, O(L) scan | yes, O(log L) bisect |
which observation set key X's current value | no | no (no provenance) | yes (blame) |
what did step t change | no | yes, diff two lines | yes (changed_by) |
| merge two agents' memories, surfacing conflicts | no | no | yes |
storage after L steps of a 100-key memory | O(keys) | O(L x keys) | O(distinct values) content-addressed |
JSONL answers the time questions but at linear storage and with no provenance; the dict answers almost nothing. Mnemosyne answers all of them, and its storage is proportional to distinct content because objects are content-addressed (re-writing the same value is free).
4. Metrics and expected values
| Metric | Definition | Expected | Fails the release if |
|---|---|---|---|
reconstruction_exact | state_at == recorded over all commits, all seeds | 100.00% | any seed is not exact |
golden_bytes_stable | the frozen format vectors still hash identically | pass | any drift |
bisect_exact | found == k | 100.00% | any miss on a monotonic planted fault |
bisect_error_bits | histogram of |found - k| | all zero | any non-zero |
blame_commit_acc | correct introducing commit, linear history | 100.00% | below 100 |
blame_commit_acc_merge | correct origin commit when the value came via a merge | 100.00% | below 100 |
blame_source_acc | correct source string | 100.00% | below 100 |
merge_* | the #49 invariants over a large sweep | all hold | any violation |
write_ms_p50 / p99 | per-commit latency, 100-key memory | report; expect single-digit ms p50 | regression vs the last published number by > 2x |
read_ms_p50 | working-memory read latency | report | as above |
bytes_per_step | .mnem growth per commit, vs baseline B | report the multiple; expect « JSONL | as above |
The correctness metrics have a hard target (100%). The overhead metrics are
reported, not gated on an absolute, but a large regression from the last
published docs/benchmark.md should fail CI, so the number cannot rot silently.
5. Where it lives, and CI
- The correctness and precision harness:
crates/mnem-core/tests/benchmark.rs(orbenches/, decided in the ADR), hand-rolled, seeded. CI runs it at a small trial count as an ordinary test; aMNEM_BENCH_*env override drives the large sweep. - The overhead-and-baseline comparison:
benchmarks/overhead.py(a new top-level dir, likeexamples/). It builds a dict, a JSONL file and a.mnemstore over the same synthetic run, times the operations, measures sizes, and prints the audit-query table. Deterministic; CI runs it and asserts the audit table and a latency ceiling. - The output:
docs/benchmark.md, written by #65 from a full sweep, in the shape ofdocs/chaos-report.md: what was measured, the numbers, the reproduction command. Re-run and updated whenever the core changes.
No new dependency: timing is std::time::Instant in Rust and time.perf_counter
in Python, consistent with the project dropping proptest and time for
hand-rolled equivalents.
6. Out of scope, and the v2 seam
- Accuracy. Not measured. Cited from GitOfThoughts. Mnemosyne is a
substrate, not a retrieval strategy; a vector index over
working_memoryis a wrapper someone else writes. - Real models. The workload is synthetic and deterministic so the benchmark is a regression gate, not a paper.
- The prolly tree. The
bytes_per_stepandread_msnumbers are the evidence for whether the flatStateis still enough (ADR-0012's reassess trigger). The benchmark surfaces the number; it does not decide. - The v2 seam (#63, ADR-0018). Declaring the
SemanticMergetrait and the sync-protocol shape is a separate design with its own grilling. It is not benchmarked (there is no implementation). This research does not touch it.
Recommendations, in one place
- Measure operational properties and overhead, not accuracy. Cite GitOfThoughts for parity.
- Correctness metrics (
reconstruction,bisect_exact,blame_*,merge_*) have a hard 100% target and gate the release. - Overhead metrics are reported in
docs/benchmark.mdand gated only against a large regression from the last published run. - Baselines: a dict ("none") and JSONL snapshots ("naive history"), compared on an audit-query table and storage.
- Two harnesses: a seeded Rust one for correctness, a Python one for
overhead-vs-baseline. Both feed
docs/benchmark.md. No new dependency. - Synthetic, seeded workload parameterised on run length and fault position.
Open questions for the grilling (#62)
- The audit-query table. Is it in the benchmark (asserted in CI) or only in
docs/benchmark.mdprose? Which queries are on it? - Gating overhead. What is a "large regression": a fixed multiple (2x?) of the published p50, or a percentage, and is it p50 or p99?
- Harness location.
tests/benchmark.rs(runs withcargo test) orbenches/(needs a bench harness, andcargo benchis not in CI today)? - The Python baseline dir.
benchmarks/at the top level, or a script underscripts/? - Run length for the published sweep. How large is
L, and how many seeds, before the numbers are "the published numbers"? - Blame-through-merge. Is a less-than-100% number here acceptable to
publish with a caveat, or must it be 100% to ship
v0.0.7? - A prolly-tree trigger. Should the ADR name a concrete
bytes_per_steporread_msthreshold that flips the reassessment, or keep it judgement?
Sources
- GitOfThoughts (arXiv 2606.14470): the five-backend comparison, the study / ingest / test protocol, paired-bootstrap CIs, and the "engineering trade-off at accuracy parity" conclusion
- GitOfThoughts HTML v2: Table 2 (replay / audit / merge demonstrations), Table 3 (write / read latency per backend), Table 4 and 5 (accuracy CIs and the failed replication)
- State of AI Agent Memory 2026 (Mem0): the benchmark landscape
- MemoryAgentBench (ICLR 2026): incremental multi-turn memory evaluation, an accuracy-focused contrast
crates/mnem-core/tests/time_travel.rs,tests/merge_chaos.rs,tests/buggy_run.rs: the seeded harnesses this benchmark extendsdocs/chaos-report.md: the output formatdocs/benchmark.mdfollows
Research: an OpenAI Agents SDK adapter (issue #249)
Feeds the ADR that fixes mnem-openai-agents's contract, the same way
issue #55 fed ADR-0016 for mnem-mcp and mnem-langgraph. Read against the
real SDK source (openai/openai-agents-python, MIT, PyPI openai-agents,
v0.22.3 at research time), not its docs pages, matching the discipline
docs/comparisons.md (#237) used for Memoria and ByteRover.
Two questions:
- What is the SDK's own pluggable memory primitive, and what shape does it require?
- Does that shape match Mnemosyne's node model well enough to be a thin
wrapper, the way
BaseStoredid for LangGraph - or does it need a different answer?
1. The SDK's memory primitive: Session
src/agents/memory/session.py defines a Protocol (and an ABC twin for
internal use) with exactly four methods:
class Session(Protocol):
session_id: str
session_settings: SessionSettings | None
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: ...
async def add_items(self, items: list[TResponseInputItem]) -> None: ...
async def pop_item(self) -> TResponseInputItem | None: ...
async def clear_session(self) -> None: ...
This is a first-class parameter on the main run loop, not a side extension:
Runner.run(agent, prompt, session=session) (src/agents/run.py, every
run / run_sync / run_streamed overload). Passing a Session is how an
agent gets automatic conversation-history management at all; the SDK ships
one reference implementation, SQLiteSession (:memory: by default, or a
file path), and a compaction-wrapper Session for long-running
conversations. This is the exact role BaseStore plays for LangGraph and the
tool surface plays for MCP - the one seam a third-party memory backend is
meant to fill.
The item type. TResponseInputItem = ResponseInputItemParam, a type
alias into OpenAI's Responses API - a tagged union covering a chat message,
a function (tool) call, a function-call output, a reasoning item, an MCP
list-tools/approval item, and a handful of others (src/agents/items.py).
It is a raw, ordered transcript entry, not a belief with a stable
identity. Nothing in the type carries an id of its own; identity is
positional.
Session's shape is a flat, ordered, position-addressed list.
get_items(limit) returns the last N items in chronological order.
add_items appends. pop_item removes and returns the single most recent
item. clear_session empties it. There is no key, no namespace, no
concept of "this entry versus that entry" beyond order - it is closer to a
JSONL append log (the exact thing docs/benchmark.md's Baseline B
represents) than to a keyed store.
session_settings. One field today, limit: int | None, resolved
per-call or from the session's default. Room for more later
(SessionSettings is a pydantic.dataclasses.dataclass), but nothing else
is defined yet.
2. The shape mismatch, and how the LangGraph adapter's precedent answers it
Mnemosyne's node model is a keyed map: id -> MemoryNode, read as
working_memory() -> BTreeMap<String, MemoryNode> (checkout.rs), each
write a commit. BTreeMap ordering is lexicographic by id string, not
insertion order - "item:10" sorts before "item:2". The one place
Mnemosyne stores things in a true chronological order is the commit graph
itself: Store::log(start, first_parent, limit) walks commits newest
first, deterministic by record time then id (log.rs).
So a Session item has no natural Mnemosyne node id (an OpenAI message or
tool call is not "a belief with a stable identity" the way a LangGraph
(namespace, key) pair is), and Session's list semantics (pop_item,
positional get_items(limit)) map onto Mnemosyne's commit history, not
onto its keyed node space, the reverse of how the LangGraph adapter works.
ADR-0016 already answered a structurally similar problem for LangGraph by
not forcing every LangGraph concept through BaseStore alone: BaseStore
is implemented for drop-in compatibility, and branch / switch / why /
history are extra methods on the same adapter class, outside the
interface, for the differentiator surface. That precedent - implement the
framework's protocol narrowly and put Mnemosyne's actual value (branch,
merge, blame, bisect) on the adapter object itself, not squeezed through the
foreign interface - is the strongest candidate answer here too, since
Session's four methods have no way to express a branch, a conflict, or a
blame result at all.
Two live design questions this doesn't settle, both real forks with different downstream code:
- What is a Session item's Mnemosyne node id? A synthetic, sortable key
(e.g. a zero-padded or numeric-suffixed
id, sinceBTreeMapordering is lexicographic and"item:10"would otherwise sort before"item:2") makes each transcript entry its own node - individually blameable, at the cost of one node per turn in a possibly long-running conversation. Storing the whole item list as one node's content (one commit peradd_itemscall) is far cheaper and simpler, but thenblame/bisectonly resolve to "this commit changed the transcript," not to a specific message - a real loss of the thing that makes Mnemosyne worth adopting overSQLiteSessionat all. - Does
get_items/pop_itemread from the keyed node space (via a synthetic id scheme) or fromStore::log(walking commits, since that is Mnemosyne's one truly-ordered primitive)? These are different implementations with different failure modes -logis already ordered and paginated (limit) for free, but conflates "one commit" with "one Session item" in a way that may not surviveremember_many-style batching or a future merge into the session's history.
3. A second seam: RunHooks / AgentHooks, for provenance
src/agents/lifecycle.py defines RunHooksBase and AgentHooksBase, async
callback classes an agent or a Runner.run call can be given, covering
on_llm_start/end, on_agent_start/end, on_handoff, and - the relevant
pair - on_tool_start / on_tool_end. This is a second, independent
integration point, orthogonal to Session: MCP's precedent
(remember's source argument) and LangGraph's precedent (provenance
lifted from a reserved _meta key, ADR-0016) both had to invent a carrier
for provenance because their host interfaces don't have one either. Here,
on_tool_end receives the tool's name and output directly, which is a
cleaner, unprompted way to populate Provenance.source / observation
automatically for any node written as a result of that tool call - worth
deciding whether the adapter wires this by default or leaves it to the
caller, the same kind of call ADR-0016 made explicit for _meta.
Recommendation
Implement Session for drop-in compatibility (so Runner.run(agent, prompt, session=MnemosyneSession(store)) works with zero other changes to an
existing OpenAI Agents SDK program), backed by a synthetic per-item node id
scheme rather than one node per whole transcript, so blame and bisect
stay meaningful per message - matching packages/mnem-mcp's existing
precedent of "one write, one commit" rather than batching for throughput.
Put branch / switch / merge / why / bisect on the adapter class
directly, outside the Session protocol, exactly as ADR-0016 did for
BaseStore. Wire on_tool_end as an optional AgentHooks subclass the
adapter ships, off by default, that auto-populates provenance - documented,
not silent, matching the _meta precedent.
This is a recommendation, not a decision: the id-scheme and ordering-source questions in section 2, and the hooks default in section 3, are exactly the kind of fork a grilling should pressure-test rather than settle unilaterally in a research doc.
Open questions for the grilling
- Node id scheme for a
Sessionitem: synthetic per-item id (one node per message/tool-call/tool-output) versus one node peradd_itemsbatch. If per-item, what does the id look like, and does it survivepop_itemcleanly (does popping tombstone the node, or does it need a real delete)? - Ordering source: reconstruct
get_itemsfrom the keyed node space (via the id scheme) or fromStore::log? Doesclear_sessionmean "tombstone every node in this session's namespace" or "branch to a fresh empty history"? - Namespacing: one Mnemosyne store per
session_id(mirroringmnem-mcp's one-store-per-process rule), or one store with an id-prefix namespace per session (mirroring the LangGraph adapter's namespace-as-prefix)? - Hooks default: does the adapter's
AgentHooksprovenance auto-capture ship on by default, or opt-in? MCP made provenance a first-class tool argument; LangGraph made it an opt-in_metakey. Which precedent does this adapter follow? - Package name and scope:
packages/mnem-openai-agents/, PyPI name, and whetherSessionSettings.limitneeds any Mnemosyne-side equivalent (a boundedget_itemsis a paginatedlog/working_memoryread, not a new primitive) or is just passed through.
Sources
openai/openai-agents-python(cloned at research time, MIT, PyPIopenai-agentsv0.22.3):src/agents/memory/session.py,sqlite_session.py,session_settings.py,src/agents/items.py,src/agents/lifecycle.py,src/agents/run.py,examples/memory/*.py.- ADR-0016 (the MCP/ LangGraph adapter contract precedent this research follows).
crates/mnem-store/src/checkout.rs(working_memory'sBTreeMapordering),crates/mnem-store/src/log.rs(Store::log's chronological order).
Research: a CrewAI adapter (issue #250)
Feeds the ADR that fixes a mnem-crewai adapter's contract, following the
same discipline as #249's OpenAI Agents SDK survey: read against the real
crewAIInc/crewAI source (MIT, lib/crewai), not its docs pages.
Two questions:
- What is CrewAI's own pluggable memory primitive, and what shape does it require?
- Is that shape compatible with Mnemosyne at all, given Mnemosyne is
explicitly not a retrieval/semantic layer (
docs/comparisons.md, ADR-0016) - and if so, on what terms?
1. The contract: StorageBackend, a vector-search-first protocol
crewai.memory.storage.backend.StorageBackend is the pluggable seam,
swapped in globally via set_memory_storage_factory() (a one-time,
process-wide setter, factory.py) rather than passed per-construction -
closer to a registration hook than an argument. It is far richer than the
OpenAI Agents SDK's four-method Session: save / search / delete /
update / get_record / list_records / get_scope_info / list_scopes /
list_categories / count / reset, each with a sync and an async
(a-prefixed) form.
The record, MemoryRecord (types.py): id, content: str,
scope: str (a hierarchical path, e.g. /company/team/user), categories: list[str], metadata: dict, importance: float (0-1, affects ranking),
created_at / last_accessed, embedding: list[float] | None, and -
notably - source: str | None and private: bool are already first-class
fields, described in the docstring as exactly what Mnemosyne calls
provenance and access scoping. No reserved _meta-key workaround is needed
here the way ADR-0016 needed one for LangGraph.
Search is vector-first and mandatory. search(query_embedding: list[float], scope_prefix=None, categories=None, metadata_filter=None, limit=10, min_score=0.0) takes only a pre-computed embedding vector, no
query string - unlike LangGraph's search(ns, query?), there is no text to
fall back to filtering on. factory.py's docstring confirms only two
built-in backends exist, LanceDB and Qdrant, both vector stores; no
non-vector backend ships in-tree as precedent.
2. The finding that changes the picture: the adapter never computes embeddings
Read crewai.memory.unified_memory.Memory (CrewAI's own concrete class,
what a crew actually talks to) end to end for remember(). It routes
through _encode_batch, which builds each MemoryRecord using
self._embedder (CrewAI's own configured embedder) before calling
storage.save(records). Records handed to a StorageBackend.save() call
already carry a real embedding. Symmetrically, Memory.recall(query: str, depth="shallow"|"deep") computes embedding = embed_text(self._embedder, query) itself before calling storage.search(query_embedding=...) (depth ="deep", the default, additionally runs an LLM-driven RecallFlow that
decomposes the query and selects scopes - all inside Memory, invisible to
the backend).
This means a StorageBackend implementation never needs its own embedder
or embedding-model dependency. Its job is to persist vectors it is handed
and compare vectors it is handed - not generate them. That removes the
biggest apparent obstacle (Mnemosyne pulling in an LLM/embedding dependency,
which ADR-0004 would never allow into the offline core, and which even an
adapter package taking on would be an odd new commitment).
What is left, and is real: Mnemosyne has no vector index. A correct backend
can still do brute-force cosine similarity over every record's stored
embedding within the searched scope - honest, exactly as accurate as
LanceDB's own ranking (same vectors, same math, no approximation), just
O(records in scope) per query rather than sublinear. factory.py's own
docstring names "an in-memory fake for tests" as a legitimate custom-backend
use case, which is a lower bar than what's proposed here, but confirms
CrewAI itself doesn't assume every backend is an indexed vector database.
3. Scope is a tree, not a flat namespace tuple
scope: str paths (/company/team/user) with list_scopes(parent)
walking immediate children make CrewAI's scoping model hierarchical, unlike
LangGraph's flat namespace: tuple[str, ...] (ADR-0016) or the {session_ id}: flat prefix ADR-0020 chose for the OpenAI Agents SDK adapter. Two
live questions this raises:
- Node id scheme: does a Mnemosyne node id literally embed the
/- delimited scope path ({scope}/{record_id}, honoring CrewAI's own separator), or does it get normalized to Mnemosyne's existing:-prefix convention (ADR-0016, ADR-0020) for consistency across adapters, at the cost of translating between two path grammars on every call? get_scope_info/list_scopes/list_categories: these are genuine tree-walk operations (count records, list child scopes, aggregate categories, recursively under a path) with no equivalent in any adapter built or designed so far.working_memory()'sBTreeMapordering makes a prefix scan cheap; a child-scope walk (one level of a tree, not a flat prefix match) needs the id scheme to actually reflect scope nesting, not just be a flat string that happens to contain slashes.
4. Provenance and privacy map directly, unusually cleanly
MemoryRecord.source and .private are already first-class - source
maps straight onto Mnemosyne's Provenance.source with no adapter-invented
carrier, and private is a real access-scoping bit CrewAI's own recall()
already enforces (include_private, matching by source). This is the one
part of this adapter that is more straightforward than any prior one.
5. A striking vocabulary match, likely coincidental
Memory's own public API is remember() / remember_many() / recall() /
forget() - the same verbs mnem.agents already uses (remember,
remember_many, forget, ADR-0016), independently arrived at. Worth naming
in an adapter's own docs as a point of natural fit, not engineered.
Recommendation
Implement StorageBackend for real, not degraded: store each MemoryRecord
(content, scope, categories, metadata, importance, source, private,
embedding included) as one Mnemosyne node's content, one node per record,
mirroring ADR-0020's per-item granularity for the same reason - blame and
bisect should resolve to which memory, not just which save call.
Implement search as real, un-approximated brute-force cosine similarity
over the embeddings already present in the scanned scope - accurate, just
not sublinear, and documented as such (a scale ceiling, not a silent
approximation). Map source and private straight through, no invented
carrier. Use a /-based node id honoring CrewAI's own scope grammar rather
than translating into the :-prefix convention the other two adapters use,
since list_scopes's child-walk semantics depend on real hierarchy, not a
flat prefix.
This is a recommendation, not a decision: whether brute-force search's scale ceiling is acceptable to ship, and the exact id-scheme choice, are real forks for the grilling to settle.
Open questions for the grilling
- Is un-approximated, unindexed cosine-similarity search an acceptable
v1? It is provably correct at any scale, just O(records in scope) per query - fine for hundreds to low thousands of records per scope, a real ceiling beyond that. Does the adapter's own docs state a recommended ceiling and point to LanceDB/Qdrant beyond it, or is that premature before anyone hits it? - Node id / scope-path scheme: honor
/-delimited scope paths literally in the node id, or normalize to the:-prefix convention ADR-0016/ ADR-0020 already established? The tree-walk operations (list_scopes/get_scope_info) are the forcing function either way. importanceandlast_accessed: CrewAI's own ranking usesimportance(0-1) and presumably recency; does the adapter treat these as opaque fields it round-trips faithfully (simplest, most honest), or try to derive/update them itself (risks silently disagreeing with what CrewAI's ownMemoryclass would have computed)?- Async: mirror the LangGraph adapter's precedent (sync methods call the
SDK directly, async methods wrap in
asyncio.to_thread, ADR-0016) rather than a native async re-implementation? - Package name and scope:
packages/mnem-crewai/, PyPImnem-crewai, following the shipped (not ADR-0016's original) naming convention.
Sources
crewAIInc/crewAI(cloned at research time, MIT):lib/crewai/src/crewai/ memory/storage/backend.py,factory.py,types.py,unified_memory.py(remember,_encode_batch,recall).- ADR-0016 (the
LangGraph namespace-as-prefix and
_meta-carrier precedents this research compares against). - ADR-0020 (the per-item node-granularity precedent this research follows).
Research: Mem0 and Zep against the audit-query table (issue #282)
Feeds the ADR that supersedes ADR-0017's scoping decision (docs/benchmark.md's
audit-query table stays scoped to dict/JSONL/mnem) by adding Mem0 and Zep as
verified columns - not an accuracy benchmark (ADR-0017's own point stands: that
axis measures the wrong thing for a retrieval-first tool), but the table's
seven structural questions, which are mnem's actual axis and are answerable
from Mem0's and Zep's own source, not their marketing pages.
Read against real source, not docs: mem0ai/mem0 (Apache-2.0,
mem0/memory/{storage,main}.py) and getzep/graphiti (Apache-2.0,
graphiti_core/{edges,graphiti}.py, search/search_filters.py) - Graphiti is
the open-source temporal-graph engine Zep Cloud is built on; its repo is what's
actually inspectable, same reasoning docs/comparisons.md already used citing
Graphiti for Zep.
The seven questions, verified
1. What does it believe now? Both: yes, trivially - Memory.get_all()
(Mem0) and a search() call with no time filter (Graphiti) both read current
state. Not interesting on its own; every memory system clears this bar.
2. What did it believe at step t?
- Mem0: no built-in call.
storage.py'shistorytable is scoped to onememory_idat a time (get_history(memory_id)-WHERE memory_id = ?). There is no API that reconstructs the whole memory set as it stood at an arbitrary past time; a caller would have to walk everymemory_id's own history table and manually take the latestnew_memorybefore a cutoff for each one. The schema doesn't forbid this (it's the same trick a raw JSONL replay uses), but nothing in the SDK does it for you. - Zep (Graphiti): yes, via explicit filter construction.
EntityEdgecarries real bi-temporal fields -valid_at,invalid_at,expired_at(edges.py) - andSearchFilters(search_filters.py) exposesvalid_at/invalid_atas first-classDateFilterlists with comparison operators (gt/lt/gte/lte). A caller who constructsvalid_at <= t AND (invalid_at IS NULL OR invalid_at > t)gets a real point-in-time reconstruction. Not a singlestate_at(t)call the waymnem's is - the caller builds the filter - but genuinely supported by the data model, not approximated.Graphiti.search()'s convenience wrapper itself always uses "now" as its reference time (stated in its own docstring), so this only works through the lower-level filter API, not the one-liner.
3. When did belief X first go wrong (bisect, ideally sublinear)? Both:
no. Neither exposes anything resembling a binary search over history.
Mem0's history table and Graphiti's temporal edges are both linearly
queryable by a caller willing to write the loop themselves, but there is no
built-in bisect-shaped primitive in either - this is the one row where both
tools are flatly behind a plain JSONL log's O(L) linear scan, since neither
even offers that as a single call.
4. Which observation set X (provenance)?
- Mem0: a shallow but real tag. Every
historyrow carriesactor_idandrole(storage.py's schema) - who or what triggered the change. No structured source/step/observation object, just an actor identifier per event. - Zep (Graphiti): a real reference, not just a tag.
EntityEdge.episodes: list[str](edges.py) holds the UUIDs of the episodic node(s) that support a fact - an actual link back to the raw observation(s) that established it, closer in spirit tomnem'sProvenancethan Mem0's actor tag is.
5. What did step t change (diff)? Both: qualified yes, scoped to one
item, not a global step. Mem0's old_memory/new_memory pair on a history
row is a real diff, but only for that one memory_id's own trail - there is
no cross-memory "commit" concept, so "what did step t change" has no global
answer the way mnem whats_new(commit) does (a commit can touch many nodes
atomically). Graphiti has no explicit diff API at all; a caller would compare
two point-in-time SearchFilters queries themselves - technically derivable
from question 2's answer, not a first-class call.
6. Merge two agents' memories, surfacing conflicts? Both: no -
reconfirms docs/comparisons.md's existing finding (#237). Mem0 has no
branch/fork concept at all. Graphiti's graph is a single shared structure per
group_id; two divergent views can't be reconciled as a flagged, resolvable
conflict - a later fact simply invalidates an earlier one via expired_at,
which is a real form of history but not a merge.
7. Storage after L steps?
- Mem0: current state lives in whatever vector-store backend is
configured (Qdrant, etc.), roughly
O(keys), updated in place; the SQLitehistorytable is append-only and grows with every event,O(events). Structurally this is close tomnem's own current-state-plus-full-history split, not meaningfully worse on this axis alone. - Zep (Graphiti): the graph grows monotonically - a superseded fact's edge
gets
expired_atset, it isn't deleted - so storage isO(all facts ever asserted), closer to a JSONL log'sO(L)character than a dict'sO(keys).
Recommendation
Both are real "qualified yes / no" tables, not clean yes/no rows - exactly the
nuance issue #282 itself flagged as an open question. Recommend: qualified
answers, not forced booleans (e.g. "no built-in call, but the schema
permits it manually" is a materially different claim than a flat "no", and
collapsing it loses real information a technical reader would want). Recommend
the table lives in docs/comparisons.md (a written comparison, room for the
qualification text a cell needs) rather than docs/benchmark.md (CI-gated,
terse, boolean-shaped, and ADR-0017's own point - this isn't a number the
build should assert on, since Mem0's and Zep's behaviour isn't mnem's to
verify on every CI run the way its own metrics are).
Open questions for the grilling
- Where does the table live?
docs/comparisons.md(recommended above) or a new subsection ofdocs/benchmark.md, kept separate from the CI-gated numbers? - Format for a qualified answer - a short parenthetical in the cell (as used above), or a table of qualifiers plus footnotes, given at least three of the seven rows need one for at least one of the two tools?
- Does this table also gain Letta, the third real comparison already in
docs/comparisons.md's prose ("closest of the mainstream memory tools" per the earlier research), or stay scoped to Mem0/Zep as #282 was filed? - The ADR that supersedes ADR-0017: does it fully replace ADR-0017's scoping paragraph, or add a narrower amendment specifically carving out the structural-question table from the numeric-benchmark scope ADR-0017 still correctly states for accuracy-style comparisons?
Sources
mem0ai/mem0(cloned at research time, Apache-2.0):mem0/memory/storage.py(thehistorytable schema,get_history),mem0/memory/main.py(history,get,get_allas the only public read surface).getzep/graphiti(cloned at research time, Apache-2.0):graphiti_core/edges.py(valid_at/invalid_at/expired_at/episodesfields),graphiti_core/graphiti.py(retrieve_episodes,search's "current date and time" docstring note),graphiti_core/search/ search_filters.py(SearchFilters'DateFilterlists).- ADR-0017 (the scoping decision this research proposes narrowing, not reversing).
docs/comparisons.md(#237's existing Mem0/Zep prose, which this table makes concrete and numeric without repeating).
Research: an AutoGen adapter (issue #251)
Feeds the ADR that fixes a mnem-autogen adapter's contract, following the
same discipline as #249 and #250: read against the real
microsoft/autogen source (MIT, python/packages/autogen-core,
autogen-core v0.7.5 at research time), not its docs pages.
Two questions:
- What is AutoGen's own pluggable memory primitive, and what shape does it require?
- How is it actually wired into an agent's turn - who calls what, and when?
1. The contract: Memory, the narrowest of the three adapters so far
autogen_core.memory.Memory (_base_memory.py) is an ABC with five
methods: update_context(model_context), query(query: str | MemoryContent, **kwargs), add(content: MemoryContent), clear(),
close(). Narrower than CrewAI's eleven-method StorageBackend (#250) and
even narrower than the OpenAI Agents SDK's four-method Session (#249) in
one specific way: MemoryContent has no id field at all - just
content (str | bytes | dict | Image), mime_type, and an optional
metadata dict. There is no get, no list, no delete-by-id, no pop -
only wholesale clear(). Items are genuinely unkeyed.
query takes a raw string, not a pre-computed embedding - unlike
CrewAI's search(query_embedding: list[float], ...) (#250's central
finding). This matters: whatever computes a ranking here would need to
embed the string itself, since AutoGen's Memory doesn't hand over a
vector the way CrewAI's Memory class does.
2. The reference implementation is honestly trivial - and that's the answer
ListMemory (_list_memory.py), the bundled reference implementation, is
a plain Python list. Its query() ignores its argument entirely and
returns every stored item, docstring: "Return all memories without any
filtering... Args: query: Ignored in this implementation." Its
update_context dumps every memory as one numbered SystemMessage, in
chronological (insertion) order.
This directly answers the question CrewAI's research had to work hard for
(#250: "does a backend need its own embedder?"). Here, the SDK's own
bundled default doesn't attempt ranking at all - so a Mnemosyne-backed
Memory that also ignores query and returns everything chronologically
is not a degraded implementation, it is the same real behaviour as the
reference implementation itself, not a compromise invented to work around
a missing capability. autogen_ext.memory ships real ranked backends
(ChromaDB-, Redis-backed) for anyone who wants that; Memory as a protocol
does not require it.
3. update_context is a genuinely new integration shape
No adapter studied so far has this: Memory.update_context(model_context)
is called automatically by AssistantAgent before every model inference
(_assistant_agent.py, confirmed: update_context_result = await mem.update_context(model_context) inside the agent's run loop) and
actively mutates the passed context by appending a message - the
memory implementation pushes itself into the conversation, rather than the
caller pulling data out and deciding what to do with it (MCP's tool call,
LangGraph's get, the OpenAI Agents SDK's get_items, CrewAI's search
are all pull; this is push). A Mnemosyne-backed implementation needs to
decide what it injects and in what format - ListMemory's own choice
(a single numbered-list SystemMessage, chronological) is the natural
default to match, since it's what any caller already expecting a
ListMemory-shaped Memory would see.
4. add() is caller-driven only - no automatic hook exists
Unlike the OpenAI Agents SDK's on_tool_end (ADR-0020) or CrewAI's
Memory class auto-computing embeddings before save() (#250), nothing in
AutoGen calls Memory.add() automatically from a tool call or an agent
turn. Every example (including AssistantAgent's own docstring) calls
await memory.add(MemoryContent(...)) directly from application code. So
provenance has nowhere to travel except the optional metadata dict a
caller chooses to populate - closer to LangGraph's opt-in _meta precedent
(ADR-0016) than to either #249 or #250's adapters, since there is no hook
to make it automatic even if we wanted to.
Recommendation
Implement Memory matching ListMemory's own real semantics, not a lesser
version of something fancier: query() ignores its argument and returns
every item in the scoped memory, chronologically - honest, not degraded,
since it's what the SDK's own default does. update_context formats
exactly as ListMemory does (one numbered SystemMessage, chronological)
for drop-in familiarity. One Mnemosyne node per MemoryContent item (same
per-item-granularity reasoning as #249/#250 - blame/bisect should
resolve to which memory item), with a synthetic id scheme since
MemoryContent has none of its own. clear() as a tombstone commit via
the already-shipped mnem.agents.forget, never a hard delete - the fourth
adapter running into the exact same "routine clear() call must not
defeat the whole point" reasoning ADR-0020 already settled for
clear_session.
This is a recommendation, not a decision: the id scheme, namespacing, and
whether metadata provenance gets any adapter-side structure are real
forks for the grilling.
Open questions for the grilling
- Node id scheme:
MemoryContenthas no id. A synthetic per-item sequence id (mirroring ADR-0020's{name}:{seq:010d}scheme, withListMemory's ownnamefield as the natural namespace) is the obvious candidate - is there a reason to deviate from that established pattern here, given nothing about AutoGen's own model argues for a different shape the way CrewAI's/-scoped tree did? - Namespacing: one Mnemosyne store per named
Memoryinstance (mirroringmnem-mcp's one-store-per-process rule), or a shared store with thenamefield as an id prefix (mirroring LangGraph/OpenAI Agents SDK)? AMemoryinstance is typically constructed once per agent, closer in spirit toSession's per-Runner.runscoping than to a store an application shares across many conversations. - Does
metadataget any adapter-side structure (e.g. reserved keys the adapter lifts intoProvenance, mirroring LangGraph's_meta), or is it stored and returned completely opaque, with no attempt to interpret it? There's no hook to populate it automatically either way, unlike #249'son_tool_end- this is purely about whether the adapter tries to be helpful with whatever a caller puts there. update_context's exact format: matchListMemory's plain numbered list precisely, or a documented Mnemosyne-flavoured variant (e.g. including provenance inline if it's present)? Byte-for-byte compatibility withListMemory's output isn't a real constraint (nothing parses it back), but staying close avoids surprising a caller who's used to it.- Package name and scope:
packages/mnem-autogen/, PyPImnem-autogen, following the established naming convention.
Sources
microsoft/autogen(cloned at research time, MIT,autogen-corev0.7.5):python/packages/autogen-core/src/autogen_core/memory/_base_memory.py(theMemoryABC,MemoryContent),_list_memory.py(ListMemory's real behaviour),python/packages/autogen-agentchat/src/ autogen_agentchat/agents/_assistant_agent.py(update_contextcalled automatically before inference;memory.add()only ever called from application code in every example).- ADR-0020 (the per-item granularity and tombstone-not-delete precedents this research follows).
- ADR-0021 (the "does a backend need its own embedder" question this research resolves differently, and why).
Research: a Letta adapter (issue #252)
Feeds the ADR that fixes a mnem-letta adapter's contract - or, depending on
what this research finds, the ADR that explains why no adapter is the right
call right now. Unlike #249-#251, the premise itself needed checking first:
issue #252 was filed on the strength of docs/comparisons.md's Letta section
("agent templates carry numbered versions with a documented rollback
endpoint, and a block_history mechanism chains a hash over each change to a
memory block"). That description is no longer accurate.
1. The premise has gone stale: Letta pivoted since that research was done
letta-ai/letta (the repo docs/comparisons.md researched) now ships only
policy and legal files at its root - no source at all. Its own README:
Letta (f.k.a. MemGPT) is actively developed. The current source code lives in
letta-ai/letta-code... Thearchivebranch contains the retired Letta V1 API server.
The block-history/checkpoint-rollback API docs/comparisons.md described is
the retired V1 server, now on an archive branch, not the active
product. This needs its own correction to docs/comparisons.md regardless of
what this ticket concludes about an adapter - a separate, smaller finding,
noted here and worth its own ticket.
2. What Letta is today: letta-code, a CLI tool, and its memory model is a real git repo
letta-ai/letta-code (Apache-2.0, npm @letta-ai/letta-code, Bun/TypeScript)
describes itself as "a CLI tool for interacting with stateful Letta agents
from the terminal" - the same category as Claude Code, not a Python agent
framework. Its own source docstring, verbatim:
Git operations for git-backed agent memory. When memFS is enabled, the agent's memory is stored in a git repo on the server at
$LETTA_MEMFS_BASE_URL/v1/git/$AGENT_ID/state.git... clone on first run, pull on startup, commit memory writes, post-turn push for clean pending commits.
This is not an analogy or a hash-chain-as-marketing-claim (which is roughly
what the retired V1 block_history was). It is a literal git repository,
cloned, pulled, and pushed like any other, holding the agent's memory as real
files at ~/.letta/agents/<agentId>/memory/. src/agent/memory-git.ts is
2,092 lines with real credential handling, commit signing, retry-on-transient-
network-error, and Windows-specific credential plumbing - substantial,
production infrastructure, not a toy.
But it is plumbing, not a versioning feature. The exported surface
(commitMemoryWrite, pushMemory, pullMemory, cloneMemoryRepo,
getMemoryHeadRevision, getMemoryGitStatus) has no log, no checkout-at- revision, no branch, no merge. pushToMemoryRepository's non-fast-
forward handling (isNonFastForwardPushError) just returns the raw error
text as a failure - it does not merge, does not retry with a rebase, does not
produce anything resembling mnem's Conflict object. Confirmed in Letta's
own documentation (src/skills/builtin/migrating-memory/SKILL.md): the
recommended, first-party workflow for moving memory between agents is
cd ~/.letta/agents/$LETTA_AGENT_ID/memory && git add ... && git commit -m "..." && git push - literally telling the user to drive raw git themselves.
The same doc states plainly: "the legacy block-level CLI commands have been
removed."
This is the sharpest positioning point found in any adapter research so
far. Letta gives a user a real git repository and tells them to use real
git by hand if they want history. mnem gives blame, bisect, a typed
merge-conflict object, and a real object model on top of the same idea. Same
substrate, completely different level of actual capability.
3. There is no Python API for the current memory model
The python/ directory in letta-code is a packaging shim only:
wheel_backend.py's own docstring - "Wheel-only backend... There is
deliberately no sdist: installing must never compile or fetch Node/npm... The
launcher is Python ABI independent, but its payload is platform specific."
pip install letta gets you the compiled CLI binary via a Python entry point
(letta = "letta_code:main"), not a memory API. There is no Python object
resembling a Session, Memory, or StorageBackend for this adapter to
implement - the pattern every prior adapter (#249-#251) followed doesn't
apply here at all.
A separate PyPI package, letta-client (v1.12.1, last released 2026-06-02),
exists - "the official Python library for the letta API." This is almost
certainly a REST client generated against Letta Cloud's hosted API (the same
scope as the TS @letta-ai/letta-client package letta-code itself depends
on for AgentState/CreateBlock types), not the git-backed memfs system
specifically - CreateBlock being a live type import suggests the
block-oriented Cloud API is still reachable there even though the CLI's own
local-first path has moved past it. Not independently verified against a live
server in this research pass (no credentials, no running instance) - a real
open item, not assumed.
Recommendation
Two honest paths, not a single clear winner - this research surfaces the fork rather than resolves it:
(A) No adapter for now; fix the stale docs/comparisons.md claim instead.
There is no clean Python integration surface matching any prior adapter's
shape. The interesting technical opportunity - mnem's object model layered
on the same git repo Letta already syncs - is a contribution to Letta's own
CLI, not an mnem-side adapter package, and is a materially different kind
of undertaking (upstream PR to someone else's TypeScript codebase) than
anything ADR-0020/0021/0023 scoped.
(B) A thin Python package against letta-client's REST API, scoped
honestly to whatever that client actually exposes (verified against a real
Letta Cloud account before any contract is fixed, not assumed from package
metadata) - if it's block-CRUD only, this would be a mnem-agents-adjacent
sync helper more than a Session/Memory-shaped adapter, and would need its
own real research pass first to fix scope.
This research recommends (A) as the honest default - fix the stale comparisons claim now, defer the adapter question until either Letta ships a real Python-side memory API, or there's a concrete reason to build the REST-client-only version B describes. But this is exactly the kind of call that belongs to the grilling, not to this doc alone.
Open questions for the grilling
- Build nothing, or build the thin REST-client version (B)? If (B): what
does
letta-clientactually expose today, verified against a live account - and is a block-CRUD sync helper worth building at all given the CLI itself has moved past blocks? - The stale
docs/comparisons.mdLetta section: fix it as part of this ticket's own close-out, or file it as its own separate, smaller ticket? Either way it needs fixing - it currently describes retired software as current. - Is the "contribute upstream to Letta" angle worth naming publicly
anywhere (a discussion post, an issue on
letta-ai/letta-code), even though it's explicitly out of scope for this project's own adapter work?
Sources
letta-ai/letta(cloned at research time, currentmain):README.md(confirms the pivot and thearchivebranch).letta-ai/letta-code(cloned at research time, Apache-2.0, npm@letta-ai/letta-codev0.33.0):src/agent/memory-git.ts(the git-backed memory implementation,commitMemoryWrite/pushMemory/pullMemory/isNonFastForwardPushError),src/agent/memory-filesystem.ts("With git-backed memory, most sync/hash logic is removed"),src/skills/builtin/migrating-memory/SKILL.md("the legacy block-level CLI commands have been removed", thegit add/commit/pushrecommended workflow),python/wheel_backend.py(confirms the Python package is a binary-distribution shim, not an API).pypi.org/pypi/letta-client/json(live PyPI metadata, checked at research time: v1.12.1, released 2026-06-02) - existence confirmed, contents not independently verified against a live server.docs/comparisons.md(#237's existing Letta section - the claim this research found to be stale).
Research: a Mem0 migration/import path (issue #253)
Feeds the ADR that fixes what this actually is - an import tool, a written
guide, or both - not an adapter contract; Mem0 is a memory store, not an
agent framework, so there is no Session/Memory/StorageBackend-shaped
object to implement the way #249-#251 had.
Builds on #282's already-verified mem0ai/mem0 source reading (Apache-2.0),
extended here with the specific question this ticket needs answered: what
does reading a caller's existing Mem0 data actually look like, end to end.
1. The real read API: get_all, scoped and capped, no cursor
Memory.get_all(filters, top_k=20, show_expired=False) (mem0/memory/ main.py) is the only bulk-read entry point. Two real constraints an
importer has to design around:
filtersmust name a scope up front - at least one ofuser_id,agent_id,run_id- enforced by aValueErrorif none is given. There is no "list every memory in the store" call; a caller has to already know which scopes exist. An importer cannot discover what it can migrate on its own - it needs the user to supply the scope identifiers themselves (their ownuser_idetc. from their Mem0 usage).top_kis a cap, not a cursor. Default 20, no offset/cursor parameter anywhere in the signature. A real migration needs an explicit, largetop_k(or repeated calls if a caller's memory count could exceed any single value trusted) - silently defaulting to 20 would migrate a small, arbitrary slice and call it done.
2. What a memory item actually contains on read
_get_all_from_vector_store (same file) builds each returned item as:
MemoryItem(
id=mem.id,
memory=mem.payload.get("data", ""),
hash=mem.payload.get("hash"),
created_at=mem.payload.get("created_at"),
updated_at=mem.payload.get("updated_at"),
)
plus, if present in the payload: user_id, agent_id, run_id,
actor_id, role, attributed_to, expiration_date (promoted to
top-level fields), and anything else bucketed into a metadata dict. No
embedding field is returned to the caller at all - vectors stay internal
to the configured vector store, so an importer never needs to handle them.
The null/empty-content question a real external contributor raised on
#253 (samvallad33's comment) has a concrete, sourced answer now:
mem.payload.get("data", "") - Mem0's own code silently defaults to an
empty string when a record's data key is missing, not an error. This
confirms the concern was real, not hypothetical: a malformed or edge-case
Mem0 record can and does produce empty content through Mem0's own official
read path. An importer has to decide explicitly what to do with that,
rather than assume it can't happen.
3. History: current state only, confirmed again
#282 already established Mem0's history table is scoped per memory_id,
no project-wide event log. get_all reads current state from the vector
store, not history - so an import, whatever form it takes, brings across
current beliefs, not Mem0's own edit trail. This isn't a limitation of
the importer; it's a fact about what data exists on the Mem0 side to bring
across, exactly as issue #253's own body already anticipated.
Recommendation
Both a tool and a guide, not a choice between them - they answer different
questions. A script (or a mnem.agents function, better matching this
project's existing SDK-ergonomics pattern than a scripts/ one-off) that
takes a Mem0 Memory instance plus explicit scope filters and remembers
each item into a target mnem store, one node per Mem0 memory id, with
user_id/agent_id/run_id/actor_id mapped into Provenance the same
way every adapter's provenance mapping has worked. A short migration-guide
doc explaining the real, honest limitation up front: this imports current
state as a fresh mnem history, not a ported-over timeline, because Mem0
never captured one.
The empty-content question resolves cleanly given the evidence: skip and log, not import-as-empty-string and not hard-fail the whole batch. A migration tool importing someone else's real data should be conservative about what it writes, and a loud skip is more useful to someone debugging a partial migration than a silent empty node or an all-or-nothing failure.
This is a recommendation the grilling should still confirm, particularly the "skip vs. import-empty vs. fail" three-way choice, since a real external contributor already has a stake in this exact question.
Open questions for the grilling
- Skip, import-as-empty, or fail the batch on empty content? Leaning skip-and-log per the reasoning above - confirm.
- Where does the tool live: a
mnem.agents.import_mem0(...)SDK function (versioned with the SDK, testable, matches ADR-0016's "the SDK holds the ergonomics" precedent), a standalonescripts/entry, or a small dedicated package (packages/mnem-mem0-importor similar)? - Node id scheme: Mem0's own
memory_idused directly as themnemnode id, or namespaced (e.g.mem0:{memory_id}) to make the import's origin visible in the id itself? - Scope discovery UX: does the tool require the caller to pass
user_id/agent_id/run_idexplicitly every time (simple, matches Mem0's own API constraint), or attempt some convenience layer on top (there's no Mem0 API to discover scopes, so this would mean asking the caller to enumerate their own known scopes some other way)? top_k/ completeness: a single large default, or a real paging loop that keeps raisingtop_k(or otherwise confirms completeness) until a call returns fewer results than requested?
Sources
mem0ai/mem0(cloned for #282, re-read here, Apache-2.0):mem0/memory/main.py(get_all,_get_all_from_vector_store's exact per-item field construction, themem.payload.get("data", "")default),mem0/memory/storage.py(the per-memory_idhistorytable, already verified in #282).- The real external comment on issue #253 from
samvallad33(empty- embedding rejection question) - the concrete finding above is a direct, sourced answer to it, reframed correctly around content rather than embeddings (Mem0'sget_allnever returns embeddings to begin with).
Research: the Mnemosyne naming collision (issue #339)
Feeds a decision, most likely an ADR per #339's own scoping - a rename would touch the project's display name everywhere it appears, which is squarely inside ADR-0011's own "needs an ADR" trigger list for a public-surface change.
A Reddit commenter on the 2026-09-29 launch post flagged one specific
project, mnemosyne-oss/mnemosyne, as a same-named prior work. Checked
directly rather than taken on the commenter's word, and the real picture is
bigger than one collision.
Four questions, per #339's own framing:
- What does
mnemosyne-oss/mnemosyneactually do, and how much does it really overlap? - Is this a single collision, or a wider pattern?
- What would a rename actually cost?
- Is "Mnemosyne" the real differentiator, or is the git-verb positioning?
1. mnemosyne-oss/mnemosyne: substantive, but on a different axis
Confirmed via the GitHub API and its own README, not assumed from the Reddit comment:
- 3,336 stars, 286 forks, pushed 2026-10-03 (two days before this research) - a large, actively maintained project, not a stale namesake.
- Backed by NousResearch (the Hermes model family's own lab) - its own
topics list
hermes,hermes-agent,nousresearch; its Discord badge points at the NousResearch server. - ProductHunt-launched, benchmarked on BEAM (ICLR 2026), with its own
mnemosyne-memoryPyPI package. - Positioning: "Zero-cloud AI memory that works everywhere. SQLite-backed. One pure-Python dependency." Local-first, no external services, same general pitch shape as this project's own "local, deterministic, no network, no model calls."
Its own README's "Why Mnemosyne?" table compares itself against Mem0,
Letta, Honcho, SuperMemory, Hindsight, and ChromaDB - the same competitive
set this project's own docs/comparisons.md already addresses (Mem0, Zep,
Letta). Its own CLI has remember/recall/sleep (consolidation),
export/import (a backup/restore pair, not a content-addressed object
graph), and a sync command (bidirectional sync between instances) - but
no commit, branch, merge, blame, or bisect anywhere in the README, the
CLI reference, or the Python API. It is a retrieval and consolidation
memory layer, scored on recall benchmarks (BEAM, LongMemEval), not a
version-control system.
This confirms the axis this project's own former FAQ already drew in
general terms ("why not mem0, Zep, or a memory-layer product? those manage
what an agent remembers... mnem manages the history") applies here
too, specifically. The overlap is real at the category and name level; it
is not real at the feature level.
2. Not one collision - a crowded, non-distinctive name
A plain web search for "mnemosyne agent memory github" surfaces at least
eight distinct, unrelated GitHub projects named mnemosyne or a close
variant, all in the agent-memory space:
mnemosyne-oss/mnemosyne(above)rand/mnemosyne- "agentic memory and orchestration... persistent semantic memory across sessions" for Claude Codenikolaef43/mnemosyneandkirocop/mnemosyne- both "The Zero-Dependency, Sub-Millisecond AI Memory System for Hermes Agents"onfire7777/Mnemosyne- explicitly claims "THIS IS THE ORIGINAL MNEMOSYNE PROJECT"nianpangzhi233/Mnemosyne-AI-Memory- "Bionic memory... GraphRAG, predictive memory, dream consolidation"28naem-del/mnemosyne- "Cognitive Memory OS for AI Agents"- this project,
Nabzx/mnemosyne
Separately, the plain package name mnemosyne on PyPI belongs to yet
another, unrelated project ("a Python package for memory-related
utilities") - a ninth, independent claim on the name.
"Mnemosyne" (the Greek personification of memory) is an obvious name for
any agent-memory project, which is almost certainly why so many
unrelated teams landed on it independently. This is a real, structural
finding the original ticket framing did not anticipate: fixing "the"
collision with mnemosyne-oss would not fix the deeper problem, since at
least seven other same- or near-named projects exist in the identical
category regardless.
One mitigating factor, also checked directly rather than assumed: this project's own GitHub description - "Version control for AI agent memory. Commit, branch, merge, blame and time-travel what your agents know." - appears distinctly in the same search results, and reads unambiguously different from every other result's own description. The name collides; the actual positioning, once a reader's eyes reach the description line, does not.
3. The real cost of a rename is lower than it first looks
Checked what actually carries the name "Mnemosyne" as a technical identifier, not just a display name:
- No PyPI collision on the real package names. This project's own
packages are
mnem-agents,mnem-mcp,mnem-langgraph,mnem-openai-agents,mnem-crewai,mnem-autogen- none ismnemosyneormnemosyne-*. The plainmnemosynePyPI name is taken by a third, unrelated party (confirmed directly via the PyPI JSON API), and would have been unavailable to this project regardless of the rename question. - No crates.io collision. The Rust crate is
mnem-git, notmnemosyne. - The CLI binary is already
mnem, notmnemosyne. A user's actual typed commands (mnem init,mnem commit,mnem merge, ...) never contain the contested name at all.
What a rename would actually touch is narrower than the ticket's own
framing worried: the project's display name and GitHub repo slug
(Nabzx/mnemosyne - GitHub redirects the old slug automatically, lowering
this cost further), the docs site title and URL
(nabzx.github.io/mnemosyne), and every README/docs/ADR prose reference to
"Mnemosyne" the name - a real but mechanical find-and-replace, not a
package-republishing or user-migration problem. No existing user's
pip install, cargo install, or CLI invocation would break.
4. Is the name the differentiator, or is the positioning?
The evidence above answers this directly: across every project found that shares the name, none describes itself with commit/branch/merge/blame/ bisect language. The git-verb positioning is this project's own, real, and - as far as this research found - unique within the set of projects that happen to share its name. The name "Mnemosyne" itself carries no part of that differentiation; it is a generic, independently-rediscovered label for "a thing that remembers," not a brand asset tied to the actual technical claim.
What this research does not decide
Whether to rename, and if so to what, is the real open decision - a product and branding call for the project's own maintainer to make, not one this research should make unilaterally. This document states the facts a decision needs: the overlap is real and wider than one project, it is category-and-name-deep rather than feature-deep, and the technical cost of a rename is lower than the ticket's own original framing assumed.
Research: a command-reference template and category scheme (issue #549)
Feeds the decide ticket (#550), which locks the per-page template, the category scheme, and the generated-versus-hand-written question before any reference page (#551-556) or guide (#557-559) gets written.
Three questions:
- What does git-scm.com/docs' own per-page template and category scheme actually look like?
- What is mnem's own real command set, and how does a scaled-down version of git's categories fit it?
- Hand-written pages or pages generated from each subcommand's own
--helptext: what is the real cost of each?
1. Git's real template and category scheme
Checked directly against git-scm.com/docs and a real command page
(git-commit), not from memory.
The category scheme. Commands are grouped by what a user is doing,
not alphabetically: Setup and Config, Getting and Creating Projects,
Basic Snapshotting, Branching and Merging, Sharing and Updating
Projects, Inspection and Comparison, Patching, Debugging, Email,
External Systems, Administration, Server Admin, and a large Plumbing
Commands category (50-plus low-level commands) kept separate from the
porcelain a user actually types. Alongside the command pages sits a
separate, non-command Guides layer: gitattributes, gitcli,
giteveryday, gitfaq, gitglossary, githooks, gitignore,
gitmodules, gitrevisions, gitsubmodules, gittutorial,
gitworkflows - conceptual documents, not tied to one command.
The per-page template. A real command page (git-commit) follows
a fixed order: NAME (a one-line description), SYNOPSIS (formal usage
syntax), DESCRIPTION (prose), OPTIONS (every flag), EXAMPLES (real
usage), then command-specific sections where the command needs them
(git-commit has its own COMMIT INFORMATION and DATE FORMATS
sections), then ENVIRONMENT AND CONFIGURATION VARIABLES, HOOKS, FILES,
SEE ALSO, and a standardised GIT footer naming the suite version. Not
every section appears on every page - HOOKS and FILES are specific to
commands that touch them - but the order is fixed, and SEE ALSO always
closes the page before the footer.
Navigation. A quick-reference cheat sheet, a complete alphabetical command list, and a version switcher sit above the category browsing; the Guides are reachable separately from the main command list, not mixed into it.
2. mnem's real command set against a scaled-down scheme
mnem has twenty-six real subcommands today (every one's doc comment
read directly from crates/mnem-git/src/main.rs, not from memory of
building them): init, add, rm, commit, log, status,
inbox, branch, checkout, show, blame, bisect, merge,
approve, reject, diff, changelog, similar, contradicts,
completions, export, import, and registry's own four
(publish, list, fork, render). Git's own category list assumes
a command set an order of magnitude larger - Email and Server Admin
alone have no mnem equivalent at all, and Plumbing-versus-porcelain is
not a real distinction here (every mnem command is a "porcelain"
command; there is no internal plumbing layer exposed to a user).
A scaled-down scheme that actually fits twenty-six commands, grouping by what a user is doing the way git's own scheme does rather than by implementation detail:
| Category | Commands |
|---|---|
| Getting started | init, add, rm, commit, status |
| History and inspection | log, show, diff, blame, bisect, changelog, inbox |
| Branching and merging | branch, checkout, merge, approve, reject |
| Portability | export, import, registry (and its own four subcommands) |
| Suggestion heuristics | similar, contradicts |
That fifth category - "suggestion heuristics" - has no real git
equivalent: similar and contradicts are both explicit, crude,
never-auto-resolving market-test spikes (#443, #449), a different kind
of command from everything else mnem ships, and worth keeping visibly
separate rather than folded into Inspection just because they both read
working memory.
completions does not fit any of the five categories above - it is
about the shell, not about memory - and fits git's own Guides layer
(it mirrors gitcli's own territory: real cross-cutting CLI mechanics)
better than a sixth, one-command reference category.
Git's own Guides layer has a real, mostly-already-written mnem
equivalent: gitglossary maps onto CONTEXT.md's own glossary
(already written, just not surfaced in the docs site), gitworkflows
maps onto the branch-per-hypothesis and review-gate patterns several
real examples (cross_framework_merge.py, fork_via_export_import.py,
multi_agent_incident_retro.py) already demonstrate but never collect
in one place, and gitcli maps onto the cross-cutting conventions -
$MNEM_AUTHOR/$MNEM_REGISTRY fallbacks, NO_COLOR, exit codes,
--content-json - that cut across every command rather than belonging
to one.
3. Hand-written pages or generated from --help
Generated from --help. Every subcommand's clap-derived --help
text is real, accurate by construction (it comes straight from the
same doc comments and #[arg(...)] attributes that drive the parser),
and can never drift out of sync with the CLI - a page regenerated on
every release is correct by definition. Checked directly against
several real --help outputs already produced while building #447,
#448, #451 and #453: they give NAME, a compressed SYNOPSIS, and
OPTIONS for free, but never an EXAMPLES section - clap has no concept
of a worked example, and the one-line doc comments are deliberately
terse (the house style note in main.rs's own module doc: "this crate
holds no operation semantics of its own, only argument parsing").
Hand-written. git's own pages earn their value almost entirely in
the sections --help text cannot produce at all: EXAMPLES, DISCUSSION,
SEE ALSO - cross-references and real worked scenarios, not restated
flag syntax. A hand-written mnem page could lift a real worked example
straight from an existing example script (fork_via_export_import.py
for export/import, the cross_framework_merge.py conflict for
merge) rather than inventing one, keeping the examples honest and
tied to something already tested.
The real cost of each. Generated pages risk nothing but add
nothing beyond what a terminal already shows; hand-written pages add
real value but drift the moment a flag changes and nobody updates the
page - a real risk this project has already hit once elsewhere
(ROADMAP.md's own stale backlog line, #296). The two are not
mutually exclusive: NAME, SYNOPSIS and OPTIONS are exactly the part
--help already gets right and never drifts on, while EXAMPLES,
DISCUSSION and SEE ALSO are exactly the part worth writing by hand and
worth a CI check that each page's SYNOPSIS/OPTIONS still matches the
real --help output, so hand-written prose never silently rots next
to a changed flag.
Recommendation
- Template: NAME and SYNOPSIS generated from the real
--helpoutput (verified by a CI check against the real binary, not just pasted in once); DESCRIPTION, EXAMPLES, and SEE ALSO hand-written, lifting real examples from existing example scripts where one already exists; OPTIONS listed from--helpdirectly, one line of hand-written context added only where a flag's own one-liner needs it. No HOOKS or FILES section - mnem has no hook concept, andFILEScollapses into a single pointer atdocs/format/rather than repeating the on-disk layout on every page. - Categories: the five in the table above, plus
completionsfolded into a CLI-conventions guide rather than its own page. - Guides: a glossary (linking
CONTEXT.mddirectly, never redefining a term), a workflows guide (collecting patterns the real examples already prove), and a CLI-conventions guide (env vars,NO_COLOR, exit codes,completions). - Drift check: whichever build ticket writes the first reference
page should also add a small script (or extend
scripts/ check_changelog.py's own sibling scripts) asserting each page's SYNOPSIS/OPTIONS block matches the real--helpoutput in CI - not scoped here, but named so the decide ticket can place it.
Research: ADK's memory seam against the real source (issue #569)
Feeds ADR-0026. Read directly against google/adk-python's real
source, pinned to the v2.12.0 tag (the version actually published to
PyPI on 2026-10-13 and the version in the project's own .venv), not
from documentation or from memory. Three real corrections to the map's
own "Decisions so far" follow; everything else checked out exactly as
stated.
What the map got right
MemoryEntry(src/google/adk/memory/memory_entry.py):content(google.genai.types.Content),custom_metadata(dict[str, Any]),id(Optional[str]),author(Optional[str]),timestamp(Optional[str], ISO 8601 preferred). Exact match.Session(src/google/adk/sessions/session.py):id,app_name,user_id,state,events,last_update_time. Exact match (plus a private_storage_update_marker, internal, irrelevant here).Eventfieldsid,author,invocation_id,timestamp(afloat, defaulted viaplatform_time.get_time()),content,branch,partialall real, confirmed insrc/google/adk/events/event.py.add_session_to_memory(session)is abstract, and its docstring says "A session may be added multiple times during its lifetime", verbatim.add_events_to_memory(*, app_name, user_id, events, session_id=None, custom_metadata=None)is real, not abstract: it raisesNotImplementedErrorby default, with a message pointing the caller back toadd_session_to_memory. Its own docstring states plainly thateventsis a delta ("intended for cases where callers want to persist only a subset of events... Implementations should treateventsas an incremental update (delta) and must not assume it represents the full session").add_memory(*, app_name, user_id, memories, custom_metadata=None)is real, not abstract, sameNotImplementedError-by-default shape.search_memory(*, app_name, user_id, query)is abstract, returnsSearchMemoryResponse(memories: list[MemoryEntry]). Exact match.InMemoryMemoryService's real ranking (src/google/adk/memory/in_memory_memory_service.py): top 10 (_MAX_SEARCH_RESULTS = 10) by word-overlap count, a stable sort (the comparator reads only the count, so ties keep insertion order), and a real Latin/non-Latin token split (_extract_searchable_wordsadditionally splits a mixed-script token into single-script runs viaitertools.groupby(word, _is_latin), specifically so an embedded Latin word inside unspaced CJK text, the docstring's own example, matches). It skips events with no content parts, checked twice (on add and on search).load_memory(src/google/adk/tools/load_memory_tool.py) is a realFunctionToolwrappingtool_context.search_memory(query).preload_memory(preload_memory_tool.py) is a realBaseToolthat overridesprocess_llm_requestand runs automatically before every LLM call, never exposed to the model as a callable tool - both exactly as the map described.
Three real corrections
1. delete_memory does not exist in the released 2.12.0 package
The map lists delete_memory(*, app_name, user_id, memory_id=None)
as a real BaseMemoryService method. It is real code, but not in
2.12.0: a GitHub code search for delete_memory in
google/adk-python hits a commit (557743c) on the default branch
with the exact signature and docstring the map describes, but
git compare v2.12.0...557743c reports "status": "diverged",
ahead_by: 132. Fetching base_memory_service.py at the literal
v2.12.0 tag (not the default branch) confirms it directly: no
delete_memory anywhere in the file. PyPI's own google-adk JSON API
confirms 2.12.0, uploaded 2026-10-13T16:42:55, is still the latest
release - no 2.12.1 or 2.13.0 exists yet to have shipped it either.
This means: delete_memory is a real, upcoming method, not yet
in anything installable today. Building against it now is building
against unreleased, unversioned API surface that could still change
shape before it ships for real.
2. The "keeps deleted ids" claim is not true of 2.12.0, because there is no delete to defend against
Follows directly from #1. With no delete_memory anywhere in 2.12.0,
InMemoryMemoryService has no delete operation at all - nothing is
ever removed from memory once added, so there is nothing for a
tombstone or an id-exclusion rule to protect. The id-tracking the map
pointed to (existing_ids = {event.id for event in existing_events}
inside add_events_to_memory) is real code, but it does a different
job: it stops the same event id from being appended twice to the
same session's delta list on a repeated call - plain deduplication of
new writes, not protection against a deleted id resurrecting. There is
no code path in 2.12.0 where a previously-removed id is re-excluded,
because there is no removal.
This means: ticket #6's own premise, "confirm how
InMemoryMemoryService keeps a tombstoned id from resurrecting", has
no real behaviour to confirm in 2.12.0 - the reference implementation
doesn't do this because it has nothing that deletes. mnem-adk's own
tombstone-not-delete convention (matching ADR-0020/ADR-0023's own
precedent) can still be built as an addition beyond what 2.12.0
itself defines, the same way the other adapters' "extra methods" sit
outside the adopted interface - just not as matching an existing
reference behaviour, because none exists yet.
3. Runner(...) is keyword-only, not positional
The map's shorthand, Runner(app_name, agent, session_service, memory_service), reads as positional construction. The real
constructor (src/google/adk/runners.py) is def __init__(self, *, app=None, app_name=None, agent=None, node=None, plugins=None, artifact_service=None, session_service, memory_service=None, credential_service=None, plugin_close_timeout=5.0, auto_create_session=False) - the bare * right after self makes
every one of those keyword-only. Runner(app_name, agent, session_service, memory_service) would raise a TypeError outright;
it has to be Runner(app_name=..., agent=..., session_service=..., memory_service=...). Also worth carrying forward: the constructor's
own docstring recommends passing a real App instance over a bare
agent, calling that "the recommended way to create a runner" -
agent/node get wrapped into an App internally either way.
Recommendation for ADR-0026
- Do not implement
delete_memoryagainstBaseMemoryServiceas an override in this round - it isn't a real override of anything 2.12.0 defines. Ifmnem-adkwants a delete capability now, build it as an extra method on the adapter class, outsideBaseMemoryService, exactly the shape ADR-0016/ADR-0020/ADR-0023 already use forbranch/switch/why/bisect. Revisit a real override once agoogle-adkrelease actually ships the method. - Ticket #6's own job needs rescoping: there is no real
InMemoryMemoryServicebehaviour to match for "keeping a deleted memory deleted", because 2.12.0 has no delete path at all. The grilling question becomes "doesmnem-adkwant a delete capability ahead of ADK itself shipping one", not "how does the reference implementation keep a deletion from resurrecting." - Every other construction detail, Runner included, needs the keyword-only form in every example and spike from here on.
Related: #568 (map), #571-574 (downstream research, unaffected by
this), #578 (granularity, unaffected), #584 (the offline spike, must
use Runner(...) with keywords).
Research: how ADK apps call the memory service in practice (issue #570)
Feeds ADR-0026. Read against ADK's own real documentation
(docs/guides/memory/memory_service/index.md) and a real worked
sample (contributing/samples/context_management/memory/), both
pinned to the v2.12.0 tag, not from a secondhand summary.
Two real call patterns, not one
Manual, explicit, after a batch of turns. The real sample
(main.py) runs several turns on a session, then calls
await runner.memory_service.add_session_to_memory(session_1) once,
by hand, after the whole conversation is judged complete - not after
every turn. This is the pattern an application controls directly.
Callback-driven auto-save, documented but not shown as a full
sample. ADK's own guide recommends attaching a callback (an
after_agent_callback) that calls callback_context. add_session_to_memory() (no arguments - Context already knows which
session it is scoped to), so each turn is ingested as it finishes
"rather than at some later point you have to remember to trigger."
Worth being precise about the evidence here: this is a real,
documented recommendation with a real code snippet in the guide
itself, not something found wired up end-to-end in a runnable sample
file the way the manual pattern is - the guide presents it as the
fix for "I forgot to call add_session_to_memory", not as a pattern
with its own worked example.
A real, load-bearing detail: re-fetch before ingesting
Both the guide and the real sample agree on something easy to miss:
the session object a caller already holds may be stale by the time
ingestion happens. The guide states it directly ("Re-read it first
so the ingested copy has the final events"), and the sample's own
run_prompt helper returns await runner.session_service. get_session(...) after every turn specifically so the caller always
holds the freshest copy before the eventual
add_session_to_memory call. Any offline spike or test
(#584, #596) that constructs a Session once and reuses that same
in-memory object across multiple turns, then calls
add_session_to_memory on it, is testing a scenario the real
ecosystem does not actually rely on. The test should re-fetch via
session_service.get_session(...) first, matching real usage.
Ingestion is opt-in, confirmed directly
The guide states plainly: "Sessions do not reach memory on their
own. If no one calls add_session_to_memory, memory stays empty."
No autosave exists at the framework level; every real call pattern
above is something an application author wires up deliberately, by
hand or by callback. There's no third, implicit path.
Memory is a corpus, not a dictionary: bears on node-id scope (#571)
The guide draws a real distinction worth carrying into the node-id
research: session state is a dictionary the caller addresses by a
chosen key, but memory is "a corpus, not a dictionary" - a caller
cannot read an entry back by name, only search it. MemoryEntry.id
exists but is optional and never caller-assigned anywhere in the
real guide or sample; nothing in ADK itself expects a stable,
caller-chosen memory id the way mnem's own node id is. This is
good news for #571's own job: ADK imposes no competing id
convention mnem-adk's own scheme would have to fight.
The two memory tools really do compose
The real sample's agent.py builds one agent with both
load_memory_tool and preload_memory_tool in its tools list at
once, confirming the guide's own claim that they compose rather than
being mutually exclusive choices: preload_memory covers the common
case automatically (injecting results into a
<PAST_CONVERSATIONS> block on every request, no tool-call round
trip, but every request pays for a search), and load_memory lets
the model dig further on demand, costing a real tool call only when
the model decides to use it.
VertexAiMemoryBankService is the only built-in service implementing all three write methods
InMemoryMemoryService implements only add_session_to_memory and
search_memory - add_events_to_memory and add_memory both raise
NotImplementedError by default and InMemoryMemoryService never
overrides them. Of ADK's own three built-in services, only the
managed VertexAiMemoryBankService implements every one of
add_session_to_memory, add_events_to_memory, add_memory,
search_memory. If mnem-adk implements all three write paths plus
search (as scoped), it becomes the most complete open-source
BaseMemoryService implementation that exists today - a real,
checkable positioning claim for the README (#599) and any naming
decision (#580), not a marketing one.
Recommendation for ADR-0026
- Support both real call patterns - the manual batch call and the callback-driven per-turn call - since both are real, documented ADK usage, not just test against one.
- Every offline spike, example, and integration test must re-fetch
the session via
session_service.get_session(...)before callingadd_session_to_memory, matching real usage exactly, not a same-object-reused shortcut. - No id convention in ADK itself competes with mnem-adk's own node id scheme; #571 has a clear field to design in.
- The "most complete open-source
BaseMemoryService" framing is a real, verifiable claim worth using in the README and the launch posts, distinct from and stronger than naming Gemini or Argon.
Related: #568 (map), #569 (the base contract this call-pattern research assumes), #571 (node id scheme, informed directly by the memory-is-a-corpus finding above).
Research: dependency fit of google-adk in the adapters CI venv (issue #576)
Feeds ADR-0026. A real venv, not a paper check: built the exact
sequence the adapters CI job runs - uv venv --python 3.12, then
the SDK and all four existing adapter packages in the job's own
order - then installed google-adk on top, exactly reproducing CI's
own python-version: "3.12" pin rather than whatever Python happens
to be on hand.
The mcp pin is compatible for real, not just on paper
google-adk[mcp]==2.12.0 and mnem-mcp's own mcp>=2.2,<3 both
resolve to a single shared mcp==2.3.0 - checked both with and
without requesting google-adk's own mcp extra, same result
either time, no conflict uv's resolver had to break.
Three shared dependencies get downgraded, with no observed breakage
Installing google-adk==2.12.0 on top of the four existing adapters
downgrades three packages already resolved by them:
| Package | Before | After |
|---|---|---|
opentelemetry-api | 1.45.1 | 1.42.1 |
tenacity | 9.2.1 | 9.1.4 |
websockets | 16.1.1 | 15.0.1 |
Not a hard conflict - uv found one consistent resolution - but a
real version regression on three packages the existing adapters
depend on. Ran the real test suites afterwards rather than assuming
the downgrade was safe: pytest packages/mnem-mcp packages/ mnem-langgraph packages/mnem-openai-agents packages/mnem-autogen
still passes all 88 tests with every downgraded package in place.
A real, confirmed incompatibility: the Python floor
google-adk's own PyPI metadata declares requires_python: ">=3.11".
Every mnem-* package in this repo, mnem-adk would be the first
exception, currently declares requires-python = ">=3.10". Checked
directly, not just read off metadata: building a real Python 3.10
venv and running uv pip install google-adk==2.12.0 fails outright:
No solution found when resolving dependencies:
Because the current Python version (3.10.20) does not satisfy
Python>=3.11 and google-adk==2.12.0 depends on Python>=3.11, we can
conclude that google-adk==2.12.0 cannot be used.
This means: packages/mnem-adk/pyproject.toml needs its own
requires-python = ">=3.11", one version higher than every other
adapter package. This is a real decision for ADR-0026, not a detail
to default past - a user on Python 3.10 can install every other
adapter here today and would hit a hard install failure on this one
alone.
Windows: no red flags in the dependency tree, real CI run still owed
google-adk itself ships as a pure py3-none-any wheel - platform
irrelevant for the package itself. Checked the two packages in its
real resolved dependency tree with compiled extensions
(uuid-utils, ormsgpack): both publish real Windows wheels on
PyPI, not sdist-only. No package in the full resolved set (74
packages) showed a Windows-only gap. This is real evidence, not a
guess, but it is not the same as a real Windows install: #19's own
windows-python CI job is still the actual proof, and should run
before this finding is treated as settled.
Recommendation for ADR-0026
- The
mcppin compatibility claim is confirmed: no change needed to either package's own constraints. - Record the three dependency downgrades and the fact that all 88
existing adapter tests still pass under them - a real, checked
fact, not an assumption, worth keeping in case a future
google-adkbump tightens these further. packages/mnem-adk/pyproject.tomlmust declarerequires-python = ">=3.11"- a real, necessary exception to this project's otherwise-uniform>=3.10floor across every adapter package. State this plainly in the ADR and inmnem-adk's own README, rather than letting a Python 3.10 user discover it as an install failure.- Windows has no found red flags but is not yet proven; #19 is the ticket that actually proves it.
Related: #568 (map), #569 (the base contract this dependency check
assumes nothing about), #583 (ADR-0026), #587 (CI wiring, must set
Python 3.11+ for the mnem-adk job specifically, not reuse the
other adapters' 3.10 floor), #598 (the cross-adapter id collision
test, unaffected by this finding).
Research: node id scheme and scope mapping (issue #571)
Feeds ADR-0026. Scopes a node id convention for ADK's own real
app_name/user_id/session_id/event_id hierarchy, read against
all four existing adapters' own real precedent (ADR-0016, ADR-0020,
ADR-0021, ADR-0023) rather than inventing a fifth scheme from a blank
page.
The real scoping key is flat, not a tree: (app_name, user_id)
Checked directly against search_memory's own real signature (#569):
search_memory(*, app_name, user_id, query) - no session_id
parameter at all. add_events_to_memory's own docstring confirms
session_id is optional and implementation-defined ("Implementations
may ignore session_id if it is not applicable"). ADK's own guide
states the scoping rule in plain language (#570): "Memory is scoped
by the (app_name, user_id) pair, so one user never sees another's
memories." There is no real tree-walk operation anywhere in
BaseMemoryService the way CrewAI's list_scopes/get_scope_info
are real one-level walks (ADR-0021) - session_id is a property of
where a memory came from, not a retrieval key ADK itself ever
searches by.
This means: the scope prefix should be flat, :-joined
({app_name}:{user_id}:), matching LangGraph's, the OpenAI Agents
SDK's, and AutoGen's own precedent (ADR-0016/0020/0023) - not
CrewAI's /-delimited real hierarchy (ADR-0021), which earns its own
path syntax specifically because CrewAI has real nested-scope
operations to support. ADK has none, so fighting for path semantics
here would be convention for its own sake, the exact trap ADR-0021's
own "alternatives considered" section already named and rejected for
the opposite reason.
A genuine hybrid: real ids exist for events, optional for direct writes
Two different situations, not one:
- Events, via
add_session_to_memory/add_events_to_memory:Event.idis a real, always-present field (#569) - the same situation CrewAI'sMemoryRecord.idput ADR-0021 in: a real id already exists, so honor it literally rather than inventing a parallel scheme. Node id:{app_name}:{user_id}:{session_id}: {event.id}. - Direct writes, via
add_memory:MemoryEntry.idisOptional[str] = None(#569), and #570's own research found no real guide or sample that ever populates it - the same situation the OpenAI Agents SDK's transcript items and AutoGen'sMemoryContentput ADR-0020/ADR-0023 in: no real id to honor, so mnem-adk needs its own.
Recommendation for ADR-0026
Reuse ADR-0020/ADR-0023's own reserved-sequence-counter idiom for the
one real gap (add_memory entries with no id), rather than inventing
a sixth idiom for a problem two adapters already solved:
- Scope prefix:
{app_name}:{user_id}:, flat and:-joined. - An ingested event's node id:
{app_name}:{user_id}:{session_id}: {event.id}- the real id, honored literally. - An
add_memoryentry that already carriesMemoryEntry.id:{app_name}:{user_id}:_memory:{memory_entry.id}- still honored literally, placed under a reserved_memory:sub-prefix so a caller's ownsession_idcan never collide with it. - An
add_memoryentry with no id:{app_name}:{user_id}: _memory:{seq:010d}, backed by a reserved{app_name}:{user_id}: _memory:_seqcounter node, zero-padded for the same reason ADR-0020 zero-pads -BTreeMaplexicographic order then matches insertion order with no scan needed. _memory:_seqis excluded fromsearch_memoryand any content iteration, the same treatment ADR-0020's_seqand ADR-0016's_metaalready get as adapter-internal bookkeeping, not a real memory.
Related: #568 (map), #569 (the real field types this scheme is built on), #570 (the scoping-by-pair finding this directly follows from), #574 (the delete-capability decision, which will need this same scope prefix to address a node by).
Research: provenance mapping from ADK events (issue #572)
Feeds ADR-0026. Maps ADK's real Event fields onto mnem's real
Provenance struct (agent_step, observation, tool_call,
source, note - every field Option<String>, confirmed directly
in crates/mnem-store/src/object.rs), against the real
google.genai.types.FunctionCall/FunctionResponse shapes, pinned
to google-genai 2.31.0.
No hook needed: ADK's own Event already carries what ADR-0020 needed a callback for
ADR-0020 had to install an internal on_tool_end hook for the OpenAI
Agents SDK, because Session.add_items() receives raw transcript
items with no structured way to tell which tool produced which item.
ADK's own Event does not have that gap: author, invocation_id,
and the structured function_call/function_response parts inside
event.content.parts are all already present on the object handed
to add_events_to_memory/add_session_to_memory directly - confirmed
in the real sample fetched for #570, which reads
event.content.parts[0].function_call.name and
.function_response.response straight off a plain event, no
callback involved. This is a simpler problem than ADR-0020's, not a
harder one: pure field mapping, no hook to install.
The real field shapes
google.genai.types.FunctionCall: id, name, args (a
dict[str, Any]). FunctionResponse: id, name, response (a
dict[str, Any], with an "output"/"error" key convention the
caller's own tool decides).
The mapping
mnem Provenance field | ADK source |
|---|---|
source | Event.author - the agent, "user", or (on a function response event) the tool's own name |
tool_call | On a function_call part: FunctionCall.name, with args if it fits as a string - "what was called, and with what" |
observation | On a function_response part: FunctionResponse.response - "what came back" |
agent_step | Event.invocation_id - the closest real ADK concept to "which step of the run produced this"; every event sharing one invocation_id came from one agent turn |
note | Left empty by default; a real escape hatch for anything the four fields above do not capture |
A real naming collision worth flagging plainly, not just mapping around
Event.branch is real (confirmed in #569's own field list) and its
real docstring says something structurally unrelated to mnem's own
Branch: "The format is like agent_1.agent_2.agent_3... used when
multiple sub-agent shouldn't see their peer agents' conversation
history." ADK's branch is a dotted sub-agent visibility path;
mnem's Branch is a version-control ref. Same word, two unrelated
concepts. Per docs/agents/domain.md's own instruction to flag a
term collision openly rather than let it drift: mnem-adk's own
code and docs should never write "branch" unqualified when
event.branch is meant - always event.branch or "ADK's sub-agent
branch", never bare "branch" in a context that could read as mnem's.
Recommendation for ADR-0026
- Lock the five-row mapping table above.
- State the naming-collision rule explicitly in the ADR and in
mnem-adk's own README - the same kind of plain disclosure ADR-0016 already gave_metaand ADR-0020 gave_seq. - No hook, no callback, no new mnem-adk-internal state needed for
provenance capture - it is a pure, synchronous field read on the
Eventobject already in hand at write time.
Related: #568 (map), #569 (the real Event/Provenance field
types this mapping is built from), #571 (the node id scheme this
mapping's node writes will be stored under).
Research: search_memory ranking parity with InMemoryMemoryService (issue #573)
Feeds ADR-0026. Confirms InMemoryMemoryService.search_memory's real
ranking algorithm against its actual source at v2.12.0, and settles
whether mnem-adk should match it or degrade, against both existing
precedents for this exact question (ADR-0021, ADR-0023).
The real algorithm, read directly from in_memory_memory_service.py
Not paraphrased from the earlier map notes - fetched fresh from the
v2.12.0 tag:
- Tokenise the query: NFC-normalise,
re.findall(r'\w+', text), lowercase - one plain token set. - Tokenise each candidate event's text the same way, then widen it: for any token that mixes scripts, split it on Latin/non-Latin script-run boundaries too (so a CJK sentence embedding a Latin word still exposes that word as its own token).
- Score an event by counting query tokens it matches. A query token matches if it is in the event's token set outright, or - a real detail the map's earlier notes did not carry - if it is non-ASCII, by plain substring search over the event's lowercased text. That second path exists because CJK text carries no spaces, so the token-set widening in step 2 cannot be exhaustive the way whitespace-delimited ASCII tokenising already is.
- Drop any event scoring zero. Sort what is left by score,
descending; ties keep insertion order, because the sort key reads
only the score. Keep the top 10 (
_MAX_SEARCH_RESULTS).
Scoping is (app_name, user_id) only, confirmed again directly in
_user_key - consistent with #571. The returned MemoryEntry here
never sets id at all, only content, author and timestamp -
confirming #570's "memory is a corpus" finding from the write side
now also on the read side.
Which precedent actually applies: neither transplants whole
ADR-0023 (AutoGen) and ADR-0021 (CrewAI) already settled this question twice, but they point in different directions, and ADK's case is closer to neither:
- AutoGen's
ListMemory.query()ignores its own argument outright- its own docstring says so. Matching that reference's real
behaviour meant building no ranking at all. That precedent does not
transplant here:
InMemoryMemoryService.search_memoryis not an unranked reference implementation, it has a real, specific ranking algorithm, and the ADR-0023 principle ("match the reference, don't invent") now points at building that ranking, not skipping it.
- its own docstring says so. Matching that reference's real
behaviour meant building no ranking at all. That precedent does not
transplant here:
- CrewAI's
search()takes a vector, not a string - there is no keyword to match against, so a degraded keyword fallback would answer a different question than the one asked. ADK'ssearch_memory(*, app_name, user_id, query: str)is the opposite shape: a plain string, no embedder anywhere in the contract. There is no vector to be faithful to here either.
Both ADRs share one underlying rule, not a specific algorithm: match
whatever the reference implementation's contract actually expects,
rather than inventing capability nothing in the ecosystem asked for.
For ADK, the reference contract expects keyword-overlap ranking
specifically, because that is what InMemoryMemoryService - the
service every load_memory/preload_memory sample in #570 runs
against by default - actually does.
Recommendation for ADR-0026
Match InMemoryMemoryService's algorithm exactly: same tokenising
(NFC, \w+, lowercase), same Latin/non-Latin script-run widening,
same ASCII-token-set-membership-or-non-ASCII-substring matching rule,
same zero-score exclusion, same stable descending sort, same top-10
cap. Any caller that swaps mnem-adk in for the reference service
during development should see the same ranking on the same corpus -
that parity is the actual bar here, not an approximation of it.
Related: #568 (map), #569 (confirmed InMemoryMemoryService's
behaviour exists and is real), #570 (the load_memory/preload_memory
call sites this ranking ultimately feeds), #571 (the (app_name, user_id) scope this search already shares).
Research: Gemini 4 Argon through ADK (issue #575)
Feeds #579 (the demo model decision), alongside #614's watch for a
fresher candidate. Confirms Argon's real model id, how ADK routes to
it, which capabilities actually work on that path, the real access
gate, and a real cost estimate for one demo run - all checked
directly against ADK's own v2.12.0 source and Google's own current
docs, not assumed from the map's earlier secondhand notes.
Model id and ADK's real routing: no special-casing needed
Model id is gemini-4-argon, confirmed directly on Google's own
Argon developer guide. ADK's Gemini class (models/google_llm.py)
registers itself for supported_models() with the regex r'gemini-.*'
(confirmed in that file directly) - a plain prefix match, no
allow-list of specific model names. gemini-4-argon matches that
regex the same way gemini-2.5-flash does, so ADK routes it through
its native google.genai client path - the same Gemini model class
every other sample in #570 already uses, not LiteLlm, and nothing
in mnem-adk or the demo agent needs to special-case it. This settles
the map's own open Fog item ("whether ADK 2.12 routes gemini-4-argon
cleanly") with a real yes, by source, not by trying it.
Function calling and thinking: both pass through generically, with one real narrow gate
Neither capability is gated by model name in ADK's own code:
- Function calling is not conditioned on which Gemini model is in
use anywhere in
google_llm.pyor_capabilities.py. Tools are attached to the request generically; Argon accepts them the same way every other Gemini model does. - Thinking is passed straight through: when
llm_request.config.thinking_configis set, ADK forwards it untouched to the realgoogle.genairequest (confirmed atgoogle_llm.py:598-600). ADK does not validate the level against the model; whetherlow/medium/highare accepted is entirely the live API's own call. Argon's own developer guide confirms it accepts exactly those three levels,mediumthe default. - The one real gate found:
_capabilities.py'sgemini_output_schema_and_tools()returns true only when the backend variant is Vertex AI and the model is a Gemini model - pairing a structured output schema with tool calls in the same request needs Vertex AI specifically, not AI Studio. This is narrow (only the combination, not tools or thinking alone) but real, and worth knowing before scripting a demo that pairs both: AI Studio access does not clear that particular combination.
Access: two different gates, not one
The map's earlier note ("AI Studio and Antigravity access needs AI
Ultra") describes the consumer UI gate correctly, but ADK itself
never goes through that UI - it calls the Gemini API directly with a
key. That path's real gate, confirmed on Google's own pricing page, is
simpler: Argon is "not available on the Free tier" of the Gemini API,
so the key needs the API's own paid tier (billing enabled on the
underlying Cloud project), not an AI Ultra personal subscription.
These are two separate products gating two separate access paths; the
map's existing plan to set up paid Gemini API billing is the one that
actually unblocks mnem-adk's own call path.
Cost: a real estimate, and it is not the obstacle
Introductory pricing, confirmed on Google's own pricing page: $2/$10
per million input/output tokens through 30 November 2026, rising to
$4/$20 standard after. A short scripted demo (a handful of turns,
growing conversation history, one or two tool calls, a modest
preloaded-memory block) runs on the order of 10-20k input tokens and
2-5k output tokens total across the whole run - call it $0.03-$0.09 at
input/output pricing alone. thinking="high" bills its reasoning
tokens as output tokens too, which can add real cost on a harder
prompt, so a wider honest bound is a few cents to perhaps $0.50 for
the whole recorded run, not per turn. Either way this confirms
#580's own existing framing directly: Argon's problem for this launch
was never affordability, it is zero-friction access - the real
billing-setup step the maintainer still owes, not the cost of running
the demo once that is done.
Recommendation for #579
Nothing here argues against Argon on technical grounds - ADK routes it cleanly, function calling and thinking both work, and one full demo run costs cents. The decision in #579 is unchanged by this research: it is still a timing and friction question (zero-friction access vs. four-days-old freshness), not a capability or cost question. If #614 does not surface a fresher candidate before ship, Argon is a technically clean fallback with no new blocker found here.
Related: #568 (map), #569/#570 (the Gemini/LlmAgent call patterns
this routing confirms), #579 (the decision this feeds), #580 (the
locked pitch this cost finding supports), #614 (the parallel watch for
a fresher candidate).
Plain notes
What has been built, in plain words. One entry per release tag, newest first. Written like meeting notes. If you have never seen the code, this is the page for you.
v0.0.8 - install it without Rust, back it up in one file
The one-line version: two things people asked for once v0.0.7 was out:
mnem as a plain binary you download, not something you compile, and a way
to back up a whole store without knowing anything about .mnem/'s internals.
New this release:
- Prebuilt
mnembinaries. A shell or PowerShell one-liner installsmnemon Linux, macOS or Windows with no Rust toolchain at all - the binary is attached to the GitHub Release.cargo install mnem-gitstill works for anyone who already has Rust. export/import.mnem export -o backup.jsonwrites the whole store - every commit, every branch, everything - as one JSON file you can read withjq, put under someone else's version control, or just keep safe.mnem import backup.json ./restoredrebuilds a working store from it. The same two operations are now callable from the Python SDK too (store.export(),mnem.Store.import_(path, data)), not just the CLI.- A round of pre-launch polish, found by re-reading the whole project as a first-time visitor would: a stale README, missing on-ramps (shell completions, worked examples, an FAQ), CI gaps (nothing tested the Windows build of the Python bindings until now), and small metadata fixes (crates.io was missing a homepage link).
What is guaranteed: everything v0.0.7 guaranteed, unchanged. export/
import do not touch format_version - they are a view for backup and
portability, not a new on-disk format, and a tampered or hand-edited export
is a clean error on import, not a silent misread.
What it still does not do (Era 2 and later): the same as v0.0.7 - no shared memory between two agents, no semantic merge, no sync between stores.
Under the hood: cargo-dist (ADR-0019), crates/mnem-store/src/ portable.rs, crates/mnem-py/src/lib.rs's export/import_ bindings.
v0.0.7 - the substrate, measured
The one-line version: Era 1 is done. mnem is a working, single-agent memory
version-control tool, and now there is a benchmark that says plainly what it does
and does not buy you, plus a docs site and a frozen on-disk format.
New this release:
- A benchmark.
docs/benchmark.mdreports two things. First, correctness: over thousands of seeded runs, reconstructing memory at any past commit is exact,bisectlands on the exact commit a wrong belief entered,blamenames the right commit and observation, and the merge never loses a write. All at 100%. Second, cost: about 12 ms and a few kilobytes per commit, compared against a plain dict and a JSONL log. A CI check fails if either number doubles. - A docs site. The format spec, the benchmark, every architecture decision and every research survey, browsable at the project's GitHub Pages URL. Built from the repo on every change.
- The on-disk format is frozen for Era 1 at
format_version1. Every0.0.xrelease reads and writes it; a store written by one works with every other. The next format change is Era 2. - The Era 2 seam is in place. A
SemanticMergetrait and aStore::merge_withentry point exist in the core, with a no-op Era 1 implementation, so the "semantic merge" of Era 2 becomes a new package rather than a rewrite. Nothing about today's behaviour changes. (ADR-0018.) - A Claude agent demo.
examples/claude_agent.pyand the README GIF: an agent is told a past answer was wrong, then usesbisectandblameto trace it to a misread note and commits a correction.
Named for publishing: the Rust crates are mnem-store (the core) and
mnem-git (the mnem command); the Python packages are mnem-agents (the SDK),
mnem-mcp and mnem-langgraph. The mnem command itself, import mnem, and
the .mnem/ store directory are unchanged.
What is guaranteed:
- The core still never touches the network and never calls a model.
- One on-disk format (
format_version1), now frozen for the0.0.xline. - The benchmark's correctness checks run on every push, so "time travel is exact", "bisect is precise" and "blame is accurate" are tested, not claimed.
What it still does not do (Era 2 and later):
- No shared memory between two agents. No semantic merge, no sync between stores, no review step. That is the whole of Era 2.
mnemdoes not make an agent give better answers. The benchmark says so directly. It gives you history, audit and safe merging.
Under the hood: benchmarks/, book.toml + docs/SUMMARY.md, the docs
workflow, crates/mnem-store/src/semantic.rs. ADR-0017 and ADR-0018. Published
to crates.io and PyPI for the first time with this tag.
v0.0.6 - plugging in
The one-line version: Mnemosyne now drops into the two ways people actually build agents: as an MCP server, and as a LangGraph memory store. Your agent gets versioned memory without changing how it is written.
New this release:
- An MCP server.
mnem-mcp --store ./agent-memoryspeaks the Model Context Protocol over stdio. Any MCP-capable agent (Claude Desktop, an SDK client) can call tools:remembera fact (with where it came from),recallone or all,whydid the agent conclude this,when_dida belief go wrong, and more. The current memory is also readable as an MCP resource, so it can sit in the agent's context without spending a tool call every turn. - A LangGraph store.
MnemosyneStoreis a drop-inBaseStore. Point a LangGraph agent at it and its long-term memory gains history: every write is a commit. Extra methods let a graph nodebranchto test a hunch, or askwhya memory says what it does. mnem.agentsin the SDK.remember/remember_many/forget: one call records a fact (or several) as one commit, and retries quietly if two writers race. Every result type can now turn itself into plain JSON with.to_dict().- Three worked examples in
examples/, one per surface, each a short script you can run: a support agent that traces a wrong answer, a LangGraph agent with branching memory, and an MCP client talking tomnem-mcp.
What is guaranteed:
- The Rust core still never touches the network and never calls a model. The MCP server and the LangGraph adapter are separate Python packages that sit on top of the SDK; a CI check keeps the core clean.
- Every write through an adapter is one commit, so the memory an agent builds
over a run is fully inspectable afterwards with
log,blameandbisect. - Still one on-disk format (
format_version1).
What it still does not do (next phases):
- The MCP server speaks stdio only. A remote, multi-client HTTP transport (and the auth that comes with it) is a later phase.
- No shared memory between two agents yet. That is the collaboration era.
- The LangGraph
searchis a plain text filter, not semantic. Wrap a vector store if you need ranking.
Under the hood: packages/mnem-mcp/ (FastMCP over the SDK) and
packages/mnem-langgraph/ (a BaseStore subclass), both new. mnem.agents and
to_dict() added to the SDK. Design in ADR-0016. Not on PyPI yet.
v0.0.5 — explaining
The one-line version: you can now ask the agent's memory two questions — "where did this belief come from?" and "when did this go wrong?" — and get an exact commit back, not a guess.
New this release:
- Blame.
mnem blame plantells you which commit last set the memory calledplan, when, and — if the agent recorded it — which step of the run and which observation it came from. Likegit blame, but for one memory instead of one line of code. - Blame sees through merges. If a belief came in from a branch you merged, blame follows it back to the commit that actually wrote it, not just to the merge.
- Bisect.
mnem bisect --node plan --equals '"pro"'binary-searches the history for the first commit whereplanbecame"pro". It does aboutlog2(n)checks, notn, so it stays fast over long runs. You can also ask--absent(first commit where a memory is gone) or--present(first commit where it appears). - Bisect explains itself. When it finds the commit, it immediately runs
blameon it, so one command tells you both where the bad belief entered and what caused it. mnem show <commit> --stat. A quick list of which memories a commit added, changed or removed, without printing all their contents.
What is guaranteed:
- Blame and bisect are exact and deterministic. There is a test fixture — a synthetic support-agent run that misreads a billing note and writes the wrong plan tier — and the tests assert that bisect lands on exactly that commit and blame names exactly that observation.
- A small index (
commit_nodes) makes some of this faster, but it is never trusted: every answer is recomputable from the history alone, and a missing or stale index just means a slightly slower walk, never a wrong answer. - Still no internet, still no AI model calls, still one on-disk format
(
format_version1).
What it still does not do (next phases):
- No
bisect run <command>yet (handing bisect an arbitrary script). The built-in--nodepredicates cover the common case. - Bisect assumes the thing you are looking for, once true, stays true, and walks
a single line of history — same simplification
git bisectmakes. - No sharing between two agents yet. That is the collaboration era.
Under the hood: the Rust engine gained blame, bisect and the
commit_nodes index; the mnem CLI and the Python package both gained blame,
bisect, changed_by and show --stat. Decisions in ADR-0015. Still built from
source; not on PyPI or Homebrew.
v0.0.4 — merging
The one-line version: two branches of an agent's memory can now be combined back into one. Where they don't overlap, it just works. Where they do, you get a clear list of the clashes and simple ways to settle each one.
New this release:
-
Merge.
mnem merge experimenttakes everything that happened on theexperimentbranch and folds it into the branch you are on. -
Clean merges just happen. If the two branches changed different memories (or one branch only added things), there is nothing to decide — the merge goes through and makes one new snapshot with both sets of changes.
-
Fast-forward. If your branch has not moved on and the other one has, the merge is free: your branch just catches up to the other. No extra snapshot.
-
Conflicts are objects, not text. When both branches changed the same memory to different things, that memory becomes a conflict. The merge stops and shows you, for each clash: the original value, your value, and their value. Nothing is written until you resolve it.
-
Resolving. Per clash you can say
--resolve <name>=ours,=theirs,=base(the original), or=delete. Or settle every clash the same way with--strategy ours/--strategy theirs. Then run the merge again. -
In Python:
result = store.merge("experiment") if not result.ok: for c in result.conflicts: print(c.id, c.ours.content, "vs", c.theirs.content) store.merge("experiment", resolutions={"plan": "theirs"})You can also hand in a brand-new
MemoryNodeas the resolution for a clash.
What is guaranteed:
- The merge is deterministic and structural: it works on which memory changed, never on what the text says. Same inputs, same result, every time — swapping which branch is "ours" gives the identical merged memory.
- A conflicted merge writes nothing. You never end up in a half-merged state.
- A seeded chaos harness builds thousands of random branch histories and checks
the merge never loses a write, never leaves a memory in a limbo state, and
that merging two branches together and then back again lands on exactly the
same memory. 50,000 cases in the last sweep, no failures
(
docs/chaos-report.md). - Still no internet, still no AI model calls, still one on-disk format
(
format_version1). A merge snapshot is just an ordinary snapshot with two parents.
What it still does not do (next phases):
- No semantic merge. It won't read two versions of a belief and reconcile the wording — that is a later era.
- No criss-crossed histories. If two branches were already merged into each
other in a tangle,
mnem mergeasks you to merge one side by hand first. A single agent with a person in the loop rarely hits this. - No blame or bisect yet (tracing a belief back to where it came from). That is the next release.
- No sharing between two agents yet.
Under the hood: the Rust engine gained the three-way merge, the merge-base
walk, and the conflict/resolution types; the mnem CLI and the Python package
both gained merge. Decisions written up in ADR-0013 and ADR-0014. Still built
from source; not on PyPI or Homebrew.
v0.0.3 — branches and time travel
The one-line version: the agent can now try an idea on a copy of its memory and keep it or throw it away, and you can look at exactly what it knew at any point in the past.
New this release:
-
Branches.
mnem branch experimentmakes a cheap copy of the current memory line. It costs almost nothing: a branch is just a pointer, like in Git. -
Switching.
mnem checkout experimentmoves you onto that branch;mnem checkout mainmoves back. Your work on each branch stays separate. -
Try-an-idea, in code. In Python:
with store.branch("assume-downgrade"): store.add("customer", "downgraded to Pro") store.commit("test this assumption") # back on the main line; the "assume-downgrade" branch is still there if you want it -
Time travel.
mnem show <commit>prints the agent's full memory exactly as it stood at that commit. Nothing is reconstructed or approximated; it is the real stored state. -
Forgetting.
mnem rm <name>removes a memory from the next snapshot. The old snapshots still have it. -
Seeing what changed.
mnem diffshows what is different between two points: which memories were added, changed (old value then new value), or removed.mnem statusis the quick version: your branch, and what is staged.
What is guaranteed:
- Time travel is exact. There is a test that builds forty different random histories and checks, for every commit, that "show me this commit" gives back precisely the memory that existed when that commit was made.
- Still no internet, still no AI model calls, still one on-disk format
(
format_version1) that every0.0.xrelease can read.
What it still does not do (next phases):
- No merge yet. You can branch, but you cannot yet combine two branches back together. That is the next release.
- No blame or bisect yet (tracing a belief to where it came from).
- No sharing between two agents yet.
Under the hood: the Rust engine, the mnem CLI, and the Python package all
gained the branch, checkout, diff and time-travel operations. Still built from
source; not on PyPI or Homebrew.
v0.0.2 — the first working version
What Mnemosyne is: a tool that keeps an AI agent's memory the way Git keeps code. The agent learns things as it works; this saves each change so you can look back later.
What works now:
- Make a memory store. Run
mnem initin a folder. It creates a hidden.mnemfolder that holds everything, like.gitdoes for code. - Add a memory.
mnem add customer-4821 "the customer is on the Enterprise plan". You can also attach where it came from: which step of the run, which support ticket, when the agent formed it. - Save a snapshot.
mnem commit -m "learn the plan tier". This freezes the current set of memories as one permanent, timestamped record. Each record points back to the one before it, so there is a full chain. - Look at the history.
mnem loglists the snapshots, newest first, with who made each one and when. - Update a memory. Add it again with the same name. The next snapshot has the new value; the old snapshot still has the old value. Nothing is lost.
- Do all of this from Python.
import mnem, thenstore = mnem.init(path),store.add(...),store.commit(...),store.log(). Same features, for agent code. Installs asmnemosyne-agents.
What is guaranteed:
- Every memory and every snapshot has a fingerprint (a hash of its exact bytes). Change one character and the fingerprint changes. You cannot quietly rewrite history.
- Save a store, close everything, open it again: you get back the exact same bytes. There is an automated test that does this on every change.
- The on-disk file format is written down in full and locked for the whole
0.0.xseries. A later version will still be able to read a store made today. - The core never touches the internet and never calls an AI model. A build check blocks any library that could.
What it does not do yet (these are the next phases):
- No branching. You cannot yet try an idea on a copy of memory and then keep or throw it away.
- No time travel. You cannot yet rebuild memory exactly as it stood at an older snapshot.
- No merge. You cannot yet combine two separate lines of memory.
- No blame or bisect. You cannot yet trace a belief back to the moment and the observation that created it.
- No safe sharing between two agents writing to the same store.
Under the hood: a Rust engine (mnem-core), a command-line tool (mnem),
and a Python package. Not published to PyPI or Homebrew yet; you build it from
source.
How the project is run: every change goes through a pull request with seven automated checks (formatting, linting, tests, a minimum-Rust-version check, a wheel build, commit-message format, and repo consistency). Every real decision is written down as a numbered ADR; there are eleven. The roadmap and the issue tracker are public.
Phase 0 progress
Bootstrap. Get to "clone and build" with the process scaffolding in place.
Done
- Repo renamed from
YC27tomnemosyne. - Rust workspace:
mnem-core(library, one placeholder function and a test) andmnem-cli(themnembinary,--versionand a declared command tree with every subcommand stubbed out). Rust CI (fmt,clippy -D warnings,test,build --release) green onmain. - Python SDK skeleton:
mnemexposes its version and raises a clear error on any API that is not built yet. A smoke test covers both. Python CI (ruff,pytest) green onmain. - CI: a
rustjob and apythonjob on every pull request and on push tomain. - Docs:
README.md,CONTEXT.md(seed glossary),ROADMAP.md,AGENTS.md(one-maintainer and offline-core invariants),CONTRIBUTING.md,docs/adr/0000-template.md, ADR-0001, the ADR index, the agent workflow docs, and thedocs/format,docs/research,examplesplaceholders. - Issue and pull request templates, including the Wayfinder ticket template.
- Licence: Apache 2.0.
- Labels:
phase-0tophase-7, thewayfinder:*set,ready-for-agent,track-a/track-b,adr,format,benchmark,spike. - Milestones: one per roadmap phase (renumbered to
v0.0.2tov0.0.7in ADR-0010). - The Phase 0 Wayfinder map (#1), now with ten child tickets: #2 to #7 plus #15 to #18 (commit hashing research and ADR-0005, ADR-0007 versioning, repo hygiene).
- Maps for Phases 1 to 6 (#9 to #14), each a kept-open epic with a checklist comment linking its children.
- The full backlog: 68 issues in total. 57 granular one-PR-sized child tickets
across the six phase maps, sub-issue linked, with 48
blocked_bydependency edges. Every task ticket is scoped to something a single pull request could close. - The scaffold (#2) built on
chore/phase-0-scaffold, merged via PR #8 with green CI. - Tag
v0.0.1pushed. - The GitHub project (Mnemosyne roadmap), linked to the repo, with all open issues added.
- Decision tickets closed: #3 and #15 (research), #4, #5, #6, #7, #16 (research and grilling). ADR-0002 (object format), ADR-0003 (memory node model), ADR-0004 (core and SDK boundary) and ADR-0005 (commit identity) all Accepted.
- Repo hygiene (#18):
SECURITY.md,CODE_OF_CONDUCT.md,.github/dependabot.yml(cargo, pip, github-actions),rust-toolchain.toml, and anmsrvCI job at Rust 1.85 (the floor set byclap'sedition2024transitive requirement). - ADR-0007, versioning and release policy: Accepted (#17). SemVer 2.0.0;
format_versionon its own integer track; additive format changes are a MINOR software release, breaking ones a MAJOR withmnem migrate; hand-writtenCHANGELOG.md; a gated release workflow (built in Phase 6).
Phase 0 is complete. Definition of done met: cargo build and cargo test
green in CI, import mnem works, mnem --version runs, v0.0.1 tagged.
ADR-0001 to ADR-0005 and ADR-0007 Accepted.
Pending, not blocking
- The
v0.0.1GitHub release. The tag is pushed; the release page was blocked by a session safety check. Cut it from the tag in the web UI, or add a Bash permission rule forgh release. - Project board views. The project has all items; add a "group by Milestone" view and a "group by Status" board in the UI.
Next
Phase 1 (v0.0.2, "it commits"), map #9. First: research #19 (object encoding and
the store engine), then ADR-0008 (#20) and ADR-0009 (#21), then the build tickets
starting at #22 (object types).
Notes
- The pyo3 and maturin binding was moved from Phase 0 to Phase 1, where the core first has something to bind. Phase 0 ships a pure-Python placeholder. ROADMAP updated to match.
- Two CI fixes were needed for the Python job: the runner's system interpreter is
externally managed, so
uv pip install --systemfails, andsetup-uvwithpython-versionalready creates the.venv, so a seconduv venvclashes. Settled on:setup-uvwithpython-version, thenuv pip installanduv runstraight into that venv. - The initial commit on
mainshows a red CI run: it predates the Cargo and pyproject files.mainhas been green since PR #8 merged.
Phase 1 progress
v0.0.2, "it commits". The content-addressed object store and the commit graph.
Decisions (all Accepted)
- ADR-0008 object encoding and the store engine:
redb, CBOR restricted to RFC 8949 §4.2 plus our 64-bit-float rule,ciboriumplus a thin pass. - ADR-0009 the ref model: branches only, flat namespace, one-line text
HEAD, compare-and-swap ref moves, reflog reserved.
mnem-core, built
| Ticket | Module | What |
|---|---|---|
| #22 | object, id | Object (kind-tagged), MemoryNode, State, Commit, Provenance; ObjectId (BLAKE3, hex, CBOR byte string) |
| #23 | codec, objects, error | canonical CBOR encode/decode; put/get/has over a redb txn; MnemError |
| #24 | store, config | Store::init/open (upward discovery, nested-store guard), config parsing with the format_version gate |
| #25 | refs, head | the refs table with compare-and-swap; Head::{Attached,Detached}; Store::head/set_head/resolve_head |
| #26 | commit, staging | persistent staging; Store::stage/staged/unstage/commit (one write txn per commit) |
| #27 | log | Store::log/log_head, first-parent and full-graph walks |
ADR-0004 tracer-bullet checkpoint
The minimum vertical slice (#23 to #27) is working, ~43 tests, no walls hit.
Friction: a redb borrow-lifetime quirk (fixed by inlining a helper) and a
clippy version drift (fixed by pinning CI's rust job to 1.93.0). The Rust
core continues; the Python fallback is not needed.
mnem CLI, built (#28)
init, add, commit, log, each a thin wrapper over the core. Verified end
to end:
$ mnem init
$ mnem add customer-4821 "the customer is on the Enterprise plan" --source ticket-4821
$ mnem commit -m "learn the plan tier"
$ mnem add customer-4821 "the customer downgraded to Pro"
$ mnem commit -m "record the downgrade"
$ mnem log --oneline
ddb2a9ea60a3 record the downgrade
45ee8a6b98b1 learn the plan tier
mnem-py, the Python binding (#29)
A pyo3 cdylib (_mnem) over mnem-core, built by maturin as an abi3
wheel (abi3-py310, one wheel for CPython 3.10 and up). It exposes Store
(init, open, add, commit, staged, unstage, log, head,
format_version, root) and maps every MnemError variant onto a Python
exception hierarchy rooted at mnem.MnemError (ADR-0004). pyproject.toml
moved from hatchling to the maturin backend; the version is read from the Cargo
workspace at build time. python/mnem/__init__.py re-exports the binding; the
ergonomic layer is #30.
The rust CI job excludes mnem-py from cargo test/build (its test binary
would need libpython to link); the python job builds the real extension
through maturin and runs the binding's smoke tests. The behaviour itself stays
tested in the core.
mnem, the Python SDK (#30)
The agent-facing layer, pure ergonomics over the binding (ADR-0004). Store
takes str | PathLike, add takes any JSON-serialisable value and does the
json.dumps, commit defaults the author to $MNEM_AUTHOR then "unknown"
and the time to now (matching the CLI), and log returns Commit dataclasses.
Provenance and MemoryNode are dataclasses; add_node stages a MemoryNode.
Module-level mnem.init / mnem.open mirror the classmethods. The binding's
add grew the remaining three Provenance fields so the SDK carries all of
ADR-0003's provenance. A _mnem.pyi stub types the raw extension. Binding tests
moved to mnem._mnem; test_sdk.py covers the wrapper.
Round-trip test (#31), the definition of done
crates/mnem-core/tests/round_trip.rs: a fixed synthetic run (a support agent
triaging one ticket over three commits, mixing fresh nodes with updates, string
/ nested / unicode content, and provenance from empty to every field set) is
persisted, the Store is dropped so the database file closes, then it is
reopened from disk. The test asserts:
- the whole object table is byte-identical before and after the reload
- every stored id is still the BLAKE3 hash of its stored bytes, and decoding then re-encoding an object reproduces those bytes exactly
- the commit history reloads in the same order with the same parents, messages, authors and times
- HEAD's state holds every node id pointing at its latest value, and an earlier commit's state still holds the pre-update value
Runs in the rust CI job. An agent loop can persist its memory and read it back
byte for byte.
The format spec, frozen (#32)
docs/format/README.md is now the full specification: the store directory,
discovery and atomicity, the config and HEAD grammars, the three redb
tables, object identity, the canonical CBOR profile (RFC 8949 section 4.2 plus
the three Mnemosyne rules), and a field table for each object kind. It is marked
frozen for the 0.0.x line: format_version 1, the same for every 0.0.x
release.
docs/format/golden-vectors.md pins six canonical objects with their exact CBOR
hex and ObjectId. crates/mnem-core/tests/golden_vectors.rs checks both that
the encoder still produces those bytes and that the doc still lists them, so the
format cannot drift without a red test.
The wheel job (#33)
CI now has a wheel job separate from the editable python job: it builds a
release wheel with maturin build --release, checks the archive bundles
_mnem.abi3.so, _mnem.pyi and py.typed, installs it into a clean
environment, and runs the SDK tests from outside the repo so import mnem can
only resolve to the installed wheel. The wheel is uploaded as a build artefact.
Phase 1 complete, tagged v0.0.2
The substrate core, the mnem CLI, the pyo3 binding, the Python SDK, the format
spec and golden vectors, the round-trip test, and the wheel job are all in. The
repo-hardening epic (#99) landed alongside: conventional commits, the
offline-core deny check, issue forms, the justfile, the consistency checks,
and workspace lints. The definition of done is met.
v0.0.2 tagged on main (ADR-0007): workspace version bumped, the changelog
section dated, docs/progress/plain-notes.md written. The pending-release
issues and the v0.0.2 milestone are closed. Next: Phase 2 (v0.0.3), map #10.