Files
oc/plugins/memory/README.md
T
xiaoshuai a64ea2acb3 Add memory plugin: three-tier markdown store with plugin-driven retrieval
A durable memory layer for OpenCode. Structure:

  ROOT.md          total plan: goals, hard rules, directory registry,
                   and a scenario -> file routing table
  <category>/
    index.md       one line per file, COMPILED from frontmatter
    <slug>.md      the memory itself

Two roots, same shape: <config>/memory/ (global) and
<project canonical>/.memory/ (project). Project wins on conflict; global
hard rules stay resident everywhere.

Why a plugin rather than a skill that tells the model to read a folder:
model-driven retrieval is unreliable — the model forgets, reads half the
files, or reads the wrong ones. Markdown is the human-facing format; the
plugin is the engine that parses, scores, and injects.

Measured, not assumed: FTS5's two built-in tokenizers both fail on this
corpus. On SQLite 3.53.4 with "部署脚本用 pnpm 而不是 npm...": unicode61
emits 7 terms for the whole sentence and matches 0 hits for "部署";
trigram needs >= 3 characters and also returns 0 for that two-character
word. So tokenization happens in the plugin (CJK bigrams, latin words)
and no SQL index is used — a thousand memories are fine to scan.

index.md and ROOT's two tables are compiled, never hand-edited: a
hand-maintained index drifts, a generated one cannot. Hit counters live
in .state.json so cross-device sync does not conflict on every recall.

Tools are named recall/remember/forget/inspect rather than
write/read/status: generic names were observed shadowing the harness's
own built-in tools.
2026-10-09 10:17:45 +08:00

7.3 KiB
Raw Blame History

memory 插件

给 OpenCode 加一套跨会话记忆。三级结构、多根并存、纯文本可手改,检索和注入由插件自己完成。

核心思想:ROOT 是索引,不是记忆

模型每次不需要读完所有记忆。读一份小的 ROOT.md 就知道"我有什么、什么时候该取哪一块", 需要细节时再用工具深挖。

ROOT.md          总纲:当前目标 / 铁规则 / 目录登记 / 加载路由表
  <category>/
    index.md     每个文件一句话 —— 编译产物,不要手改
    <slug>.md    记忆本体,带 frontmatter

注入分三层,预算递减:

层 内容 何时进上下文
铁规则 用户声明的硬性要求 每次必带,不受相关性影响
记忆大纲 有什么、每个文件一句话、触发场景 每次必带(很便宜)
记忆正文 命中最强的几条 每次最多 4 条,受字符预算限制

剩下的靠 memory_recall 按需取。

双根:全局 + 项目

两个根结构完全相同,可同时存在:

~/.config/opencode/memory/            全局:偏好、决策、通用坑
<项目 canonical>/.memory/             项目:项目事实、专属决策
  • 冲突时项目级赢 —— 更具体的说法更有用
  • 全局铁规则始终常驻,不能被项目覆盖;项目铁规则追加在后面
  • 项目根不存在就自动创建,不存在时静默跳过

项目根取自 ctx.location.project.canonical,也就是项目根目录(不是会话工作目录 —— 两者在 worktree 里不同)。

为什么不是 skill + 文件夹

原来的做法是"写个 skill 告诉模型去读 .memory/"。这个方案的致命缺陷是检索依赖模型自觉: 模型会忘记读、只读一半、或读错文件。

这里把职责拆开:

  • Markdown 是人类面向的格式 —— 纯文本、可手改、git 可 diff
  • 插件是检索引擎 —— 解析、评分、注入全部由代码保证

记忆会自动抽取(模型忘了写也有兜底),但铁规则永远只能由人写 —— 允许模型生成"铁规则"等于让幻觉变成永久策略。

中文分词:实测结论,不是推测

这是本插件唯一必须自己动手的地方。在 SQLite 3.53.4 上实测字符串 部署脚本用 pnpm 而不是 npm,因为 lockfile 不兼容:

FTS5 分词器 查"部署" 查"部署脚本" 原因
unicode61(默认) 0 命中 0 命中 整句只产生 7 个词条,把 部署脚本用 当一个 token
trigram 0 命中 1 命中 要求查询 ≥3 字符,词表是「不兼」「而不」这种三字滑窗

中文里「部署」「配置」「依赖」「接口」这类两字词占比极高,trigram 漏掉的正好是最常用的一批。

所以分词在插件里做,不在 SQL 里做:CJK 切字符二元组,拉丁切整词。两字中文能精确命中, 多出来的噪声词交给 idf 自动降权。

(也因此没有用 SQLite 索引 —— 记忆是千级条目,全表扫描足够,索引换不来收益。)

评分

score = 词项重叠 × idf × 作用域先验 × 时间衰减 × 复述加成
  • 作用域先验:项目 1.0 / 全局 0.5 —— 同分时更具体的赢
  • 时间衰减:半衰期 14 天(halfLifeDays: 0 关闭)。不删除,只降权
  • 复述加成:memory_recall 命中过的记忆会累加计数,越常用越靠前
  • 钉住:pinned: true 的条目不衰减、不被自动取代
  • 取代:新记忆的 supersedes 指向旧的,旧的标 superseded_by 保留可追溯, 但不再参与检索

工具

工具 作用
memory_recall 搜记忆。返回全文 + 命中理由(匹配了哪些词、命中几次)
memory_remember 写记忆。自动同步目录 index.md 和 ROOT 路由表
memory_forget 删记忆。路径穿越会被拒绝
memory_inspect 看当前所有记忆 + 索引一致性自检 —— 调优全靠它

工具刻意不叫 write / read / status:实测发现这类通用名会遮蔽 harness 自带的 同名工具(write 曾直接让本会话失去建文件能力)。叶子名必须避开通用词。

记忆文件格式

---
id: pitfalls/sqlite-cjk-tokenizer
kind: pitfall
summary: unicode61 把整段中文当一个 token,trigram 漏掉两字词。
highlight: 实测:查"部署"两种分词器都是 0 命中
created: 1791512014824
updated: 1791512014824
load_when:
  - SQLite
  - FTS5
  - 分词
pinned: true
---

正文:上下文、决策、原因、根因。

load_when 是路由表的唯一来源 —— 插件扫所有文件的这个字段编译出 ROOT 的路由表, 所以表永远不会和实际内容对不上。

index.md 和 ROOT 的两张表同理,都是从 frontmatter 编译的。手写索引一定会漂移。

该记什么

该记 不该记
用户偏好 代码里已经能看到的东西
架构决策 + 原因 当前任务状态
非显然的坑 + 根因 git 分支名、文件路径
项目约定 一次性对话

插件会在写入前拦截疑似密钥(记忆会进 git)。

配置

默认零配置。放在 opencode.jsonc 的 plugins 里可调:

{
  "plugins": [
    { "package": "./plugins/memory", "options": {
      "maxInjectChars": 1200,     // 注入字符硬上限
      "maxInjectEntries": 4,      // 每次内联几条正文
      "halfLifeDays": 14,         // 衰减半衰期,0 = 不衰减
      "autoExtract": true,        // 自动抽取
      "minTurns": 4,              // 会话至少多少轮才抽取
      "extractModel": { "providerID": "anthropic", "id": "claude-haiku-4-5" },
      "categories": ["rules", "preferences", "decisions", "pitfalls", "facts"]
    } }
  ]
}

不指定 extractModel 时自动挑最便宜的可用模型(按 cost 排序,并过滤掉上下文窗口 < 8k 的)。

自动抽取

监听 session.idle → 取本轮对话 → 廉价模型抽取结构化记忆 → 关键词去重 → 写入。

三重保护:

  1. 独立模型,不碰主对话 —— 抽取失败不影响用户
  2. 只写普通记忆,铁规则降级为 rule-proposal 等人确认
  3. 相似度去重 —— 与已有记忆重叠的候选会被跳过并记日志

跨设备同步

memory/ 已被 git 跟踪,.state.json(命中计数)被忽略 —— 否则每次检索都改文件, 跨设备必然冲突。

判据是陈旧风险:偏好、决策、坑、约定可以同步;当前分支、本地路径、任务状态不同步。 同步错的事实比漏同步更糟。

项目级 .memory/ 不在全局仓库里,需要哪个项目同步,就在那个项目仓库里提交它。

排查

opencode service restart     # 改完插件代码后
opencode plugin list         # 确认 memory 已加载
  • 加载失败看 ~/.local/share/opencode/log/opencode.log,过滤 role=server
  • 索引漂移用 memory_inspect 检查(它会核对 index.md 是否漏了文件)
  • 想知道"模型现在到底看到了什么",看日志里的抽取日志

已知边界

  • 记忆到万条量级时全表扫描会变慢,那时再换 SQLite 索引(正文是纯文本,迁移成本低)
  • 多设备同时同步同一份记忆会有 git 冲突,需要手动 rebase