Adoption and Adaptation

Comparison of eight frameworksidentified several areas where other implementations do better. This page answers the next question: how each practice fits our structure, or why it does not. The authoritative text is in overview.md, and mechanism diagrams are in Memory subsystem: mechanisms illustrated.

Layer 1

Our current implementation

Verified against the running code in openprogram/memory/, with line numbers recorded verbatim. Measured results include numbers.

Layer 2

Other implementations

The original approach in the referenced implementation and why it takes that form. The form may not apply; the motivation usually does.

Layer 3

Our plan

The adapted design, or the reason not to adopt it. Each item has a decision, not a “worth investigating” conclusion.

00 Four decisions 01 Retrieval without an index 02 Trimming resident content 03 Departed branches 04 Cursor fallback 05 Change locations

00Four decisions

The conclusions come first. Three practices are adopted directly or after adaptation; one is rejected because our structure already handles its purpose elsewhere or places its cost on the wrong operation.

Reference practice Source Decision One-sentence reason Index-free retrieval: file-header descriptions and model selection of five files Replace the index with one extra small-model call per retrieval claude-code Reject That call provides semantic matching without an index; we already have an index at finer-than-file granularity Trim resident content by ownership: remove only automatically promoted paragraphs, never handwritten ones Return the removed-item list to the caller openclaw Adopt with adaptation · implemented Ownership matters because removal destroys the content. Derive core.md; removal preserves source content Summarize a branch when leaving it and attach the summary to the tree Preserve statements from abandoned branches pi-mono Adopt with adaptation Keep the objective, omit summary nodes. Enumerate all branches at session boundaries and stop at marked prefixes If a cursor cannot be found, treat all messages as unwritten again Returning zero messages stops extraction permanently; repeating work has bounded cost claude-code Adopt Migration already covers half; the other half remains: an unreadable ledger currently raises and retries indefinitely

01Retrieval without an indexReject

claude-code puts a one-line description at the start of each memory file. Retrieval lists those descriptions and uses one inexpensive small-model call to choose five filenames, without building an index. Our retrieval runs BM25 every turn between the user message and model call.

Layer 1 · our current implementation Layer 2 · claude-code Layer 3 · our plan A user sends a message provider.search() once per turn retrieval/inspect.search, top_k=5, internally capped at 10 BM25 index, persist=False Fully rehash and reparse topics/ and sources/ every turn Time is proportional to workspace size; see the chart below Five paragraphs with block IDs and footnotes Insert directly into the prompt inside memory-context; zero model calls Extra model round trips in the same turn: 0 One description at each memory file’s start List all descriptions No inverted index, vectors, or cache to maintain One inexpensive small-model call More selective than “choose if relevant”: omit uncertain matches, prefer empty results findRelevantMemories.ts:18-24 Five filenames The agent receives names and performs Read itself Extra model round trips in the same turn: 1, on the user’s wait path Do not introduce this call It provides semantic lookup without an index. We already have an index: BM25 exists, and the semantic path has retrieval/embedding.py; neither adds a model round trip. Do not introduce file descriptions Our return unit is a paragraph, not a file. The filename already describes it: one topic per file is the write contract; topics/people/dave.md needs no repeated explanation. Keep only one selection criterion “Omit uncertain matches; prefer empty results.” Our current cutoff drops results only when lexical<=0 and adjustment<=0.02, effectively matching any shared word, so even an unrelated turn gets five paragraphs. Add a score threshold.
Measured: In this workspace, inserting an introductory paragraph without footnotes into topics/ is immediately rejected by strict parsing (TopicFormatError: memory block ID required); blockquotes are also rejected, and only HTML comments pass. A file-header description would therefore require changing our parser contract, while the path already provides that information.
Measured per-turn retrieval time (local machine, synthetic workspaces, mean of three runs) This is where an extra model call would add latency. The current time budget is already tight because of local parsing, not ranking. 0.50 MB cold 66 ms 3.34 MB cold 431 ms 9.80 MB cold 1292 ms 9.80 MB warm cache 981 ms (persist=True; a cache hit still rebuilds the scoring corpus) 0 500 ms 1000 ms
Synthetic workspace: 12 blocks per topic file and 60 messages per source archive, generated in the real format. Time grows linearly with total workspace bytes because inspect.search uses persist=False, fully rehashing and parsing both directory trees every turn.Implication: Per-turn retrieval’s time-budget problem is local and can be fixed locally; an added model round trip can only be avoided.

Rejected because it duplicates an existing capability. claude-code has no index; this approach avoids building one and helps minimize its implementation size in the same comparison. We have already paid the indexing cost. Adding another model round trip duplicates that capability at the cost of waiting every turn. The first two retrieval-quality improvements are local: avoid full reparsing on every retrieval, and add a meaningful minimum result score.

02How to trim full resident contentAdopt with adaptation · implemented

When openclaw trims MEMORY.md, it removes only automatically promoted paragraphs, preserves handwritten content unchanged, and returns removed dates to the caller. Adopting this first requires answering: what is core.md in the new structure? Once answered, ownership itself need no longer be distinguished.

Layer 1 · before our change Writer agent Prompt: update core.md only for stable information Append a paragraph to core.md With a block ID and evidence footnotes Same contract as topic files 2000-token gate block_views.py:27-36 Raise if exceeded Reject the entire transaction Roll back this turn’s topic-file changes too The workspace remains byte-for-byte unchanged Repair instruction: core.md is full. Leave it alone and put this in a Topic file. agent.py:96-99 Every subsequent stable fact is directed elsewhere core.md remains as it was when first filled and never changes Nothing maintains it Nightly reorganization handles only topics/: organize_topics lists topics/**.md in its prompt, excluding core.md (management/api.py:83-94). Before the refactor, a deterministic rebuild maintained it as a mirror of a designated page; the refactor removed that process without replacement.
Layer 2 · openclaw: one file, removal destroys content Layer 3 · our current design: canonical content plus derived view, removal is non-destructive MEMORY.md is the entire store, with a 10000-character limit Handwritten paragraphs · preserve all Automatically promoted · remove oldest dates first Removed content · no other copy remains Because removal destroys content, ownership must be distinguished first. Returning removed dates supplies an audit record. memory-budget.ts:25,100-164 By ownership, not time topics/core.md canonical source A topic file using the same contract as other topic files, with no limit Paragraph ^a1 · resident Paragraph ^b2 · resident Paragraph ^c3 · exceeds budget, remains here Render core.md derived view Rebuild after each successful write alongside the timeline/ views Paragraph ^a1 Paragraph ^b2 2000-token budget; overflow allowed Ownership is no longer an issue Excluded text stays canonical and retrievable; footnotes still cite original statements. Since removal does not destroy content, deciding who wrote a paragraph is unnecessary. Put a paragraph first to keep it resident Render in canonical file order; overflow comes from the end. Manual source edits and nightly ordering changes are existing actions; resident content needs no new marker.

Answer to the prerequisite question: core.md is a derived view, not independent content. It renders the single topic file topics/core.md, in the same category as timeline/, recent_events.jsonl, relations.json; manual edits to the view have no effect, so edit the canonical source. The writer prompt changes from “update core.md” to “put stable facts that should always be visible in topics/core.md”, leaving only one file type for the writer to understand. The old statement “core.md mirrors a designated page” remains true; the designated page is now a topic file rather than a wiki page.

Why a derived view is required before trimming The workspace enforces a hard contract: every block ID present before editing must remain findable afterward, or the entire transaction is rejected. workspace.py:147-162 includes core.md block IDs in the baseline set; topic_normalization.py:296-322 compares them and rejects with “block ID must not be removed”. Before derivation: core.md may hold unique IDs core.md paragraph ^c3 No identical ID exists in topics/ Trimming it removes ^c3 from the entire workspace, violating “block ID must not be removed”; the transaction and all other changes are rejected. Trimming is invalid in this structure. After derivation: core.md IDs are always a subset core.md paragraph ^c3 ^c3 in topics/core.md Remove rendered ^c3 while its canonical ID remains; the contract is satisfied. Remove two core.md special cases: baseline collection and validation.
DimensionBefore our changeopenclawOur current implementation
Who writes The writer agent edits core.md directly Automatic promotion and manual edits share one file Nobody writes it. Write topics/core.md; Runtime renders
Limit 2000 tokens; exceeding it raisesmanagement/config.py:7 10000 characters 2000 tokens becomes a rendering budget, not an exception
When full Reject the transaction; the repair instruction directs the writer elsewhere, permanently freezing core.md Remove automatic paragraphs, preserve handwritten ones Render in canonical file order up to budget; overflow remains in the source
Does removal destroy content Not applicable; nothing was ever removed Yes, so ownership must be distinguished first No; the canonical source is complete and remains retrievable
When it runs Never. Nightly reorganization lists only topics/ Trim to budget during writes Rebuild with other derived views after each write transaction installs successfully
What it reports reorganize returns {"status":"ok","topics":N}, with no place for core Return the list of removed dates Return rendered token count and excluded block IDs

Also report whether anything actually changed. Nightly reorganization depends on model judgment, whose failure mode is silently doing nothing while satisfying its own criterion: under the same reorganization prompt, a 520,000-character single-topic conversation produced a 34.4k-character file that was never split despite repeated reorganization, because the criterion was “covers two topics,” not size. Therefore reorganize must also return the files actually changed in the run; an empty list visibly means “nothing changed.” Revising the criterion is separate, but without visibility we cannot even assess whether revision is needed.

03Departed branchesAdopt with adaptation · implemented

pi-mono summarizes a branch when the user leaves it and attaches the summary to the tree. This addresses the gap acknowledged in overview.md: both write paths use only the branch beneath head, and nobody revisits branches abandoned midway. Keep the objective, omit summary nodes.

Layer 1 · our current implementation Layer 2 · pi-mono Layer 3 · our plan Start Shared prefix head, will be written Never revisited Both write paths call get_branch(session_id), receiving only the head branch; other branches are unseen. Branch summary It has no long-term memory layer, so the summary is the only destination for abandoned statements. branch-summarization.ts:200-262 Enumerate all branches at session boundaries list_branches(session_id) returns every branch tip Call get_branch(session_id, tip) for each tip Written-message IDs exclude shared prefixes get_branch already filters rewound nodes Non-head: batch threshold; head: forced writes No new node types, events, or model calls. We need not infer deliberate abandonment; session storage records it When the user rewinds, _rewind.py marks the entire segment rewound. Nodes remain in the graph but leave the conversation path; get_branch filters them directly (session_store.py:1030). Retry sibling branches have only one or two turns and naturally fall below batch size. Branches with substantial work before returning meet the threshold, exactly matching the gap.

Why not summary nodes. pi-mono’s session JSONL tree serves replay and branching; across sessions, only AGENTS.md remains, so abandoned branches truly disappear without summaries. We have a full topic-organized memory layer. Facts stated on a branch belong in topics/people/dave.md, not in a summary node attached to the DAG, where they have no footnotes or block IDs and cannot be retrieved. We already have a DAG-attached summary type (the compaction summary in context/summary), serving the context window rather than memory. Another type would add another location to search for a statement.

Furthermore, leaving a branch is not currently an observable event in our system. The event registry has no head-movement or branch-switch event. Head writes are spread across five locations (chain advancement in set_head, append_message, direct index modification in SessionNodeWriter.append, update_session, _rewind.py); their only common call is SessionMemoryIndex.set_head, which discards the old value. Copying pi-mono would require a new event in this path and retention of the previous head, solely to trigger work that is equally useful at session end. The session-boundary entry already exists: session_watcher.py:103 currently queries only the head branch.

One criterion must change together. should_incremental_write currently has two conditions: pending tokens exceed the threshold, or something is pending and the last message is over an hour old. An abandoned branch’s last message is necessarily old, so the second condition would also write one-turn retry siblings. Non-head branches must therefore use only the first condition and disable idle_after. This is the only additional decision this design needs.

04Cursor fallbackAdopt · implemented

When claude-code cannot find the cursor’s message, it treats all messages as unwritten again. Its comment explains that returning zero messages would stop extraction for the remainder of the session. Seeding from source archives in our write-cursor design makes the same tradeoff, but covers only migration; a similar ordinary-runtime failure remains.

Layer 2 · the path claude-code handles Layer 1 · our analogous current path Layer 3 · one rule The cursor is a message UUID; scan backward That message is no longer found in the session Naive result: zero messages No further extraction for the rest of the session Its choice: treat everything as unwritten Repeated writes: bounded cost; silent stops: unbounded Cursor state is in runtime.json The file is unreadable or invalid JSON RuntimeStateStore.load raises directly state.py:51-55: json.loads has no fallback Becomes WriteFailure(retryable=True) The watchdog never marks the session, retries every turn, and never writes successfully Unreadable cursor → empty set It is not an exception. An unreadable cursor means “I do not know what was written”; in a set-based model, the only safe interpretation is “nothing was written.” Rewriting a batch has bounded cost, and transactions already merge paragraphs that state the same fact. The structure already covers the other half The cursor file is inside the workspace, not alongside it: memory/.scriptorium/cursors/. Backups, Git commits, and rollbacks include it, preventing content from reverting a week while a current cursor marks that whole week written.

How this differs from migration. “Migrating from positional cursors” in overview.md addresses a one-time event: the old state cannot reconstruct the ledger, so source archives seed it, preferring a repeated batch to dropping everything. The tradeoff is the same, but occurs only during upgrade. Here, the ledger itself fails during ordinary operation, independent of migration. Both paths are needed and follow the same principle.

Where this applies after the ledger changes shape. The final write-cursor design uses “written” markers on nodes, rather than a separate memory-owned ID set, so there is no cursor file to become unreadable. The principle still applies to a different object: unreadable accounting means “nothing written,” rather than an exception that prevents the session from ever writing. For node markers, a missing marker causes repeated work; additionally, RuntimeStateStore.load encountering an unparseable runtime.json should return empty counters rather than raise. The two approaches are compared side by side in layer 3 of written-marker.md.

05Change locations

These are the file locations for the three adopted practices. The rejected practice changes no files.

Design Location What the location currently does Derived core.md · implemented Section 02 The first five rows change behavior; the next five remove special cases that are unnecessary once core.md is no longer canonical content runtime/derived_views.py Rebuild timeline, recent_events, and relations; add core.md management/block_views.py:24-36 _synchronize: budget 2000 tokens; report excluded block IDs management/agent.py:96-99 Remove the repair instruction “core.md is full; leave it alone” prompts/write.py:15 Change direct writer edits of core.md to topics/core.md prompts/system.py:7,15 The system prompt repeats the same instruction twice; change both management/transaction.py:27,169-190 WRITABLE_FILES: derived core.md becomes read-only management/patching.py:1-5 The patch writable surface likewise lists topics/** and core.md Four baseline locations in workspace.py and transaction.py All four include core.md block IDs in validation baselines; remove them topic_normalization.py:100,222,250,311 Block-ID allocation appends core.md to the file list as a fifth topic file store.py:95 ensure() initializes an empty # Core; rendering owns it after derivation Boundary: write all branches Section 03 memory/writing.py:231-291 _branch takes only head; forced mode loops until no pending content remains runtime/state.py:111-121 The idle branch of should_incremental_write must be disableable memory/session_watcher.py:103 Idle entry: db.get_branch(sid) returns only head Unreadable cursor → empty set runtime/state.py:51-55 json.loads in load has no fallback and raises on unreadable state

Node markers have replaced positional cursors. Implementation resides in SessionDB node metadata, runtime/online.pyandwriting.py at the _records boundary. Legacy migration restores verifiable nodes from strictly parsed Source prefixes. Session boundaries now process the current head first, then enumerate other live branches; node markers deduplicate shared prefixes. For the full definition, see written-marker.md. The derived-view change replaces _synchronize’s exception above 2000 tokens with a rendering budget and reports excluded block IDs. WRITABLE_FILES previously listed core.md as manually writable; the derived file is read-only.

Implementation status (2026-08-11): Section 01 rejects index-free model selection based on measured latency; section 02 implements the canonical topics/core.md source and deterministic core.md derived view; section 03 implements live-branch enumeration at session boundaries; section 04 recovers corrupt state as empty state together with strict legacy migration and node markers. The current implementation still uses post-turn thresholds and an idle watcher, with no direct writer notification from head-change events.