分层记忆

记忆在磁盘上分哪几层、谁写谁读,写入闸门怎样把关,FTS5 索引与 memory 工具怎样检索,以及记忆经由哪几条通道进入上下文。

作者 David更新于 第 9 篇(共 33 篇)

上游 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.mdDream;也可手写重建注入
项目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)只管记忆树内的路径,依次判断:

  1. memory.disable_write 为真时一律拒绝(memory-path-guard.ts:186)。注释解释了报错为什么要特意说明是写入被关、不要换一条记忆路径重试、已有记忆仍可检索:模型读到的若只是“这里不许写”,往往会换条路径再试,陷入循环。这个开关只在 isMemoryWriteEnabled(packages/opencode/src/memory/write-gate.ts:31)里读取,只有字面量 true 才算关闭。
  2. 路径必须是 <scope>/<scope_id>/<key>.md,scope 只能是 global、projects、sessions(memory-path-guard.ts:200),否则报错并附上主 agent 应当使用的两个规范路径。
  3. 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)。
  4. 其他 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 的内容。完整地看,记忆进入模型视野有五条通道:

  1. 系统提示词里的说明:buildMemoryInstructions(llm.ts:132)告诉模型各层文件的绝对路径、什么时候可以直接改 MEMORY.md、notes.md 怎么记、重建之后不要把已注入的文件再整篇 Read 一遍。它只注入给主 agent 和 peer(由 servesCheckpoint 判断,packages/opencode/src/actor/registry.ts:415),而且只有路径和规则,不含文件内容。
  2. 每步的回忆提醒:只要会话记忆目录非空或已有任务,主循环每一步发请求前都往最后一条用户消息末尾追加一段 <system-reminder>,提示用 memory、task、actor 或 Read 找回不在上下文里的内容(prompt.ts:3674)。这段文字只加在内存里、不落库,注释估算每轮约 120 token。
  3. 重建注入:上下文逼近上限自动重建,或手动执行 /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),之后继续这个会话,包括关掉再打开,模型看到的都是重建那一刻的快照。预算、排序与等待写手的逻辑见上下文重建与预算化注入。
  4. 派生子智能体时:actor 工具的 context 设为 state 时,把父会话当前的 checkpoint.md 整篇包进 <session-state>,放在子任务提示词之前(packages/opencode/src/tool/actor.ts:763),见 Actor:子智能体的生成与回收。
  5. 按需检索: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 给出的才是真实路径。

下一篇:历史轨迹与全文检索——记忆是提炼过的笔记,原始对话怎样建全文索引、怎样回查原话。

本页目录