Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 at format_version 1.
  • Benchmark report: what the substrate delivers, measured. Reconstruction, bisect precision, blame accuracy, 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

  1. The substrate (now, 0.0.x): single-agent versioned memory. Local and deterministic.
  2. The collaboration layer: semantic merge that reasons about contradiction, a sync protocol between stores, and a review step. Pull requests, for agent memory.
  3. 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. merge today 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 mnem binary 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 memorya dicta JSONL logversion control
what does it believe nowyesyesyes
what did it believe at step tnoyesyes
when did belief X first go wrongnoyes, linear scanyes, binary search
which observation set Xnonoyes
what did step t changenoyesyes
merge two agents' memories, surfacing conflictsnonoyes

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:

  1. It opened the case, reading the original ticket: the customer is on the Enterprise plan.
  2. It noted a primary contact.
  3. 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.

KeyValueMeaning
format_versionintegerthe 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_algostringthe object hash. blake3 is the only accepted value.
default_branchstringthe 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

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 detached HEAD is 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:

TableKeyValueHolds
objects32 raw bytes, an ObjectIdthe object's canonical CBORevery memory node, state and commit
refsbranch name, UTF-832 raw bytes, a commit ObjectIdone row per branch
stagingnode id, UTF-832 raw bytes, a node ObjectIdthe nodes staged for the next commit
staging_tombstonesnode id, UTF-8emptythe nodes staged for deletion (ADR-0012)
commit_nodes32 raw bytes, a commit ObjectIdCBOR { 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. content numbers 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.

FieldCBOR typeRequiredNotes
kindtext stringyes"memory_node"
idtext stringyesthe stable logical key
contentany JSON valueyesstored verbatim; string-keyed maps, finite numbers
content_kindtext stringyes"note" or "claim". 0.0.x only writes and reads "note"; "claim" is defined for a later era
provenancemapomitted when emptysee below; an entirely empty provenance is not encoded
event_timeintegeromitted when absentwhen 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).

FieldCBOR typeRequiredNotes
kindtext stringyes"state"
nodesmapyesnode 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.

FieldCBOR typeRequiredNotes
kindtext stringyes"commit"
parentsarray of 32-byte stringsyesempty for the first commit, one normally, two or more for a merge. Order is significant; parents[0] is the first parent
state32-byte stringyesthe ObjectId of this commit's state
messagetext stringyesfreeform
authortext stringyeswho or what made the commit, opaque to the core
timeintegeryesthe 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_time is 1b 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.
  • seats is 18 28, an unsigned integer in one trailing byte (40).
  • Inside provenance the keys are note, 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 memorya dicta JSONL logMnemosyne
what does it believe nowyesyesyes
what did it believe at step tnoyesyes
when did belief X first go wrongnoyes, O(L)yes, O(log L)
which observation set Xnonoyes
what did step t changenoyesyes
merge two agents' memories, surfacing conflictsnonoyes
storage after L stepsO(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.

MetricWhat it checksResult
reconstruction_exactstate_at(commit) equals the working memory recorded at that commit100.00%
golden_bytes_stablethe frozen canonical encoding still hashes identicallytrue
bisect_exactbisect returns the exact commit a planted monotonic fault began100.00%
bisect_error_maxthe largest |found - k| seen0
blame_commit_accblame resolves to the correct introducing commit, linear history100.00%
blame_commit_acc_mergecorrect origin commit when the value arrived on a merged-in side100.00%
blame_source_acccorrect provenance source string100.00%
merge_invariantssymmetry, 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.

Stepsbackendwrite p50write p99read (full)bytes / step
256dict~0 ms~0 ms0
256JSONL0.03 ms0.19 ms830
256Mnemosyne12 ms24 ms1.5 ms10,320
1024JSONL0.04 ms0.65 ms955
1024Mnemosyne12 ms24 ms1.5 ms7,470

Reading:

  • Write latency is ~12 ms, flat with run length, and it is the durable write: a commit fsyncs a new State, a Commit and an index entry. A dict is free; a JSONL append is a small fsync. 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 State and Commit objects are the floor. This is the number a prolly-tree State would later share down, and the trigger for revisiting it is a real store's .mnem exceeding ~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 as merge_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:

InvariantAssertion
Totalityevery id across base ∪ ours ∪ theirs is in merged or in conflicts, never both, never neither
No lost writesfor 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 idsnothing in merged or conflicts that was not in an input
Conflict orderingconflicts come back in id order, no duplicates, each carrying the true (base, ours, theirs) triple
Clean-merge symmetryswapping ours and theirs gives the identical merged map and the identical conflict id set (the kinds mirror: edit/delete ↔ delete/edit)
Idempotencemerge_state_maps(base, ours, ours) is clean and returns ours unchanged
Base identitymerge_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 always Merged, FastForwarded or AlreadyUpToDate;
  • a real merge commit has exactly two parents, ours first;
  • convergence: merging main back into feature afterwards 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.

SweepTrialsResult
CI (every push)600 pure + 24 storepass
Manual, this report50,000 pure + 250 storepass, 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 mnem does. 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):

QuestionMem0Zep (Graphiti)
what does it believe nowyesyes
what did it believe at step tno 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)nono
which observation set Xshallow: actor_id/role per history eventreal: EntityEdge.episodes references the originating observation(s)
what did step t changequalified: 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 conflictsnono (a later fact invalidates an earlier one, not a flagged conflict)
storage after L stepsO(keys) live state + O(events) append-only historyO(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_rows distinguishes 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's branches.merge(name, strategy="accept") defaults to auto-accepting conflicts rather than refusing to complete. Resolving one conflict by hand needs a separate diff_items() call followed by apply(accept_branch_conflicts=[...]), or a pick() 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

ArgumentDescription
[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

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

FlagDescription
<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 what add staged.
  • blame - reads the provenance add recorded.

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

ArgumentDescription
<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 what rm staged.
  • 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

FlagDescription
-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 what commit wrote.
  • 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

FlagDescription
--all-branchesA 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-branches surveys.

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

FlagDescription
--first-parentFollow only the first parent of each commit.
-n, --max-count <N>Show at most this many commits.
--onelineOne 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 / FlagDescription
[REV]A branch name or commit id.
--statInstead 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 point show at.
  • diff - the difference between two show-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 / FlagDescription
[FROM]The from side: a branch or commit. Default: the HEAD commit.
[TO]The to side: a branch or commit. Default: working memory.
--statOne classified line per node, no content.
--name-onlyJust 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

ArgumentDescription
<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; blame is for when you do.
  • add - where the provenance blame reads 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 / FlagDescription
[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.
--absentTrue when the node is not in memory.
--presentTrue 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; bisect is for when you do not.
  • log - the history bisect searches.

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

ArgumentDescription
[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 of inbox'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 / FlagDescription
[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 / FlagDescription
[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.
--discardDrop 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

FlagDescription
<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-runShow 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 / FlagDescription
<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 - what approve wraps; 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 / FlagDescription
<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-core and the CLI crate mnem-cli. Because those names were already taken on crates.io, before the first publish the core crate became mnem-store and the CLI crate mnem-git (the binary is still mnem); the published packages are mnem-store, mnem-git, and on PyPI mnem-agents, mnem-mcp, mnem-langgraph. import mnem and the .mnem/ store directory are unchanged. See the CHANGELOG.md entry 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.

ADRTitleStatus
0001Record architecture decisionsAccepted
0002On-disk object formatAccepted
0003The memory node modelAccepted
0004The Rust core and Python SDK boundaryAccepted
0005Commit identity, hashing and signingAccepted
0007Versioning and release policyAccepted
0008Object encoding and the store engineAccepted
0009The ref modelAccepted
0010What 1.0 means, and the 0.0.x roadmapAccepted
0011Conventional commits and the issue lifecycleAccepted
0012The branch and checkout modelAccepted
0013The deterministic merge algorithmAccepted
0014The conflict object and the resolution APIAccepted
0015The provenance index, blame and bisectAccepted
0016The MCP tools and the adapter contractAccepted
0017The benchmark and its metricsAccepted
0018The Era 2 seam: the semantic merge trait and the sync protocolAccepted
0019Prebuilt mnem binaries via cargo-distAccepted
0020The OpenAI Agents SDK adapterAccepted
0021The CrewAI adapterAccepted
0022Mem0 and Zep on the audit-query tableAccepted
0023The AutoGen adapterAccepted
0024The Mem0 migration pathAccepted
0025CrewAI private-filtering is not the adapter's jobAccepted
0026The Google ADK adapterAccepted

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:research ticket 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.md for 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.
  • prollytree as the core data structure would make our frozen format someone else's, resting on their release cadence.
  • Git4Data needs a database server; DVC and oras are 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 redb table 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. If redb ever breaks its format without an upgrade path, that is our problem to absorb in a mnem migrate.
  • Anyone wanting to read a store needs our spec, not just git cat-file. The inspection commands and docs/format/ are load-bearing, not optional.
  • A store cannot be inspected or repaired with generic tools while mnem is 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.

FieldTypeRequired in v0.1Notes
idstringyesa stable logical key
contentstring or any JSON valueyesfreeform, stored verbatim
content_kindenumyes, always notenote or claim
provenanceobjectyes, may be emptyhow the node came to exist
event_timetimestamp or nullnowhen 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.

FieldTypeNotes
agent_stepstring or nullwhich step of the run produced it
observationreference or nullthe observation or input it was drawn from
tool_callreference or nullthe tool call, if one produced it
sourcereference or nullan external source identifier
notestring or nullfree 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:

FieldTypeNotes
subjectreferencethe entity the claim is about
predicatestringthe attribute or relation
valueany JSON valuethe asserted value
confidencefloat 0 to 1 or nullthe agent's stated confidence
evidencelist of referenceswhat 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, provenance and an optional event_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.
  • blame quality depends on the agent populating provenance. An agent that passes nothing gets blame that 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-semantic crate, 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 variantPython exception
NotFoundNotFoundError
InvalidRefInvalidRefError
ConflictConflictError
CorruptStoreCorruptStoreError
FormatVersionFormatVersionError
StoreExistsStoreExistsError
NoStoreNoStoreError
IoStoreIoError

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 redb or pyo3 limitation, or the graph code fighting the borrow checker past reasonable effort), the maintainer switches mnem-core to 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

FieldTypeNotes
kindstring, always "commit"ADR-0002's self-describing objects
parentslist of commit ids[] for the first commit, one normally, two or more for a merge. No sentinel.
statestate idthe state this commit points at
messagestringfreeform
authorstringopaque 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.
timeintegerUnix 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 one author is enough.
  • a run reference: grouping commits by agent run is deferred; not in v0.1.
  • signature: held separately, see below.
  • format_version: lives in config, 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_algo field reserves that path; it is not built.
  • A bare-string author means the core offers no identity guarantees in v1. The v2 agent-identity work fills this in.
  • time as Unix milliseconds is unambiguous and trivial to serialise, at the cost of not carrying a timezone. mnem renders 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 changeformat_versionSoftware bumpMigration
noneunchangedPATCH or MINOR per the APInone
additive (a new object kind, a new optional field)+1MINORnone. 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)+1MAJORmnem 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:

  1. main is green.
  2. The version in Cargo.toml is bumped to the intended X.Y.Z and matches the tag about to be pushed.
  3. CHANGELOG.md has a dated section for X.Y.Z, moved down from Unreleased.
  4. The tag vX.Y.Z is pushed.
  5. A release workflow 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, and mnem --version will 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-object decodes 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.
  • redb and ciborium are the two dependencies the frozen format rests on. Both are widely used with stable formats. A break in either is absorbed by a mnem migrate under ADR-0007.
  • mnem commit fsyncs, so a commit-heavy workload is bounded by disk sync latency until the --no-fsync flag 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>. HEAD follows the branch; a commit moves the branch and HEAD with it.
  • Detached: <64-hex-commit-id>. HEAD points straight at a commit; committing from here is refused until a branch is created.
  • Fresh store, no commits: ref: main. main does not exist in refs yet. 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 HEAD points 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 redb table 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.2 through v0.0.7. The ROADMAP.md table, the README.md arc, 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.x period and schedules when the rest applies; it supersedes nothing.
  • Until 0.1.0, the format_version integer, not the software version, is the stability signal. A reader who sees 0.0.4 knows 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 main conflates "in main" with "shipped". A store written by v0.0.2 is the contract, not whatever is on main.

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 --oneline become 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 a refs row at the given start point, or where HEAD resolves. Fails if the name already exists (no silent move) or is invalid (ADR-0009 rules).
  • Store::branches() is refs::list.
  • Store::delete_branch(name) refuses the branch HEAD is 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 attached HEAD; a commit target gives a detached HEAD.
  • Refused when staging or staging_tombstones is non-empty, with a message naming the pending ids, unless discard is set (then both are cleared).
  • Writes HEAD atomically (temp file plus rename). Nothing else is touched. No lock is taken.
  • Checkout of the current branch, or of the commit HEAD already 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 HEAD commit's State, minus the ids in staging_tombstones, with staging overlaid.

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 show with no argument prints working memory.

Time travel reads any commit

  • Store::state_at(commit) -> BTreeMap<String, MemoryNode> loads a commit's State and its nodes, decoded. Store::state_map_at(commit) -> BTreeMap<String, ObjectId> is the raw form for callers that do not need content (diff uses it internally).
  • Any commit that exists in objects and is a Commit. 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 new staging_tombstones table (a set of node ids). This table is local working state, like staging: it is not part of the portable history and does not travel with a store.
  • rm of an id that is neither in the HEAD state nor staged is an error. rm of an id that is only a staged add (not in HEAD) unstages it instead of tombstoning.
  • commit builds the new State from the parent's State plus staging, then removes every tombstoned key. A commit with only tombstones staged is valid.
  • unstage(id) clears the id from both staging and staging_tombstones.

CLI: mnem rm <id>.

Structural diff

  • Store::diff(from: DiffTarget, to: DiffTarget) -> Vec<NodeChange> where DiffTarget is Commit(ObjectId) or Working.
  • NodeChange is Added { id, new }, Removed { id, old }, or Modified { id, old, new }, carrying ObjectIds only. Computed as a merge-join over the two sorted State maps: O(nodes). Any two states; no ancestry required.
  • The SDK returns list[NodeChange], a frozen dataclass (id, kind, and old / new as MemoryNode | 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 h1 at HEAD, checkout h1. Entering with dirty staging raises, per checkout's rule.
  • On exit, normal or exception: checkout back to the branch that was current on enter; leave h1 in place for the caller to merge (Phase 3) or delete_branch; re-raise on exception.

No automatic merge. Merge is Phase 3.

Deferred

  • The prolly-tree State form. Phase 2 keeps the flat State (a full id-to-ObjectId map per commit). Storage grows as commits times nodes, which is fine at the scale an agent runs at. A prolly form (a second State kind, a format_version bump) 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 from mnem log for now.

Consequences

  • Branching and checkout cost one small write each. The design adds no on-disk format structure that a store reader sees; format_version stays 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_tombstones is a second piece of local-only state. commit, unstage and status all have to account for it.
  • An agent can now forget a stale belief (rm), fork memory to try an approach (branch plus the context manager), see what it changed (diff, status), and read what it knew at any past point (show <commit>).
  • The flat-State ceiling 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):

botresult
xxxkeep x
xyxkeep y (ours changed)
xxykeep y (theirs changed)
xyykeep y (convergent edit)
xyzconflict EditEdit
xabsentxabsent (ours deleted)
xxabsentabsent (theirs deleted)
xabsentabsentabsent (convergent delete)
xabsentzconflict DeleteEdit
xyabsentconflict EditDelete
absentyabsentkeep y (ours added)
absentabsentzkeep z (theirs added)
absentyykeep y (convergent add)
absentyzconflict 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 resolutions entry 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 to o; Some(Theirs) to t. An explicit resolutions entry overrides the strategy for that id.
  • If, after applying resolutions and any strategy, some conflicts remain unresolved, merge returns Conflicts with 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, so log --first-parent follows the branch we were on)
  • message = the caller's, or merge <theirs> into <branch>
  • author, time from the caller, as with commit
  • 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:

  • HEAD is detached (merge only from a branch, like commit)
  • staging or 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

  • merge is 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_version change: a merge commit and a merged State are 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 merge with 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 --resolve flags or --strategy.
  • --resolve with a set (a supplied node) is not offered on the CLI in v0.0.4; use the SDK or resolve to ours / 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 ours is 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 Conflict shape 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 same Added / Removed / Modified classification Store::diff already produces (ADR-0012). The first commit's entry is every node id in its state, all Added.
  • Merge commits. Diffed against parents[0] only, consistent with the first-parent model log and blame use. 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::merge and Store::commit each 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 on open: a fresh 0.0.5 store 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> --stat prints 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>;
}
}
  • at is a commit-ish (branch name or hex prefix, resolve_commitish), defaulting to HEAD. A detached HEAD or 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 a bisect predicate or a future mnem log --follow, not blame.
  • Walk the first-parent chain from at. At each commit Ci with first parent Cp: if state_map_at(Ci)[node_id] != state_map_at(Cp)[node_id], then Ci changed the value. The root commit (no parent) always counts.
  • Through a merge. If Ci is a merge and its value for node_id differs from parents[0] but equals some parents[k], continue the walk from parents[k] instead of stopping. This follows the value to the commit that actually wrote it, rather than reporting "a merge happened".
  • Index use. blame may consult commit_nodes as a skip filter — if a commit's change set does not contain node_id, its state need not be loaded. The index is an accelerator, never the source of truth.
  • provenance is read off Blame.node.provenance; an empty provenance is legal and means blame resolved 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
}
}
  • bad and good are commit-ishes. good defaults to the root commit reached by the first-parent walk from bad.
  • Up-front checks: good is an ancestor of bad (is_ancestor); the predicate is false at good and true at bad. 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: false up to a boundary, true from 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 bad back to good, giving an ordered list, and binary-search it: O(log N) state_at evaluations. 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 plan prints the short commit id, the effective time, the author, and the provenance fields that are set, then the node content.
  • mnem bisect builds the predicate from --node plus one of --equals (content equals this JSON value), --absent, --present. On success it prints the boundary commit and its blame for --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 run style) is a later ticket.
  • SDK: store.blame(node_id, at="HEAD") -> Blame; store.bisect(bad="HEAD", good=None, predicate=...) -> str taking a Python callable over a {id: MemoryNode} dict; store.changed_by(commit) -> dict. Blame is 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_nodes is 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 --stat output now and the Era 2 audit view later — not speculative infra.
  • blame and bisect are correct with an empty or absent index; the index only makes blame skip work. A store can always rebuild_index().
  • blame follows values through merges, so its answer is "who wrote this", which is what a user debugging a belief wants.
  • bisect inherits git bisect's monotonicity assumption and its linear-history simplification. Documented; revisited only if a real multi-agent history needs more.
  • blame quality still depends on the agent populating provenance (ADR-0003). An agent that passes nothing gets blame that 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/ -> PyPI mnemosyne-mcp, import mnem_mcp, console script mnem-mcp.
  • packages/mnem-langgraph/ -> PyPI mnemosyne-langgraph, import mnem_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:

ToolDoesArguments
rememberrecord one fact, one commitid, content, source?, step?, observation?, summary?
revisesame as remember; the name signals "this changes a belief"same
remember_manyrecord several facts as one commititems: [{id, content, ...}], summary?
forgettombstone one node, one commitid, summary?
recallread one node, or the whole current memoryid?
recall_atthe memory as of a past commitcommit, id?
historyrecent commits, newest firstlimit?
whythe commit and provenance that set a nodeid, at?
when_didfirst commit where a node reaches a value / is absent / is presentid, one of equals / absent / present, good?
whats_newwhat a commit changedcommit

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

URIBacked byCache
mnem://memoryworking_memoryshort ttlMs, cacheScope per-store, invalidated on any write
mnem://memory/{node_id}working_nodeas above
mnem://loglog(limit)short ttlMs, per-store
mnem://commit/{id}state_at + changed_bylong 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:

BaseStoreMnemosyne
namespace: tuple[str, ...]an id prefix: the node id is ":".join(namespace) + ":" + key
key: strthe node id within the namespace
value: dictthe 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 _meta key of the opaque value dict ({"...": ..., "_meta": {"source": ..., "step": ...}}), which the adapter lifts into Provenance and strips from stored content. Absent _meta means empty provenance, which is legal (ADR-0003).
  • search is 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 in asyncio.to_thread so the event loop is not blocked.
  • Extra methods. store.branch(name), store.switch(name), store.why(namespace, key) and store.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:

  1. mnem.agents (remember / remember_many / forget) and the to_dict() methods, above.
  2. A bounded retry-on-ConflictError wrapper for the atomic write helpers (a few attempts with a short backoff), then a raised ConflictError the caller maps to busy.
  3. to_dict() round-trips on Blame, NodeChange, Commit, MemoryNode, Conflict, MergeResult.
  4. Store thread-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:

  1. SDK - a support agent that records beliefs with provenance, gets a plan tier wrong, and uses bisect + blame to find the misread observation (the running example, the buggy_run fixture as a real script).
  2. LangGraph - an agent using MnemosyneStore as BaseStore across two threads, with a hypothesis branch around a sub-task.
  3. MCP - a client script driving mnem-mcp over stdio through remember / recall / why.

Consequences

  • The core and the SDK are untouched except for one additive module (mnem.agents) and to_dict() methods. No format_version change.
  • mnem-mcp is 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 BaseStore subclass plus a handful of extra methods. An agent author swaps their store backend and gets history, blame and branching, with search degrading to a filter.
  • The stateless, one-store-per-process design means an agent runtime scales memory the way it scales agents: one mnem-mcp each.
  • Provenance survives the LangGraph path only if callers populate _meta. Documented; the MCP remember tool 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):

MetricDefinitionTarget
reconstruction_exactstate_at(commit) equals the working memory recorded at commit time, over every commit of every seed100.00%
golden_bytes_stablethe frozen format vectors still hash identically (reuses the golden_vectors assertion)true
bisect_exactbisect returns the exact commit k where a monotonic planted fault began100.00%
bisect_error_maxthe largest |found - k| seen0
blame_commit_accblame resolves to the correct introducing commit, linear history100.00%
blame_commit_acc_mergecorrect origin commit when the value arrived on the merged-in side100.00%
blame_source_acccorrect provenance source string100.00%
merge_invariantsthe #49 chaos invariants (totality, no lost writes, symmetry, convergence) at the bench trial counttrue

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):

MetricDefinition
write_ms_p50, write_ms_p99latency of one commit, a 100-key memory
read_ms_p50latency 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 L commits; 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 the bisect predicate key == wrong_value is 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.

QuerydictJSONLMnemosyne
current memoryyesyesyes
memory as of step tnoyesyes
when did key X first become value Vnoyes, O(L)yes, O(log L)
which observation set X's current valuenonoyes
what did step t changenoyesyes
merge two agents' memories, surfacing conflictsnonoyes

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 with cargo test at a small trial count; MNEM_BENCH_RUNS / MNEM_BENCH_LENGTHS env overrides drive the published sweep.
  • benchmarks/overhead.py: a new top-level directory (mirroring examples/). Builds a dict, a JSONL file and a .mnem store over the same synthetic run, times the operations with time.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.md is 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 of docs/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 rust job already runs benchmark.rs (it is a test) at the small trial count: L in {16, 64}, 40 seeds.
  • A new benchmark job: Python plus the SDK, runs benchmarks/overhead.py, asserts the audit-query table, and fails if write_ms_p50 or bytes_per_step exceeds 2x the value in benchmarks/baseline.json. read_ms_p50 is reported, not gated (it is dominated by content decode).

The published sweep

docs/benchmark.md reports:

  • correctness: L in {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 benchmark job.
  • docs/benchmark.md is the artefact for the launch: honest, reproducible, and it says plainly what version control does and does not buy.
  • The bytes_per_step and read_ms_p50 numbers are the evidence for the prolly-tree decision, without this ADR making it.
  • Two more files to keep current on a core change (benchmark.rs is automatic; baseline.json and docs/benchmark.md need 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 SemanticMerge trait: 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 fetch cheap.
  • A Proposal: a cross-store change does not write a shared branch directly. It opens a Proposal (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 from v0.0.7.
  • Store::merge and MergeOutcome are untouched, so ADR-0014 stays frozen with the rest of the Era 1 surface.
  • format_version 2's contents are now named in one place (docs/format/ links here): the stored Contradiction, whatever sync adds to a commit or the refs, and the prolly-tree State if 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 SemanticMerge impl that fails (a model call errors) surfaces as MnemError. 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 on workflow_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-git remains 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 root Cargo.toml (added by dist init, inherits = "release", lto = "thin") is the profile the release binaries build with; it does not affect cargo build --release.
  • dist-workspace.toml is the source of truth; .github/workflows/release.yml is generated output, checked in for review but not meant to be edited by hand. A future dist version bump or a target change means editing the config and re-running dist generate, the same discipline release-pypi.yml does not need because it has no equivalent generator.
  • PyPI trusted publishing (#165) is scoped to a workflow filename; the rename to release-pypi.yml means a trusted publisher must be added under that name, not release.yml, when #68's remaining PyPI packages are configured.
  • No format_version change, 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 (or session_settings.limit if unset) takes the tail. SessionSettings.limit passes 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 from working_memory.
  • clear_session(): one batched tombstone commit forgetting every node under {session_id}: (including resetting _seq), mirroring remember_many's batch shape. Not a hard delete for the same reason: SQLiteSession callers 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-mcp and mnem-langgraph.
  • blame and bisect stay meaningful per message, not just per API call - the reason to choose this adapter over SQLiteSession at 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_session is 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 _seq reserved-key idiom is now used twice (LangGraph's _meta shares 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-unbuilt mnem-openai-agents), same independent-versioning shape.
  • blame/bisect work per memory record, and CrewAI's own semantic recall keeps working exactly as before - the adapter is a real StorageBackend, 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

QuestionMem0Zep (Graphiti)
what does it believe nowyesyes
what did it believe at step tno 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)nono
which observation set Xshallow: actor_id/role per history eventreal: EntityEdge.episodes references the originating observation(s)
what did step t changequalified: 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 conflictsnono (a later fact invalidates an earlier one; not a flagged, resolvable conflict)
storage after L stepsO(keys) live state (vector store) + O(events) append-only historyO(all facts ever asserted), monotonically growing

Consequences

  • docs/comparisons.md gains a concrete, sourced table alongside its existing prose - strengthens the same claims with specifics rather than duplicating them.
  • docs/benchmark.md and 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 where mnem wins.

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-unbuilt mnem-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/bisect resolve 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-agents release.
  • Provenance survives the move (Mem0's actor_id/role/created_at are 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 native mnem content 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_records are 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_records directly (outside CrewAI's own RecallFlow) gets every record back, private ones included - matching what LanceDB/Qdrant's own backends would do if called the same way. RecallFlow remains the only real enforcement point CrewAI itself ships, and this adapter does not invent a second one.
  • #312's already-shipped list_records never filtered on private either; 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_memory write with its own id: {app_name}:{user_id}:_memory:{memory_entry.id}.
  • A direct add_memory write with none: {app_name}:{user_id}:_memory:{seq:010d}, backed by a reserved {app_name}:{user_id}:_memory:_seq counter 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 fieldEvent source
sourceEvent.author
tool_calla function_call part's name/args
observationa function_response part's response
agent_stepEvent.invocation_id
noteleft 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-crewai and mnem-autogen.
  • The widest BaseMemoryService implementation that exists today: add_session_to_memory, add_events_to_memory, add_memory and search_memory all real, where only the managed VertexAiMemoryBankService currently implements all four.
  • packages/mnem-adk/pyproject.toml carries a real, stated requires-python = ">=3.11" exception to this project's otherwise uniform >=3.10 floor, and the adapters CI job installs it in the same sequence as the other adapter packages.
  • blame/bisect resolve per event, same reasoning as every prior adapter.
  • Callers get a real delete_memory ahead 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_id and claim_ref as 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:

Fieldv0.1Notes
idyesa stable logical key, so updates across commits target the same node. Caller-provided, or generated if absent. Not content-derived.
contentyesfreeform: a string or an arbitrary JSON value
content_kindyes, always notenote or claim. claim follows the claim schema.
provenanceyes{agent_step, observation, tool_call, source}, all optional individually
event_timeyeswhen 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_time in v0.1: include it now, as recommended, or rely on the commit timestamp until v2. One field, and blame reads 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

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. gitoxide gives 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. redb uses 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 objectsbranch coststructural diffmergeformat stabilitybuild effort
1 Git loose + packpoor without packingO(1)nonoexcellenthigh (build packing + GC)
2 KV object storegoodO(1)nonogood (redb stable)low to moderate
3 prolly state + KVgoodO(1), delta-costyes, cheapyes, node-levelours to fixmoderate to high
4 prollytree crategoodO(1)yesyesnot ourslow, but risky
5 Git4Datan/an/ayesyesn/an/a, wrong shape
6 DVC / orasn/an/anonon/an/a, wrong shape

Recommendation for ADR-0002

Option 2 now, option 3 by Phase 3. Concretely:

  1. .mnem/ is one redb file (store.redb), plus a plain-text HEAD and a plain-text config for legibility and easy inspection.
  2. An object database: a redb table objects: hash -> bytes holding memory nodes, states and commits. The hash function and commit header are ADR-0005's call; assume a 32-byte content hash for now.
  3. Refs: a redb table refs: name -> hash, with HEAD mirrored to the text file.
  4. State: a plain sorted content-addressed map in v0.1 (option 2). The prolly tree lands in Phase 2, when diff first 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; prollytree is a reference and a spike target, not a core dependency.
  5. No packfiles, no bespoke garbage collection in v0.x. redb handles compaction. A mnem compact command can come later if a store grows unreasonably.
  6. Legibility is recovered with mnem cat-object, mnem verify and mnem fsck style 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 redb acceptable as the one storage dependency to freeze on for years. Its format is documented and stable, but name it explicitly as load-bearing.
  • One redb file, or redb for 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

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-256securitystandardRust
SHA-1~2xbroken (SHAttered)legacyyes
SHA-256baseline (~3 GB/s)strongNIST, FIPSyes
SHA-3-256~0.3x (slower)strongNIST, FIPSyes
BLAKE2b~3xstrongRFC 7693yes
BLAKE34 to 10x, parallel (tens of GB/s on many cores)strong, same profile as SHA-2 and SHA-3not a NIST standardreference 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 mnem accepting 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_ed25519 key, 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 redb table signatures: commit hash -> signature. The commit object has no signature field, so signing does not change a commit's hash. mnem verify checks 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

FieldTypeNotes
kindstring, always commitADR-0002's self-describing objects
parentslist of commit hashes0 for the first commit, 1 normally, 2 or more for a merge
statestate hashthe state this commit points at
messagestringfreeform
authorstringwho 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.
timetimestampthe 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 author is 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_algo recorded in config for 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 verify checks signatures where present. Sigstore deferred.
  • Commit header: {kind, parents, state, message, author, time}. One author, no committer. 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_algo built 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 sign and mnem verify ship 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

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

  1. Deterministic and canonical (ADR-0005): the same logical object must produce the same bytes, so the same hash.
  2. Self-describing and evolvable (ADR-0002, ADR-0007): objects carry a kind tag, and additive changes (a new field, a new kind) must not break an older reader of the objects it does understand.
  3. Legible (ADR-0002): mnem cat-object must produce something a person can read.
  4. Compact and quick (ADR-0002): many small objects per commit.

The candidates

FormatDeterministic modeSelf-describingSchema evolutionLegibilitySize and speed
bincodenonenono (field order is declaration order)nonefastest, small
postcardnonenononone~1.5x bincode, ~70% its size
MessagePack (rmp-serde)weak, no standardpartly (type tags)tolerablepoorsmallest on the wire, slower to decode
CBOR (ciborium, cbor2)RFC 8949 §4.2, plus the CDE and dCBOR profilesyesyes (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 standardyesyesbestlargest, 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 kind tag 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 its kind. 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-object can 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, tables objects and refs as 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: ciborium for 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. cbor2 is the alternative, with §4.2 canonical encoding built in but a shorter track record. The grilling picks.
  • Framing: none beyond CBOR itself. kind is the first map entry. format_version stays in config (ADR-0007), not per object. redb frames 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)

  • ciborium plus a hand-rolled §4.2 pass, versus cbor2's built-in canonical encoding, versus a fully hand-rolled canonical binary form. This survey leans ciborium plus a thin pass.
  • Which determinism profile exactly: RFC 8949 §4.2 core, or CDE, or dCBOR.
  • Confirm content maps straight to the CBOR data model, and how floats inside content are canonicalised.
  • Confirm no per-object framing header: kind as the first CBOR map entry is enough.

Sources

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:

  1. How do Git and Jujutsu keep branch creation constant time, and does Mnemosyne already have that?
  2. What does checkout have to touch, and what is "working memory" here?
  3. Is the flat state 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 a refs row pointing at where HEAD resolves.
  • Store::branches() — refs::list, already there.
  • Store::delete_branch(name) — refs::delete, plus a guard so the branch HEAD is 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 in objects and be a Commit.
  • The HEAD write 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 State plus 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 State maps once in lockstep and classifies each id as added / removed / modified (different ObjectId) / unchanged: O(nodes). BTreeMap makes 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 working table now?
  • checkout with a dirty staging: 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 merge or drop later?
  • mnem show with no argument: working memory, or the HEAD commit's state?
  • Diff output shape: the NodeChange list above, or a richer object that also carries the decoded old and new content for rendering?
  • Confirm flat State for Phase 2 and the prolly triggers above.
  • checkout and the reflog: still reserved, or does Phase 2 start writing it?

Sources

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:

  1. What does a three-way merge of two State maps do, per node id?
  2. How is the merge base found, and what happens on a criss-cross history?
  3. How are conflicts represented, and what does the resolution API look like?
  4. What invariants must the chaos harness check, and is flat State still 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):

botresult
xxxkeep x (unchanged)
xyxkeep y (ours changed, theirs did not)
xxykeep y (theirs changed, ours did not)
xyykeep y (both changed the same way, convergent)
xyzconflict: edit/edit
xabsentxabsent (ours deleted, theirs unchanged)
xxabsentabsent (theirs deleted, ours unchanged)
xabsentabsentabsent (both deleted, convergent)
xabsentzconflict: delete/edit
xyabsentconflict: edit/delete
absentyabsentkeep y (ours added)
absentabsentzkeep z (theirs added)
absentyykeep y (both added the same, convergent)
absentyzconflict: 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. State is 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 ort strategy 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 O is an ancestor of T (or vice versa), the merge is a fast-forward: the result is T, no merge commit. merge should 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 same State object id. Conflict sets are equal; the merge commit differs only in parent order and message.
  • Idempotence. merge(O, O) is a fast-forward to O; merging an ancestor is a no-op.
  • Base identity. merge(O, B) where B is the base is a fast-forward or a no-op (O already contains the base).
  • Convergence. After resolving conflicts, merge(O, T) then merge(T, O') (where O' 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 State kept, prolly deferred again.
  • ADR-0014 (conflict object + API): Conflict { id, kind, base, ours, theirs }; transient (returned) rather than stored for v0.0.4, stored form deferred to the Era 2 review model; resolutions are ours | theirs | base | delete | set(node); merge / merge_resolve / merge_continue / merge_abort; pending resolutions in a local table; mnem merge + --continue / --abort, mnem status shows unresolved ids.

Open questions for the grillings (#43, #44)

  • #43: single-base-or-refuse versus recursive virtual base. Fast-forward: silent, or announced? Does merge ever touch HEAD other 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 --continue flow, or must a merge be resolved in one process. What mnem status shows mid-merge. The set(node) resolution: does it go through add (staging) or a dedicated path.

Sources

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:

  1. What does blame resolve, and how, given that provenance lives on the node?
  2. 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?
  3. What does bisect search, and how is the range given?
  4. 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: is not in memory at "). Walk the first-parent chain from C towards the root. For each commit Ci with first parent Cp:

  • if state_map_at(Ci)[id] differs from state_map_at(Cp)[id], then Ci is the introducing commit for the value as of C. 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 equals parents[k], continue the walk from parents[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:

OperationWithout an indexWith an index
blame <id>O(history) first-parent walk, ~µsO(1) lookup of the introducing commit
bisectO(log n) state_at readsunchanged — 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 commit for the current tip (answers blame in 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 (diff the new commit against parents[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:

  1. records that blame/bisect walk the graph directly and why that is fine at this scale (the numbers above);
  2. 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 where blame latency or an Era 2 UI needs it;
  3. 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), and blame reads 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 ", "not an ancestor").

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 , written by " in one shot — this is the phase's headline demo.


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. plan flips from enterprise to pro off 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) bisect with the predicate plan == "pro" and asserts the boundary is exactly commit k; (b) blame plan at HEAD and asserts it resolves to commit k and 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 (default HEAD) 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 new redb table, 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 between good (predicate false, ancestor) and bad (predicate true, default HEAD); predicate is Fn(&state_at map) -> bool; assumes monotonic; linear history only; O(log N) state_at reads. Helper for the "one node, one value" case.
  • Fixture (#54): seeded synthetic run, wrong belief at a known commit, tests that bisect finds it and blame explains 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?
  • blame through merges. Report the merge commit (simple) or recurse to the true origin (a few more lines, better answer)?
  • blame output. Just the introducing commit, or also the chain of every commit that touched the node (a mnem log --follow <id> in disguise)?
  • bisect predicate. 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 like git bisect run)?
  • bisect range. Require good explicitly, 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 blame care 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, run with a predicate command
  • How git bisect works (Julia Evans) — the binary-search framing
  • ADR-0003 (the memory node model) — provenance fields, event_time, "bisect operates 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:

  1. Which SDK operations become MCP tools, and which become MCP resources?
  2. What is the write contract: one commit per call, or a stage/commit split?
  3. How is a store bound to a server, given MCP 2026-07-28 is stateless?
  4. What does the LangGraph adapter implement (BaseStore, BaseCheckpointSaver, or both), and how does its namespace/key model map onto Mnemosyne?
  5. What is the shared adapter contract, and does the SDK need anything new?
  6. 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 ttlMs and cacheScope. 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:

ToolSDK call(s)Notes
rememberadd + committhe common case: record one fact with provenance, in one commit. { id, content, source?, step?, observation? }
reviseadd + commitsame shape; a separate name so the model signals "this changes an existing belief"
forgetrm + committombstone one node in a commit
recallworking_node / working_memoryread one node or the whole current memory. Also a resource (below); the tool form is for when the model decides it needs it
recall_atstate_atthe memory as of a past commit (time travel)
historylogrecent commits, newest first, { limit? }
whyblameresolve a node to the commit and provenance that set it
when_didbisectfirst commit where a node reaches a value, is absent, or is present
whats_newchanged_bywhat 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

URIBacked bycacheScope
mnem://memoryworking_memoryper-store; short ttlMs, invalidated on any write
mnem://memory/{node_id}working_nodeper-store
mnem://loglog(limit=N)per-store
mnem://commit/{id}state_at + changed_byimmutable, 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.open is cheap, a redb handle), acts, closes. Stateless, trivial, correct. This is the v0.0.6 recommendation: an agent runtime launches one mnem-mcp per agent, pointed at that agent's memory.
  • (b) Store selected per call. A store argument on every tool, or the Mcp-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, key a string, value an 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:

BaseStoreMnemosyne
namespace: tuple[str, ...]a prefix on the node id (":".join(namespace)), or a branch, per the ADR
key: strthe node id (within the namespace)
value: dictMemoryNode.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 / Commit to 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 / forget atomic helpers (above).
  • A retry-on-write-conflict wrapper, or a clear ConflictError the caller can catch (compare_and_set already raises one in the core).
  • to_dict() on Blame, NodeChange, Commit, MemoryNode for the JSON boundary (the binding already emits dicts; the SDK dataclasses should round-trip).
  • Confirm thread-safety of a Store handle, 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/ (or adapters/) directory in this repo, each its own pyproject.toml, not a Cargo member, published to PyPI separately. One repo, one issue tracker, and CI can test them against the built wheel. Recommended for v0.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 mcp Python 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 / merge behind 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 BaseStore first; branch / why as extra adapter methods; BaseCheckpointSaver deferred with a trigger.
  • Contract: a shared remember / forget / to_dict helper set in the SDK, consumed by both adapters.
  • Packaging: a packages/ directory in this repo, PyPI mnemosyne-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) in v0.0.6, or is one-fact-one-commit enough to ship?
  • The _meta channel. How does provenance travel through BaseStore.put's opaque value dict: a reserved _meta key, a separate put argument the adapter adds, or dropped (provenance only via the MCP remember tool)?
  • search. Prefix/substring filter over working_memory, or "not in v0.0.6, raise NotImplementedError"? Mnemosyne is explicitly not a retrieval layer (README, prior work).
  • Namespace. Does a BaseStore namespace 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 / merge from the model by default, or expose everything and trust the framework?
  • One repo or many. packages/ here, or Nabzx/mnem-mcp + mnem-langgraph from the start?
  • Checkpointer. Confirm BaseCheckpointSaver is deferred, and name its trigger (someone wants a rewindable run).

Sources

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:

  1. What does the benchmark measure?
  2. What synthetic workload produces the runs?
  3. What are the baselines to compare against?
  4. What is the metric for each property, and its expected value?
  5. Where does the harness live, and does CI run it?
  6. 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:

PropertyThe claimMeasured as
Reconstructionstate_at(commit) is the exact memory of that momentover 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 precisionbisect finds the exact commit a belief went wrongplant 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 accuracyblame resolves a belief to the commit and observation that set itplant provenance on every write; blame every node at HEAD; report the fraction resolving to the correct introducing commit and the correct source
Merge correctnessa structural merge never loses a write or leaves a node in limbore-run the chaos harness (#49) at a large trial count and report totality, no-lost-writes, symmetry and convergence
Overheadversion control has a bounded, small costwrite 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 L commits. 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 the buggy_run.rs fixture, 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 querydictJSONLMnemosyne
current memoryyesyes (last line)yes
memory as of step tnoyes (line t)yes (state_at)
when did key X first become value Vnoyes, O(L) scanyes, O(log L) bisect
which observation set key X's current valuenono (no provenance)yes (blame)
what did step t changenoyes, diff two linesyes (changed_by)
merge two agents' memories, surfacing conflictsnonoyes
storage after L steps of a 100-key memoryO(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

MetricDefinitionExpectedFails the release if
reconstruction_exactstate_at == recorded over all commits, all seeds100.00%any seed is not exact
golden_bytes_stablethe frozen format vectors still hash identicallypassany drift
bisect_exactfound == k100.00%any miss on a monotonic planted fault
bisect_error_bitshistogram of |found - k|all zeroany non-zero
blame_commit_acccorrect introducing commit, linear history100.00%below 100
blame_commit_acc_mergecorrect origin commit when the value came via a merge100.00%below 100
blame_source_acccorrect source string100.00%below 100
merge_*the #49 invariants over a large sweepall holdany violation
write_ms_p50 / p99per-commit latency, 100-key memoryreport; expect single-digit ms p50regression vs the last published number by > 2x
read_ms_p50working-memory read latencyreportas above
bytes_per_step.mnem growth per commit, vs baseline Breport the multiple; expect « JSONLas 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 (or benches/, decided in the ADR), hand-rolled, seeded. CI runs it at a small trial count as an ordinary test; a MNEM_BENCH_* env override drives the large sweep.
  • The overhead-and-baseline comparison: benchmarks/overhead.py (a new top-level dir, like examples/). It builds a dict, a JSONL file and a .mnem store 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 of docs/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_memory is 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_step and read_ms numbers are the evidence for whether the flat State is still enough (ADR-0012's reassess trigger). The benchmark surfaces the number; it does not decide.
  • The v2 seam (#63, ADR-0018). Declaring the SemanticMerge trait 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.md and 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.md prose? 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 with cargo test) or benches/ (needs a bench harness, and cargo bench is not in CI today)?
  • The Python baseline dir. benchmarks/ at the top level, or a script under scripts/?
  • 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_step or read_ms threshold 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 extends
  • docs/chaos-report.md: the output format docs/benchmark.md follows

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:

  1. What is the SDK's own pluggable memory primitive, and what shape does it require?
  2. Does that shape match Mnemosyne's node model well enough to be a thin wrapper, the way BaseStore did 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, since BTreeMap ordering 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 per add_items call) is far cheaper and simpler, but then blame/bisect only resolve to "this commit changed the transcript," not to a specific message - a real loss of the thing that makes Mnemosyne worth adopting over SQLiteSession at all.
  • Does get_items/pop_item read from the keyed node space (via a synthetic id scheme) or from Store::log (walking commits, since that is Mnemosyne's one truly-ordered primitive)? These are different implementations with different failure modes - log is already ordered and paginated (limit) for free, but conflates "one commit" with "one Session item" in a way that may not survive remember_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 Session item: synthetic per-item id (one node per message/tool-call/tool-output) versus one node per add_items batch. If per-item, what does the id look like, and does it survive pop_item cleanly (does popping tombstone the node, or does it need a real delete)?
  • Ordering source: reconstruct get_items from the keyed node space (via the id scheme) or from Store::log? Does clear_session mean "tombstone every node in this session's namespace" or "branch to a fresh empty history"?
  • Namespacing: one Mnemosyne store per session_id (mirroring mnem-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 AgentHooks provenance auto-capture ship on by default, or opt-in? MCP made provenance a first-class tool argument; LangGraph made it an opt-in _meta key. Which precedent does this adapter follow?
  • Package name and scope: packages/mnem-openai-agents/, PyPI name, and whether SessionSettings.limit needs any Mnemosyne-side equivalent (a bounded get_items is a paginated log/working_memory read, not a new primitive) or is just passed through.

Sources

  • openai/openai-agents-python (cloned at research time, MIT, PyPI openai-agents v0.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's BTreeMap ordering), 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:

  1. What is CrewAI's own pluggable memory primitive, and what shape does it require?
  2. 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()'s BTreeMap ordering 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.
  • importance and last_accessed: CrewAI's own ranking uses importance (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 own Memory class 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/, PyPI mnem-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's history table is scoped to one memory_id at 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 every memory_id's own history table and manually take the latest new_memory before 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. EntityEdge carries real bi-temporal fields - valid_at, invalid_at, expired_at (edges.py) - and SearchFilters (search_filters.py) exposes valid_at/ invalid_at as first-class DateFilter lists with comparison operators (gt/lt/gte/lte). A caller who constructs valid_at <= t AND (invalid_at IS NULL OR invalid_at > t) gets a real point-in-time reconstruction. Not a single state_at(t) call the way mnem'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 history row carries actor_id and role (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 to mnem's Provenance than 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 SQLite history table is append-only and grows with every event, O(events). Structurally this is close to mnem'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_at set, it isn't deleted - so storage is O(all facts ever asserted), closer to a JSONL log's O(L) character than a dict's O(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 of docs/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 (the history table schema, get_history), mem0/memory/main.py (history, get, get_all as the only public read surface).
  • getzep/graphiti (cloned at research time, Apache-2.0): graphiti_core/edges.py (valid_at/invalid_at/expired_at/episodes fields), graphiti_core/graphiti.py (retrieve_episodes, search's "current date and time" docstring note), graphiti_core/search/ search_filters.py (SearchFilters' DateFilter lists).
  • 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:

  1. What is AutoGen's own pluggable memory primitive, and what shape does it require?
  2. 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: MemoryContent has no id. A synthetic per-item sequence id (mirroring ADR-0020's {name}:{seq:010d} scheme, with ListMemory's own name field 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 Memory instance (mirroring mnem-mcp's one-store-per-process rule), or a shared store with the name field as an id prefix (mirroring LangGraph/OpenAI Agents SDK)? A Memory instance is typically constructed once per agent, closer in spirit to Session's per-Runner.run scoping than to a store an application shares across many conversations.
  • Does metadata get any adapter-side structure (e.g. reserved keys the adapter lifts into Provenance, 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's on_tool_end - this is purely about whether the adapter tries to be helpful with whatever a caller puts there.
  • update_context's exact format: match ListMemory's plain numbered list precisely, or a documented Mnemosyne-flavoured variant (e.g. including provenance inline if it's present)? Byte-for-byte compatibility with ListMemory'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/, PyPI mnem-autogen, following the established naming convention.

Sources

  • microsoft/autogen (cloned at research time, MIT, autogen-core v0.7.5): python/packages/autogen-core/src/autogen_core/memory/_base_memory.py (the Memory ABC, MemoryContent), _list_memory.py (ListMemory's real behaviour), python/packages/autogen-agentchat/src/ autogen_agentchat/agents/_assistant_agent.py (update_context called 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... The archive branch 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-client actually 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.md Letta 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, current main): README.md (confirms the pivot and the archive branch).
  • letta-ai/letta-code (cloned at research time, Apache-2.0, npm @letta-ai/letta-code v0.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", the git add/commit/push recommended 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:

  • filters must name a scope up front - at least one of user_id, agent_id, run_id - enforced by a ValueError if 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 own user_id etc. from their Mem0 usage).
  • top_k is a cap, not a cursor. Default 20, no offset/cursor parameter anywhere in the signature. A real migration needs an explicit, large top_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 standalone scripts/ entry, or a small dedicated package (packages/mnem-mem0-import or similar)?
  • Node id scheme: Mem0's own memory_id used directly as the mnem node 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_id explicitly 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 raising top_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, the mem.payload.get("data", "") default), mem0/memory/storage.py (the per-memory_id history table, 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's get_all never 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:

  1. What does mnemosyne-oss/mnemosyne actually do, and how much does it really overlap?
  2. Is this a single collision, or a wider pattern?
  3. What would a rename actually cost?
  4. 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-memory PyPI 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 Code
  • nikolaef43/mnemosyne and kirocop/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 is mnemosyne or mnemosyne-*. The plain mnemosyne PyPI 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, not mnemosyne.
  • The CLI binary is already mnem, not mnemosyne. 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:

  1. What does git-scm.com/docs' own per-page template and category scheme actually look like?
  2. What is mnem's own real command set, and how does a scaled-down version of git's categories fit it?
  3. Hand-written pages or pages generated from each subcommand's own --help text: 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:

CategoryCommands
Getting startedinit, add, rm, commit, status
History and inspectionlog, show, diff, blame, bisect, changelog, inbox
Branching and mergingbranch, checkout, merge, approve, reject
Portabilityexport, import, registry (and its own four subcommands)
Suggestion heuristicssimilar, 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 --help output (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 --help directly, 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, and FILES collapses into a single pointer at docs/format/ rather than repeating the on-disk layout on every page.
  • Categories: the five in the table above, plus completions folded into a CLI-conventions guide rather than its own page.
  • Guides: a glossary (linking CONTEXT.md directly, 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 --help output 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).
  • Event fields id, author, invocation_id, timestamp (a float, defaulted via platform_time.get_time()), content, branch, partial all real, confirmed in src/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 raises NotImplementedError by default, with a message pointing the caller back to add_session_to_memory. Its own docstring states plainly that events is a delta ("intended for cases where callers want to persist only a subset of events... Implementations should treat events as 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, same NotImplementedError-by-default shape.
  • search_memory(*, app_name, user_id, query) is abstract, returns SearchMemoryResponse(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_words additionally splits a mixed-script token into single-script runs via itertools.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 real FunctionTool wrapping tool_context.search_memory(query). preload_memory (preload_memory_tool.py) is a real BaseTool that overrides process_llm_request and 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

  1. Do not implement delete_memory against BaseMemoryService as an override in this round - it isn't a real override of anything 2.12.0 defines. If mnem-adk wants a delete capability now, build it as an extra method on the adapter class, outside BaseMemoryService, exactly the shape ADR-0016/ADR-0020/ADR-0023 already use for branch/switch/why/bisect. Revisit a real override once a google-adk release actually ships the method.
  2. Ticket #6's own job needs rescoping: there is no real InMemoryMemoryService behaviour to match for "keeping a deleted memory deleted", because 2.12.0 has no delete path at all. The grilling question becomes "does mnem-adk want a delete capability ahead of ADK itself shipping one", not "how does the reference implementation keep a deletion from resurrecting."
  3. 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

  1. 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.
  2. Every offline spike, example, and integration test must re-fetch the session via session_service.get_session(...) before calling add_session_to_memory, matching real usage exactly, not a same-object-reused shortcut.
  3. No id convention in ADK itself competes with mnem-adk's own node id scheme; #571 has a clear field to design in.
  4. 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:

PackageBeforeAfter
opentelemetry-api1.45.11.42.1
tenacity9.2.19.1.4
websockets16.1.115.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

  1. The mcp pin compatibility claim is confirmed: no change needed to either package's own constraints.
  2. 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-adk bump tightens these further.
  3. packages/mnem-adk/pyproject.toml must declare requires-python = ">=3.11" - a real, necessary exception to this project's otherwise-uniform >=3.10 floor across every adapter package. State this plainly in the ADR and in mnem-adk's own README, rather than letting a Python 3.10 user discover it as an install failure.
  4. 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.id is a real, always-present field (#569) - the same situation CrewAI's MemoryRecord.id put 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.id is Optional[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's MemoryContent put 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_memory entry that already carries MemoryEntry.id: {app_name}:{user_id}:_memory:{memory_entry.id} - still honored literally, placed under a reserved _memory: sub-prefix so a caller's own session_id can never collide with it.
  • An add_memory entry with no id: {app_name}:{user_id}: _memory:{seq:010d}, backed by a reserved {app_name}:{user_id}: _memory:_seq counter node, zero-padded for the same reason ADR-0020 zero-pads - BTreeMap lexicographic order then matches insertion order with no scan needed.
  • _memory:_seq is excluded from search_memory and any content iteration, the same treatment ADR-0020's _seq and ADR-0016's _meta already 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 fieldADK source
sourceEvent.author - the agent, "user", or (on a function response event) the tool's own name
tool_callOn a function_call part: FunctionCall.name, with args if it fits as a string - "what was called, and with what"
observationOn a function_response part: FunctionResponse.response - "what came back"
agent_stepEvent.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
noteLeft 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

  1. Lock the five-row mapping table above.
  2. 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 _meta and ADR-0020 gave _seq.
  3. No hook, no callback, no new mnem-adk-internal state needed for provenance capture - it is a pure, synchronous field read on the Event object 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:

  1. Tokenise the query: NFC-normalise, re.findall(r'\w+', text), lowercase - one plain token set.
  2. 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).
  3. 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.
  4. 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_memory is 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.
  • 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's search_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.py or _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_config is set, ADK forwards it untouched to the real google.genai request (confirmed at google_llm.py:598-600). ADK does not validate the level against the model; whether low/medium/high are accepted is entirely the live API's own call. Argon's own developer guide confirms it accepts exactly those three levels, medium the default.
  • The one real gate found: _capabilities.py's gemini_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 mnem binaries. A shell or PowerShell one-liner installs mnem on Linux, macOS or Windows with no Rust toolchain at all - the binary is attached to the GitHub Release. cargo install mnem-git still works for anyone who already has Rust.
  • export / import. mnem export -o backup.json writes the whole store - every commit, every branch, everything - as one JSON file you can read with jq, put under someone else's version control, or just keep safe. mnem import backup.json ./restored rebuilds 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.md reports two things. First, correctness: over thousands of seeded runs, reconstructing memory at any past commit is exact, bisect lands on the exact commit a wrong belief entered, blame names 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_version 1. Every 0.0.x release 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 SemanticMerge trait and a Store::merge_with entry 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.py and the README GIF: an agent is told a past answer was wrong, then uses bisect and blame to 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_version 1), now frozen for the 0.0.x line.
  • 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.
  • mnem does 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-memory speaks the Model Context Protocol over stdio. Any MCP-capable agent (Claude Desktop, an SDK client) can call tools: remember a fact (with where it came from), recall one or all, why did the agent conclude this, when_did a 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. MnemosyneStore is a drop-in BaseStore. Point a LangGraph agent at it and its long-term memory gains history: every write is a commit. Extra methods let a graph node branch to test a hunch, or ask why a memory says what it does.
  • mnem.agents in 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 to mnem-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, blame and bisect.
  • Still one on-disk format (format_version 1).

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 search is 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 plan tells you which commit last set the memory called plan, when, and — if the agent recorded it — which step of the run and which observation it came from. Like git 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 where plan became "pro". It does about log2(n) checks, not n, 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 blame on 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_version 1).

What it still does not do (next phases):

  • No bisect run <command> yet (handing bisect an arbitrary script). The built-in --node predicates 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 bisect makes.
  • 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 experiment takes everything that happened on the experiment branch 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 MemoryNode as 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_version 1). 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 merge asks 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 experiment makes a cheap copy of the current memory line. It costs almost nothing: a branch is just a pointer, like in Git.

  • Switching. mnem checkout experiment moves you onto that branch; mnem checkout main moves 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 diff shows what is different between two points: which memories were added, changed (old value then new value), or removed. mnem status is 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_version 1) that every 0.0.x release 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 init in a folder. It creates a hidden .mnem folder that holds everything, like .git does 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 log lists 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, then store = mnem.init(path), store.add(...), store.commit(...), store.log(). Same features, for agent code. Installs as mnemosyne-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.x series. 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 YC27 to mnemosyne.
  • Rust workspace: mnem-core (library, one placeholder function and a test) and mnem-cli (the mnem binary, --version and a declared command tree with every subcommand stubbed out). Rust CI (fmt, clippy -D warnings, test, build --release) green on main.
  • Python SDK skeleton: mnem exposes 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 on main.
  • CI: a rust job and a python job on every pull request and on push to main.
  • 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 the docs/format, docs/research, examples placeholders.
  • Issue and pull request templates, including the Wayfinder ticket template.
  • Licence: Apache 2.0.
  • Labels: phase-0 to phase-7, the wayfinder:* set, ready-for-agent, track-a / track-b, adr, format, benchmark, spike.
  • Milestones: one per roadmap phase (renumbered to v0.0.2 to v0.0.7 in 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_by dependency 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.1 pushed.
  • 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 an msrv CI job at Rust 1.85 (the floor set by clap's edition2024 transitive requirement).
  • ADR-0007, versioning and release policy: Accepted (#17). SemVer 2.0.0; format_version on its own integer track; additive format changes are a MINOR software release, breaking ones a MAJOR with mnem migrate; hand-written CHANGELOG.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.1 GitHub 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 for gh 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 --system fails, and setup-uv with python-version already creates the .venv, so a second uv venv clashes. Settled on: setup-uv with python-version, then uv pip install and uv run straight into that venv.
  • The initial commit on main shows a red CI run: it predates the Cargo and pyproject files. main has 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, ciborium plus 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

TicketModuleWhat
#22object, idObject (kind-tagged), MemoryNode, State, Commit, Provenance; ObjectId (BLAKE3, hex, CBOR byte string)
#23codec, objects, errorcanonical CBOR encode/decode; put/get/has over a redb txn; MnemError
#24store, configStore::init/open (upward discovery, nested-store guard), config parsing with the format_version gate
#25refs, headthe refs table with compare-and-swap; Head::{Attached,Detached}; Store::head/set_head/resolve_head
#26commit, stagingpersistent staging; Store::stage/staged/unstage/commit (one write txn per commit)
#27logStore::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.