机制图解

一段对话怎么变成磁盘上的主题文件,写到一半失败会怎样,会话分叉之后靠什么认出哪些已经写过, 每次会话都注入的那一小块记忆归谁维护。 正文在 docs/reference/design/memory/overview.md,第 04 节的完整设计在 written-marker.md,这页只画机制。 别家怎么做的、我们的选择落在哪一格,在 记忆写入:八个框架横向对比。

在跑的

两个写入入口、五步写入、暂存事务、夜间整理、沉默即成功的返回值契约, 以及第 05 节的常驻块派生视图:core.md是topics/core.md的渲染结果, 预算不再拒事务。

定稿了还没落地

第 04 节的写入游标:跑着的仍是位置游标,分叉之后整条分支漏写, 或者只写下一个从对话中间开始的片段。要走的是在节点上打「已写」标记, 从 head 往回走到第一个有标记的为止。

接口留着没接线

name is_available initialize shutdown extract_before_discard:五个方法运行时没有任何调用方。

01 一条对话怎么变成长期记忆 02 写入的五个步骤 03 事务:改在暂存,验过才装 04 游标:身份不是位置 05 常驻块:谁在维护它 06 接口的九个方法 07 失败会怎样 08 还没解决的 09 Web 页面状态 10 统一修改接口

01一条对话怎么变成长期记忆

两个入口,一段写入逻辑。两条路的区别只有 force 这一个开关:一个够了才写,一个剩多少写多少。

一条对话怎么变成长期记忆 对话不在写入时缓存在进程里。每一次写入都重新从会话存储读回来,所以进程重启不丢。 会话存储 持久、有序,每条消息一个稳定 id 用户 助手 用户 助手 用户 助手 … get_branch(session_id) 沿 predecessor 边往回走, 拿回 head 那条分支,按顺序 只有人说的和助手答的算数。工具调用和结果、runtime 自己排的轮次(子 agent 回报、分支合并提示)、空消息,都不进记忆。 入口一 · 每轮结束 agent/dispatcher write(session_id=…) force=False 不传消息,自己去会话存储读 每轮都调,多数时候什么也不做 入口二 · 会话闲下来 memory/session_watcher write(messages, …) force=True 5 分钟扫一次,30 分钟没动静算闲 剩多少写多少,不管够不够一批 每天 03:00 · 整理 memory/scheduler reorganize(model=…) 不写新内容,只重排已经落盘的 writing.write() 两条路在这里汇合 force=False 走一趟就回来。不到阈值 就一趟也不写。 force=True while 还欠着:一趟接一趟, 直到不欠为止。 会话结束后没有下一批, 停在半路就永远补不上。 write_session() 一趟 五步,见第 02 节 1 挑欠账 · 游标没记过的 2 分批 · 累加到 16k token 3 调模型 · 在暂存里改文件 4 整理 · 攒够了就重排 5 提交 · 验过才整体安装 记忆库 <state>/memory/ topics/ 主题文件。模型只改这里 sources/ 原文存档,只增不改 core.md 每次会话都注入 timeline/ 等三处 派生视图,写完就重建 .scriptorium/ 游标、写锁、写手历史 写成一次,就 commit 一版 force 时:还欠着就再来一趟 整理不走写入这条路:它只重排 topics/,不动 sources/,也不推游标
怎么读:左边三个入口,中间是它们共用的那段逻辑,右边是磁盘。 绿色那条每轮都触发,但只有攒够 16k token 才真的写;蓝色那条在会话闲置后触发,把剩下的全部写完,所以它要循环。 紫色那条不产生新内容。
为什么每轮那次不传消息:会话存储本来就是持久有序的,从它读回来的每条消息都有稳定 id, 进程里再缓一份只会在重启时丢掉,而且给出的位置每次运行都不一样。

02写入的五个步骤

一趟里发生的事。阈值只回答"值不值得叫一次模型",不回答"一次写多少",所以跑了一天的会话要走好几趟。

1 挑欠账 分支上的每一条 减去游标记过的 = 这个会话还欠着的 runtime.pending() 顺手滤掉工具调用 和 runtime 自排的轮次 2 分批 从头一条条累加 够 16k token 就切 切下来的是这一批 剩的留给下一趟, 一次模型调用装不下 一整天的对话 3 调模型改主题文件 写手拿到这一批原文 和当前的目录结构 用文件工具改 topics/ 改的是暂存目录里的 拷贝,见第 03 节。跑在 用户自己的登录上 4 整理 攒够 5 批或 4 万 token → 重排一次 距上次全局整理满一天 → 再重排一次 同一个动作,两个触发 条件。都不满足就跳过 5 事务提交 校验通过 → 整体安装 不通过 → 整批丢弃 装进去了才推游标 这一批的原文在调模型 之前就已经存档,所以 存档永远不落后于游标 整趟持一把跨进程写锁,等 1 秒拿不到就放弃:正在聊天的会话要写记忆是常事,让用户等不如下一轮再来。

Topic 规范化在清理空章节或 topic 之前,以 INVALID_TOPIC_FORMAT 拒绝实质性的无引用正文。changes、patch、编辑器和 CLI staged edit、writer shell commit 共享该校验;拒绝时保留已提交 Topic/Source 字节、Runtime 状态、revision 和 Git 历史。带引用的原生/旧格式记录及真正空 topic 保持现有规则。

实例级可信安装前回调在校验和stage准备完成后、移动已提交文件之前记录autosave或restore历史。无效编辑不会创建手动checkpoint;回调失败不会安装。有效编辑继续保留准确的编辑前快照和现有checkpoint期限。tools参数或请求正文不能提供该callable。

03事务:改在暂存,验过才装

模型不直接改记忆库。它改的是一份临时拷贝,只有整份验过才被整体安装;验不过整份丢掉,记忆库一个字节没动。

一次写入事务的全过程 记忆库 topics/ sources/ core.md 事务期间它是只读的 参照物,谁也不在这里 直接改 整份拷贝 暂存目录 系统临时目录里新建 topics/ 可写。写手这一轮的每次编辑都落这儿 sources/ chmod 0444 文件系统这一层就拒写,不等到最后才发现 基准线在拷贝之前从记忆库读:拷完再读,等于 拿这次编辑去量它自己,丢掉的块 ID 会像从没存在过 四道校验 1 sources/ 的指纹没变 原文存档只增不改 2 改之前的块 ID 一个不少 段落可以改写、合并、搬家 3 主题间的链接指到 #^块 ID 4 引用的来源真的存在 core.md 不超额,块链接不悬空 通过 · 整体安装 现有的先挪进备份 暂存整份装入 重建派生视图,commit 不通过 · 整批丢弃 暂存目录重新拷一份 记忆库保持原样 抛出被拒的原因 被驳回之后:只有一次修复机会 第一次驳回 把被拒的原文,加上一句针对这条 规则的具体改法,发回写手重做 工作区已经回到这一轮之前的样子 重做一遍 同样在暂存里改,同样四道校验 基准线在修复之前重新取一次:改完再取会去 解析模型刚写坏的文本 第二次还驳回 → 这一批彻底不写 报 COMMIT_REJECTED,游标不动,这些轮次仍然欠着。 在这里报成功,就等于让调用方把游标推过一批从没落盘的内容。 同一条管线也给人用:手改主题文件走的是同一套暂存和校验,改坏了当场被拒,提交过的文件一字不动。 块 ID 是别的段落、时间轴和关系图找到这一段的唯一途径,所以"内容随便改、ID 必须还在"是这套校验里最硬的一条。

04游标:身份不是位置

一条会话是一张图,不是一条线。重问一句、重试一次回答、派一个子 agent,都会从中间分叉出一条新分支。 分支上说的话都该进记忆,共享前缀上说的话不该进第二遍。

同一个会话,主干写完之后从第三条消息分出去 主干 9 条已经写进记忆。用户回到 m3 重新问,新分支往下又聊了 6 条,head 现在在 b9。 主干 m1 m2 m3 m4 m5 m6 m7 m8 m9 这 9 条已经写过 从 m3 分出去 新分支 b4 b5 b6 b7 b8 b9 head 在这里 这 6 条从没写过 共享前缀 现在跑的 · 记「写到第几条」 游标存 ordinal=9,只收编号大于 9 的 45 67 89 六条全部 ≤ 9 一条都不写 要走的 · 在节点上打「已写」标记 从 head 往回走,收没标记的,遇到有标记的就停 无标记无标记无标记 无标记无标记无标记 六条全部欠着 走到 m3 有标记,停 另一个能走通的 · 记忆自己存 ID 集合 走完整条分支,减去集合里的 ID 新 id新 id新 id 新 id新 id新 id 六条全部欠着 结果一样,代价不一样 分叉点之前为什么自动不会重复写: 分支这条线往回走,走到 m3 还是那条消息,标记就在它身上,走行到此为止。 两条线之间不需要任何顺序,也不需要互相知道:谁先写,谁就给共享的那几条打上标记,另一条来的时候正好停在它们上面。 标记写在哪 节点 metadata,键名带 provider 值是记忆工作区的标识,导出的会话 带着标记去到别处,不会被误认 rewind 盖 rewound 就是同一条路 什么时候打上 两个条件:事务安装成功,并且这一趟 确实改动过文件 实测:二十轮撞同一处被拒编辑, 返回成功,主题文件只有一个字节 这一节的状态 设计定了,代码还没换。跑着的仍是位置 游标,上面那排红叉是改之前的真实行为 实测:存的是 9,一条五轮的分支被算成零轮, 一条十一轮的分支被算成最后两轮
为什么位置当不了身份:编号是链表被展开成列表的那一刻数出来的,它描述的是这次展开, 不是消息本身。展开之前没有这个号,展开之后这个号和消息的 ID 没有关系。 同一条消息沿两条线走到会拿到两个号,两条线上深度相同的两条不同消息会拿到同一个号; 而且 get_branch 会滤掉 rewind 标记过的轮次、压缩会把摘要节点接在中间, 所以同一条线在两次读取之间也不稳定。
这个形状连纠正都不接受:advance_cursor 在新序号低于已存序号时抛 cursor cannot move backwards,而往回走恰恰是分叉需要的。 单调计数器和会分叉的对话是两个形状,其中一个修不成另一个。
两种失效不是一样响的:分支还没超过已写高度时一条都不认领,是漏掉一条分支; 超过之后只认领超出去的那一段,是写下一个从对话中间开始的片段,然后把整条分支记成已写。 后一种是成功返回的,没有任何东西会提示它出过错。
第一层 · 我们现在 位置游标:runtime.json 里一个 thread 一条 {message_id, ordinal},只认领序号更大的记录 序号来自 enumerate(get_branch(...)),而 advance_cursor 明确拒绝往回移动,所以连改对都做不到 第二层 · 别人怎么做 读了九个参考框架。三个存的对话会分叉(claude-code、openclaw、pi-mono,都是一份追加写的文件里每条带父指针);三个是直线;一个在重试时把整个会话重写掉、旧分支直接毁掉;两个不存对话。 直线让位置就是身份,所以大多数根本碰不到这个问题。没有任何一个把「记忆取走过这一轮」写到对话轮次上。 codex-cli:分叉开一份新文件,抽取任务按 thread 记水位和租约,把单位放粗到分叉不再重要 pi-mono:存储根本不提供改动一条记录的方法,加注解和移游标都靠追加一条指向它的记录,分支摘要覆盖谁在切换的那一刻从树上现算,从拓扑推增量 claude-code:游标放进程内存,指的消息找不到就整个会话重新处理;openclaw:写 promotedAt,但写在派生记录上,它的记忆钩子读对话时平着取最后十五行 第三层 · 两种做法并排 在节点上打标记 欠账:从 head 往回走,遇到第一个有标记的就停 要存的:一个本来就存在的节点上多一个字段 增长:不增长;一次读取只走几步 迁移:把归档覆盖到的节点标上 丢一个标记:走行提前停下,更老的全部搁浅 耦合:记忆往会话存储里写一个字段 记忆自己存一份 ID 集合 欠账:走完整条分支,减去集合 要存的:每个 thread 一个文件,装下写过的每个 ID 增长:每条消息一项;一次读取要整份清单 迁移:用同一份归档播种同一个集合 丢一个条目:那一轮被重新拿出来,精确 耦合:记忆自己记账,但带出文件、迁移、只增不删 第四层 · 取哪一个 取节点标记。 「记在外面」一旦要在分叉之后仍然正确,就只能是集合,而集合带出文件怎么放、怎么迁移、ID 为什么不能删、读取代价随会话增长这一串。 代价是集合精确而走行不精确:走行假定标记构成一段前缀,靠「一批永远是最老的那几轮」成立,它是假定不是不变量。会话存储本来就在给节点存来源、显示类别、分支根、rewind 状态,多一个字段是同一类东西。
先做能幂等重放的那一步:三步的顺序是先归档证据、再写主题文件、最后打标记。 归档是追加式、按内容寻址的,同一批归档两次逐字节不变;主题文件带块 ID 和脚注, 半写会污染,所以走整装或不装的事务。写入器死在两步之间,留下的是没人引用的证据、 没有标记、下次仍然欠着的同一批,也就是一次重做。反过来先打标记,同一次崩溃变成安静地丢掉这些轮次。
「做完了」都要有可核对的产物:标记要事务安装成功并且这一趟确实改动过文件, 夜间那一趟要报出改过哪些文件而不是看过哪些文件。没有报错不是产出。
它让会话存储付出什么(核实过):会话那边没有整树哈希, 唯一那个逐字节整树哈希是记忆工作区的版本号,根在 <state>/memory, 和 <state>/sessions 不相交,所以标记不会被读成一次并发的记忆写入。 会被碰到的是会话索引缓存:过期判定看 history 目录的 mtime,一次重写和一次追加一样会让它变, 别的进程重建一次索引,实测 289 个节点 4.2 MB 是 14 到 50 毫秒,而且自限。 真正会增长的是 git:会话目录每轮 git add -A 一次, 字节变过的节点都会成为新 blob,所以标记只打在这一批上,不做全量扫一遍, 否则每轮加上整个会话的重量,一生下来是平方级。 另外两条硬约束:标记不能动 updated_at(闲置看门狗就是按它判断"已处理", 动了等于把会话送回一次强制写入),也不能走"把会话记成已同步"的那条路径。

05常驻块:谁在维护它

core.md是每次会话开头都注入的那一小块。它现在是 topics/core.md的渲染结果,2000 token是渲染预算而不是闸门。 改之前它是被写的正本,顶到上限的那一刻就是它最后一次改变的时刻,而且此后每一笔事务都被它拒掉。

同一个文件,两种身份:被写的,和被渲染的 左边是改之前的,右边是现在跑着的。分界线就一句话:正本在哪。 改之前 · core.md就是正本 写入agent直接编辑它 prompts/write.py · prompts/system.py 2000 token闸,超了就抛 management/block_views.py的_synchronize 整笔事务被拒,这一轮的主题改动一起回滚 修复话术只有一句:core.md满了,别动它,这条放进主题文件。 从此每一条稳定事实都被劝走 而且没有任何东西让它变短:夜间整理的文件列表只从topics/**.md来。 闸量的是暂存里的整份core.md,不是这次改动 所以一份已经超标的core.md会拒掉它之后的每一笔事务,包括只改主题文件的, 和一个字都没改的。而它本来就允许人手编辑,是普通Markdown。 实测:人在编辑器里把它撑大之后,一笔没有任何改动的事务报 Core Memory exceeds 2000 tokens: 4004 两次被拒之后:这个会话的对话不进记忆,下一个也不进 报COMMIT_REJECTED,它不在可重试集合里,所以观察者照样把会话标成已处理。 这个状态要靠人去改短那个文件才会结束。 现在 · topics/core.md是正本,core.md是渲染结果 topics/core.md正本 一个普通主题文件,不设上限 段落^a1 段落^b2 段落^c3 · 这次没装进去 写入方和夜间整理都只认识它 渲染 core.md派生 每次写入装成之后跟着重建 段落^a1 段落^b2 装到2000 token为止 装不下的不报错,只报出块ID 预算是渲染上限,不是闸门 被挤出去的段落还在正本里,照样被索引,照样能被search和memory_get找到。 所以留在外面只损失可见性,不损失内容,裁剪也就不必先分清是谁写的。 想让某一段一直在,就把它在正本里排到前面,这是普通编辑。 为什么必须先变成派生视图,才谈得上裁 事务有一条硬契约:改之前存在的块ID,改之后必须还找得到。 core.md里今天可能有独苗,裁掉它整笔就被拒;渲染结果的ID永远是正本ID 的子集,裁掉一段,ID在正本里活着。 这一节的状态 已经落地。左边画的是改之前的行为,不再发生。 实测:一份11883 token的手写core.md,一笔不碰它的写入照样装成,渲染出1983 token。 老实现里这件事是有人管的:memory/core.py的refresh_from_wiki把core.md从一个指定的wiki页重建出来,按标题边界截到预算,由sleep的deep阶段调用。 换成topics/sources这套结构时那条流程被删掉。右边那一栏就是把它接回来,只是指定页从wiki页换成主题文件。
两个方向的区别:现在是拒绝,拒绝落在写入的路上,代价是内容进不来; 计划是不装,不装落在渲染的路上,代价只是这一次没被看见。 为什么不给它加第二道闸:第二道闸仍然是闸,仍然在写入的路上,仍然会把稳定事实挡在外面。 别家要区分"人写的"和"自动写的"才敢裁,是因为它们的常驻文件是唯一一份,丢一行等于销毁一行; 这里正本是全量的,所以归属这件事根本不用判。

Web 默认展示实际注入块

GET /api/memory/core同时返回topics/core.md正本、根目录core.md渲染结果和考虑memory.core.inject后的有效注入内容。Core 页默认只读展示有效注入内容,并显示实际 token 数与 2000 token 预算;“完整记录”视图才进入可编辑正本。现有content/size/mtime字段继续表示正本,避免破坏旧调用方。

Writer 与 Organizer 控制内容用途

Writer 阅读完整输入,但只记录以后仍有用途的事实;不记录重复状态检查、未变化的测量、一次性工具过程和可从项目文件重新读取的细节。项目进度进入对应项目 Topic。Organizer 在处理topics/core.md时,把项目专属、过期或重复内容移入普通 Topic 或删除,只保留多数未来会话都需要的规则、偏好与身份事实。
验收入口要求
GET /api/memory/core在工作区锁内读取完整快照;兼容返回正本content/size/mtime,并额外返回渲染内容、有效注入内容、注入开关、token 数与预算。
Memory → Core默认显示实际注入内容和 token 预算;关闭注入时明确显示空注入。用户可切换到完整正本并沿用现有事务保存,保存期间的新草稿不会被刷新覆盖;所有 Markdown 预览经过共享清洗。
Writer prompt不再要求把每个重复过程写成 Topic;Core 不再接收普通项目进度。
Organizer prompt显式整理 Core 中的项目过程、重复记录和过期状态;删除后仍由 Git 保留历史。
实现状态(2026-08-17):已实现,修复后待复核。Web 默认展示考虑注入开关后的有效 Core,完整 records 保存采用 compare-and-set 同步 Runtime 规范化正本,Markdown 经过共享清洗,GET 在事务安装期间只读取完整快照;Writer 与 Organizer 已加入上述内容用途约束。正式工作区通过结构化 Memory 事务从 186 条整理为 15 条,Topic 文件从 9 个整理为 4 个;topics/core.md从 31 条、约 15305 token 整理为 3 条、约 429 token,实际注入约 425 token。重复探测、旧工具清单、项目重复检查及失效状态均已从当前视图删除,原内容仍保留在 Memory Git 历史中。自动写入阈值调整为 8000 token,检索只使用整理后的 Topic,不再直接混入 Source 原文。当前修复覆盖特殊 token 计数和保存期间继续编辑的状态边界;复核结论在验证完成后记录。

06接口的九个方法

记忆和 agent 运行时之间只有这一个接口。除了 name 之外每个方法都有默认实现, 所以换一套记忆系统只需要写它有事可做的那几个。下图按会话的生命周期摆开,并如实标出哪些还没接线。

按生命周期摆开的九个方法 线上方 = 运行时真的在调;线下方 = 接口留着,运行时还没有任何调用方。 system_prompt() 会话开始注入 core.md 包在 memory-context 里, 免得被当成用户现在说的 search(query) 每轮开跑前搜一次 默认 BM25,取前五条 时间预算很紧,要快 write(…) 每轮一次,闲置再一次 两次的差别只有 force, 所以是一个方法不是两个 reorganize() 每天 03:00 重排一次 也可以手动触发: openprogram memory sleep 选中 会话开始 每轮开跑前 上下文压缩时 每轮结束 会话结束 夜里 03:00 name is_available() 选哪一套记忆系统要用的 两件事。但现在没有"选"这 个动作:直接构造出来的 initialize(…) 本该在会话开始时调一次 附带后果:内置实现拿它记 会话 id,那条兜底路是死的 extract_before_discard() 方向和别的都相反:它什么也不存。 压缩器手里攥着一批准备丢掉的消息, 问记忆哪些该留进摘要 shutdown() 本该在最后一轮之后调 现在收尾靠闲置观察者, 它走的是 write(force=True) 这五个不是多余的方法,缺的是运行时那几行接线。 它们是留给第三方记忆系统的插件点:外部系统往往要建连接、要在会话结束时冲刷缓冲、要在压缩前抢救内容。 内置实现恰好这几件事都不需要做,所以缺了接线也没人发现。真接进来一套 mem0 之类的,第一件事就是把这几个调用点补上。
另外两条约定:一是回忆文本在产生的地方就已经包好 <memory-context>,出口不再包一次, 包两层会把里面那层剥掉、只剩一个空壳。二是接口里没有"注册工具"这一项: 带工具的记忆系统按插件接进来,插件本来就能注册命令、技能、MCP、hook 和 agent,再开一条私道只会绕过它。

07失败会怎样

写入的返回值只有三种,观察者读的就是它。不吭声等于成功,失败要说清是哪一种:会自己好的,和再试也一样的。

write() 返回什么,闲置观察者就怎么做 write() 一次调用 三种回答 什么也不返回 写完了,或者本来就没到阈值不欠什么 两种情况都是"没有问题要说" 观察者:标记已处理 这个会话不再被提起 下次轮询直接跳过 为什么方向不能反 忘了写 return 也是"什么也不返回"。 反过来读,这种疏忽就成了永久重试 还欠着 · 值得再试 别的写手正拿着写锁 git 提交失败、向量后端不可用 模型不可达、写手进程起不来 观察者:不标记 下一轮轮询原样再来 对话本来就安全躺在 会话存储里,不会丢 这一类的共同点 条件自己会消失:锁会释放,模型会 恢复。同样的内容下次送过去,结果 可能就不一样 还欠着 · 再试也一样 写手改两遍都被事务驳回 补丁引用了没提供的来源、格式不合 压根没给会话 id 观察者:照样标记 并且把原因发上事件总线 memory.ingest_ended ok: false 为什么明知没写完还标记 同样的内容再送一次还是同样的驳回, 一直重试只烧模型额度。而没人看见 的失败,就是永远不会被修的失败 每轮那条路用同一套返回值:它也会撞上被别人占着的写锁。以前那次撞锁被当成"还不够写"咽掉,和真的没到阈值长得一模一样,于是一轮没写成、什么也没说。 每轮那条路拿到"还欠着"只记一行调试日志:下一轮自然会再来。真正要把一个会话收尾的是闲置观察者,所以判断标不标记的责任在它那边。 无论哪一种,记忆都不会把对话带下水:每个钩子都自己吞掉自己的异常并记日志。吞掉不等于装作没发生。

08分支相关的实现状态

节点写入标记和会话边界的活分支枚举已经实现。其余问题分别是互斥分支的语义表达、跨会话spawn关系和压缩摘要去重;这些不属于位置记录本身。

一条分支只在会话边界上被回访 head,会被写 要等会话闲下来 每一轮里,两条写入路径要的都是"结束在head的那条分支"。 会话边界上才会枚举每条分支各要一遍。
已实现。强制会话边界写入先处理当前head,再枚举其他未归档分支tip;共享前缀由节点标记去重。当前仍通过闲置观察器触发,尚未改成head变化事件通知。
两条互斥的分支,一份主题文件 分支甲:用 Flask 分支乙:改用 FastAPI topics/projects/x.md 这个项目用 Flask 这个项目用 FastAPI 两段并排 谁也没说 它们互斥 检索会把两条都返回;夜里整理甚至可能把它们合成一段。
主题文件的格式里没有"这两条是互斥选项"这种说法,所以来自不同分支的记录只能并排堆着。
跨会话 spawn:两半各记各的账 会话 A · 主对话 cursors/openprogram/A.json 会话 B · 子 agent 的分支 cursors/openprogram/B.json spawn 线程键就是会话 id,所以两半落在两个游标文件下, 两次回溯谁也不会走进对方的图。 不重、不漏。 缺的是"这条分支接着那段对话"的那条链接。
同一会话内的 spawn 分支共用这个会话的游标和存档,不需要额外东西。跨会话那种,记忆里看不出两段是一回事。
压缩摘要一旦成为 head 原始轮次,已经写过 压缩摘要 它取的是被压那一段 第一条的前驱,所以平常是旁支 head 一旦挪到摘要这条线上,摘要就成了一条没写过的助手轮次: 内容是记忆里已有内容的复述,id 却是游标从没见过的。
于是同一批事实被换一种说法又写了一遍。平常不发生,因为摘要节点是旁支而不是主线上的一员。

09Memory Web 页面

页面采用 OpenProgram 现有“顶部标题栏 + 二级栏 + 内容区”结构。现代化来自清楚的层级、统一的行高、可读的内容宽度和稳定的窄宽度行为,不增加新的数据层或装饰性仪表盘。

当前问题

四个分区、上下文列表和 Topic 行使用接近的视觉权重;选中前右侧几乎全空。Core 的说明与视图切换在左栏,实际内容和 token 状态在右栏,关系不清楚。

结构原则

顶部只负责页面身份与设置入口;二级栏先选择分区,再显示该分区的列表或过滤器;右侧标题区集中名称、元数据和操作,正文使用受控阅读宽度。

保持不变

继续使用 Topics、Timeline、Recent、Core 四个现有入口和现有 API。没有 writer 状态条、Commitments、Scheduler 状态、统计仪表盘、新依赖或新的持久化 UI 状态。

目标页面 · 宽屏结构

点击左侧四个分区,可检查对应的列表与内容状态。

当前数据:0 Topics / 3 条 Core records / 2 个 Timeline 日期

记忆
主题
项目、偏好与关系的长期记忆
▧

暂无主题

对话与后台写入器生成的长期记忆显示在此处。常驻规则单独管理。

打开记忆设置
2026年8月15日
2 条记忆记录 · 派生自主题
时间线

星期六

时间线仍是按日期排列的派生视图,展示所选日期生成的 Markdown,不添加事件时间或变更元数据。

常驻记忆 · 2026-08-15

不得编造工具调用或结果。只报告可用工具实际返回的结果。

常驻记忆 · 2026-08-15

直接使用“你”,除非 owner 明确选择其他称呼,否则不得自行添加尊称。

最近记忆
3 / 50 条记录 · 最新长期记录优先 · 派生自主题
常驻记忆2026-08-15

不得编造工具调用或结果。报告实际观察到的结果;没有可用工具返回结果时明确说明。

常驻记忆2026-08-15

直接使用“你”,除非 owner 明确选择其他称呼,否则不得自行添加尊称。

常驻记忆2026-08-10

讨论记忆与其他操作工作时默认使用中文。

常驻记忆
已启用注入 · 425 / 2,000 token
提示词预览完整记录
只读提示词预览

常驻记忆

由完整记录生成并插入新的系统提示词。编辑完整记录即可更改此预览。

语言·8月10日

讨论记忆与其他操作工作时默认使用中文。

称呼·8月15日

直接使用“你”,除非 owner 明确选择其他称呼,否则不得自行添加尊称。

工具结果报告·8月15日

不得编造工具调用或结果。报告实际观察到的结果;没有可用工具返回结果时明确说明。

窄宽度 · 可操作样稿

小于 720px 时不把分区导航、列表和详情纵向堆叠。点击 Topic 进入详情,点击返回键恢复列表。

记忆
▧

暂无主题

新的长期主题显示在此处。

窄宽度规则

分区导航只在列表态出现;详情态占据全部内容宽度并提供明确返回按钮。Core、Timeline 和 Recent 没有文件列表时直接进入内容态。桌面二级栏的展开状态不被窗口宽度改写。

可用宽度优先级 内容详情 > 上下文列表 > 分区标签文字。宽度不足时先隐藏分区文字,只保留图标;不压缩正文到不可读宽度。
区域目标结构避免的问题
顶部标题栏页面标题与 Memory settings不显示无法由现有 API 准确计算的总量,也不放 writer 状态、刷新状态或重复说明
二级栏分区下面的 Folder、Topic、Timeline 年/月/日、Recent 与 Core 入口全部使用同一套 36px 行高、10px 横向内边距、8px 圆角、13px 字号和选中背景。Folder 与 Timeline 年/月必须复用同一个 14×14 线性 ChevronRight SVG、路径和展开旋转规则,并使用相同的数字徽标;Timeline 只用缩进表达层级;Topics 不重复显示由 Core 分区管理的 topics/core.md展开控件不再使用过小的实心三角,Timeline 不再显示与 Topics 不同的计数文本
内容标题名称、类型、路径、修改时间、视图与编辑操作集中在一个 76px 区域删除独立 meta 横条和分散的 Core 状态
正文Preview/Timeline/Recent/Core 使用最大 780px 阅读面;Edit 保持全宽等宽字体编辑器避免超宽文本和大片无结构空白
Markdown 预览Topics、Timeline 与 Core 在现有 marked 和 HTML 清洗链路内解释 Memory 使用的 Obsidian Flavored Markdown:命名 [^id] 显示为连续 [1] 上标,定义集中到带分隔线的文末有序列表,支持两个空格缩进的续行与 ^[inline footnote];段末或独立行 ^block-id 只保留不可见锚点,ID 仅接受 Latin 字母、数字和连字符;围栏代码、四空格缩进代码以及反斜杠转义的脚注语法保持字面内容不在阅读正文中暴露内部 evidence ID 和 block ID;不改变可编辑的磁盘原文
窄宽度分区图标栏 + 列表/详情单页切换不自动折叠桌面栏,不把三块上下堆叠,不允许内容区重叠
上游依据:Obsidian 风格 Markdown 定义 CommonMark、GFM、LaTeX 和 Obsidian 扩展的组合;Footnotes 定义命名脚注数字化、两个空格续行与 inline footnote;Internal links 定义 block ID 的位置和字符集。Obsidian 主应用闭源,公开 API 与帮助仓库不能提供核心 renderer 源码,因此本实现对齐官方语法和 Reading/Publish 的可观察输出,不复制未公开实现。

实现范围

页面布局主要由 apps/web/components/memory/index.tsx、parts.tsx 和 memory-page.module.css 维护。Memory 固定格式的阅读转换集中在专用 Markdown helper,复用现有 marked 与 HTML 清洗,不改变编辑内容、API、writer 或磁盘格式。

明确排除

不增加首页 dashboard、统计图、Topic 创建向导、拖拽排序、批量编辑、writer 状态、自动折叠栏、新 API 或新依赖。脚注来源链接继续使用原有 URL;本轮不增加来源浏览 API,也不把 Memory 专用语法扩展到 Chat Markdown。没有选择内容时只显示简洁的选择提示与现有设置入口。
验收边界:公开入口 /memory 在桌面宽度显示固定二级栏和完整内容区;Recent 与 Core 不在中间栏重复标题、数量、视图切换或注入状态,Core 的切换和状态只在右侧内容头部出现一次;Recent 右侧标题显示当前记录数与实时 memory.recent.limit,例如 7 / 50 records,不把记录条数误写成 token 预算;顶部不把受截断的 Recent 数量表述为 Memory 总量;右侧 Preview、Timeline、Recent 与 Core 使用居中的文档面,空 Topics 使用独立空状态卡;Topics、Timeline 与 Core 的 Markdown 预览把已定义命名脚注和 inline footnote 按出现顺序显示为 [1] 上标和文末脚注列表,两个空格缩进的续行属于上一条脚注,正文不显示 evidence ID 或合法的段末 block ID,脚注链接、重复引用和未定义引用保持可预测,行内、围栏、列表围栏和四空格缩进代码中的相同文本不参与转换,反斜杠转义引用保持字面内容,任意原文字符不能与内部占位符冲突;Timeline 按年、月、日折叠且不把派生文件重建时间显示成记录时间;Core 明确区分只读 Prompt preview 与可编辑 Source records;Topics 列表不显示 core.md;小于 720px 时不得产生横向重叠,编辑器操作进入独立下一行并可换行。页面不请求或渲染 writer 状态、pending 数、Commitments 或 Scheduler 状态。
本轮实现状态:Recent 和 Core 已删除中间栏的重复上下文块;Recent 的标题与容量只在右侧内容头部显示,并从 Recent API 响应头读取当前 memory.recent.limit,呈现为“当前记录数 / 配置上限”;Core 的 Prompt preview / Source records 切换、注入状态和 token 用量也只在右侧显示一次。Timeline 年/月/日折叠继续保留。正式 Memory 已通过结构化事务删除 survey test 项目记录;后台 writer 后续仍可按正常行为生成新 Topic。居中文档面、卡片化空状态、76px 单一标题区、780px 阅读宽度和窄窗编辑操作换行已经进入生产代码。Topics 文件行、Timeline 年/月/日入口继续复用 36px 行高、10px 横向内边距、8px 圆角和 13px 字号的侧栏行;Folder、Timeline 年和 Timeline 月也已复用同一个 14×14 线性 ChevronRight 与数字徽标,Timeline 只以缩进表达层级。Memory 专用 Preview 已把命名脚注和 inline footnote 转换成 Obsidian Reading/Publish 风格的数字上标与文末有序列表,并隐藏合法的段末 block ID;编辑器和磁盘原文保持不变。小于 720px 的完整列表/详情互斥仍不在本轮生产实现范围。

实现证据

提交 5f1f06c4 实现文档面、空状态和紧凑上下文栏;1c770a4e 将路径元数据合入 76px 标题区并统一 36px/780px 尺寸;b583833d 删除不准确的顶部总量并补齐窄窗操作换行;7ef440c6 统一 Topics、Timeline、Recent 与 Core 的二级侧栏行。check-memory-status 从父提交 RED 变为 GREEN;Memory 设置、Markdown 清洗、TypeScript、production build、HTML、链接与 diff 检查通过。Memory Markdown 行为测试覆盖命名脚注、重复引用、inline footnote、两个空格续行、行内/围栏/列表围栏/四空格缩进代码字面保留、转义引用、占位冲突、block ID 隐藏与 sanitizer;默认 18100 App 的真实 Topic 显示 [1]–[4] 及带 1.–4. marker 的文末脚注列表,正文不再出现内部 ID。

兼容要求

保留四个分区、全部现有请求、Core 保存 compare-and-set、Markdown 清洗、Timeline partial date、键盘焦点、Topics 排除 Core 与现有设置跳转。脚注转换发生在清洗前;生成的链接和锚点仍经过统一 sanitizer。不新增组件库、API、数据库字段、自动栏状态或测试 Memory。

10统一修改接口

Memory 同时支持模型直接编辑 Topic 文件,以及模型或其他程序调用结构化接口。两种方式不互相替代;区别只在于调用方如何表达修改。所有修改最终进入同一套 Runtime 暂存、校验、派生视图、原子安装和 Git 流程。

方式一 · 直接编辑文件

后台 writer 或整理模型在 Runtime 提供的暂存目录中直接创建、重写、移动或删除 topics/**/*.md。新脚注中的每个 Source 写成 [简短关键词](source_ref);模型只决定关键词和选择哪个已有 Source,不能决定最终链接。编辑结束后不能直接覆盖正式 Memory;必须调用 commit_edits,由 Runtime 分配和保留 ID、把 source_ref解析为归档链接、检查 Source 与 block 链接、重建 Timeline/Recent/Relations/Core,再整体安装。Web 与 CLI 的文件编辑走同样的 staged_edit 收口。

方式二 · 调用结构化接口

模型工具和 owner REST 接收 memory_changes[]。内容操作是 create_record、update_record、delete_record,结构操作是 move_records。Runtime 把这些操作渲染为 Topic Markdown,然后进入与直接编辑相同的安装流程。

Source 身份

source是不可变且必须验证的归档身份。Runtime 只根据它生成链接、检查 trust state 和恢复派生视图;显示文本不能改变引用目标。

引用关键词

label是本条引用的局部显示文本,由同一次 Memory 写入调用生成,不是 Source 的永久标题。Memory agent 的 prompt 与工具 schema 要求它使用简短、与上下文相关的关键词,并避免 Markdown、URL、Source ID、说话人或时间模板。除必须是非空字符串这一 Markdown 结构要求外,Runtime 不判断关键词质量,不截断或改写模型返回的 label,也不为标签增加模型调用。
规范输出:{"content":"...","sources":[{"source":"provider/thread/message","label":"调研范围"}]}。Topic 文件最终保留标准 Markdown:[调研范围](../../sources/...#source-...)。Parser 从链接目标恢复 Source 身份,不能再把方括号内的显示文本当成 Source ID。
参考接口可复用约定本项目取舍
RFC 6902 JSON Patchadd/remove/replace/move分开;没有语义化 merge/split。保留内容 CRUD 与结构 move 的边界,但目标使用稳定 memory_id,不暴露 Markdown 路径指针。
Notion 子块创建内容时同时指定插入位置;位置属于父级结构。create_record.destination同时描述 Topic、标题链与位置。
ProseMirror 变换join/split 等高层意图最终分解为可验证的 transaction steps。合并与拆分由模型规划成 update/create/delete/move,不增加含义不唯一的 Runtime 原语。
Mem0 / Letta公开 Memory 写接口以记录或 block 的增删改为主。沿用记录级 CRUD,并补充本项目多文件 Topic 所需的批量 move。
结构化操作必要数据Runtime 行为
create_record最终 content、time、非空 sources[{source,label}],以及 destination:topic_path、headings、position;before/after 时再给 anchor_memory_id。模型 schema 用 undated 表示无日期;owner REST 也接受 null。验证 Topic、位置和每个 Source,原样使用 label 作为显示文本,写入规范 Markdown,分配新的 block 与 evidence ID。
update_record现有 memory_id 加完整的最终 content/time/sources。只修改记录内容,保留 block ID 和位置,以最终值替换该段及其 evidence definition。
delete_record现有 memory_id。删除对应记录和不再使用的 evidence definition;存在悬空 block 链接时整笔拒绝。
move_records有序且非空的 memory_ids,以及与创建相同的 destination。按给定顺序批量移动记录;保留 ID、内容、时间、Source 和创建顺序。可在同一章节重排,也可跨章节或 Topic 移动。
共同事务:两种方式都先修改暂存副本,再执行 Topic normalization、Source 和 relation 校验、Timeline/Recent/Relations/Core 重建与原子安装,最后记录 Git。任何安装前错误都不能改变正式 Memory。Git 失败发生在安装之后时不伪装成回滚,返回值用 memory_committed 与 git_committed区分。
兼容入口:memory_update 与 POST /api/memory/changes新增 memory_changes。现有整个 Topic 文件的 changes[path/action/content] 和旧 unified diff patch继续兼容;三种 API 表达格式每次只能选择一种,最终都调用 MemoryWorkspace.update。
Source 输入:memory_changes[].sources[].source可引用归档中已经存在且可验证的 Source,也可引用同一请求顶层 sources 中的 new-source-*标签;后者先由 Runtime 转换为正式 Source ID。调用方不能提交 provenance、trust state 或 owner 身份。预发布的 source_refs: [string]继续接受,并以原字符串作为兼容显示名;新请求只公开 sources。
兼容与迁移:现有 Topic 中的显示名继续解析,Runtime 不在没有语义模型参与时猜测关键词。新写入和显式更新使用 Memory agent 提供的 label;label 在移动、派生视图重建和正文更新时原样保留。不会修改 Source archive、真实 Source ID、evidence ID 或 block ID。
位置契约:destination.position只接受 start、end、before、after。before/after 的锚点必须位于目标 Topic 与标题链,且不能属于本次移动集合。批量移动后的顺序严格等于 memory_ids顺序。同一 ID 最多接受一次内容操作和一次 move;delete 与 move 不能同时指向它。
组合操作:语义合并由一次 update_record、若干 delete_record 和必要的 move_records表达;语义拆分由一次 update、若干 create 和必要的 move 表达。章节或文件的合并、拆分只需批量 move。Runtime 不提供 merge_records、split_record、place_records 或 reorder_records。
结构清理:移动或删除后,Runtime 自动移除没有任何 Memory 记录的标题链;Topic 文件不再包含记录时直接删除。派生 Timeline、Recent、Relations 与 Core 在同一事务重建,调用方不修改这些文件。
旧格式边界:mem_* 记录继续保留原 ID。结构操作不得拆开共享同一物理段落的多个旧 ID 或规范 block alias;需要移动时必须把该物理段落的全部 ID 按段内原顺序、连续放进同一个 move_records。
Evidence 冲突:跨 Topic 移动时,若目标 Topic 或同批其他源 Topic 已有相同 evidence label,但时间或 Source 语义不同,Runtime 以 INVALID_ARGUMENT 原子拒绝整个事务;相同语义仅因相对链接路径不同不视为冲突。
预发布兼容:早期结构化接口的 create/update/delete输入仍由 Runtime 转换为新名称;模型 schema 和本文档只公开四个正式操作,旧别名不再扩展。
Scheduler 边界:本地 owner 创建的 prompt task 可以显式调用 memory_status、memory_get 与 memory_update,维护通过 MemoryRef 关联的长期记录;Scheduler service 不隐式双写。记录删除后不保留占位对象或 ID 转发,旧 memory_id 的当前查询返回 NOT_FOUND。外部 token capability 与非 owner 改写范围继续延期。
模型修改范围:交互模型、后台 writer 与整理模型都可重写、合并、拆分或删除当前 Topic。删除 block 时,同一事务必须移除或更新指向它的 Topic 链接;Source 归档仍只追加。增量写入和整理完成后均尝试记录 Git 提交。
验收入口可观测结果
直接文件编辑后 commit_edits[调研范围](source_ref)被规范化为显示相同关键词的真实归档链接;正式 Topic、Timeline、Recent、Relations 和 Core 同时反映模型编辑,且 parser 恢复完整 Source ID;校验失败时正式文件不变。
MemoryWorkspace.update(memory_changes=...)create_record/update_record 接受 sources[{source,label}]并保留调用方提供的 label;旧 source_refs继续工作。四种操作可单独或组合执行;任一项失败时 revision 和所有 Memory 字节不变。
memory_updatememory_changes、整个文件 changes 和旧 unified diff 得到相同的事务与返回字段。
POST /api/memory/changes接收相同的 memory_changes,返回 revision、changed_files、block/evidence ID 与 Git 提交信息。
实施简报:范围限于 Writer Prompt、Topic footnote normalization/parser、结构化 memory_changes、Topic 移动与 Timeline 派生显示;Source archive、检索身份、Preview renderer、数据库和模型调用次数不变。公共入口 RED 覆盖直接文件编辑与 MemoryWorkspace.update(memory_changes=...),受影响验证覆盖 Topic parser、结构化修改、Writer prompt、派生视图、owner REST、HTML 与文档链接。
Source label 实现状态:已实现。Writer 同一次写入按 prompt 与工具 schema 生成短关键词;Runtime 保留并验证完整 Source ID,把 agent 返回的 label 原样渲染为链接文本,移动记录、重建 Timeline 和构建检索索引时继续使用真实身份。结构化接口公开 sources[{source,label}],旧 source_refs、pre-v2 provider 归档和文件名含编码字符的 Source 仍可读取和提交;同一 Source 归档在一次 Topic tree 解析中只扫描一次。Runtime 不再对 label 做语义分类、截断或回退。
检索性能与信任:生产 BM25 使用版本化磁盘缓存,并在进程内按 Topic/Source 文件签名复用索引;embedding 同样复用已解析事件和文档向量。Topic 或 Source 文件的时间、大小或 inode 变化会使缓存失效,因此内容修改和 Source promotion 都会重新解析信任状态。BM25、Embedding 与 Hybrid 在返回 Topic 前统一根据其全部 Source 引用解析 trusted/pending,pending 支撑的 Topic 不进入自动上下文。
实现状态(2026-08-15):模型工具与 owner REST 已统一提供上述四个正式操作;整个文件 changes 和旧 unified diff 保持兼容。直接编辑 Topic 与结构化操作共用暂存、格式校验、空结构清理、Timeline/Recent/Core 派生、原子安装和 Git 历史。批量 move、位置与共享物理段落校验、首次结构调整时的 Recent 创建顺序保留、跨 Topic evidence 冲突拒绝均有公共入口回归测试。Scheduler 的内部 Agent 生命周期写入按 Scheduler 设计文档执行;记录删除后旧 memory_id 的当前查询返回 NOT_FOUND,不保留已删除记录或旧 ID 映射。外部 token capability 仍按本节边界延期。
实现状态(2026-08-11):每轮写入与闲置写入使用同一事务;节点自身记录工作区写入标记;会话边界枚举活分支;常驻块由topics/core.md渲染;后台writer沿用默认聊天agent,并允许memory.writer.model实时覆盖;未分类异常默认不可重试。memory.backend=none既关闭提示、召回、自动写入、整理和记忆线程,也在创建工作区前拒绝全部CLI memory动词与/api/memory/*路由。writer最近成功、失败分类、retryable判定和待处理数可由工具、CLI及Web查询。一次性memory backfill已实现,只处理未被Topic引用的trusted Source、排除pending并按批复用正常writer事务;组合测试覆盖SessionDB、默认模型解析、managed tools、暂存安装、Topic、marker与watcher状态。正式工作区验收中,23个旧v1 source文件(154个frame)经origin_scope到authority_tier的只读兼容后全部解析;2条待处理消息提交为3个Topic文件、5个唯一block,6个source引用和relation目标均通过校验,两条节点marker写入成功,第二次扫描处理数为0且revision不变。该验收未执行历史backfill,因此152个未引用frame仍存在。仍未实现:越权请求hold队列、按请求方档位过滤读取结果、互斥分支语义、跨会话spawn关系和由head变化事件直接触发写入。写入失败原因码是封闭枚举,受限writer阶段看不到Source归档。