Status: Implemented as of 2.0.0 (see mesh-ops.md for day-to-day operation). Release: 2.0.0 (architecture shift). Supersedes: the per-machine, stdio-only model where each box runs its own
obsidian-mcpagainst a separately-synced copy of the OMI folder. Date: 2026-06-10.
This document records the design for turning omind from a single-machine memory tool into a mesh: every machine in the house runs a full local memory node, and the nodes replicate to one another peer-to-peer over git. Originally a scope/decision record; the design shipped in 2.0.0 essentially as written (deviations are noted inline).
The end goal is shared memory across every Claude client in the house (this laptop, Pluto, the macmini, and any future node). The pre-mesh reality fell short on two fronts at once:
- A stdio MCP server is a per-client local subprocess — it cannot be shared across machines at all. "Sharing" therefore depended entirely on a file-sync layer (Hermes' cron) keeping N copies of the vault in agreement, which can drift, conflict, or lag.
- That same stdio server (
obsidian-mcp1.0.6, now an abandoned upstream) was the source of intermittent multi-minute tool-call hangs — see troubleshooting.md.
The first design was a hub — one always-on HTTP/SSE MCP server (on the macmini) that every client connects to over the LAN. It is simpler (one source of truth, strong consistency) but was rejected because it makes the always-on box a single point of failure: if it is down, no machine has memory, and nothing works offline. We deliberately traded strong consistency for availability and partition-tolerance.
Every machine runs an identical omind node:
┌─────────────────────── omind node (per machine) ───────────────────────┐
│ │
Claude ──▶│ local MCP server ──▶ OmiStore ──▶ OMI folder (a git working tree) │
clients │ (omind, stdio) │ │
│ ▼ │
│ replication daemon (commit + sync) │
└───────────────────────────────────┬──────────────────────────────────────┘
│ git fetch/merge/push over ssh
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
other node other node other node
- Claude clients talk only to their local node. Reads and writes never cross the network — they are fast and work fully offline.
- The OMI folder is a git working tree. The replication daemon commits local changes and syncs with whatever peers are reachable; partitions heal on reconnect. Git's decentralization is the mesh — there is no central node.
- Plain Markdown stays the canonical on-disk form, so Obsidian and the existing
omind serveweb UI keep working unchanged.
| Module | Responsibility |
|---|---|
omind/server.py |
Local node MCP server exposing OmiStore as MCP tools over stdio. Replaces the provisioned obsidian-mcp. (Since 2.27.0 the debounced-sync write signal lives in OmiStore itself, so every write surface — MCP, web UI, omind note, import — triggers it.) |
omind/clock.py |
Logical versioning — a Lamport counter + stable node-id stamped into each note, the source of ordering truth for merges. |
omind/merge.py |
The git merge driver for OMI notes (the core of the project). Field-level 3-way merge over NoteFields. |
omind/mesh.py |
Replication daemon — init, commit_local, sync(peers), daemon, clone, peer membership. |
OmiStore(store.py) — the entire read/write/search/tag/backlink API. ("Unchanged" means the API shape and call sites; the locked Rev-in-Metadata decision requiresNoteFields/parse_note/render_fieldsto learn theRevandDisabledlines, added as defaulted fields so legacy notes round-trip byte-identical.)parse_note→NoteFields→render_fields— the structured model the merge driver operates on.- The CLI scaffold, the ruff + mypy-strict + pip-audit CI bar, and the dual-mirror (GitHub + Codeberg) shipping flow.
A node is any machine that holds a replica and runs the store + the replication daemon. Two kinds of writer live on a node:
- Claude Code (plus the
omind serveweb UI and cron) — already write throughOmiStore, so they inherit the.omi.lockflock, the atomicos.replace, and thenote_versioncompare-and-swap. - hermes-agent — Hermes is a first-class node, not merely a sync source.
It is Python and already uses the same primitives (
os.replace,fcntl.flock), so it runs omind natively.
Invariant: every OMI writer on a node goes through OmiStore — no raw
writes. The within-node concurrency guarantee holds only if all writers
serialize under the one shared lock; a single raw write_file() into the OMI
folder can interleave with the replication daemon's git commit or clobber a
concurrent store write, defeating the note_version CAS.
Today Hermes' hermes-omi-memory-sync skill writes raw
(write_file(".../OMI/<title>.md", ...)). To make Hermes a safe node, it writes
in-process through omind instead:
from omind.store import OmiStore, NoteFields
store = OmiStore(omi_dir)
store.create_note(NoteFields(title=..., summary=..., details=..., tags=[...]))This inherits the lock, the atomic write, and the note_version CAS — and, as a
bonus, emits omind's clean note format rather than Hermes' §-block dump, ending
the long-standing format divergence between the two writers.
A mesh accepts eventual consistency: two nodes can edit the same note while partitioned, and the merge must converge them without losing data.
SQL Server keeps concurrent writes from interfering with one authoritative transaction manager — locks (pessimistic) or row-versioning under snapshot isolation (optimistic), all serialized through a single log. That same idea applies inside a node, but not across the mesh, and the split is the whole point:
- Within a node (yes — already built): multiple local writers — several
Claude clients, the web UI, the cron, the sync daemon — are kept from
clobbering each other by optimistic concurrency control, which is exactly
SQL Server's snapshot/row-versioning model. As of the inter-process
write-safety work,
OmiStorealready serializes every write under an advisoryflockon a shared.omi.lock, routes note/index writes through an atomic same-dir temp-file +os.replace, and re-validates thenote_versioncompare-and-swap inside the lock (write only if unchanged, elseNoteConflictError); reads stay lock-free. The mesh adds only one local piece on top: the replication daemon's git operations are serialized behind that same per-node lock, so a sync never interleaves with a half-written note. - Across the mesh (the idea, not the mechanism): a SQL-style transaction needs one coordinator that sees and serializes every write — which is the hub we rejected. By the CAP theorem you cannot keep that single-authority strong consistency and stay available while partitioned (a node editing offline cannot acquire a lock held on a node it cannot reach). So the mesh uses the distributed cousin of row-versioning: the per-note Lamport clock. Writes proceed independently and conflicts are merged after the fact rather than prevented by locking — optimistic replication.
Distributed locks / leases (only the lease-holder may write note X) would serialize cross-node writes, but they reintroduce a coordinator and forbid offline writes, so they are rejected for the same reason as the hub.
OmiStore.note_version is size-blake2(content) — a local token (content-
based since 2.15.0: mtime+size collided for same-size writes within one
timestamp tick on coarse filesystems). It carries no causality and is kept
only for intra-node optimistic locking. Cross-node ordering uses a Lamport counter +
node-id stamped into each note's ## Metadata section (Obsidian-visible,
keeps notes plain-Markdown). Clock skew across the laptop / Pluto / macmini is
real and is never trusted.
Because notes round-trip through a structured model, the merge driver parses
base/ours/theirs into NoteFields and merges field by field rather than
diffing raw text:
tags,connections,references→ 3-way set merge (conflict-free; an element removed by one side and untouched by the other stays removed — a plain union would resurrect every removal).action_items→ union by text;doneis logical-OR.title,summary,related_to→ scalar last-writer-wins by Lamport rev, tie-broken by node-id.details(free text) → diff3; disjoint additions concatenate, conflict markers only when the same region truly diverges.index.mdand any generated file →merge=oursin.gitattributes, then regenerated after merge. Generated files are never merged.- Disable, not delete → a "deletion" sets a
disabled: trueflag in the note's## Metadatarather than removing the file (see Disable instead of delete below). It is an ordinary field edit, so it merges with the same last-writer-wins-by-Lamport rule — no tombstone, no delete-vs-edit race.
The whole driver is lossless-biased and loud: when unsure, keep both and report it. The merge driver is where the bugs will live, so it gets the heaviest test coverage.
Hard-removing a file is the worst case in a mesh: a peer that still holds the note resurrects it on the next sync unless every node remembers the deletion forever (a tombstone), and tombstones carry their own garbage-collection and delete-vs-edit headaches. So omind does not hard-delete by default — it disables:
- The delete tool sets
disabled: true(with the disabling node-id + Lamport rev) in the note's## Metadata. The file stays in git. - The local MCP server and the
omind serveweb UI hide disabled notes from listings and search by default, with an explicit "show archived" / restore path. Restore just clears the flag. - Because disabling is an ordinary field edit, it merges conflict-free with the same machinery as any other change; a concurrent disable + edit resolves by Lamport rev on a note that is still present — the delete-vs-edit race is gone.
- True removal still exists as a rare, deliberate
omind mesh purge— an explicit hard-delete-with-tombstone for the unusual case where a note must really leave every node. It is the exception, not the common path.
This is strictly simpler than tombstoning every delete, and it is reversible.
- Transport: ssh git remotes between boxes — reuses the LAN and existing ssh keys, encrypted in transit, durable.
- Partitions are normal, not exceptional: the laptop roams, Pluto is a dual-boot box that is often off. Sync is best-effort, idempotent, and order-independent; an unreachable peer is skipped and retried.
- The macmini, being always-on, is a natural seed for
omind mesh clonewhen bootstrapping a new node — but it is a seed, not a hub. Any node can sync to any reachable peer. - A seed can also be a dedicated passive bare repo provisioned with
omind mesh add-seed: nodes push their outbox refs to it, its post-receive hook pointsmainat the freshest one (so fetching from it yields a mergeable branch), and--mirrorreplicates the whole seed to a hosted git repo (keep it private — notes travel in plaintext). Losing it loses nothing: every node still holds the full history.
| Command | Does |
|---|---|
omind node |
Run the local node MCP server (stdio). |
omind mesh init |
Make OMI a git repo; install the merge driver + .gitattributes; write node config (node-id, sync cadence). Peers live as ordinary git remotes, not in the config. |
omind mesh add-peer <name> <git-url> / remove-peer |
Manage peer remotes. |
omind mesh add-seed <name> <url> [--mirror <git-url>] |
Provision a passive bare seed repo (local path or over ssh) — init, post-receive hook (main pointer + optional mirror push), register as a peer here. Converges on re-run. |
omind mesh sync |
One-shot fetch + merge + push against reachable peers. |
omind mesh daemon |
Interval sync loop + on-write debounce trigger. |
omind mesh clone <url> |
Seed a fresh node from a peer. |
omind mesh purge <note> |
Rare, deliberate hard-delete-with-tombstone. The default "delete" only disables; this is the exception. |
omind setup |
Register the local node with Claude Code (stdio). |
omind doctor |
Extended: git health, merge driver installed, per-peer ahead/behind, last-sync time, unresolved-conflict list. |
tests/test_merge.py— base/ours/theirs fixtures for every field type; concurrent disable + edit, and restore; assert deterministic and lossless.tests/test_mesh.py— two temp repos as peers; partitioned edits to the same note; sync; assert convergence with no data loss.- Existing
test_store/test_web/test_provisionstay. - Git is driven via subprocess (not a native binding such as pygit2) to keep
the
pip-auditdependency surface clean.
- Local node —
omind node(stdio MCP overOmiStore) +mesh init(OMI → git) + Lamport rev in notes. Immediate win: retires the flakyobsidian-mcplocally. - Merge engine —
merge.py+.gitattributes+ fulltest_merge.py. The core. - Replication —
add-peer/sync/daemon/clone, ssh remotes, partition tolerance,test_mesh.py. - Integration —
setup/doctorextensions, launchd (macmini) + systemd (Linux nodes) service units, docs. - Release — dual-remote-pr → 2.0.0.
Rough sizing: ~4–5 focused sessions. The merge driver and convergence tests are the real work; the storage engine and CI already exist.
- Topology: full peer-to-peer — every node knows every node.
- Concurrency: SQL-style optimistic compare-and-swap within a node; per-note Lamport versioning + merge across the mesh; no global or pessimistic locking (that would be the rejected hub). See Concurrency above.
- Deletion: disable (soft-delete) — hidden by default, restorable; hard
removal only via the explicit
omind mesh purge. (Supersedes the earlier "delete-wins-unless-newer-edit" tombstone default.) - Lamport rev placement: in the note's
## Metadatasection (Obsidian-visible). - Sync trigger: daemon interval + on-write debounce.
- Version bump: 2.0.0.
This is a breaking architecture change → 2.0.0. Existing nodes migrate by
running omind mesh init on each box and adding peers; the per-machine
obsidian-mcp registration is replaced by the local omind node server.