分层记忆
记忆在磁盘上分哪几层、谁写谁读,写入闸门怎样把关,FTS5 索引与 memory 工具怎样检索,以及记忆经由哪几条通道进入上下文。
上游 OpenCode 没有跨会话记忆:上下文满了就压缩成摘要(见上下文压缩),新开一个会话一切从头。MiMo Code 在 packages/opencode/src 下新增了 memory/ 目录(上游 v1.14.19 没有),思路很朴素:记忆就是数据目录里的一棵 Markdown 文件树,模型用普通的 Write、Edit 去写,用 Read、Grep 或 memory 工具去读,SQLite FTS5 只是给这棵树建的检索缓存。真正要设计的是两件事:谁可以写哪个文件,这些文件又在什么时候进入上下文。
怎么用
- 记忆根目录是
<data>/memory/。data 目录默认是 XDG 数据目录下的mimocode/(Linux 上通常是~/.local/share/mimocode/);设置了MIMOCODE_HOME(必须是绝对路径)时改为它下面的data子目录(packages/shared/src/global.ts:26)。 - 想让一条规则跨会话生效,直接告诉 agent:系统提示词允许它在用户给出项目级规则时直接改项目
MEMORY.md的## Rules一节。自己动手改也行,它就是普通 Markdown。 - 模型查记忆用
memory工具;人查记忆直接 Grep 这棵目录树。 - 配置项(
packages/opencode/src/config/config.ts:347起):memory.disable_write停止写入新记忆,已有记忆照样可读;memory.cc_index把 Claude Code 的记忆一并纳入索引,默认关;checkpoint段的memory_reconcile_on_search(默认开)和memory_search_score_floor(默认 0.15)调检索;checkpoint.push_caps.*决定重建时各段注入多少,见上下文重建与预算化注入。 - 环境变量
MIMOCODE_DISABLE_CHECKPOINT关掉检查点,系统提示词里的记忆说明段也随之消失(packages/opencode/src/session/llm.ts:393)。
四层文件
各层路径由 packages/opencode/src/session/checkpoint-paths.ts 统一拼出。下表路径相对 <data>/memory/:
| 层 | 路径 | 主要写者 | 怎样被读到 |
|---|---|---|---|
| 全局 | global/MEMORY.md | Dream;也可手写 | 重建注入 |
| 项目 | projects/<pid>/MEMORY.md | 检查点写手、主 agent、Dream | 重建注入;写手每次先读 |
| 会话检查点 | sessions/<sid>/checkpoint.md | 检查点写手 | 重建注入;派生子智能体时可附带 |
| 会话笔记 | sessions/<sid>/notes.md | 主 agent 追加 | 写手分拣后清空;重建注入 |
| 任务进展 | sessions/<sid>/tasks/<TID>/progress.md | 绑定任务的子智能体 | 写手增量整合 |
几个容易忽略的细节:
<pid>是项目 ID。上游取仓库根提交的哈希,MiMo 改成生成一个随机 UUID,存进主.git目录下的mimocode-project-id文件(packages/opencode/src/project/project-id.ts:30);向上找不到.git时项目 ID 就是global(packages/opencode/src/project/project.ts:215),所有非 git 目录共用projects/global/MEMORY.md。- 全局记忆没有模板,也不会自动创建,路径注释称它对 agent 只读(
checkpoint-paths.ts:35);但下文的守卫并不拦主 agent 写它,Dream 的提示词也允许把跨项目的偏好提升到这里。 - 项目记忆若不存在,会在启动检查点写手之前按
MEMORY_TEMPLATE(packages/opencode/src/session/checkpoint-templates.ts:60)生成,分## Project context、## Rules、## Architecture decisions、## Discovered durable knowledge四节,写手侧的分节预算合计约 1 万 token(checkpoint-templates.ts:105)。旧版小写的memory.md会被改名为MEMORY.md(migrateProjectMemory,checkpoint-paths.ts:48),每次组装系统提示词时(llm.ts:405)以及检查点、重建路径上都会调用一次,幂等。 notes.md是主 agent 唯一合法的草稿本,每条以## [turn N · YYYY-MM-DDTHH:MM:SSZ]开头。它实际上是个收件箱:检查点写手每次先读它,把条目分拣到检查点的对应小节,或者MEMORY.md的## Rules、## Discovered durable knowledge,然后按模板整个覆盖掉(packages/opencode/src/agent/prompt/checkpoint-writer.txt:114)。progress.md由内置插件逼出来:绑定了任务 ID 的子智能体收尾时,actor.postStop钩子检查文件是否存在、是否含§1到§5五个固定小节,不合格就让子智能体再跑一轮补写,合格后由运行时写入written-at时间戳(packages/opencode/src/plugin/subagent-progress-checker.ts:102)。写手启动前比较这个时间戳与检查点里记下的last-reconciled-written-at,只把新增或有变化的任务列进写手提示词(packages/opencode/src/session/checkpoint-progress-reconcile.ts:45)。写手本身见检查点写手,任务编号见树状任务。
代码内部对 progress.md 的描述前后不一:注入系统提示词的记忆说明(llm.ts:144)称它由写手从会话级进度拆分而来、不由模型撰写;路径注释(checkpoint-paths.ts:80)、写手提示词和上面的插件则都表明它由子智能体自己写。实际行为以插件为准。
写入闸门
上游 v1.14.19 的 write、edit、apply_patch 在落盘前只调用 assertExternalDirectoryEffect,检查目标是否越出工作区(见工具契约与注册表)。MiMo 把这几处连同新增的 notebook_edit 一起换成 assertWriteAllowed(packages/opencode/src/tool/external-directory.ts:109),把越界检查和两道记忆守卫串成唯一入口。记忆树里的路径不再询问 external_directory(external-directory.ts:39),每次写入的 edit 询问也跳过(askEditUnlessMemory,external-directory.ts:162),注释给的理由是:记忆树有更细的守卫,这两类询问在这里是多余的,而且在没有应答者的场景里有害——headless 运行时前者会卡死在永远不会完成的 Deferred 上,继承了父 agent edit 规则的后台派生会让后者把检查点写入拒掉或跳过。放行之后,裁决权交给两道守卫(external-directory.ts:130):
// System-agent write sandbox: checkpoint-writer is memory-only, while
// dream/distill may also write <worktree>/.mimocode.
assertAgentWriteSandbox({
target,
agentName: ctx.agent,
memoryRoot: path.join(Global.Path.data, "memory"),
worktree: (yield* InstanceState.context).worktree,
})
assertMemoryWriteAllowed({
target,
agentName: ctx.agent,
memoryRoot: path.join(Global.Path.data, "memory"),
projectID,
sessionID: ctx.sessionID,
taskId: ctx.taskId,
writeEnabled: yield* memoryWriteEnabled,
})两道守卫都在 packages/opencode/src/tool/memory-path-guard.ts,都是不碰文件系统的纯函数。
assertAgentWriteSandbox(memory-path-guard.ts:23)只约束三个系统智能体:checkpoint-writer 只能写记忆树,dream、distill 只能写记忆树或 <worktree>/.mimocode/,其他 agent 直接放行。比较前先 path.resolve,堵住 .mimocode/../src/x.ts 这种靠字符串前缀蒙混的写法(memory-path-guard.ts:35)。
assertMemoryWriteAllowed(memory-path-guard.ts:159)只管记忆树内的路径,依次判断:
memory.disable_write为真时一律拒绝(memory-path-guard.ts:186)。注释解释了报错为什么要特意说明是写入被关、不要换一条记忆路径重试、已有记忆仍可检索:模型读到的若只是“这里不许写”,往往会换条路径再试,陷入循环。这个开关只在isMemoryWriteEnabled(packages/opencode/src/memory/write-gate.ts:31)里读取,只有字面量true才算关闭。- 路径必须是
<scope>/<scope_id>/<key>.md,scope 只能是global、projects、sessions(memory-path-guard.ts:200),否则报错并附上主 agent 应当使用的两个规范路径。 checkpoint-writer走精确白名单(memory-path-guard.ts:79):projects/<pid>/下的MEMORY.md或memory-*.md溢出文件;sessions/<sid>/下的checkpoint.md、checkpoint-*.md、notes.md;以及sessions/<sid>/tasks/<TID>/*.md,TID 须形如T3或T3.1.2(TASK_ID_RE,memory-path-guard.ts:66)。- 其他 agent 不许碰
sessions/<sid>/tasks/下的任何文件(memory-path-guard.ts:134),唯一例外是绑定了任务<TID>的子智能体,可以写自己的tasks/<TID>/子树(memory-path-guard.ts:230)。
这道闸门的边界也要看清:
bash不经过它,注释承认那一侧靠提示词约束(memory-path-guard.ts:18)。- 守卫只看路径形状,不核对其中的
<sid>、<pid>是否就是当前会话与项目。 - 主 agent 写
checkpoint.md在代码层面并不被拦,注释明说主 agent 可以写(memory-path-guard.ts:131);禁止编辑它的要求只出现在系统提示词里(llm.ts:203)。
索引:文件是真相,FTS5 是缓存
索引表由迁移创建:memory_fts 存路径、scope、scope_id、类型、正文和指纹,memory_fts_idx 是挂在它上面的外部内容 FTS5 虚表,分词器为 unicode61 remove_diacritics 1(packages/opencode/migration/20260521010000_memory_fts_v6/migration.sql:18)。这张表前后经历了三个迁移:最初以 path 为主键(packages/opencode/migration/20260515010000_memory_fts/migration.sql:2),v6 改用自增整数 id 作 content_rowid,紧接着又重建了触发器,迁移注释写明了原因(packages/opencode/migration/20260521020000_memory_fts_triggers/migration.sql:1):
Fix v6.1 trigger pattern: external content FTS5 vtab requires the 'delete' magic command to remove OLD body's tokens, NOT a plain DELETE FROM the vtab.
把磁盘同步进索引的是 reconcileMemory(packages/opencode/src/memory/reconcile.ts:94):遍历记忆根目录(开了 cc_index 再加上 ~/.claude/projects/*/memory/),先从索引里删掉磁盘上已经不存在的文件,再逐个比较指纹。指纹只是“文件大小-修改时间”(reconcile.ts:57),变了才重读正文并 upsert。它在三处被调用:实例启动时在后台跑一次(packages/opencode/src/project/bootstrap.ts:43);每次 memory 搜索之前(packages/opencode/src/memory/service.ts:61);重建时生成记忆键索引之前(packages/opencode/src/session/checkpoint.ts:1497)。没有文件监听,靠“用前对账”保证模型刚写进去的内容下一次就能搜到。
类型从路径推断(packages/opencode/src/memory/paths.ts:26):key 为 MEMORY 或 memory-* 的归为 memory(只有这一类不区分大小写,为了兼容旧文件名),checkpoint 与 checkpoint-* 归为 checkpoint,tasks/<id>/progress 归为 progress,tasks/<id>/notes 归为 notes,其余都是 free。所以会话级的 notes.md 并不属于 notes 类型,而是 free。Claude Code 的记忆文件则从 frontmatter 里缩进的 type: 行(对应 metadata.type)取 feedback、project、reference、user 之一(paths.ts:84)。
检索:OR 连接加相对分数线
Memory.search(service.ts:52)先用 buildFtsQuery 把查询串变成 FTS5 表达式(packages/opencode/src/memory/fts-query.ts:28):
export function buildFtsQuery(raw: string): string | null {
const tokens =
raw
.match(/[\p{L}\p{N}_]+/gu)
?.map((t) => t.trim())
.filter(Boolean) ?? []
if (tokens.length === 0) return null
const quoted = tokens.map((t) => `"${t.replaceAll('"', "")}"`)
return quoted.join(" OR ")
}连续的字母、数字、下划线切成一个词,每个词加引号变成短语字面量,FTS5 语法里的特殊字符就不会让解析器报错。用 OR 而不用 AND 是踩过坑的,同一文件的注释(fts-query.ts:18)记录了实测结果:
Empirically (80-doc real memory) AND returned 0 results for nearly all multi-word queries, even 2-word ones like "permission deadlock".
OR 召回率高,代价是只沾上一个常见词的文档也会进来。于是 SQL 按 bm25() 排序、用 snippet() 截取片段(命中词用 <<、>> 标出,最多 32 个词),先多取 Math.min(limit * 3, 50) 条(service.ts:116),再划一条相对分数线:第一名总是保留,其余分数不到第一名 15% 的丢弃(service.ts:136)。注释解释了为什么用相对值:BM25 的量级随语料规模变化,小语料里所有分数都趋近 0,任何绝对门槛都会误杀。limit 默认 10。
中文检索要留意分词。unicode61 分词器只把空白和标点当分隔符,连续的汉字、字母、数字会连成一个词,查询端的正则切法相同。笔者用 SQLite 3.50.4、同样的外部内容表与分词器配置验证过:正文是“我们决定使用端口5433,数据库是postgres”时,查“端口”或“postgres”都搜不到,查被逗号隔开的整段“我们决定使用端口5433”才能命中。中文记忆最好拿英文标识符、路径片段或标点隔开的短句去搜,搜不到就直接 Grep。
memory 工具
memory 工具(packages/opencode/src/tool/memory.ts:9)始终注册(packages/opencode/src/tool/registry.ts:306),只有一个操作 search,参数为 query、scope(global、projects、sessions、cc)、scope_id、type、limit。它不负责写:写记忆就是用 Write、Edit 写文件,走上面的闸门。工具描述 packages/opencode/src/tool/memory.txt 主要教模型怎么查:
- 用一到三个罕见词,别堆 config、params、database 这类通用词;
- 命中即权威,不要因为换个说法没搜到就断定从没记过;
- 需要连接串、端口、完整命令这类精确字面量,而命中内容只是转述时,去
history工具查原话; - 0 结果时逐级升级:换更少、更罕见的词重试,再直接 Grep 记忆目录,最后用
history查原始对话,范围按会话、项目、全局、历史逐步放宽(memory.ts:46返回的提示与此一致)。
开着检查点时,描述末尾会多一句话,说明检查点文件也在索引里(packages/opencode/src/tool/checkpoint-description.ts:3)。type 参数描述里举的例子是 pinned、snapshot、learning、progress、free(memory.ts:20),前三个是旧版名字,现在的类型推断不会产生。另外 memory 没有命令行式调用形式,模型总是用 JSON 调它(packages/opencode/src/session/prompt.ts:160),命令行式语法见命令行式工具调用与 Token 精简。
cc 范围需要在配置里打开 memory.cc_index。它的配置说明专门提醒了隐私:Claude Code 的 user、feedback 类记忆可能含有个人偏好与指导,打开后所有 MiMo agent,包括可能读到注入内容的子智能体,都能检索到(config.ts:364)。关掉之后,下一次对账会把这些行清出索引。
记忆怎样进入上下文
README 说记忆会在会话恢复时自动注入上下文(README.zh.md:134)。对照代码,自动把记忆文件内容放进上下文的只有检查点重建这一条路,新会话开头并不会读取 MEMORY.md 的内容。完整地看,记忆进入模型视野有五条通道:
- 系统提示词里的说明:
buildMemoryInstructions(llm.ts:132)告诉模型各层文件的绝对路径、什么时候可以直接改MEMORY.md、notes.md怎么记、重建之后不要把已注入的文件再整篇 Read 一遍。它只注入给主 agent 和 peer(由servesCheckpoint判断,packages/opencode/src/actor/registry.ts:415),而且只有路径和规则,不含文件内容。 - 每步的回忆提醒:只要会话记忆目录非空或已有任务,主循环每一步发请求前都往最后一条用户消息末尾追加一段
<system-reminder>,提示用memory、task、actor或 Read 找回不在上下文里的内容(prompt.ts:3674)。这段文字只加在内存里、不落库,注释估算每轮约 120 token。 - 重建注入:上下文逼近上限自动重建,或手动执行
/rebuild时,renderRebuildContext(checkpoint.ts:1252)按预算读入checkpoint.md(默认 11000 token)、项目MEMORY.md(10000)、notes.md(6000)和global/MEMORY.md(6000),见checkpoint.ts:1339起,分别放在# Session checkpoint、# Project durable knowledge、# Session notes、# Global memory标题下,再附一份按当前会话、项目与全局过滤的记忆键索引(只列路径,默认 500 token)。这些文字作为合成文本持久化在重建边界消息里(checkpoint.ts:1705),之后继续这个会话,包括关掉再打开,模型看到的都是重建那一刻的快照。预算、排序与等待写手的逻辑见上下文重建与预算化注入。 - 派生子智能体时:
actor工具的context设为state时,把父会话当前的checkpoint.md整篇包进<session-state>,放在子任务提示词之前(packages/opencode/src/tool/actor.ts:763),见 Actor:子智能体的生成与回收。 - 按需检索:
memory工具、Read 与 Grep。
memory.disable_write 打开时,重建直接退化成压缩(prompt.ts:699),第 3 条通道随之关闭,记忆只能靠第 5 条按需去取。
兜底模型提示词 packages/opencode/src/session/prompt/default.txt(未匹配到专用提示词的模型都用它,见 packages/opencode/src/session/system.ts:50)的 Memory 小节仍写着记忆位于 ~/.claude/projects/<project>/memory/、分 user、feedback、project、reference 四类(default.txt:121),与 MiMo 的实际布局不符。同一请求里 buildMemoryInstructions 给出的才是真实路径。
下一篇:历史轨迹与全文检索——记忆是提炼过的笔记,原始对话怎样建全文索引、怎样回查原话。