When the same memory is written twice
Blind last-write-wins was never the right default. In v0.2.4 Memoir resolves a write collision by what kind of fact it is — events accumulate, facts consolidate by confidence, procedures merge, scratch memory is replaced. One typed merge against history, not an overwrite.
A coding agent learns you indent with tabs and writes it to
preferences.coding.style. A week later it watches you
reformat a file with spaces and writes that to the same path.
Two facts, one key. What should happen?
Until v0.2.4, Memoir's answer was the laziest one possible: the second write clobbered the first. Last-write-wins, no questions asked. The classifier is deliberately write-blind — it never reads what's already there — so a collision was a blind overwrite. The only safety net was git history.
# the agent learns a preference, twice
memoir remember "use tabs" -p preferences.coding.style
memoir remember "use spaces" -p preferences.coding.style
# before v0.2.4: the second write silently clobbers the first.
# the projected value is now "use spaces" — "use tabs" is gone
# from view (recoverable only by digging through git history). A conflict is never resolved in general. How you resolve it depends on what kind of fact it is. A preference should consolidate to the current best answer. An event log should accumulate. A workflow should fold in the refinement. A scratchpad should just be replaced.
That's the whole idea behind the v0.2.4 conflict model. A write to an occupied key is no longer an overwrite — it's a typed merge against history, and the type is read straight off the taxonomy path.
Step 1 — store facts, not a string
You can't merge what you've already thrown away. So the substrate
changed first: a memory's content is no longer a single
string but a list of timestamped facets
(schema_version: 2). Each entry carries its own content,
confidence, and a status — active entries
contribute to what you read, superseded ones are kept for
the audit trail.
{
// projected top-level — always present, legacy-compatible
"content": "use spaces",
"confidence": 1.0,
"timestamp": 1718841600.0,
"key": "preferences.coding.style",
// the new facet layer (schema_version 2)
"schema_version": 2,
"entries": [
{ "content": "use tabs", "confidence": 1.0, "status": "superseded" },
{ "content": "use spaces", "confidence": 1.0, "status": "active" }
]
}
The trick that makes this safe is the projection: the
top-level content is a deterministic roll-up of the active
entries, joined exactly the way the old append code did. It is
byte-for-byte what readers saw before — so the 22+ existing
readers and the entire web UI keep working untouched. A bare v1 blob is
just a single implicit entry, upgraded lazily on its next write. No bulk
migration, no flag day.
One rolled-up string at the top is the single lever that let the storage model change underneath without touching a single reader.
content field;
superseded ones are kept, not deleted.
Step 2 — resolve by memory type
With facets in place, the resolver can do something smarter than overwrite. Cognitive science has long split memory into four kinds — working, episodic, semantic, procedural — and each one wants a different conflict rule. Memoir's taxonomy already encodes the type in the path's first segment, so the right strategy comes for free.
There are six strategies in all; the four above are the per-type defaults:
append— add a new active entry. Episodic logs should grow.confidence_gated— the new fact wins only if it's at least as confident as what's there. Otherwise it's a no-op (no commit).llm_merge— a small model (haiku) consolidates the old and new into one entry; the priors are superseded.replace— drop the prior actives, keep the new one. Plain last-write-wins, now a deliberate choice.merge_on_read— store like append, defer consolidation to read time.reject— refuse the write and hand back a machine-readable conflict signal. This is what powers the interactive resolver.
Why confidence gates the facts that matter
Semantic memory — your preferences, project facts, what the agent knows
about you — is where blind overwrite did the most damage. A
high-confidence belief shouldn't be flattened by a low-confidence guess
just because the guess came later. confidence_gated fixes
that: the incoming entry only takes over if its confidence meets or
beats the best active one.
One subtlety worth stating, because it's the difference between a
surprise and a feature: the gate only bites the classifier
branch. When you supply a path yourself with -p,
the write is hard-set to confidence 1.0 — so the gate
always passes and you get replace-like behavior, exactly as before. The
gate only changes what happens when an LLM-classified write
(confidence < 1.0) lands on something it isn't sure it
should replace.
Who decides — and how to override
The per-type default is the floor, not the law. Strategy selection follows a strict precedence, so a one-off never forces you to change a key's nature:
# one-off override for a single write
memoir remember "use spaces" -p preferences.coding.style --merge-policy append
# session-wide override
export MEMOIR_MERGE_POLICY=replace # the global escape hatch
export MEMOIR_FACET_MAX_ENTRIES=20 # cap growth on append keys
The escape hatch matters most for the rollout: defaults flip to the
per-type table in this release (semantic keys becoming
confidence_gated is the headline change), and
MEMOIR_MERGE_POLICY=replace restores the old wholesale
last-write-wins behavior for anyone who wants it back. A
MEMOIR_FACET_MAX_ENTRIES cap keeps append keys from growing
without bound.
When you'd rather just look first
Automation is the default, but some conflicts deserve a human. The
reject strategy turns a collision into a structured signal
instead of a write, and the CLI's --interactive flag uses
it to show you both sides before anything is committed.
$ memoir remember "use spaces" -p preferences.coding.style -i
conflict on preferences.coding.style
─────────────────────────────────────
existing "use tabs" conf 1.00 2d ago
incoming "use spaces" conf 1.00 now
─────────────────────────────────────
[k]eep [r]eplace [a]ppend [m]erge [s]kip ?
The service itself never prompts — it returns a ConflictInfo.
The CLI renders it; an agent over MCP reads it and runs its own
read-merge-write loop. Same contract, different front ends.
Still just git underneath
Every one of these strategies writes a commit. replace
supersedes the prior entry but the old value is still in history;
llm_merge tombstones what it folded in. Nothing is ever
destroyed in place — which is the same promise
the write path made
from the start, now extended to the moment two writes disagree.
This is where "Git for AI memory" earns its name: a write to an occupied key is a typed merge against history, not a blind overwrite.
One boundary worth drawing: this is about two writes hitting the same key on the same branch. Reconciling two branches that remember differently is a related but separate problem — three-way merge is deliberately out of scope here. What v0.2.4 ships is the typed, per-key resolution that makes each memory type behave correctly by default, on the substrate that the prolly-tree already gives us.
What you get
- Correct by default — events accumulate, facts consolidate, procedures merge, scratch is replaced. No per-call configuration.
- No silent loss — a low-confidence guess can't flatten a high-confidence fact; superseded values stay for
blame. - Bounded growth — only episodic keys append, with a cap, so blobs stop ballooning.
- An override at every layer — flag, env, or the global escape hatch back to last-write-wins.
Read the conflict & merge theory
Last-write-wins was the bug. The fix was to ask what kind of fact it is — and let the answer pick the merge.