注入侧 - context 在每次模型请求都触发,改为按 session + 最新用户消息去重: 一个 turn 里 n 次工具调用不再重复注入 n 个 memory 块 - 默认注入从 1200 字符 / 内联 4 条正文,降到 600 字符 / 内联 0 条: 大纲已说明存在什么,正文交给 memory_recall 按需取一次,避免每次 请求都为同一批正文付费 - halfLifeDays 此前被静默丢弃,注入始终按 14 天半衰期排序,现已接通 - 召回缓存 2s -> 500ms,同一工具链里 remember -> recall 能读到刚写的文件 排序 - idf 原先拿「单文件匹配数」当分子、「总文件数」当分母,每个文件得到 相同 idf,排序退化为纯词频;改为按文档频率计算的真实 idf 抽取 - 移除 400 段上限:它会静默截断长对话,300 轮只贡献前 ~50 轮 - role 在顶层或 message 下两种返回结构都读,之前只读 .role 得到空转录, 长度门槛不过,抽取看起来永远不触发 - 去重改按关键词重叠计分,不再用 bigram 重叠:模型改写与人工措辞几乎 不共享 bigram,真实改写只拿到 0.43;关键词是双方被迫共用的词汇 - 新增 memory_extract_dryrun:对真实模型跑完整抽取链路,不必改 minTurns 或等长会话 - 新增 memory_inspect:诊断记录按 scope 存入 storage key,并保留历史 与 fires/injected 计数,去重是否生效可被证伪 其他 - Context 改从 Plugin 命名空间导入,此前类型退化为 unknown - store 暴露 kind 字段;compileRootTables 内联进 compileRoot - frontmatter 列表元素统一 String(),index 不再出现数字 验证:由仓库所有者端到端验证(真实模型调用、真实会话);提交者未复跑, 以上为 diff 复核结论。
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, // 会话至少多少轮才抽取
"settleMs": 20000, // 一轮对话安静多久才认为结束
"extractModel": { "providerID": "anthropic", "id": "claude-haiku-4-5" },
"categories": ["rules", "preferences", "decisions", "pitfalls", "facts"]
} }
]
}
不指定 extractModel 时自动挑最便宜的可用模型(按 cost 排序,并过滤掉上下文窗口 < 8k 的)。
自动抽取
一轮对话安静下来后触发 → 取本轮对话 → 廉价模型抽取结构化记忆 → 去重 → 写入。
触发用的是去抖,不是 session.idle。 实测把 ctx.event.subscribe 收到的
所有事件类型都收集起来看过,这个流里根本没有 session.idle —— 它在协议里
定义了,却不投递。所以改成监听真正会到达的 session.step.started /
session.tool.called / session.inbox.delivered,并要求静默 settleMs(默认 20s)
才动手。等价于"turn 结束了",且不依赖一个不存在的事件。
四重保护:
- 独立模型,不碰主对话 —— 抽取失败不影响用户
- 只写普通记忆,铁规则降级为
rule-proposal等人确认 - 词项覆盖率去重,不是分数阈值 —— 分数带 idf 和作用域先验,会随语料增长漂移, 固定阈值会悄悄失效。覆盖率问的是"是不是已经说过了",阈值稳定
- 密钥拦截,写入前过一遍正则
memory_inspect 的"运行状态"会报告抽取最近一次的结果和原因(轮数不足、对话太短、
选不到模型、去重拦截),所以"没抽取"和"抽取坏了"不会混为一谈。
跨设备同步
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