opencode v2 原生本地记忆插件:自动把跨会话的用户消息、工具调用摘要存进本机持久存储,并在每次模型请求前按关键词相关度注入一块 <memory> 上下文。单文件、零 npm 依赖、无需构建。
An opencode v2 native local-memory plugin: it persists user messages and tool-call summaries across sessions into a plugin-private durable store, then injects a relevance-ranked
<memory>block into each model request. Single file, zero npm dependencies, no build step.
- 背景:为什么需要它
- 它做什么
- 安装
- 零依赖
- 检索与打分
- 容量与性能
- 去重:为什么需要确定性 key
- 前端差异:用户消息的双路径捕获
- 注入块要过滤宿主文本
- 调试
- 一次性导入(迁移旧记忆)
- 已知坑
- 已知局限
- 测试
- 版本与发布
- 许可
opencode V2 的插件加载器要求模块 default 导出是对象,形如 { id, setup } 或 { id, effect }。而 V1 插件(default 是函数)在 V2 里完全不跑,官方没有兼容层 —— 官方 v2 文档 docs/build/plugins/migrate-v1 原文:
"V1 plugin implementations do not run in V2"
因此,很多现成的 V1 记忆插件升级到 V2 后会“静默失效”。本插件是从零按 V2 契约写的:权威类型包是 @opencode/plugin@2.0.x(注意不是 @opencode-ai/plugin,后者 latest 仍停留在 1.19,是 V1 时代的包)。
只注册三个钩子和一个工具:
| 机制 | 说明 |
|---|---|
session.hook("prompt") |
记录用户消息(V1 chat.message 的等价物;非 exactly-once,按 messageID 去重) |
tool.hook("execute.after") |
记录工具调用摘要,跳过噪音工具(read/grep/glob/ls/execute 等) |
session.hook("context") |
把 <memory> 块 push 进 event.system;只影响本次模型请求,不改持久历史,绝不改写 event.messages |
memory 工具 |
注册一个 memory 工具,action = search | add | list | forget | stats |
存储用宿主的 ctx.storage(插件私有命名空间、durable JSON),不碰任何外部数据库。
把 local-memory.ts 放进 opencode 的插件目录即可,宿主自动发现:
- 全局:
~/.config/opencode/plugin/local-memory.ts - 项目级:
<项目>/.opencode/plugins/local-memory.ts
无需把路径写进 opencode.json 的 plugin 数组 —— 本地 plugin/ 目录会被自动加载。
卸载:删除该文件即可。
源码不 import 任何 npm 包(包含 @opencode/plugin、effect、zod),只用 Node 内置模块 node:fs / node:os / node:path(仅用于写调试日志)。
因此:
- 没有安装/构建步骤;
- 不需要版本对齐(不存在 peerDependency 冲突);
- 工具入参的 schema 是纯字面量 JS 对象,不走 zod,也不带
~standard之类的库痕迹。
纯关键词检索,没有向量检索(不加载任何嵌入模型)。打分规则:
score = Σ token 命中分
命中 tags × 4
命中 title × 3
命中 body × 1
+ 基础加权:kind == "user" → +3,kind == "note" → +2
再乘时间衰减:score / (1 + ageDays / 14) // 14 天半衰期
Tokenizer 同时支持中文与英文:CJK 汉字/假名/谚文按 单字 切也是 token(中文不需要分词器),ASCII 走词边界(MIN_TOKEN_LEN = 2),另带停用词表与 MAX_TOKENS = 24 上限。
注入时取 top CONTEXT_MAX_ITEMS = 12 条,渲染成 <memory ...> 块,受 CONTEXT_BUDGET = 6000 字符预算约束(≈1.5k token,超出整块丢弃以保证不撑爆上下文)。
| 常量 | 值 | 含义 |
|---|---|---|
MAX_ENTRIES |
2000 |
总条目上限,超出删最旧 |
RECENT_MAX |
2000 |
meta:recent 保留的 key 数 = 保留上限 |
RECENT_TAKE |
2000 |
相关度打分的回读窗口(与保留上限一致) |
TRIM_BATCH |
32 |
每次触发治理时批量删多少条最旧 |
CACHE_TTL_MS |
45000 |
语料缓存 TTL(打分窗口靠它摊销) |
打分走全量回读:一次读最近 RECENT_TAKE 条做打分,结果缓存 45s,所以连续对话只付一次回读成本。实测 1270 条语料冷启约 200ms、缓存命中约 20ms。
⚠️ RECENT_MAX的语义坑:它同时是“保留上限”和“打分窗口”。早期版本把它设成300,等价于“记忆只保留 300 条”—— 批量导入时会静默丢弃更旧的条目。务必保持RECENT_MAX == MAX_ENTRIES。
宿主会把同一个插件文件加载两次(两个实例并发跑钩子)。这种情况下,读-改-写式的去重(先查再写)挡不住并发:两个实例会同时看到“没见过”而双双入库。
本插件从 key 层面根治,为可去重的写入生成确定性 storage key:
| 来源 | 前缀 | key 形态 |
|---|---|---|
| 用户消息 | mem:u_… |
mem:u:<sessionID>:<textHash> |
| 工具调用 | mem:t_… |
mem:t:<sessionID>:<callID> |
| 一次性导入 | mem:import_… |
mem:import_<sanitize(source)> |
写之前先 get 这个确定性 key,命中即跳过,并发下也不会重复。
某些前端不会触发 session.prompt 钩子(实测 Code Mode 宿主 0 次调用;CLI / TUI 会触发)。因此除了 prompt 钩子,context 钩子还会从 event.messages 里兜底捕获最近 2 条用户文本并落库。
两条路径共用同一个确定性 key(u:<sessionID>:<textHash>),所以无论哪条先写,同一条消息都只会出现一次。
event.messages 里的 user-role 文本不都是用户说的话 —— 宿主会把自己的物料塞进来。以下已过滤,避免污染检索:
Instructions from: …(AGENTS.md 等指令)<system-reminder><available_skills># Code Mode
两种开关,无需重启宿主(判定延迟上限 5s,因为标记文件每 5s 复查一次):
- 标记文件:建
%TEMP%\opencode\local-memory.debug(空文件即可)即开日志;删除即关。- Windows:
%TEMP%\opencode\local-memory.debug - 日志写到
%TEMP%\opencode\local-memory.log
- Windows:
- 环境变量:
LOCAL_MEMORY_DEBUG=1(需重启宿主生效)。
追加跳过名单:LOCAL_MEMORY_SKIP_TOOLS="foo,bar"(逗号分隔,附加到默认跳过表之上)。
把待导入记录写成 JSONL(每行一个对象),放到 %TEMP%\opencode\local-memory-import.jsonl,然后重载宿主(改一下插件文件,或 opencode reload)即自动导入。导入在 setup 完成后异步执行,不阻塞启动。
- 完成后文件被重命名为
local-memory-import.jsonl.done-<yyyyMMdd-HHmmss>。 - 单次上限
IMPORT_MAX = 1500条(超出只取ts最新的那批,更旧的记日志跳过)。 - 按
ts升序写入,使最新的一批留在meta:recent窗口内。 - 幂等:
source经确定性 key(import:<source>)去重,重复运行同一文件不会重复入库。 - 撤销一次导入:删掉所有
mem:import_前缀的 key,或逐个forget。
字段格式、从旧 SQLite 库(observations / user_prompts / session_summaries / persona_memory)导出的选表与筛选策略、以及一个可直接运行的导出脚本,见 docs/import.md。
- V1 插件在 V2 不跑,且无兼容层(见背景)。迁移 V1 插件时,必须把 default 改成
{ id, setup }。 RECENT_MAX同时是保留上限与打分窗口,设小了会静默丢历史(见容量与性能)。- 双实例加载导致读-改-写去重失效;必须用确定性 key(见去重)。
- config 的
instructions数组在 V2 不生效:官方 v2docs/instructions明确“接受但不解析files/glob/URL”。只有AGENTS.md会被加载 —— 别再指望用instructions引额外规则文件。 - 某些前端不触发
session.prompt:靠context钩子兜底(见前端差异)。 - 宿主注入文本会被当成 user 消息,必须过滤(见注入块要过滤宿主文本)。
- 类型包选错:用
@opencode/plugin@2.0.x,不是@opencode-ai/plugin(后者 latest 是 1.19)。
- 无向量检索(只做关键词匹配,不加载嵌入模型);
- 不生成会话摘要、不做自动摘要压缩;
- 记忆上限 2000 条(超出按最旧淘汰);
memory工具只做关键词匹配,不做语义理解。
仓库自带一个离线 harness,用 mock ctx 驱动插件,不发任何模型请求、不联网:
bun tests/harness.mjs覆盖 11 组共 54 项断言:V2 导出契约、钩子注册、工具注册、写入与去重、工具摘要与噪音跳过、上下文注入与预算、storage 读写、memory 工具五个 action、容量治理、storage 抛错时的健壮性、cleanup。
发行方式(当前):文件投放 + GitHub —— 把 local-memory.ts 放进
~/.config/opencode/plugin/(或项目级 .opencode/plugins/)即可,不做 npm 分发。
npm 发布通道开关已就绪但默认关闭(package.json 的 "private": true,版本号仍会照常更新);
将来要发 npm 只需把它改成 false 并配好 Trusted Publishing(OIDC),步骤见
VERSIONING.md。
发版是自动的:本仓库接入 semantic-release,
版本号由 Conventional Commits 提交信息推导,
不人工改版本号。推到 main 即触发,beta 分支产出预发布版:
| 提交 | 结果 |
|---|---|
fix: … |
PATCH(0.1.0 → 0.1.1) |
feat: … |
MINOR(0.1.0 → 0.2.0) |
feat!: … / BREAKING CHANGE: |
MAJOR(0.x 期降级为 MINOR) |
chore: / docs: / ci: / test: |
不发版 |
流程细节、分支策略、三条版本轴的区分(插件版本 / 存储 schema 版本 / 目标 opencode 版本)、
以及「不要移动已发布 tag」「不要手改 CHANGELOG」「schema 变更必须写迁移段」等禁令,
见 VERSIONING.md;逐版变更见 CHANGELOG.md。
MIT © herouu