# 历史轨迹与全文检索

> 原始对话怎样建成 history_fts 全文索引：实时写入、启动回填、按 kind 抽取正文；history 工具怎样搜索与回看，以及插件拿到的轨迹格式。

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

记忆是整理过的笔记，整理就会丢东西：连接串被概括掉，用户的原话被改写成要点。MiMo Code 因此给原始对话另建了一份全文索引。模型在 `memory` 里查不到、或者只查到转述时，用 `history` 工具按关键词回查原始消息，再把命中处前后几条消息拉回来看。这部分同样是新增代码，上游 v1.14.19 既没有 `src/history/` 目录，也没有 `history` 工具。

## 怎么用

`history` 工具始终注册（`packages/opencode/src/tool/registry.ts:307`），有两个操作：

- `search`：`query` 必填；`scope` 默认 `project`，设为 `global` 搜本机所有项目；可以按 `session_id`、`kind`（数组）、`tool_name`、`time_after` 与 `time_before`（Unix 毫秒）过滤；`limit` 默认 10，上限 50。
- `around`：给一个 `message_id`，取同一会话里它之前 5 条、之后 5 条消息，数量可用 `before`、`after` 调整。

工具描述（`packages/opencode/src/tool/history.txt:3`）把它定位成最后手段，先查记忆：

> USE ONLY WHEN MEMORY SEARCH RETURNS NOTHING USEFUL.

其他入口：

- 配置文件 `history` 段的 `kinds` 决定索引哪些内容（`packages/opencode/src/config/history.ts:14`）。默认是 `user_text`、`assistant_text`、`tool_input`、`tool_error`（`packages/opencode/src/history/extract.ts:11`）；加上 `reasoning`、`tool_output` 能提高召回，代价是库变大；设成空数组则既不实时写入也不回填。
- 人工直查：索引和原始消息都在 `<data>/mimocode.db` 里。各发布渠道默认共用这一个文件（`packages/opencode/src/flag/flag.ts:390`），`MIMOCODE_DB` 可以改路径（`packages/opencode/src/storage/db.ts:37`）。内置技能 `memory-search`（`packages/opencode/src/skill/builtin/.bundle/memory-search/SKILL.md`）给了表结构和一批只读 SQL 模板，适合做 `history` 做不了的聚合统计。

## 数据怎样流进索引

```mermaid
flowchart LR
  UP["Session.updatePart / removePart"] --> EV["PartUpdated、PartRemoved 事件"]
  EV --> W["WriterService：串行队列"]
  PT[("part 表")] --> BF["BackfillService：启动时补扫"]
  W --> EX["extract：按 kind 取正文"]
  BF --> EX
  EX --> FTS[("history_fts 与 history_fts_idx")]
  FTS --> S["history search"]
  S -->|message_id| A["history around"]
  PT --> A
```

索引表 `history_fts` 以 `part_id` 为主键，一个 Part 一行，另存会话、消息、项目、kind、工具名、正文和时间，按会话加时间、项目加时间、消息各建一个普通索引；`history_fts_idx` 是挂在它上面的外部内容 FTS5 虚表，分词器与记忆索引相同，都是 `unicode61 remove_diacritics 1`（`packages/opencode/migration/20260609000000_history_fts/migration.sql:14`）。它的触发器从一开始就用 FTS5 的 `'delete'` 命令删除旧词条，记忆索引为此专门修过一次，见[分层记忆](https://daiw.org/manual/mimo-code/memory)。中文分词的局限也一样：连续的汉字会连成一个词。

## 实时写入：订阅 Part 事件

`src/history/` 提供三个服务：查询用的 History、写入器 `WriterService`、回填器 `BackfillService`。后两个必须显式 `init()` 才会启动（`packages/opencode/src/history/index.ts:5`），这件事由实例启动流程完成：MiMo 在上游的 `InstanceBootstrap` 里加了这两项，与 LSP、文件监听等服务一起后台启动（`packages/opencode/src/project/bootstrap.ts:35`）。

写入器订阅两个事件，把它们变成任务放进无界队列（`packages/opencode/src/history/writer.ts:43`）：

```ts
yield* bus.subscribeCallback(MessageV2.Event.PartUpdated, (evt) => {
  Queue.offerUnsafe(queue, { type: "upsert", part: evt.properties.part, time: evt.properties.time })
})
yield* bus.subscribeCallback(MessageV2.Event.PartRemoved, (evt) => {
  Queue.offerUnsafe(queue, { type: "delete", partID: evt.properties.partID })
})
```

用同步的 `subscribeCallback` 而不是流式订阅，注释说是为了保证 `init()` 返回前订阅已经生效，不和紧接着的发布赛跑。队列由一个常驻 fiber 逐个消费（`writer.ts:50`），写失败只记日志。处理一个 upsert 时（`writer.ts:73`），先查这条 Part 所属消息的角色、所属会话的项目 ID，两者都走容量 1024 与 512 的 LRU 缓存（`packages/opencode/src/history/resolve.ts:32`），再交给 `extract` 决定存不存、存什么：

| Part | kind | 正文 |
| --- | --- | --- |
| text，所属消息是 user | `user_text` | 文本 |
| text，所属消息是 assistant | `assistant_text` | 文本 |
| reasoning | `reasoning` | 思考文本 |
| tool，状态 error | `tool_error` | 工具名、输入 JSON、错误信息 |
| tool，状态 completed | `tool_output` | 工具名、输入 JSON、输出 JSON |
| tool，上两行对应 kind 未启用时 | `tool_input` | 工具名、输入 JSON |

pending、running 状态的工具 Part 和其余类型的 Part（文件、快照、步骤边界等）都不入库。`kinds` 的配置说明提到，开启 `tool_output` 后已完成的工具会从 `tool_input` 改归 `tool_output`，说的就是表里这条回退链。

几个由实现决定的细节：

- **写入时机**：流式输出的文本走 `PartDelta` 事件（`packages/opencode/src/session/message-v2.ts:606`），写入器不订阅；处理器在第一个增量到来时先以空文本落一次 Part（`packages/opencode/src/session/processor.ts:670`），`extract` 遇到空文本直接跳过，要等 `text-end` 时整段文本落定再发一次 `PartUpdated` 才入库。工具也一样，结束后才进索引。
- **时间戳**：实时写入用事件携带的时间，也就是每次更新时的 `Date.now()`（`packages/opencode/src/session/session.ts:637`），冲突更新时会覆盖成最后一次更新的时间；回填用的则是 `part` 表里首次落库的时间。按 `time_after`、`time_before` 过滤时两者会有细微差别。
- **合成文本照样入库**：`extract` 不看 `synthetic` 标记。检查点重建写进边界消息的那几大段合成文本（`packages/opencode/src/session/checkpoint.ts:1705`）属于 user 消息，会以 `user_text` 被索引，所以关键词有时会命中一份重建快照，而不是原始发言。
- **删除只跟 Part**：写入器只处理 `PartRemoved`。撤销时整条消息被移除发出的是 `MessageV2.Event.Removed`（`packages/opencode/src/session/revert.ts:123`），删除会话发出的是会话级的 `Deleted` 事件（`session.ts:606`），写入器都不订阅，代码里也没有找到别处清理 `history_fts`。数据库外键会把对应的消息和 Part 级联删掉，索引行却留了下来：它们仍可能被 `search` 命中，拿去调用 `around` 只会得到锚点不存在的提示。

## 回填：启动时补扫旧会话

回填器每次实例启动都会以分离 fiber 的方式跑一遍 `backfillAll`，不阻塞启动（`packages/opencode/src/history/backfill.ts:143`）。它取出数据库里的全部会话，不限当前项目，按最近更新时间倒序处理（`backfill.ts:26`）；每个会话内按 Part ID 翻页，每批 500 条，跳过已经入库的 Part（`backfill.ts:54`）：

```ts
const parts = Database.use((db) =>
  db
    .select()
    .from(PartTable)
    .where(
      and(
        eq(PartTable.session_id, session.id as any),
        gt(PartTable.id, cursor as any),
        sql`NOT EXISTS (SELECT 1 FROM history_fts WHERE history_fts.part_id = ${PartTable.id})`,
      ),
    )
    .orderBy(asc(PartTable.id))
    .limit(BATCH)
    .all(),
)
```

每批抽取后在一个事务里写入（`backfill.ts:114`），批与批之间睡 10 毫秒，会话与会话之间睡 50 毫秒。升级到带历史索引的版本后，第一次启动会把所有旧会话补进来；之后每次启动只补缺口。

`NOT EXISTS` 带来一个副作用：已经入库的 Part 不会被重新抽取。先按默认配置跑过回填，再打开 `tool_output`，旧的工具调用仍然是 `tool_input`，只有之后新写或再次更新的 Part 才按新规则归类；反过来从 `kinds` 里去掉某一类，已有的行也不会被删除。

## 查询：AND 连接，没有分数线

`History.search`（`packages/opencode/src/history/service.ts:86`）的分词函数是从记忆模块复制来的，注释说复制是为了让两边各自演进（`packages/opencode/src/history/fts-query.ts:5`）。切词方式相同，连接词却不同：记忆检索实测 AND 几乎总是零结果，已改成 OR；历史检索仍是 AND（`fts-query.ts:14`），查询里的每个词都必须出现，测试也按 AND 断言（`packages/opencode/test/history/fts-query.test.ts:15`）。查历史要用少而准的词，长描述很容易落空，工具在 0 结果时也只会提示改查记忆或放宽查询（`packages/opencode/src/tool/history.ts:74`）。

检索 SQL 和记忆一样按 `bm25()` 排序、用 `snippet()` 截 32 个词的片段，但没有相对分数线；`limit` 被夹在 `HARD_CAP` 即 50 以内（`service.ts:90`）。`scope` 为 `project` 时加上 `project_id` 等于当前实例项目 ID 的条件（`service.ts:94`），非 git 目录的项目 ID 都是 `global`，会互相搜到。每条结果输出会话 ID、消息 ID、kind 与工具名、时间、分数和片段，消息 ID 就是下一步 `around` 的锚点。

`around`（`service.ts:153`）按 `(time_created, id)` 排序，取锚点及之前 `before` 条、之后 `after` 条同会话消息。它不区分 agent 切片，同一会话里子智能体的消息也会被带出来。每个 Part 渲染成文本：text 与 reasoning 原样输出，工具 Part 拼出 `tool:`、`input:` 以及 `output:` 或 `error:`，其他类型只写 `[类型]`（`service.ts:234`）。这样的输出动辄几十 KB，工具把它截到 20 KB（`AROUND_MAX_BYTES`，`packages/opencode/src/tool/history.ts:23`），低于框架默认的 50 KB（`packages/opencode/src/tool/truncate.ts:16`），超出部分落盘，由模型再用 Read 或 Grep 去翻，截断机制见[工具契约与注册表](https://daiw.org/manual/opencode/tool-contract)。

`around` 还被其他模块当作取回原文的出口。检查点重建注入 `Recent user input (verbatim)` 一节时，超长的消息只保留头尾，中间换成一个带 `messageID` 的省略标记，提示模型用 `history` 的 `around` 取全文（`checkpoint.ts:108`）；`memory` 工具的结果提示也反复要求：需要精确字面量时去查 `history`。

## 轨迹格式：给插件的 TrajectoryMessage

`packages/opencode/src/session/trajectory.ts` 名字里带“轨迹”，但和上面的索引无关。它把会话消息序列化成 `TrajectoryMessage[]`（类型定义在 `packages/plugin/src/index.ts:376`，Part 的形状在 `packages/plugin/src/index.ts:311`），供两个插件钩子使用：

- `session.post`：一次 runLoop 结束时触发，失败或中断也会触发，载荷里带 `trajectory`（`packages/opencode/src/session/prompt.ts:3121`）。
- `session.userQuery.post`：每个模型步骤结束后触发，轨迹由 `trajectoryForStep` 生成（`prompt.ts:3155`）。

序列化几乎是原样展开：消息的全部字段、Part 的全部字段都保留，另把 `time.created` 提到顶层的 `created`（`trajectory.ts:84`）；唯一的改写是文件 Part 和工具附件里的 `data:` URL 换成 `[data-url:mime:文件名]` 形式的摘要（`trajectory.ts:9`），免得内联图片把载荷撑爆。切片方面，`session.post` 用 `MessageV2.filterCompactedEffect` 按会话的 `contextFrom`、`contextWatermark` 和 agent ID 取消息（`prompt.ts:3082`），拿到的是该 agent 切片经过压缩或重建之后实际看到的内容，不是数据库里的全部历史。插件钩子的整体机制见[插件系统](https://daiw.org/manual/opencode/plugins)；内置技能 `evolve` 的钩子清单里也写明 `session.post` 能拿到完整轨迹（`packages/opencode/src/skill/builtin/.bundle/evolve/SKILL.md:96`）。

它和 Dream、Distill 没有直接关系。这两个隐藏智能体既不调用这个序列化函数，工具白名单里也没有 `history`（`packages/opencode/src/agent/agent.ts:402`）；它们的提示词让模型在 bash 里用只读 SQLite 直接查 `session`、`message`、`part` 等原始表（`packages/opencode/src/agent/prompt/dream.txt:59`）。于是同一份对话有三种视图：原始表是事实来源，`history_fts` 是关键词索引，`TrajectoryMessage` 是插件的传输格式。

下一篇：[Dream 与 Distill](https://daiw.org/manual/mimo-code/dream-distill)——两个隐藏智能体怎样读原始轨迹，把知识并进项目记忆、把重复流程打包成可复用资产。
