OpenProgram Docs

哪些轮次已经写进记忆#

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

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

配套可视化是written-marker.html,它外面的记忆子 系统是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 把一条分支变成SourceRecordordinal=index,也就是这一行在交给它的那个列表里的位置,跳过的行也算
OnlineMemoryRuntime.pending memory/runtime/online.py 只留下record.ordinal > 已存序号的记录
RuntimeState.advance_cursor memory/runtime/state.py 写入事务安装成功之后,存下这一批最后一条的序号

每轮那次调用的路径是dispatcher/__init__.py:_memory_writeLocalMemoryBackend.writewriting.writewrite_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_watermarklast_success_watermark,外加ownership_tokenlease_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.jsonpromotedAt确实存在,但写在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的名字,值是记忆 工作区的标识:

{"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交出的那些字典的顶层。最后这条正是键名 不能和消息字段撞上的原因:idsession_idrolecontentpredecessorcallertimestamptoken_modelfunctionextrastatus都被占了,memory_written_scriptorium一个都不撞。

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

追加不变量也不挡路。_check_append_invariant是在追加一个节点的时候检查的, 改动一个节点根本走不到它;它读的是predecessorcallerroleinput, 加上三个metadata键(displayspawn_branch_root连带sourcecovers_ids)。读取一侧get_branch的走行读的是同一批,外加rewound

怎么读#

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

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.loadsruntime.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_messageSessionNodeWriter.append都会推这个值,打标记的路径不能走它们任何一条。
  • 标记不能走那条"把会话记成已同步"的路径。GitSession.write_history会顺手 调mark_synced(),而那个指纹会盖住打标记的进程从没读过的写入,一个漏掉 了那些节点的索引从此再也不会重建。打标记的路径先经SessionStore._open (它在交出(git, idx)之前会把过期的索引重建掉),然后用 atomic_write_text直接重写节点文件,这正是SessionNodeWriter.updatedispatcher/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.cursorsadvance_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_historymark_synced。单节点merge复用该批量原语; SessionNodeWriter.update通过一次_open同时更新普通字段、metadata和spill, 然后只重写一次节点。批后当前进程和其他进程的旧索引各在下一次读取时重建 一次。
  • RuntimeState.cursorsadvance_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/test_memory_written_marker.py tests/unit/test_memory_writing.py tests/unit/test_memory_write_timing.py:59项通过。
  • memory、DAG branch、predecessor和session branch相关回归组:159项通过。
  • python -m tools.docs_site.build构建415页;python -m tools.docs_site.checklinks 报告0条断链。修改的Python文件通过ruff、py_compilegit 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。

Last updated · 2026-08-13