Memory and sync

Memory is a built-in oHarness architecture plugin (memory), not an MCP server. It registers the memory tools, the system-prompt index, per-message recall, optional extraction, the Memory page and a background synchronizer, all through the same PluginHost as model, tool and audit plugins. The page extension stays small: registered descriptors plus host-owned renderers (currently memory), not arbitrary frontend JavaScript or an iframe SDK.

Where notes live

One note per Markdown file, with JSON frontmatter:

---
{"id":"pricing-borderless-3fa","summary":"Pricing cards use fills, not borders","type":"feedback","tags":["design"],"createdAt":1789000000000,"updatedAt":1789000000000}
---
Cards on the pricing page are separated by fill and whitespace.

**Why:** the outlines were the loudest thing on a page of numbers.

**How to apply:** keep the border declared but transparent. See [[pricing-layout-a91]].
ScopeDirectoryHolds
project<project>/MEMORY/Decisions, constraints and references that move and export with the project
user~/.oharness/memory/user/Who the user is and how they like to work, in every project

Every registered project carries .oharness/project.json. Its stable id keeps Home-side sessions, sync state and recall statistics attached when the folder is renamed or exported. A git repository initially uses its root commit as that id; other directories receive a random project id. OHARNESS_HOME moves user notes and machine-local state, but never relocates the project's MEMORY/ directory.

memory.dir (or the older memoryDir) in config overrides the project directory, relative to the project root. When the user directory cannot be created, project notes remain available in <project>/MEMORY; only user-level notes and Home-side Memory state are unavailable, and a warning says so.

Types

TypeDefault scopeWhat it records
useruserThe user's role, expertise and working preferences
feedbackprojectA correction, or an approach the user confirmed
projectprojectA decision and its reason, a constraint, ongoing work not derivable from code
referenceprojectWhere external information lives

Only user notes default to the user scope. Feedback defaults to the project, because most corrections are about how one codebase is worked on; one that plainly holds everywhere is put in the user scope on purpose.

Notes written before types existed have none and keep working. type is part of a note's content hash only when present, so existing notes keep the hashes their sync baselines already hold.

Writing notes

The model writes with remember, reads with memory and deletes with forget. remember from the memory plugin goes through without asking in every permission mode except plan, which refuses it like any other write. An ask or deny rule naming remember still applies, forget still asks, and another plugin's tool named remember gets no such exemption. Tags are trimmed, split on commas and spaces, and de-duplicated before the note is saved. The system prompt's Memory section says when a note is worth writing and when it is not. remember returns the existing notes most related to what was just written, flags a likely duplicate, and lists links that point at notes which do not exist yet. Anything that looks like a credential is refused. Notes are asked to carry tags for the other words someone might use: synonyms, and the term in the other language in use, since recall matches words and not meaning.

Notes link to each other in the body as [[note-id]]. Links are derived from bodies whenever notes load; nothing about them is stored separately, and the sync protocol does not know they exist. A link resolves in the project scope first, then the user scope. A link to a missing note is kept: it usually names a note someone meant to write.

Automatic extraction

Off by default, because each pass is a model call on the user's own credit. With memory.autoExtract.onStop, the end of a run hands the conversation since the last pass to the small model, once at least minUserTurns real user messages have accumulated since then. With onCompaction (or onStop), the part of the stretch compaction is about to drop that no pass has read yet is handed over first, however short, since it is the last chance to read it. A resumed session starts counting from where it was resumed rather than extracting its whole history again. Subagents are never extracted from.

The extractor is shown the existing notes most related to the conversation. The most relevant few come with their full bodies, within a size budget, and only those may be updated; the rest are listed by summary for linking. An update replaces the body, so the replaced version is backed up to ~/.oharness/memory-backups/ first. Its reply is treated as untrusted: updates to ids it was not shown are dropped, links to ids it was not shown lose their brackets, credentials are redacted, and new notes in one batch refer to each other as [[new:N]] until they have ids. A repository's config can switch extraction off; only the user's own config can switch it on.

Extraction finishes after the run that triggered it has ended, so the notes it saved are announced at the start of the next message: "saved N memories from this conversation", in every frontend.

Recall

Two paths put notes in front of the model:

  1. The index. At the start of each run, one line per note, most recently updated first, up to memory.indexChars characters. The rest are counted, not lost; the memory tool searches all of them.
  2. Per-message recall. Each user message is scored against every note, and the best matches are attached to that message inside <recalled-memory>, with the notes they link to listed by summary. The frontends show "recalled N memories". Nothing is attached when nothing clears the threshold, which is the usual case. With no notes at all, the index is replaced by two lines saying when to write one, instead of the full guidance.

Scoring is lexical and runs locally: words are stemmed, CJK text is split into overlapping pairs, and matches in the summary and tags weigh more than matches in the body. A note must match in its summary or tags to be attached at all. A note attached once is not attached again while a message carrying it remains in the transcript, including after the session is resumed or the app restarts; the ids are read back from the messages' metadata. Compaction frees the notes whose messages it summarized away. Attached notes sit in their own block of the user message, recorded in the message's metadata, so transcripts, session titles and the web history show the user's own words. Subagents do not recall.

Recall failing never stops a message from being sent.

Limits

SettingDefaultMeaning
memory.maxNotes5000Per scope. Past it, the least recently modified files are not loaded and a warning says how many; new notes are refused until some are removed
memory.maxNoteBytes131072Body size; the sync server applies its own limit too
memory.indexChars4000Characters of summaries in the system prompt
memory.recall.enabledtruePer-message recall
memory.recall.perTurn3Notes attached to one message
memory.recall.budgetChars2000Characters those notes may take
memory.recall.minScore1.2Threshold for attaching
memory.autoExtract.onCompaction / onStopfalseAutomatic extraction
memory.autoExtract.minUserTurns3Shorter conversations are skipped

How often each note has been recalled is kept per machine in ~/.oharness/memory-usage/, outside the notes, so recall never causes a sync upload. It nudges ranking and is never synced.

Cloud sync

  • Notes always work locally, signed out and offline.
  • Signing in enables automatic sync by default. Sync goes both ways: local changes go up, and changes made on other devices come down on their own.
  • The Memory page offers 关闭云同步 and 立即同步. The switch covers both scopes. Off persists across restarts and re-login, and blocks automatic and manual cloud requests until explicitly re-enabled. Local tools and editing keep working.
  • Turning sync off cancels in-flight requests. Data already accepted by Cloud cannot be retracted; turning sync off does not delete cloud copies.
  • Each logical project/user scope is pinned to its first syncing account and Cloud origin. Changing account or origin pauses sync. The page offers no one-click unsafe reassignment.

The page maintains ~/.oharness/plugins.json (OHARNESS_HOME overrides this), under runtimePlugins[id=memory].config.syncTargets[sha256(stableScopeKey)]:

{ "userId": "verified-account-label", "origin": "https://your-cloud.example", "enabled": false }

Do not put R2 keys or Supabase configuration in this file. Cloud's URL remains in cloud.json; R2 credentials exist only in the sync deployment's environment. To disable the entire plugin (page, tools, prompts, recall and background work), add { "id": "memory", "enabled": false } to runtimePlugins; that takes effect on the next Harness creation. Project registration still creates the standard empty MEMORY/ and ARTIFACTS/ directories. For embedders, harness.memory is the project store and harness.memoryUserService the user one; both are absent when the plugin is disabled. AgentOptions.memory remains a lower-level embedding API and gets recall without a plugin host.

When it syncs

One coordinator per process schedules every notes directory, however many projects a local web server or the desktop app has open. Each directory has one sync service in the process, so the user's own notes sync once rather than once per open project.

  • A local write is uploaded about three seconds later; a burst of writes is one pass.
  • Otherwise a directory is checked every minute, backing off to every five minutes while nothing changes on either side.
  • Your own notes are always checked. A project's notes are checked only while the project is in use (a message sent in it, or the Memory page open) and for thirty minutes after.
  • The process keeps itself to about twenty requests a minute, leaving room under the sync service's per-account limit for other devices.
  • A 429 pauses every directory until the time in its Retry-After, and the page says that sync was too frequent and will retry, rather than blaming the configuration.

立即同步 always runs straight away.

What the page shows

The switch, "last synced" and errors cover both scopes: sync counts as on only when it is on for both, the time shown is the older of the two, and each error says whether it is about project notes or your own. The page also shows what the last pull brought down (added, changed, removed), and, on a free plan, the plan's limits.

Tidying notes

The page's 待整理 list is a review run on this machine, without a model:

FindingMeaningWhat you can do
Same subjectTwo to six notes in one scope whose summaries and tags mostly overlapMerge, or dismiss
Broken linkA [[id]] naming no noteOpen the note, or dismiss
Missing fileA project note names a relative path that is not in the projectOpen, or dismiss
UnusedNeither recalled nor edited for memory.consolidate.unusedDays (90)Open, or dismiss
Too longA body over 8,000 charactersOpen, or dismiss

A dismissal is kept locally against the notes' current versions, so editing one of them brings the finding back.

Merging opens an editor with a draft: the small model's (生成合并建议) or every original joined together (手动合并). Applying it keeps the oldest note's id, deletes the others, points every [[deleted]] link in that scope at the kept note, and backs up every note it changes. A merge planned from notes that have changed since is refused.

With cloud sync on, the scope is synced first and the merge is committed to the cloud before this machine (commitFirst), so an edit from another device in between refuses the merge instead of turning into a conflict. The merged body must fit the sync service's per-note limit. With sync off, the merge is an ordinary local edit.

Background merging is on by default for paid plans only ("auto": "subscribers"): it runs when the sync service's last reply said the account, or any team it belongs to, is on a paid plan, and stays off for free accounts and whenever the plan is unknown (cloud sync off, signed out). true or false overrides that:

{ "memory": { "consolidate": { "auto": false, "cooldownHours": 24, "maxMerges": 3 } } }

When on, a pass starts two minutes after launch at most once per cooldown and merges up to maxMerges groups of same-subject notes with the small model, by the same path as the page. It never acts on the other findings. A project config can turn it off but not on.

Backups and restoring

A copy is written to ~/.oharness/memory-backups/ before any note is replaced or removed without someone choosing that version: deleting on the page, forget, taking the cloud side of a conflict, an automatic pull that changes or removes a note, an extractor's rewrite, and a restore replacing a newer version. The Memory page lists the most recent fifty with when and why, and restores any of them into the directory it came from; the note then syncs like any other local change. Backups written before reasons were recorded are listed and restorable too.

Storage and reconciliation

The sync server exposes authenticated /v1/memory/identity, /snapshot and /commit. It stores no database rows. R2 holds immutable Markdown revisions plus an atomically replaced JSON manifest:

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

Implementations using another identity or storage system may do so; the exact request shapes, hash algorithm, CAS behaviour and error codes are specified in Sync protocol.

The local baseline (~/.oharness/memory-sync/) enables three-way reconciliation: unchanged notes are skipped and one-sided changes are copied. Beside it the client keeps a copy of every note as last synced, so a note changed on both sides is merged rather than simply flagged:

  • the summary and the type come from whichever side changed them;
  • tags are combined as sets, keeping what either side added and dropping what either side removed;
  • bodies are merged by line, and edits that are apart are combined;
  • an edit against a deletion keeps the edit, since deleting again is one click and a lost edit is gone.

Both versions are backed up before a merge. What cannot be combined (two different summaries or types, edits to the same or neighbouring lines, a merge too large for the plan, or a note with no copy of its last synced version) becomes a conflict. A conflict holds back only its own note: everything else still uploads and downloads. The Memory page shows the last synced version, this machine's and the cloud's, and offers keeping this machine's, taking the cloud's, keeping both (the cloud's becomes a new note beside it), or merging by hand; whatever is set aside is backed up, whichever side it came from. There is no last-write-wins overwrite. Deletions are tombstones, and manifest writes use ETag compare-and-swap.

Batch changes nobody reviews note by note (consolidation) are written cloud first: every note involved must be fully in sync, the commit carries the manifest version that was checked, and only once the service accepts it is the batch written locally. A change from another device in between is refused by the service instead of becoming a conflict.

Requests list the hashes of the notes this machine holds (have); the server neither reads nor returns those bodies, so a sync where little changed transfers little. Commits are split to the change quota the server announces. Tombstones older than the server's window are dropped from the manifest; a machine that comes back after that finds a note it last saw in sync, unchanged, no longer listed, and deletes it, while a note it edited in the meantime becomes a conflict. Two note ids that differ only in letter case are refused, since macOS and Windows file systems cannot hold both.

Sync retries transient failures after a 60-second cooldown; manual sync can retry immediately. Conflicts wait for the user. The page backs up deleted notes, and losing local versions of conflicts, under ~/.oharness/memory-backups/; forget does the same. Direct filesystem edits remain supported, but a concurrent external write during a merge is not protected by the process-local write lock; keep one running process per directory.

R2 is private and uses TLS, but this is not end-to-end encryption: Cloud can read note content. Only notes sync, not session logs, files or credentials.

Quotas and plans

The server owns its quotas and announces them in every response; clients hold no copy. Defaults: 5000 live notes per project and per user, 500 changes per commit, 128 KiB per body, 16 MiB of bodies per response, 90 days per tombstone. A lowered quota blocks growth only, so a user over it can always delete. See Running your own sync server for the variables.

Free accounts are held to tighter limits on top: a number of notes per project and per user, and a size per note, defined in the sync service's src/memory-entitlements.json. Paid accounts get no plan limit, only the quota above. The plan is read from the ent claim Cloud writes into the access token: a paid plan of the user's own, or of any team they are on, is paid. Only cloud sync is limited; notes on the machine are not, and a free account over the limit keeps every local note and stops uploading new ones, with a message saying why.

Deleting an account

Deleting an account on the website erases everything it synced: session feeds and memory in both scopes. The gateway calls the sync service's POST /v1/admin/erase with a key only it holds (OHARNESS_SYNC_ADMIN_KEY on both), before the account itself is deleted, and once more afterwards. The sync service writes a marker for the account first, and from then on refuses its feed and memory requests, so a device still signed in cannot upload the data again. Notes and sessions on your own machines are not touched.

Deleting a team does the same for the team's shared session history: the gateway erases feed/team-<id>/ (the sync service's {team} erase) after cancelling the team's subscription and before removing its rows, then once more. The sync service refuses that team's feeds from then on, even for a token that still lists it. Teams hold no memory.

Upgrading

Upgrade the sync server before clients. A new client falls back to the old protocol against an old server for project notes that have no type. It cannot sync user-level notes, or notes with a type, to an old server. An old client that receives a typed note from the cloud stops syncing with a checksum error until it is upgraded.

Verification and deployment

Run npm run test:memory, npm run test:sync-memory, npm run test:plugins, npm run typecheck and npm run build. script/memory-ui-demo.ts starts an isolated signed-out UI on 4319 with a temporary home; it never reads the user's credentials. Missing R2 configuration produces a visible sync error without breaking local memory. Restart the local dev server after source changes.