# 分层记忆

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

- 作者：David（道雾轩）
- 专栏：MiMo Code 源码解读（https://daiw.net/manual/mimo-code.md）
- 最后更新：2026-09-17
- 原文：https://daiw.net/manual/mimo-code/memory
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

上游 OpenCode 没有跨会话记忆：上下文满了就压缩成摘要（见[上下文压缩](https://daiw.net/manual/opencode/compaction)），新开一个会话一切从头。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.*` 决定重建时各段注入多少，见[上下文重建与预算化注入](https://daiw.net/manual/mimo-code/context-rebuild)。
- 环境变量 `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`）。写手本身见[检查点写手](https://daiw.net/manual/mimo-code/checkpoint-writer)，任务编号见[树状任务](https://daiw.net/manual/mimo-code/tasks)。

<Callout type="warn">
代码内部对 `progress.md` 的描述前后不一：注入系统提示词的记忆说明（`llm.ts:144`）称它由写手从会话级进度拆分而来、不由模型撰写；路径注释（`checkpoint-paths.ts:80`）、写手提示词和上面的插件则都表明它由子智能体自己写。实际行为以插件为准。
</Callout>

## 写入闸门

上游 v1.14.19 的 `write`、`edit`、`apply_patch` 在落盘前只调用 `assertExternalDirectoryEffect`，检查目标是否越出工作区（见[工具契约与注册表](https://daiw.net/manual/opencode/tool-contract)）。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`）：

```ts
// 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`）：

```ts
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。

<Callout type="info">
中文检索要留意分词。`unicode61` 分词器只把空白和标点当分隔符，连续的汉字、字母、数字会连成一个词，查询端的正则切法相同。笔者用 SQLite 3.50.4、同样的外部内容表与分词器配置验证过：正文是“我们决定使用端口5433，数据库是postgres”时，查“端口”或“postgres”都搜不到，查被逗号隔开的整段“我们决定使用端口5433”才能命中。中文记忆最好拿英文标识符、路径片段或标点隔开的短句去搜，搜不到就直接 Grep。
</Callout>

## 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 精简](https://daiw.net/manual/mimo-code/token-efficient-shell)。

`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`），之后继续这个会话，包括关掉再打开，模型看到的都是重建那一刻的快照。预算、排序与等待写手的逻辑见[上下文重建与预算化注入](https://daiw.net/manual/mimo-code/context-rebuild)。
4. **派生子智能体时**：`actor` 工具的 `context` 设为 `state` 时，把父会话当前的 `checkpoint.md` 整篇包进 `<session-state>`，放在子任务提示词之前（`packages/opencode/src/tool/actor.ts:763`），见 [Actor：子智能体的生成与回收](https://daiw.net/manual/mimo-code/actors)。
5. **按需检索**：`memory` 工具、Read 与 Grep。

`memory.disable_write` 打开时，重建直接退化成压缩（`prompt.ts:699`），第 3 条通道随之关闭，记忆只能靠第 5 条按需去取。

<Callout type="warn">
兜底模型提示词 `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` 给出的才是真实路径。
</Callout>

下一篇：[历史轨迹与全文检索](https://daiw.net/manual/mimo-code/history)——记忆是提炼过的笔记，原始对话怎样建全文索引、怎样回查原话。
