历史轨迹与全文检索

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

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

记忆是整理过的笔记,整理就会丢东西:连接串被概括掉,用户的原话被改写成要点。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 做不了的聚合统计。

数据怎样流进索引

图表加载中…

索引表 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' 命令删除旧词条,记忆索引为此专门修过一次,见分层记忆。中文分词的局限也一样:连续的汉字会连成一个词。

实时写入:订阅 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):

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 决定存不存、存什么:

Partkind正文
text,所属消息是 useruser_text文本
text,所属消息是 assistantassistant_text文本
reasoningreasoning思考文本
tool,状态 errortool_error工具名、输入 JSON、错误信息
tool,状态 completedtool_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):

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 去翻,截断机制见工具契约与注册表。

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 切片经过压缩或重建之后实际看到的内容,不是数据库里的全部历史。插件钩子的整体机制见插件系统;内置技能 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——两个隐藏智能体怎样读原始轨迹,把知识并进项目记忆、把重复流程打包成可复用资产。

本页目录