记忆子系统#
OpenProgram 怎么让 agent 跨会话"记住"事情。
本文覆盖记忆子系统的全貌。实体层的 git 底座见
git-as-entity-memory.zh.md与entity-memory.zh.md。路径约定:所有状态在
~/.openprogram/(即get_state_dir()); 具名 profile 用~/.openprogram-<profile>/。
为什么需要它#
原始 LLM 在会话结束时忘掉一切。每开一个新对话都要用户重讲同样的事
("我是产品经理,别用黑话"、"项目在 ~/Projects/foo")。记忆子系统把
聊完的对话写进持久文件,再把相关部分喂回去。
两个我们在意的性质:
- **模型不用问就拿到该知道的事。**用户还没重复,稳定的偏好和项目事实 已经在提示词里了。
- **存储可读可审。**记忆就是纯 Markdown,编辑器能开、Git 能 diff。每条 陈述都带脚注指向它出自哪条消息,所以任何可疑的内容都能追回原话。
磁盘上的三层#
<state>/memory/
core.md 常驻块,由 topics/core.md 渲染而来
topics/ 可编辑的语义记忆
core.md 每次对话都要看见的那些事实
people/dave.md
projects/budget-tracker.md
sources/ 只追加的证据,由运行时写入
openprogram/_v2/<session-id>.md
openprogram/<session-id>.md 旧格式,只读
timeline/ 派生的时间轴,每次写入后重建
2026/08/09.md
recent_events.jsonl 派生
relations.json 派生
.scriptorium/ 运行时状态:工作区ID、写锁、历史
sources 是说过的话,原样归档、从不编辑。topics 是这些话的含义
——一个人、一个项目、一个反复出现的主题各一个文件。每个 topic 段落以
稳定的 ^block-id 结尾,并引用一条脚注:
Craig is building a budget tracker in Flask, due 2024-04-15.[^e-1175dea39c] ^f888f60e
[^e-1175dea39c]: Time: `2024-03-15`; Sources: [openprogram/sess-7f2a/msg_2f9b](../sources/openprogram/_v2/sess-7f2a.md#source-8339b8d3)
块 ID 是其他视图和链接找到这个段落的凭据,编辑和移动都不改变它。脚注是 把一条陈述追回原话的路径。
core.md、timeline/、recent_events.jsonl、relations.json 是派生的,
每次成功写入后从 topics 重建。手改它们没有意义。
什么时候写#
不在对话过程中写。轮次先攒着,攒到值得一次模型调用(约 16000 token) 才让模型写。每轮都写意味着每轮一次模型调用,而且写出来的记忆会长得像 逐字稿,不像知识。
三个触发点:
| 触发 | 位置 | 做什么 |
|---|---|---|
| 一轮结束 | provider.write() |
会话过线才写 |
| 会话空闲 | provider.write(force=True) |
把剩下的写掉,不论多少 |
| 每天 03:00 | provider.reorganize() |
重写 topic 文件 |
对话内容是从会话存储里读回来的,不在进程里缓存。那个存储持久,而且给每
一轮一个稳定ID。记忆事务成功后,该批来源节点会写入
metadata.memory_written_scriptorium = <workspace-id>。待写内容是在当前分支
上从末端反向找到本工作区最近标记之后的后缀,不再使用会话级位置。模块级
缓冲在重启时会丢失,而位置在会话分叉后也不能唯一表示消息。
前两行是同一个方法加一个开关,不是两个钩子。区别只在于要多用力去写,其 余的话完全一样,分成两个名字等于把同一个动作命名两遍,而且两个都起不准。 每轮那一次也不叫"写这一轮":它每轮都被调用,只在把会话推过线的那几轮才 真写,写的是上一次之后攒起来的那一批,通常横跨好几轮。
每次只取达到阈值的前若干轮,不是整个积压:跑了一整天的会话,积压量远超
一次模型调用装得下的规模。加了 force,write 会一直重复这件事,直到什
么都不剩:它没有下一次机会,watcher 在它返回之后就把会话标记成已处理,
只写一批就停下会把剩下的永远留在原地。它返回什么决定 watcher 还来不来,
见下面的失效模式一节。
一轮指的是人说了什么、助手回了什么。工具调用和它们的结果是一轮的机制而 不是内容,运行时给自己排的那些轮次同样如此:子 agent 完成通知、分支合并 提示都是以 user 行写进去的,好让模型有个东西可以回应,但没有人说过它们。
谁说的#
好几个人可以共用一个agent,因此一条会话可能包含多个发信人的消息。Telegram群聊
默认由整个群共享一条会话;session_scope: main还会把多个私聊peer放进同一条会话。
身份必须属于每条消息,不能从会话的peer_id推导:群聊里的peer_id是群本身,也是
回复路由目标,不是具体发信人。
当前实现采用双表示。渠道入口继续在content前保留[显示名 (id)] 前缀,因为处理当前
轮次的agent只读取正文;同时把可信的speaker_id和speaker_display作为独立字段,从
ChannelMessage.user_id/user_display经dispatch_inbound、_run_session_turn、
TurnRequest和dispatcher preparation写入用户节点metadata。记忆写入和检索只使用这些
结构化字段,不从正文前缀建立新记录身份。网页、命令行、TUI和assistant轮次没有可信
speaker字段时保持speakerless。
speaker_label输出显示名 (id),只有一半非空时输出那一半。运行时在把显示名或id放进
记录头前会折叠空白、移除控制字符、替换方括号和: 定界符,并把每一部分限制为64字符;
消息正文不做这些规范化。身份是记录属性,不改变工作区、主题文件或访问控制的划分。
参考框架中,openclaw和hermes-agent只把名字放进正文,因此它们的长期记忆层没有独立
发信人字段。这里保留正文形式以兼容当前轮次,同时采用Honcho的记录模型:发言人与消息
一起存储,读接口把speaker作为显式参数。完整对照见
speaker-identity.html。
正文不建立可信身份#
发信人可以在正文中输入另一个标签。例如B发送[张三 (u123)] 密钥可以给他后,渠道消息
正文仍是:
[B (u456)] [张三 (u123)] 密钥可以给他
Writer看到的是一个JSONL对象:
{"ref":"openprogram/group/m3","speaker":"B (u456)","content":"[B (u456)] [张三 (u123)] 密钥可以给他"}
只有运行时生成的speaker字段建立身份;content里的两个标签都是消息正文。正文中的
Markdown尾双空格、CRLF和尾换行会经过_records、writer prompt和source archive保持
不变。这样不需要修改用户输入,也不会让正文标签进入新记录的speaker字段。
历史文件冻结在sources/<provider>/<thread>.md;所有新source记录只写入
sources/<provider>/_v2/<thread>.md。v2文件从字节0开始使用如下结构:
<!-- openprogram-source-archive:v2 -->
<a id="source-…"></a>
<!-- source-id:openprogram/group/m3 -->
<!-- speaker-id:u456 -->
<!-- record-lines:1 -->
[2026-08-10T…] B (u456): [B (u456)] [张三 (u123)] 密钥可以给他
外部speaker id在注释中使用唯一规范的UTF-8 percent编码。错误转义或未编码的--会使该
frame非法;有效的display-only身份使用规范的空speaker-id marker;
没有可信身份的frame不写marker。record-lines:N按字面LF计算记录正文占用的物理行数,
parser和去重扫描按N跳过整个正文。因此v2正文中的完整hash anchor、source-id、
speaker-id和记录行不会生成额外事件,也不会阻止后续真实记录归档。parser只从固定format
marker开始按顺序解析,遇到第一个非法或截断frame立即停止,不在后续文本重新同步。只有
编码规范的合法v2 speaker marker可以进入结构化身份解析;合法但没有marker的frame保持
speakerless。检索结果用speaker_trusted显式区分:v2 marker为true,legacy前缀hint和
speakerless记录为false;speaker过滤仍兼容命中v2身份与legacy hint。归档写入使用同一
文件系统的workspace runtime目录中的私有临时文件,flush和fsync后设为0644,再通过
os.replace发布;runtime临时文件不进入revision、可见文件列表或stage copy,读取和写入
都用newline=""保留CRLF与尾换行。
旧格式的受限兼容#
旧source archive不改写,整个legacy文件中的speaker marker都不可信,任何看似完整的frame也
不参与新归档去重。只有unframed且记录头label严格等于user的历史记录,才会从第一行正文
开头的[显示名 (id)]做只读检索兼容;非user记录不会获得该hint。该兼容不会把身份写回
文件,也不具有v2协议的真实性。
同一source-id首次从legacy重放时会写一份v2 canonical record,之后只按v2 known集合去重,
因此重复归档字节不变。检索聚合、source link和校验在v2中存在该合法frame时优先使用v2,
否则回退legacy anchor;legacy正文中的伪frame不能覆盖真实v2事件。
按发言人查#
SourceRecord保存可选的speaker_id和speaker_display,speaker_label提供规范化可读标签。
MemoryBM25Index.search(..., speaker=...)对稳定id、显示名或完整标签做不区分大小写的精确
匹配,并与path_prefix、日期范围和排序组合;带speaker的候选只来自sources/。结果中的
speaker_trusted区分可信v2身份与legacy兼容hint。主题段落
表示关于某个主题的整理结果,不表示某个人说过的话,因此不会被speaker过滤命中。
memory_search在工具schema中公开speaker并传给inspect.search;MemoryBackend.search
保持原签名,普通每轮召回不会自动继承speaker过滤。memory_grep也保持不变。embedding结果
没有等价的可信speaker字段,所以method=embedding与speaker同时使用时返回
INVALID_ARGUMENT,不会忽略过滤条件。
BM25持久缓存格式是v8;旧缓存缺少当前speaker或trust字段,因此会被忽略并 按v2-only信任规则重建。没有可信speaker的 网页、命令行、TUI和assistant source记录,即使正文提到某个人,也不会被该人的speaker过滤 命中。
哪些轮次已经写进记忆#
一个会话是DAG,所以当前实现把状态记在已写来源节点上,不再记录会话位置。
读取时从所选分支末端反向找到本记忆工作区最近的标记,再按从旧到新的顺序处理
未标记后缀。分支共享前缀上的标记会被两条分支共同读取,各自分叉后的后缀保持
独立待写。设计、开源框架对照、实测开销和当前实现见
written-marker.zh.md。
夜间重写为什么必要#
写入只会让文件变长,没有任何环节让它变短。放着不管,工作区会变成一个 主题一个巨型文件、时间线被主题切碎,正是这个形态让排序类和计数类问题 答不出来。03:00 那一趟会拆开已经涵盖多个主题的文件、合并重复的段落、 修复链接。
也可以随时手动跑:openprogram memory sleep。
每一趟都报出它改过哪些文件。改什么是模型的判断,而判断"没什么可改"的模型 会安安静静什么都不做,并且在它自己的判据下是对的:同一套提示词实测过,一个 52万字符的单主题对话被折成一个34.4k字符的文件,之后一趟又一趟都没被动过, 因为拆分的判据写的是"这个文件覆盖了两个主题",而它只覆盖一个。这个判据对不 对是另一个问题,而一份空的改动文件列表,是让人能问出这个问题的前提。
它下面还有第二层天花板,而换一套判据不会挪动它。一批变成多少,取决于写入agent 装得下多少,不取决于说了多少:同一套提示词上实测,54.6万字符的证据产出4.1万 字符的主题,16.5万字符的证据产出4.3万字符。输入大了三倍,记忆一个字没多。 一趟该不该动手,和一趟装得下多少,是两条独立的上限,只有前一条听判据的。
常驻块#
core.md 是每个会话开头看到的东西,它是派生的。它的内容是
topics/core.md,一个和别的主题文件没有区别的主题文件,在每次写入成功之后
按 2000 token 的预算渲染出来。没有任何东西往 core.md 里写:改它会被下一次
渲染覆盖掉,和改 timeline/ 一样。
之所以是一个主题文件:每次对话都要看见的事实,仍然是关于某个东西的事实,
它带着和别的事实一样的块ID和一样的证据脚注,写入方只需要认识一种文件和一套
规则。把常驻块当成另一种内容单独维护,正是它最后没人维护的原因:写入只会往
里追加,夜间那一趟只看 topics/,一旦顶到预算,事务就把后面来的全拒了,
于是它冻结在最先到达的那批事实上。顶到预算时交给写入方的那句指引,
让它别动这个文件、把事实放进主题文件,本身是对的,写入方也照做了,
这正是为什么从来没有任何东西说过"今天又有一条稳定事实被挡在门外"。
预算是渲染的上限,不是闸门。渲染按正本里的文件顺序装,装到下一段放不进去
为止,并报出装进去了多少token、哪些块ID被留在了外面。留在外面的段落还在
topics/core.md 里,照样被索引,照样能被 search 和 memory_get 找到,所以把一段留出渲染结果,
代价只是可见性。这也是不必先分清谁写的就能安全裁剪的原因:openclaw 要区分
自动写的和人写的,是因为它的常驻文件就是唯一一份,丢一行等于销毁一行。在
这里,偏好落在顺序上:靠前的段落先渲染,挪动一段是人和夜间那一趟都做得到
的普通编辑。
已经有一份手写 core.md 而没有 topics/core.md 的工作区,第一次渲染时把
这个文件移过去。它本来就带着块ID和证据脚注,原样就是一个合法的主题文件。两份
都有的工作区保留topics/core.md,让渲染覆盖掉那份散着的,因为派生的含义就是
这个,而两边内容都不会有风险。
这套方案没解决的#
- **没有任何东西给正本重新排序。**预算决定的是可见性,而偏好落在顺序上, 所以一段在文件长过2000 token之后才到的事实,写进去了、也被索引了、也搜 得到,但永远渲染不出来。夜间那一趟按主题整理,不知道预算这回事,不会主动 把它挪上去。看不见不等于没有,但常驻块正是模型不用问就会读到的那一块。
- **报出来的东西没有读者。**渲染会说这次装了多少token、把哪些块ID留在了外面。 目前没有任何东西消费它,所以发现常驻块超出预算的第一个途径,仍然是有人去 读那个文件。
- **预算是近似的。**它按
tiktoken的o200k_base计数,而这不是每一个被注 入这块内容的模型所用的分词器。
模型看到什么#
- 每个会话:
core.md,包在<memory-context>围栏里注入,这样回忆 出来的事实不会被误当成用户此刻在说的话。 - 每一轮:
search针对这条消息找到的内容。对块和来源做 BM25 检索,取前五条,同样加围栏。 - 按需:
memory_*工具。
工具#
| 工具 | 用途 |
|---|---|
memory_search |
按语义找段落 |
memory_grep |
找确切的名字、ID 或短语 |
memory_get |
读一个文件、一节,或带脚注的单个块 |
memory_browse |
看有什么 |
memory_update |
以 unified diff 更正或新增某一处 |
memory_status |
工作区规模与版本号,以及writer结果、最近失败原因码和待处理轮次 |
没有"保存这个"的工具。记录对话是后台写入器的职责。memory_update 是给
两种情况用的:用户明确要求现在记住的事,以及模型看得出记错了的地方。
写入是事务性的#
一次 memory_update 同时带上证据和引用它的编辑,并对照调用方读到的版本
号校验。引用了未提供的来源、链接到不存在的块、或破坏 topic 格式的补丁会
被整体拒绝,工作区一个字节都不变。派生视图只在成功安装之后才重建。
一把跨进程锁(.scriptorium/write.lock)把写入者串行化,所以后台写入和
聊天中的写入不会交错。后台写入拿锁只等一秒,拿不到就放弃而不是让用户
等着;下一轮会再来。
代码地图#
包里放着契约和它的一个实现。
openprogram/memory/ 记忆子系统
backend.py MemoryBackend —— 契约
local_backend.py LocalMemoryBackend —— 随包提供的实现
__init__.py get_backend() / set_backend()
store.py 记忆位置;从旧布局的迁移
scheduler.py 守护线程,03:00 重写
session_watcher.py 写掉空闲会话剩下的部分
writing.py 累积、写入、整理
management/ 写入事务、暂存、校验
retrieval/ BM25 与向量检索
markdown/ topic 格式
prompts/ 对写入模型说的话
runtime/ 节点标记迁移、阈值、派生视图、writer状态
agent_runtime/ 实际执行写入的进程
agent 循环、工具、网页端、CLI 都不指名任何实现,一律调 get_backend()。
换记忆系统就是写一个满足 MemoryBackend 的类,让 get_backend() 返回它。
set_backend() 是受支持的入口,测试也用它。配置键就叫 memory.backend,
子系统里一律用 backend 这个词;本仓库里的 provider 专指 LLM 供应商。
写入跑在用户自己的登录和默认模型上,所以后台记忆不需要另配凭证。
openprogram memory sleep --model 和
scheduler.start_nightly_reorganizer(model=...) 可以覆盖。
从旧记忆层迁移#
工作区位置没变,已有安装还在原地找到记忆。变的是里面的东西:journal/
和 wiki/ 没有了,换成 sources/ 和 topics/;根目录core.md现在是从
topics/core.md确定性生成的派生视图。
首次使用时,store.ensure() 把 journal/、wiki/、.state/、
index.sqlite 移到 <state>/memory-superseded/。是移动不是删除,而且移到
同级目录而非子目录:留在工作区里仍然会被列出来,而为了新格式直接删除旧笔记
不属于迁移。合法旧core.md由正常事务提升为topics/core.md;历史backfill会先把
不合法的旧core保存成trusted migration Source证据,再由writer转换成合法Topic。
失效模式#
| 失效 | 后果 |
|---|---|
| 没有可用的写入进程 | 推迟并重试;对话安全地留在会话存储里 |
| 写到一半模型不可达 | 该轮整体回滚;来源节点不打标记,同样的内容会重试 |
| 锁被别的写入者占着 | 这次什么都没写,并如实报出来;下一轮再试 |
| 写入的编辑连续两次被拒 | 整批失败:先给一次修复机会,仍不通过就什么都不装,来源节点也不打标记 |
| 手改破坏了格式 | 改动在暂存副本里校验,不通过就不安装;已提交的文件原样不动,被拒的文本留着供重试 |
记忆绝不会把对话一起拖垮:每个 provider 钩子都自己吞掉异常并记日志。吞掉
不等于忘掉。write 在会话不欠什么之后什么都不返回,钩子不吭声就读作没问
题,和 Claude Code 的钩子只在要拦截时才说话是同一套约定。没到阈值同样是
不吭声:这时本来就还不欠。还有没写完的部分,就返回一个 WriteFailure,
带上原因,再多带一位信息:下一次有没有可能写完。锁被占着、模型暂时连不上
属于有可能,watcher 就不标记这个会话,下一轮再来。写入事务拒掉的内容属于
没可能,watcher 照样把会话标记成已处理,同时把原因作为 memory.ingest_ended
事件(ok: false)发到总线上。被拒的内容重试一万次结果一样,只烧模型额度;
而没人看得见的失败会一直烂在那里。
两种调用报告方式相同。每轮那一次同样可能撞上被占的锁,它以前把这件事吞成 "还没到时候",和普通的没到阈值分不开,于是一轮没写成也一声不吭。
插件点#
MemoryBackend(backend.py)是记忆与 agent 运行时之间的接口:
| 钩子 | 何时调用 |
|---|---|
name / is_available() |
选用时 |
initialize(session_id=) / shutdown() |
会话开始与结束 |
system_prompt() |
会话开始 |
search(query) |
每轮之前 |
write(messages, session_id=, force=) |
每轮之后,以及会话边界 |
extract_before_discard(messages) |
上下文压缩前 |
reorganize(**kwargs) |
每晚 |
除了 name,每个都有默认实现,所以一个实现只需要写它真正有事可做的那几
个。一个动作一个动词,两边同名:这里钩子叫什么,local_backend.py 里执行它
的函数就叫什么,跨层读代码不需要在脑子里转译。
extract_before_discard 的方向和其余几个相反,容易理解反。它不存任何东
西。压缩器手里攥着一批准备丢掉的消息,问记忆这里面有什么该留在摘要里;
返回的文本折进那份摘要,于是结论比原始轮次活得久。
没有暴露工具的钩子。带额外工具的记忆系统是作为插件装进来的,而插件本来 就通过贡献注册表登记 commands、skills、MCP 服务、模型供应方、hooks、 agents。在这个接口上再开一条私路,只会变成绕过它的办法。
召回的记忆送到模型面前时包在 <memory-context> 块里,带一条系统说明,这
样旧事实读起来是背景,而不是用户刚提的要求。system_prompt 和 search
返回的文本已经加过围栏:fence_memory 负责包,由 provider 来调用。出去的
路上不再包第二次,包两层会把里层整块剥掉、只剩一个空壳。
附录:实现状态#
分支感知的已写节点标记和可信speaker/v2来源协议都已经实现。
runtime/online.py根据当前分支的节点标记计算待写记录,只有安装成功且实际改过
记忆文件的批次才给来源节点打标记。会话结束时先处理当前head,再检查其他活分支,
共享前缀不会重复写入。
旧runtime.json在首次写入前仍可能包含cursors。一次性迁移只信任legacy
sources/openprogram/*.md标题后字节位置上的第一组合法header,以及严格
sources/openprogram/_v2/*.md中首个非法frame之前的合法前缀。两处得到的候选ID还要
在真实DAG路径上经过与在线写入相同的记录过滤,只给第一个缺口之前的连续前缀打标记;
缺口后的已归档尾部会重写。所有session的标记批次成功后才删除cursors,失败时保留
供重试。legacy后续header可能来自正文,v2 parser也不会在非法frame后重新开始解析。
完整方案和实测代价见
written-marker.zh.md,更广的采用决策见
memory-adoption.html。
"谁说的"已经按独立可信字段实现。渠道入口把实际发信人的speaker_id和
speaker_display与路由peer_id、正文分开传到持久化节点;网页、命令行、TUI和
assistant轮次不合成身份。SourceRecord.speaker_label只规范化运行时字段来生成记录
标签,正文保持原样。Final review证明裸的[ref] speaker: text存在单记录与多记录同字节歧义,
因此最终协议改为每个真实turn只占一个JSONL物理行,
对象里的ref、speaker和content分字段传输;只有speaker字段建立身份,
content里的换行、引号、伪记录和JSON文本都只是正文。标准JSON转义保证解码后精确恢复
CRLF、尾换行和Markdown,不清洗或改写用户原文。渲染器额外转义可能被模型显示为换行的
U+2028 line separator和U+2029 paragraph separator,解码后仍恢复原字符。Observed日期标题仍由runtime在JSONL外生成。该协议和碰撞、精确往返回归测试已实现。
归档在创建任何目录或修改文件前,会整批拒绝NFC/casefold等价但拼写不同的provider或
thread路径,冲突时source tree保持不变。memory_search对source结果输出真实
#source-... anchor,并显式显示speaker_trusted、speaker_id和speaker_display;
topic结果继续使用#^block-id。
新source记录只写入从固定marker开始的_v2/文件,并在source-id和可选的percent编码
speaker-id之后写record-lines:N。归档和parser按字面LF计数并跳过整段正文;parser遇到
首个非法frame停止。因此正文里的完整合法anchor/source/speaker伪块不会生成事件或干扰
去重,Markdown尾双空格、CRLF和尾换行在消息正文经过writer和原子archive替换时保持不变。
只有合法v2 frame中的speaker-id可信;合法但没有marker的frame保持speakerless。
旧文件冻结且不参与新归档去重;只有record-header label严格为user的旧unframed记录可从
历史正文前缀做有限检索hint。同一source id在v2有合法记录时,检索、链接和校验优先使用v2;
否则仍可读取legacy。legacy正文前缀不具备v2协议的真实性。
BM25缓存格式是v8,speaker可按稳定id、规范化显示名或标签做不区分大小写的精确
过滤,并与路径、日期和排序组合;带speaker的结果只来自sources/。
memory_search已经传递这个参数,MemoryBackend.search和memory_grep保持不变。
embedding结果没有等价身份字段,因此method=embedding与speaker组合会显式返回
INVALID_ARGUMENT,不会静默忽略过滤。
权限和自动写入已按2026-08-10定案实现。请求只携带owner或paired档位,唯一工具检查在
_gated_execute中按固定常量表执行;未配对消息不进入agent,未配对群聊正文以pending
source归档并排除主动提炼。已配对内容与owner内容都进入可信提炼。
Topic block本身不携带独立信任,而是继承其引用Source的信任。解析Topic文件时无法得知这一点
(信任记录在Source归档里),因此resolve_topic_trust在整个工作区读取完成后统一计算并写回事件。
规则是全有或全无:只要有一条引用是pending或无法解析,整个block即为pending,因为一个段落就是
一条主张,无法从正文分辨哪半句依赖哪条引用。pending block不进入search召回,也不渲染进
core.md;它们既不被删除也不会自动转正,把其依赖的Source提升即可自然恢复,无需额外记账。
信任资格与可见范围是两件事:可见范围复用AuthorityTier词汇记在事件的visibility字段上,
而不是另造一套字符串。
memory_promote仅允许本地
交互owner提升pending来源并写审计记录,随后用同一writer事务将该来源提炼进Topic;已有Topic引用时
直接跳过。paired可调用memory_status和memory_update,但工作区
只允许其新建或按字节追加;缺少持久化owner字段时不能改写或删除已有内容。
后台writer通过AgentSession沿用默认聊天agent的provider、模型和凭据,
memory.writer.model只覆盖writer模型且实时生效。provider返回的认证和配置错误保留
retryable=false,不会进入闲置观察器的重复重试。memory.backend=none使用空provider,并在
启动前停止记忆工具、系统提示、每轮召回、自动写入、夜间整理、闲置观察器和未配对群聊归档。
同一backend检查现已在工作区初始化前拒绝全部CLI memory动词和/api/memory/*路由,Web API
返回稳定的MEMORY_DISABLED错误。
writer把最近成功时间、最近失败分类及其retryable判定、只读待处理轮次数写入工作区runtime目录。
memory_status、openprogram memory status和GET /api/memory/status返回同一契约。状态写入是
best-effort,位于记忆事务之外;状态文件失败不会改变Topic事务的结果。
openprogram memory backfill已经实现。它选择未被任何Topic引用的trusted Source,忽略节点marker,
排除pending Source,按token预算分批,并复用正常writer事务。某批失败时不安装该批修改,再次执行
从第一个仍未引用的Source继续。旧根目录core.md先按Source证据保存,再提升为topics/core.md;
重复执行通过Topic引用保持幂等。
真实默认provider已在隔离工作区完成Topic写入和事务校验,合并后的正式工作区writer验收也已完成:
兼容读取器接受23个append-only source文件中154个frame使用的旧origin_scope元数据格式,随后
一个含2条消息的待处理会话提交了3个Topic文件和5个block。6个source引用与全部relation目标均
通过校验,两条消息都写入workspace marker;第二次扫描没有处理会话,revision保持不变。
此后历史backfill已在该工作区执行完毕:154个frame中有137个被至少一个Topic block引用,
共232次引用出现。
组合集成测试覆盖SessionDB、默认writer模型解析、managed writer工具、暂存事务、Topic安装、节点marker 和idle watcher状态,并验证model unavailable与lazy credential失败不可重试、下一次watcher扫描会跳过。 写入失败原因码是一个封闭枚举,受限writer阶段看不到Source归档。