Memory sync without last write wins

· 5 min read

Two machines, one set of notes, one of them edited while the other was asleep. Every sync system answers this, and most answer it with a timestamp comparison that silently discards one side. That is fine for a draft you can retype. It is not fine for the file an agent reads before every turn, because what it discards is the line you wrote to stop something happening again.

So memory sync in OHarness has no last-write-wins path at all. There is three-way reconciliation, and where that cannot decide, there is a conflict that waits for you.

Local first, and local alone if you want

Notes live in <project>/.oharness/memory/*.md. They work unsigned-in, they work offline, and they are plain Markdown you can edit in any editor. Nothing about the cloud is load bearing: sync is a feature layered on top of files that are already complete without it.

Signing in turns automatic sync on, detected within about fifteen seconds while the harness is running. The Memory page offers to turn it off and to sync right now. Off persists across process restarts and re-login, and blocks automatic and manual requests until you re-enable it — a switch that quietly reverts on the next launch is not a switch.

One honest limit, stated in the docs rather than discovered later: turning off cancels requests in flight, but data the cloud has already accepted cannot be retracted. Off stops the next upload; it does not delete what is already there.

Three-way, not two-way

The reconciliation needs three states, not two: what is here, what is there, and what both sides last agreed on. That last one is the baseline, kept in ~/.oharness/memory-sync/, and it is what makes the difference between "these differ" and "this one changed".

From there the cases are unremarkable, which is the point:

  • unchanged on both sides — skipped, no read, no write
  • changed on one side — copied to the other
  • changed on both sides — a conflict, shown, and left for you

Deletions are tombstones rather than absences, because an absent file is indistinguishable from a file that has not arrived yet, and treating the second as the first is how a sync system deletes your work on a slow network.

Cloud-side, R2 holds immutable Markdown revisions plus a manifest that is replaced atomically:

users/<server-verified-user-id>/projects/<project-id>/memory/
  manifest.json
  objects/<note-id>/<revision-id>.md

Manifest writes use ETag compare-and-swap, so a concurrent stale update is rejected rather than applied. Revisions are immutable, so a losing side of a conflict is still an object that exists. And when the manifest has not changed, only the manifest is fetched — the common case, a machine that has been running all afternoon with nothing to do, costs one small read.

The identity of a project is the Git root commit, which is what lets the same project on two clones at two different paths be one project. Non-Git projects fall back to an absolute-path id and therefore do not map across devices automatically. That is a real limitation rather than a rough edge to be papered over: the alternative is a heuristic that occasionally merges two unrelated directories, and there is no good version of that failure.

Each local memory directory is pinned to the first account and cloud origin that synced it. Changing either pauses sync and asks you to use a separate directory instead. There is deliberately no one-click reassignment, because the one-click version of that operation is the one that uploads one person's notes into another person's account.

What it is not

Two things worth being blunt about.

It is not end-to-end encrypted. R2 is private and everything is over TLS, but the cloud can read note content. If that is the wrong trade for what you write down, turn sync off and keep the files local, which is a supported configuration rather than a degraded one.

Only memory notes sync. Not session logs, not files, not credentials, and not the permission audit trail — that last one stays on the machine that made the decisions and is deleted with its session.

There are bounds, too, and they are the first-version kind: 500 manifest entries per project including tombstones, 128 KiB per note body, 2 MiB per serialized snapshot. Old revisions are retained; garbage collection for revisions and tombstones is not implemented yet. Deleted notes and losing conflict versions are backed up under ~/.oharness/memory-backups/ and can be restored through the editor, though there is no backup browser yet.

Why a plugin

Memory is a built-in architecture plugin, not an MCP server. It registers its tool, its per-turn system-prompt index, its page and its background synchronizer through the same PluginHost that model, tool and audit plugins go through — and { "id": "memory", "enabled": false } in plugins.json removes all four.

That is the test a plugin system either passes or fails. If the features shipped in the box reach the runtime through a private door, the public door is decoration, and everyone writing against it finds out slowly. The page extension stays small for the same reason: registered descriptors and host-owned renderers, not arbitrary frontend JavaScript and not an iframe SDK. A surface that can do anything is a surface nobody can change afterwards.


OHarness is an open source agent harness, MIT licensed. The memory and sync reference documents the conflict rules, storage layout and configuration boundaries in full, and self-hosted sync covers running the whole thing on your own infrastructure.