# 哪些轮次已经写进记忆

一个会话是DAG，不是一条直线。从早前的消息重新提问、重试一次回复、派出子
agent，都会让链分叉，于是一个会话里同时有好几条分支，共享一段前缀、在某处
岔开（[`../runtime/dag/overview.zh.md`](../runtime/dag/overview.zh.md)第4节）。
每条分支上说过的话都该进记忆，共享前缀上说过的话不该进两次。这份文档只讲
决定这两件事的那一个事实：哪些轮次记忆已经写过。

四层，回答四个不同的问题。第一层记录迁移前的实现和错误。第二层是
`references/`下各个框架怎么做，包括那些根本没有这个功能的。第三层是下一步
动手做什么，具体到文件和改动量。第四层是不受当前实现约束、重新设计这块该是
什么样，以及为什么它们都不是下一步。

配套可视化是[`written-marker.html`](written-marker.zh.html)，它外面的记忆子
系统是[`overview.zh.md`](overview.zh.md)。

---

## 第一层：迁移替换了什么

这一层保留为问题说明；附录记录当前实现。

### 一个会话一个数字

记录"写到哪了"的是一个位置游标，存在会话存储之外、记忆工作区的运行时
文件里：

```
<state>/memory/.scriptorium/runtime.json
    {"cursors": {"<session-id>": {"message_id": "a3f1c2", "ordinal": 9}}}
```

它是`openprogram/memory/runtime/state.py`里的
`RuntimeState.cursors`。键是`thread_id`，而`memory/writing.py`往里填的
是会话id，所以一个有六条分支的会话，六条分支共用一个游标。

整套机制只有三个函数：

| 函数 | 文件 | 做什么 |
|---|---|---|
| `_records` | `memory/writing.py` | 把一条分支变成`SourceRecord`。`ordinal=index`，也就是这一行在交给它的那个列表里的位置，跳过的行也算 |
| `OnlineMemoryRuntime.pending` | `memory/runtime/online.py` | 只留下`record.ordinal > 已存序号`的记录 |
| `RuntimeState.advance_cursor` | `memory/runtime/state.py` | 写入事务安装成功之后，存下这一批最后一条的序号 |

每轮那次调用的路径是`dispatcher/__init__.py:_memory_write`→
`LocalMemoryBackend.write`→`writing.write`→`write_session`。会话
边界那次是`memory/session_watcher.py:_process_session`。两条路都只向
`SessionStore.get_branch(session_id)`要一条分支：终止于会话head的那条。

### 这个数字从哪来

序号不是消息的属性。`get_branch`从head沿`predecessor`边往回走，交出一个
列表，序号是数这个列表的行数数出来的（`_records`里就是
`for index, message in enumerate(messages)`）。它在链被展开成列表的那一刻
才产生，描述的是这次展开，列表没了它也就没了。展开之前没有这个号；展开
之后，这个号和消息自己的ID之间没有任何关系。

由此有两件事。同一条消息沿两条线走到，会拿到两个号；两条线上深度相同的两条
不同消息，会拿到同一个号：主干的第四条和分支的第四条不是同一条消息，却都叫
"四"。而且同一条线在两次读取之间也不稳定，因为`get_branch`会滤掉`rewind`
标记过的轮次（`session_store.py`把metadata里带`rewound`的节点全部丢掉），
压缩会把摘要节点接在它覆盖的第一轮的位置上，两者都会让后面每一行整体挪位。

### 实测代价

一个记着"写到9"的游标，会把从第三条消息分出去、轮次从0开始编号的分支，整条
读成已经写过，一条都不给出来。而走过9的分支只交出尾巴。在一个已存序号是9的
会话上实测：

| 从m3分出去的分支 | 分支自己有几轮 | 交给写入器的轮次 |
|---|---|---|
| 短的 | 5 | **0** |
| 长的 | 11 | **2**（最后两轮） |

第二行更糟。它写进去的是一段从对话中间开始的残片，然后把整条分支记成写完
了，并且返回成功。

这两个数字是实测出来的，不是算出来的。编号轴上数的是整条分支的每一行，工具
调用行和运行时自己调度的轮次都占编号，因为`_records`是先展开再过滤的。所以
"有几行越过了已存序号"和"剩下几轮是记忆会记录的"是两个不同的数。

### 这个形状连纠正都不接受

`advance_cursor`在新序号低于已存序号时抛`cursor cannot move backwards`，
理由是游标只会往前走；而往回走恰恰是从早前消息分出去的分支所需要的，于是
就算上层算对了答案，也没有办法把它记下来。这不是少了一个判断。单调计数器
和会分叉的对话是两个形状，其中一个修不成另一个。

### 更安静的那一种失效更糟

整条被读成已写的分支什么都不产出，什么都不产出至少长得像什么都没发生。只
交出尾巴的分支产出的是一次成功的写入：记忆里多了一段从对话中间开始的残片，
整条分支被记成做完了，没有任何一条代码路径报出什么。账目最后才动的地方，
失败的默认后果是把活再干一遍；这里的默认后果是安静地把活跳过去，并且留下
一份被砍了头的记录。

---

## 第二层：别人怎么做的

`references/`下有九个目录。`claude-code/`只放了某个工具的五个文件，没有会话
代码，所以真正有答案的是八个框架。八个里有四个真的有长期记忆；另外四个也
记在这里，因为"没有"本身就是一种设计选择，而且它们的对话形状恰恰决定了这个
问题好不好回答。

| 框架 | 对话形状 | 有没有长期记忆 | 靠什么决定写哪些轮次 | 这本账记在哪 | 分叉时它会怎样 |
|---|---|---|---|---|---|
| **claude-code-leaked** | DAG。每条记录带`parentUuid`，一个会话一份追加写的JSONL | 有。`extractMemories`每轮跑一次，`autoDream`跨会话，`memdir`负责取回 | 一个按消息UUID记的游标`lastMemoryMessageUuid` | 只在进程里。一个闭包变量，从不落盘 | `/branch`拷到新文件时保留原来的UUID，游标仍然找得到。`/rewind`可能把游标指的那条消息切掉，此时计数器把全部消息重新数一遍，而不是返回零 |
| **codex-cli** | 每份rollout `.jsonl`是一条直线。一次分叉一定是一个新thread加一份新文件，靠`history_base`指针相连 | 有。`codex-rs/memories`两阶段：先按rollout抽取，再全局整合 | 一行按thread做键的记录：`jobs.input_watermark`和`last_success_watermark`，外加`ownership_token`和`lease_until` | 单独一份SQLite文件`memories_1.sqlite`，按`thread_id`做键 | 一次分叉是一个新`thread_id`，于是没有对应的行，于是这个thread从头抽取一遍。没有任何地方去join血缘，所以共享前缀被抽取了两次 |
| **openclaw** | DAG。每条记录带`id`/`parentId`，一个会话一份JSONL，并且提供了`readBranch`走行 | 有。`dreaming-phases`负责摄入，`short-term-promotion`把内容提升进`MEMORY.md` | 一个按行号记的游标`lastContentLine`，一个会话文件一个，外加一个`seenMessages`哈希集合 | `memory/.dreams/session-ingestion.json`。`promotedAt`确实存在，但写在`short-term-recall.json`里那条派生出来的候选记录上 | 一次分叉写出一份新文件，行号从0开始，于是拷过来的前缀被再摄入一遍。原地重试会让文件哈希变化，同样从第0行重扫一遍 |
| **hermes-agent** | SQLite里每个会话是一条直线。分叉发生在会话粒度上，靠`parent_session_id`，而`/branch`把每条消息拷进一个新的会话行 | 有。内置的`MEMORY.md`/`USER.md`工具，外加`MemoryProvider`背后八个可插拔实现 | 记忆这边什么都不记：`sync_all`在每一轮结束时触发一次，所以程序顺序就是游标。往SQLite刷盘另有一个自己的下标`_last_flushed_db_idx` | 在转录之外，在进程里。有一个插件在自己的缓存里给每条消息记一个`_synced`标志 | 每一处分叉都手工把`_last_flushed_db_idx`重新对齐。`on_session_switch`这个钩子是提供给各个provider的，八个里有七个没有实现它 |
| **pi-mono** | DAG。每条记录带`id`/`parentId`，一个会话一份JSONL。存储接口根本没有改动一条记录的方法：给一条记录加注解是追加一条指向它的`label`记录，移动活动叶子是追加一条指向它的`leaf`记录 | 没有 | 不适用。它的分支摘要改成走树：在切换的那一刻算，什么都不存 | 不适用 | 没有东西会过期，因为每次切换都重新算一遍 |
| **opencode** | 直线。SQLite，一个会话一条`seq`轴 | 没有 | 不适用 | 不适用 | `revert`先标一个点，下一次发消息时把这个点及其之后的消息全部硬删。更早的分支一条都不留 |
| **pi-ai** | 没有。一个无状态的流式客户端，每次调用由调用方交出整个消息数组 | 没有 | 不适用 | 不适用 | 没有分支这个概念 |
| **weclaw** | 自己不存。CLI和ACP后端只缓存一个下游会话id；HTTP后端在进程里留最近二十轮 | 没有 | 不适用 | 不适用 | 没有分叉这个概念 |

### 没有任何一个把这本账记在轮次身上

有记忆的那四个里，没有一个把"记忆取走过这一条"写到对话记录上。openclaw离
得最近，也差了一步：`promotedAt`是一个真正的"处理过了"标志，但它写在
`short-term-recall.json`里那条派生出来的候选记录上，不是写在候选来自的那
条转录行上。另外三个分别把这本账放在一个闭包变量里、一份单独的SQLite文件
里、以及程序顺序里。

八个里有三个存的对话按消息分叉（claude-code-leaked、openclaw、pi-mono）。
三个存成一条直线，只靠新开thread、新开会话或新开文件来分叉（codex-cli、
hermes-agent、opencode）。两个自己根本不存对话（pi-ai、weclaw）。一条直线
让位置就是身份，因为永远只有一个列表，所以它们大多数根本碰不到这个问题。
而在真的存了DAG的那三个里面，记忆管线做得最完整的那个，读转录文件时是平着
从头读到尾的，完全不走父指针。

### 真正在用的形状只有两种

**把单位放粗到分叉不再重要。**codex-cli的抽取任务按thread做键，于是一次
分叉不过是一个还没有行的新键。openclaw的摄入按会话文件做键，于是一次分叉
不过是一份从第0行开始的新文件。hermes-agent在每一轮结束时触发一次、记忆
这边根本不留游标，是同一招推到极限：单位就是一轮，账目就是程序顺序。

**从树上现算这次的增量。**pi-mono在切换分支的那一刻算出一次分支摘要覆盖
哪些轮次：从被放弃的叶子往上走到它和新叶子的最近公共祖先，收走中间那一段，
什么都不存。它的存储撑得住这种做法，是因为它是严格追加写的：没有任何一个
调用能改动一条已存的记录，所以移动游标本身是追加一条`leaf`记录，加注解是
追加一条`label`记录。

### 别人在分叉处的失效是重复，我们的是漏掉

上面两个按文件做键的设计，在分叉处都要付代价，而且付的是同一种。codex-cli
把分叉出来的thread从头抽取一遍，不去join `history_base`，于是它继承的那段
前缀被抽了两次。openclaw的分叉把当前分支拷进一份新文件，新文件的游标从第0
行开始，于是拷过来的前缀被摄入两次；它原地重试那条路会让文件哈希变化，同样
把整份文件从第0行重扫一遍。两家都没有漏掉任何一轮。两家都把已经读过的轮次
又读了一遍。

我们的位置游标犯的是相反的错，而且是更糟的那一个。重复是可以挽回的：夜间
重排会合并说着同一件事的段落，所以一轮被写两次的代价是一次模型调用，而且
它自己会纠正过来。漏掉挽回不了，因为再也不会有人回来找那些轮次。

### 什么值得拿过来

**账目信不过的时候重新处理一遍，不要安静停摆。**claude-code-leaked那个
游标计数器带着一条注释，说返回零会"在这个会话剩下的时间里彻底停掉抽取"，
并且在游标的UUID找不到时退回去数全部消息。这是一个决定而不是一个默认值，
在同一份代码里就看得出来：紧挨着它的那个同类计数器没有这条退路，会安静地
一直停在零。第三层把这一条落在两处：标记丢了的时候，以及`runtime.json`读
不出来的时候。

**追加一条指向节点的标记记录，是"改动节点"之外一个真实的选项。**pi-mono
证明了一整套会话存储可以这样运作。第三层把它否掉是有理由的，不是因为没有
先例。

其余的都搬不过来。上面那两种能用的形状都建立在"单位是整份文件或整个thread"
上，采用其中任何一种，都意味着放弃在一个还活着的会话里边跑边写记忆。

---

## 第三层：当前实现，在节点上打「已写」标记

边界按消息节点标识；"记忆写过这一轮"这件事存储在这一轮的节点上。

### 标记写在哪

写在节点上，写进`metadata`，键上带着写它的那个记忆provider的名字，值是记忆
工作区的标识：

```json
{"id": "a3f1c2", "role": "user", "predecessor": "9d0e77",
 "metadata": {"memory_written_scriptorium": "w-4f21c8e0"}}
```

键上带provider的名字，是因为记忆接口是可插拔的，两个provider各自想要自己的
那个答案，一个两边共用的布尔值在第二个provider出现的那天就是错的。值是工作区
的标识，是因为标记会跟着节点走。现在没有会话导出格式，一个会话目录就是自
描述的JSON，所以拷贝目录就是会话搬家的方式，拷贝会把每一个标记一起带走。
它到了一台记忆工作区是空的机器上，每一轮都在声称自己已经被写过；键上带
provider的名字分不开这两者，因为那台机器跑的是同一个provider。分得开的是
工作区。走行只在标记指着自己这个工作区时才停，于是从备份恢复出来的工作区
带着它的标记，从别处拷进来的会话则从头写一遍。

不需要教任何东西去携带这个字段。`metadata`在两个方向上都是自由字典：
`_msg_adapter._msg_to_node`把每一个它不认识的字段都放进去，`_node_to_msg`
把每一个metadata键摊回`get_branch`交出的那些字典的顶层。最后这条正是键名
不能和消息字段撞上的原因：`id`、`session_id`、`role`、`content`、
`predecessor`、`caller`、`timestamp`、`token_model`、`function`、`extra`、
`status`都被占了，`memory_written_scriptorium`一个都不撞。

往已经存下来的节点上写，也不是新事。`_rewind.py`给它退掉的轮次盖上
`rewound`并重写它们的history文件，`internals/_revert.py`用`reverted`做同
一件事，`dispatcher/finalize.py`就地重写节点文件去盖shadow-git的检查点戳记。
而`SessionNodeWriter.update`已经在做标记所需要的那个动作：把一个metadata补丁
合进节点、重写这个节点的history文件、别的什么都不碰。

追加不变量也不挡路。`_check_append_invariant`是在追加一个节点的时候检查的，
改动一个节点根本走不到它；它读的是`predecessor`、`caller`、`role`、`input`，
加上三个metadata键（`display`、`spawn_branch_root`连带`source`、
`covers_ids`）。读取一侧`get_branch`的走行读的是同一批，外加`rewound`。

### 怎么读

`_records`已经把一条分支收拢成记忆会记录的那些行：user和assistant角色、
正文非空、运行时自己调度的轮次去掉。走行跑在这个过滤后的列表上，从尾巴开始：

```python
def unwritten_turns(records, marked_ids):
    """尾部连续的、谁都没标记过的那一段，从老到新。"""
    out = []
    for record in reversed(records):
        if record.message_id in marked_ids:
            break
        out.append(record)
    out.reverse()
    return out
```

记忆从不记录的轮次，比如子agent的完成通知、分支合并的提示词，白拿到"被跨
过去"这一条，因为它们本来就没进`records`。读取的全部就是这些。它取代
`OnlineMemoryRuntime.pending`和里面那次序号比较。

交给写入器的那一批还是今天的样子：`_first_batch`取达到阈值的前若干轮，所以
一整天的欠账仍然分几趟写。

### 标记什么时候打上

两个条件，不是一个。写入事务要安装成功，**并且**这一趟确实改动过文件。

一个把每一轮都花在同一处被拒编辑上的写入器，可以既不抛异常也不碰任何文件地
结束，而只看它怎么退出的判断会把这读成"写进去了一批"。同一个写入器上实测过：
二十轮全部撞在同一处被拒的编辑上，返回成功，主题文件只有一个字节。审计记录
本来就答得上这个问题：`writing._changed_files(audit)`收集每一条
`status == "ok"`的`commit`记录改过的主题文件路径。所以写入器闭包返回这个
列表，空列表就一个标记都不打。

这条规则不是标记独有的。任何一个说"这件事做完了"的状态，都要能对着这件事
产出的东西核对，夜间那一趟报出改过哪些文件而不是看过哪些文件，也是同一条。
没有报错不是产出。

三步的顺序，取决于它们哪一步能重放：

1. **先归档证据。**`archive_source_records`是追加式的、按内容寻址：它把
   文件里已有的`<!-- source-id:… -->`注释读进一个`known`集合并跳过它们，
   所以同一批归档两次，归档逐字节不变。
2. **再写主题文件。**它们带着块ID和脚注，半写会污染，所以走整装或不装的
   事务。
3. **最后打标记。**

写入器死在第2步和第3步之间，留下的是没人引用的证据、没有标记、下次仍然欠着
的同一批，这是一次重做。反过来先打标记，同样一次崩溃就变成安静地丢掉这些
轮次，而这一种没有任何东西救得回来。

### 分叉时

开出一条分支的那一轮，带的是它所替代那一轮的predecessor，除此之外没有任何
特别之处。从这条分支的尖端往回走，会走到共享前缀，那里的轮次早就有标记，
走行就停在那里。主干和分支之间不需要谁先谁后，也不需要知道对方存在：谁先被
写，共享的那几轮就由谁打上标记，另一条来的时候正好停在它们上面。

第一层里实测的两个例子，按这条规则：5轮的分支交出5轮，11轮的分支交出11轮。
配图里画的是同一个案例，已存序号9、从m3分出去，于是那条分支自己有2轮和8轮
可交。

### 会话边界上，每条分支都问一遍

每一轮里，记忆要的是终止于会话head的那条分支，因为这一轮就发生在那条上。
到了会话边界，它把每条都要一遍：`SessionStore.list_branches`给出每个还活着
的分支尖端，每个尖端走一次`get_branch`，就是它背后那条线。共享前缀被重复走
几遍不花任何代价，走行在它第一条带标记的轮次上就停了。

head底下那条分支欠多少写多少，因为之后不会再有人回来找它。其余的要等欠账够
一批才写。只有一轮的分支是一次重试，而重试意味着有人否掉了那条回复；长到够
一批的分支，是有人真的走了一段又折回来的对话，那里说过的话和别处说过的话
一样该进记忆。`should_incremental_write`里那条让短的head分支也能写进去的闲置
放行不适用于它们，因为被放弃的分支最后一条消息必然是旧的，放行会把每一次重试
都放进来。

用户回退掉的那些轮次，在记忆看到之前就已经不在了。`rewind`给它们打上标记，
`get_branch`把它们滤掉，所以"有意放弃"是会话存储的判断，不需要记忆再判一次。

### 标记丢了怎么办

节点文件是没有锁的：在别的东西正重写同一个节点时给它打标记，两次写入会整份
丢掉一次，而不是按键合并。记忆打标记的对象是已经结束的轮次，而每一轮的写入
跑在这一轮自己的线程上、在这一轮落盘之后，所以剩下的窗口是闲置看门狗正在
扫描时遇上一个刚被唤醒的会话。

一个丢掉的标记会让比它更老的全部搁浅：走行提前停下。由此得出的规则，正是
claude-code已经用在它自己游标上的那一条：账目信不过的时候，重新处理一遍，
好过安静停摆。两处要落实：

- `RuntimeStateStore.load`用一个裸的`json.loads`读`runtime.json`，所以一份
  解析不了的文件会抛异常，那个工作区从此再也写不进去。它应当读成一份空
  状态。
- 走行一路走到分支起点都没遇到标记，就把整条分支交出来。上面那段代码本来
  就是这个行为，要做的是保留它，而不是给它加一道拦截。

### 从位置游标迁移

从位置游标升上来的安装，`runtime.json`里有
`cursors: {thread: {message_id, ordinal}}`，而所有节点上都没有标记，于是从
head往回走会把整个会话都收进来。

来源归档提供候选节点，但两种格式的可信边界不同。canonical
`sources/openprogram/_v2/<session-id>.md`把ID放在严格的`record-lines` frame中；
迁移只读取parser返回的合法前缀，正文不能产生ID，遇到非法或截断frame后不会
重新开始解析。legacy `sources/openprogram/<session-id>.md`没有正文行数或结束
标记。第一条记录开始以后，用户正文可以逐字包含一组anchor、正确hash和
`source-id`，它与下一条真实记录没有可验证的区别。因此legacy迁移只接受文件标题
`# <session-id>\n\n`后字节位置上的第一组合法header；后面的legacy记录宁可重写，
也不据此打标记。

候选集合也不能直接变成标记。旧ordinal错误可能留下“共享前缀已归档、分支中间
未归档、分支尾部又归档”的集合；如果直接给尾节点打标记，新读取会从尖端立即
停止，中间轮次永久不会再交给写入器。迁移对每个候选节点读取到该节点的真实DAG
路径，复用`writing._records`的角色、正文和runtime轮次过滤规则，只保留从第一条
可写记录开始连续存在于候选集合中的前缀，遇到第一个缺口即停止。各条路径的安全
前缀再按session合并。工具节点、runtime轮次和空正文不参与前缀也不打标记。

这条规则可能把已经归档的分支尾部再写一次，也会让legacy文件第一条之后的记录
再写一次。重复写入可由后续整理合并；多标一个尾节点会让缺口永久消失，所以迁移
选择少标。完全没有`sources/`目录的工作区从头写一遍。

所有session的安全前缀都成功写入节点之后，`cursors`才从`runtime.json`里离开；
任一批标记写入抛错时旧字段保留，下一次重试。计数器留在那里：
`creation_order`、局部批次与token计数、上一次全局整理的时间。

### 它让会话存储付出什么

**会话存储那边没有任何东西对整棵树做哈希。**这套系统里唯一一个逐字节的整树
哈希是记忆工作区的版本号：`memory/management/transaction.py`里的
`workspace_revision`，根在`memory/store.py:root()`返回的那个目录，也就是
`<state>/memory`。会话在`<state>/sessions`。两者不相交，所以一个被标记的
节点不可能被读成一次并发的记忆写入。

**会话索引缓存会重建一次。**过期判定是`GitSession.stat_fingerprint`，也就是
`history/`目录的mtime加上`meta.json`的mtime和大小，而一次重写让目录mtime变化
的方式和一次追加完全一样。另一个持有这个会话的进程会在下一次`_open`时重建
一次索引：实测**一个289个节点、4.2 MB的会话是14到50毫秒**，而且是自限的，
因为`mark_synced`记下了新的指纹，不会每读一次重来一次。

**会增长的代价在git上。**会话目录每一轮用`git add -A`提交一次
（`GitSession.commit_all`），所以每一个字节变过的节点都会成为一个新的blob。
给一次写入真正取走的那一批打标记，是每次几十个节点，仓库长大的量就是这一批的
重量。而每次写入都把整个会话的节点全标一遍，就是每一轮加上整个会话的重量，
在这个会话的一生里是平方级的。**所以标记只打在这一批上，永远不做全量扫一遍。**

**有两件事不能发生。**

- 标记不能动会话的`updated_at`。闲置看门狗判断"这个会话已经处理过"靠的就是
  比对这个值（`session_watcher._scan`里的
  `if processed.get(sid) == updated_at`），动了它等于把这个会话直接送回一次
  强制写入，以及里面那次模型调用。`SessionStore.append_message`和
  `SessionNodeWriter.append`都会推这个值，打标记的路径不能走它们任何一条。
- 标记不能走那条"把会话记成已同步"的路径。`GitSession.write_history`会顺手
  调`mark_synced()`，而那个指纹会盖住打标记的进程从没读过的写入，一个漏掉
  了那些节点的索引从此再也不会重建。打标记的路径先经`SessionStore._open`
  （它在交出`(git, idx)`之前会把过期的索引重建掉），然后用
  `atomic_write_text`直接重写节点文件，这正是`SessionNodeWriter.update`和
  `dispatcher/finalize.py`已经在做的事。

### 反面意见，以及标记为什么仍然赢

把账记在会话存储外面，这一方的说法是站得住的：

> 位置游标是一次O(1)的小文件读取，而且完全在会话树之外。在节点上打标记，
> 等于把同一件事记进第二个地方，而那个目录的mtime正是别处都在依赖的变更
> 探测器。还有，外部集合是精确的，走行不是：用集合，丢一个条目那一轮会被
> 重新拿出来；用标记，丢一个标记会让比它更老的全部搁浅。

这两半都是真的，第二半是真正的代价。走行假定标记构成分支的一段前缀。这一条
成立，是因为一批永远是欠着的里面最老的那几轮，打标记永远从最老的那一端往前
填；但它是一个假定，不是一条被校验的不变量。外部集合不需要这个假定。

决定胜负的是第一半。"在外面"只在这本账是一个数字的时候才便宜。一旦它必须在
分叉之后仍然正确，"在外面"就只能是一个消息ID的**集合**，而一个集合会带出
文件怎么放、怎么迁移、为什么一个ID永不能删（分叉可以从任意一条消息开始，
所以只要会话还在，每条消息都得一直认得出来），以及一次读取的代价随会话增长
而不是随欠账增长：

| | 在节点上打标记 | 记忆自己存一份ID集合 |
|---|---|---|
| 怎么算出还没写的 | 从尖端往回走，停在第一条带标记的 | 走完整条分支，减去集合 |
| 分叉时 | 走到共享前缀，发现有标记，停 | 前缀的ID在集合里，于是被跳过 |
| 要存什么 | 一个本来就存在的节点上多一个字段 | 每个thread一个文件，装下写过的每一个ID |
| 怎么增长 | 不增长 | 每条消息一项，会话在多久就留多久 |
| 一次读取的代价 | 从尖端往回走几步 | 这个thread的整份清单 |
| 迁移 | 从可信归档候选中只标DAG连续前缀 | 用同一份安全前缀播种集合 |
| 标记或条目丢了 | 走行提前停下，比它更老的全部搁浅 | 那一轮会被重新拿出来 |
| 耦合 | 记忆往会话存储里写一个字段 | 记忆自己记账 |

会话存储本来就在给每个节点存这一轮从哪来、是不是运行时在自言自语、它是不是
某条分支的根、`rewind`有没有把它退掉。多一个"记忆写过这一轮"的字段，和那些
是同一类东西。而标记触发的那次索引重建是有界的、上面实测过的。

追加一条指向这一轮的标记记录，也就是pi-mono的做法，是第三个选项，放在这里
更亏：它会给每一批写入在对话图上多加一个节点，此后图的每一个读取方都得知道
跳过它们。

### 要改哪些文件，改多少

按依赖顺序。除了迁移，没有一处需要新文件。

| # | 文件 | 改什么 | 量 |
|---|---|---|---|
| 1 | `openprogram/store/session/session_store.py` | 新增`merge_node_metadata(session_id, node_id, patch)`：`_open`，合进`node.metadata`，用`atomic_write_text`重写history文件。不碰`_persist_meta`，不碰`updated_at`，不走`write_history` | 新增约20行 |
| 2 | `openprogram/store/session/session_node_writer.py` | `update`把metadata重写委托给第1项，不再自己重复一遍 | 删约12行 |
| 3 | `openprogram/memory/store.py` | 新增`workspace_id()`：在`state_dir()`里读出或生成一个十六进制标识 | 新增约12行 |
| 4 | `openprogram/memory/runtime/state.py` | 去掉`RuntimeState.cursors`和`advance_cursor`，留下计数器；`RuntimeStateStore.load`读不出文件时返回空状态 | 删约10行，改约4行 |
| 5 | `openprogram/memory/runtime/online.py` | `pending`改成`unwritten_turns(records, marked_ids)`；`process`收一个`mark`回调，只在写入器报出改过文件时调用它 | 改约25行 |
| 6 | `openprogram/memory/writing.py` | `_records`去掉按位置生成ID的退路；`write_session`的写入器闭包返回`_changed_files(audit)`并提供`mark`回调；`_pending`改读标记 | 改约40行 |
| 7 | `openprogram/memory/writing.py` | `write(force=True)`向`list_branches`要每个尖端，每个尖端跑一趟，head那条先跑 | 新增约30行 |
| 8 | `openprogram/memory/runtime/mark_archived_turns.py` | 一次性迁移：读取可信归档候选，在真实DAG上只标连续前缀，全部成功后删掉`cursors` | 新增约50行 |

测试，都在`tests/unit/`下：

| 文件 | 要证明什么 |
|---|---|
| `test_memory_writing.py`（现有，515行） | 里面的`_pending`断言现在读的是游标，改成读标记。其余行为不变 |
| `test_memory_write_timing.py`（现有） | 阈值和闲置行为，不变 |
| 新增`test_memory_written_marker.py` | 实测的那两个案例：从m3分出去、自己有2轮和8轮的分支，两条都整条交出。共享前缀只写一次。被拒的一批一个标记都不打。别的工作区写的标记不算数。从`sources/`播种的迁移 |

合计**七个文件里改约200行、新增约130行**，外加一个新测试文件。第1到6项是一
组连贯的改动，可以一起落地；第7项可以拆出来跟在后面；第8项必须一次做对，
因为迁移多标了会丢对话，少标了会把历史重写一遍。改完之后唯一值得重测的运行
时代价是那次索引重建（14到50毫秒）。

### 这套方案没解决的

- **在线写入没有通用的前缀检查。**一次性迁移会在真实DAG路径上显式验证连续
  前缀；正常写入仍靠“每批只取最老的待写轮次”维持这个不变量。走行停在它遇到
  的第一条带标记的轮次上，并且相信更早的可写轮次也都带着标记。
- **一条分支只在会话边界上被回访。**两次边界之间，每一轮的路径只拿head那条，
  所以一小时前离开的分支要等这个会话闲置下来。等着不丢东西，早一点也拿不到
  更多：head的写入散在五处，它们唯一共同经过的那个函数把旧值丢掉了，也没有
  任何事件宣布这次移动。
- **多条分支共用同一批主题文件。**来自不同分支的记录折进同一批文件，而主题
  格式没有办法表达"这两条陈述是互斥分支上的两种可能"。检索会两条都返回。
- **跨会话spawn。**同一个会话内部的spawn分支就是这个会话图里普通的一部分。
  跨会话spawn的分支根指向另一个会话的图，走行到那里就终止，于是一段对话的
  两半被写在两个会话名下，两边的走行都不会跨进对方。没有重写，也没有漏写；
  缺的是"子agent这条分支接着调用方的对话"这条链接。
- **压缩摘要节点。**摘要节点取的是它覆盖的第一轮的predecessor，这让它成为
  head所在那条线的兄弟而不是成员，所以正常的走行看到的是原始轮次，写进去的
  也是原始轮次。一旦head挪到摘要自己那条线上，摘要就成了一条没写过的
  assistant轮次，内容是对已经在记忆里的那些轮次的复述。
- **标记的意思是交给写入器了，不是被引用了。**写入器把一批五轮折成一个段落、
  只引用其中一轮，五轮仍然全部被标上。这是有意的，也正是上面拿归档播种迁移
  站得住的原因；但一个标记不构成"某一轮的内容进了某个文件"的证据。

---

## 第四层：不受当前实现约束，这块该是什么样

第三层回答"下一步动手做什么"，这一层回答"如果重新设计这块该是什么样"。下面
三条不是标记的分阶段版本，它们是同一个问题的另一种摆法。

### 记忆自身的内容就是那本账

标记和位置游标都是记忆已经握着的一个事实的第二份拷贝。
`archive_source_records`在每一次写入时，都会把
`sources/<provider>/<thread>.md`里的`<!-- source-id:… -->`注释读进一个
`known`集合，正是为了跳过它已经归档过的轮次。这个集合**就是**"哪些轮次记忆
取走过"的答案，从记忆自己的文件里算出来，在热路径上，今天就在跑。

重新设计的话，别的地方一个字都不存。还没写的轮次是一次查询：从任意一个活着
的尖端可达、并且没有被任何一条记忆记录引用。这带来的性质是游标和标记都没有的。

- 它不可能和记忆对不上，因为它就是记忆。删掉一份主题文件里的一段，它对应的
  轮次就重新变成欠着的，这是正确答案，而外部账目和节点字段都给不出这个答案。
- 一个会话被拷到另一台机器上，不需要工作区标识、不需要导入路径、不需要迁移，
  它自然是对的。
- 它没有"标记丢了"这个情形，因为除了记忆本身的丢失以外没有别的可丢。

有三件事挡着它成为第三层。归档是按thread存的markdown、用正则解析，所以一次
读取是O(文件)，随会话增长，这正是反对外部集合的那个代价从另一个方向找上门。
让它变便宜就得建索引，而索引是一份派生缓存，需要一套失效规则，那比整个第三层
都大。还有，"归档过"不等于"被引用过"：归档发生在写入器跑之前，所以两者之间
崩溃会读成已写。第三层正是有意地、一次性地用了这一层松，去做迁移的起点；把
每一轮都建在它上面，就要求归档挪进安装主题文件的那个事务里，那是改事务，
不是改账目。

### 一条记录知道自己来自哪条分支

今天两条分支折进同一批主题文件，而格式没有办法表达这两条陈述是互斥的两种
可能。夜间那一趟会合并说着同一件事的段落，不管它们是不是来自同一条对话线；
而检索会把两条都返回给一个只在其中一条线上的读者。

重新设计的话，一条写下来的记录会带着它来自哪条分支，在某条分支上检索会优先
这条分支的记录，一条在另一条线上被推翻的陈述会呈现为一次分叉，而不是一处
矛盾。差距同时在三个地方：主题格式没有这个字段，`retrieval/`没有按分支过滤，
夜间重排的提示词里也没有"两条记录互为可能"这个概念。它不是下一步，是因为
这三件没有一件是小改动，而当前行为只在两条分支真的冲突时才是错的。

### 记忆是被通知的，不是靠轮询的

两条写入路径都在轮询。`_memory_write`在每一轮之后跑一次，问一个阈值问题；
闲置看门狗每五分钟醒一次，拿`updated_at`和30分钟的截止时间比。一条一小时前
被人离开的分支要等整个会话闲置才写得进去，因为没有任何东西宣布一个尖端不再
动了。

重新设计的话，会话存储会说出"某个分支尖端停下来了"，记忆据此反应。挡路的东西
第三层已经写清楚了：head的写入散在五处，它们唯一共同经过的那个函数把旧值
丢掉了，也没有任何事件携带这次移动。加上这个事件很小，让五条路都走它不小，
而且现在等着并不丢东西。

---

## 附录：实现状态

截至2026年8月11日，第一层描述的是迁移前的实现，第二层仍是对开源框架的
观察，第三层已经落地：

- 会话节点用`metadata.memory_written_scriptorium = <workspace-id>`记录当前
  记忆工作区已经处理过该轮；其他工作区的同名标记不生效。工作区标识
  在记忆运行时目录中原子创建并复用，格式为`w-`加8位十六进制字符。
- `_records`只接受节点自己的稳定ID；待写轮次改为在过滤后的分支上从新到旧
  查找当前工作区的最近标记，没有标记时交出整条分支。阈值批次仍从最老的
  待写轮次开始。
- 来源证据先归档，写入器随后安装记忆事务；只有写入器报告了非空的实际改动
  文件列表，才给这一批的来源节点打标记。异常、事务拒绝和无改动结果不打
  标记。
- `SessionStore.merge_node_metadata_batch`在一次`_open`中合并同一会话一批
  节点的metadata并逐文件原子重写history；它不改会话`updated_at`，不调用
  `write_history`或`mark_synced`。单节点merge复用该批量原语；
  `SessionNodeWriter.update`通过一次`_open`同时更新普通字段、metadata和spill，
  然后只重写一次节点。批后当前进程和其他进程的旧索引各在下一次读取时重建
  一次。
- `RuntimeState.cursors`和`advance_cursor`已经移除，其余整理计数器保留；
  损坏的`runtime.json`读为空状态。旧安装会在正常写入路径计算pending之前，
  读取`sources/openprogram/*.md`标题后字节位置上的第一组合法legacy header，
  以及`sources/openprogram/_v2/*.md`中严格解析的合法frame前缀。候选ID随后在
  真实DAG路径上经过与`writing._records`相同的过滤，只标记无缺口的连续前缀；
  legacy后续header、正文伪header、v2非法尾部后的frame和缺口后的候选都不生效。
  所有session的批量标记成功后才删除旧`cursors`，失败时保留供重试。
- 会话边界从`get_session()['head_id']`确定当前head，先写完该分支的全部欠账，
  再检查其他活分支；其他分支只有达到正常token阈值才写，不使用闲置短分支
  放行。共享前缀由先处理的分支标记，后续分支只交出分叉后的未写后缀。

验证结果如下：

- `python -m pytest -q tests/unit/memory/test_memory_written_marker.py tests/unit/memory/test_memory_writing.py tests/unit/memory/test_memory_write_timing.py`：59项通过。
- memory、DAG branch、predecessor和session branch相关回归组：159项通过。
- `python -m scripts.docs_site.build`构建415页；`python -m scripts.docs_site.checklinks`
  报告0条断链。修改的Python文件通过ruff、`py_compile`和`git diff --check`。
- 迁移加固之前的全仓基线为2556项通过、7项跳过、2项deselected、1项xfail，
  另有11项已记录的origin-guard/403失败。合并分支在发布前重新执行全量验证，
  不把这份旧基线当成本次补丁的验证结果。

第四层仍未实现：没有改成由记忆主题/来源内容本身充当唯一账本；没有把分支
来源写进记忆块或检索结果；没有用事件通知替换轮询；没有加入跨会话spawn
链接、压缩摘要语义或通用的前缀不变量检查。调度器设计、GUI记忆端点和
feature matrix也未改动。

历史backfill有意不使用这套marker判定。已经实现的`openprogram memory backfill`
选择未被任何Topic引用的trusted Source，因此会忽略旧runtime可能提前写入的节点
marker；pending Source被排除，命令也不增加或删除会话节点marker。两项定义保持分离：
节点marker表示某个工作区的在线writer已成功处理该轮，Topic引用表示历史Source是否
已经进入可召回的Topic。正式工作区尚未执行这次历史backfill。
