OpenProgram Docs

记忆子系统#

OpenProgram 怎么让 agent 跨会话"记住"事情。

本文覆盖记忆子系统的全貌。实体层的 git 底座见 git-as-entity-memory.zh.mdentity-memory.zh.md

路径约定:所有状态在 ~/.openprogram/(即 get_state_dir()); 具名 profile 用 ~/.openprogram-<profile>/

为什么需要它#

原始 LLM 在会话结束时忘掉一切。每开一个新对话都要用户重讲同样的事 ("我是产品经理,别用黑话"、"项目在 ~/Projects/foo")。记忆子系统把 聊完的对话写进持久文件,再把相关部分喂回去。

两个我们在意的性质:

  1. **模型不用问就拿到该知道的事。**用户还没重复,稳定的偏好和项目事实 已经在提示词里了。
  2. **存储可读可审。**记忆就是纯 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.mdtimeline/recent_events.jsonlrelations.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_idspeaker_display作为独立字段,从 ChannelMessage.user_id/user_displaydispatch_inbound_run_session_turnTurnRequest和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-idspeaker-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_idspeaker_displayspeaker_label提供规范化可读标签。 MemoryBM25Index.search(..., speaker=...)对稳定id、显示名或完整标签做不区分大小写的精确 匹配,并与path_prefix、日期范围和排序组合;带speaker的候选只来自sources/。结果中的 speaker_trusted区分可信v2身份与legacy兼容hint。主题段落 表示关于某个主题的整理结果,不表示某个人说过的话,因此不会被speaker过滤命中。

memory_search在工具schema中公开speaker并传给inspect.searchMemoryBackend.search 保持原签名,普通每轮召回不会自动继承speaker过滤。memory_grep也保持不变。embedding结果 没有等价的可信speaker字段,所以method=embeddingspeaker同时使用时返回 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 里,照样被索引,照样能被 searchmemory_get 找到,所以把一段留出渲染结果, 代价只是可见性。这也是不必先分清谁写的就能安全裁剪的原因:openclaw 要区分 自动写的和人写的,是因为它的常驻文件就是唯一一份,丢一行等于销毁一行。在 这里,偏好落在顺序上:靠前的段落先渲染,挪动一段是人和夜间那一趟都做得到 的普通编辑。

已经有一份手写 core.md 而没有 topics/core.md 的工作区,第一次渲染时把 这个文件移过去。它本来就带着块ID和证据脚注,原样就是一个合法的主题文件。两份 都有的工作区保留topics/core.md,让渲染覆盖掉那份散着的,因为派生的含义就是 这个,而两边内容都不会有风险。

这套方案没解决的#

  • **没有任何东西给正本重新排序。**预算决定的是可见性,而偏好落在顺序上, 所以一段在文件长过2000 token之后才到的事实,写进去了、也被索引了、也搜 得到,但永远渲染不出来。夜间那一趟按主题整理,不知道预算这回事,不会主动 把它挪上去。看不见不等于没有,但常驻块正是模型不用问就会读到的那一块。
  • **报出来的东西没有读者。**渲染会说这次装了多少token、把哪些块ID留在了外面。 目前没有任何东西消费它,所以发现常驻块超出预算的第一个途径,仍然是有人去 读那个文件。
  • **预算是近似的。**它按tiktokeno200k_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 --modelscheduler.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)发到总线上。被拒的内容重试一万次结果一样,只烧模型额度; 而没人看得见的失败会一直烂在那里。

两种调用报告方式相同。每轮那一次同样可能撞上被占的锁,它以前把这件事吞成 "还没到时候",和普通的没到阈值分不开,于是一轮没写成也一声不吭。

插件点#

MemoryBackendbackend.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_promptsearch 返回的文本已经加过围栏: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_idspeaker_display与路由peer_id、正文分开传到持久化节点;网页、命令行、TUI和 assistant轮次不合成身份。SourceRecord.speaker_label只规范化运行时字段来生成记录 标签,正文保持原样。Final review证明裸的[ref] speaker: text存在单记录与多记录同字节歧义, 因此最终协议改为每个真实turn只占一个JSONL物理行, 对象里的refspeakercontent分字段传输;只有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_trustedspeaker_idspeaker_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.searchmemory_grep保持不变。 embedding结果没有等价身份字段,因此method=embeddingspeaker组合会显式返回 INVALID_ARGUMENT,不会静默忽略过滤。

权限和自动写入已按2026-08-10定案实现。请求只携带ownerpaired档位,唯一工具检查在 _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_statusmemory_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_statusopenprogram memory statusGET /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归档。

Last updated · 2026-08-13