commit d13d7c1714bdd1c2827dfcbf97502ed08636d8b1 Author: ypshidifu Date: Fri Sep 25 15:37:17 2026 +0800 新增compaction-cn插件:向compaction钩子system注入中文输出指令,压缩摘要内容简体中文、小节标题原样英文(标题译了会过不了ww校验致压缩失败) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c82b0e4 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# compaction-cn — 让 OC 对话压缩摘要输出简体中文 + +OpenCode 原生压缩(compaction)提示词硬编码在 `opencode.exe` 二进制里,摘要默认**纯英文**; +后续任务里模型很容易被前面那段英文摘要带偏。本插件在压缩请求发出前往 `system` 追加 +中文输出指令,让摘要**内容用简体中文、标题原样保留英文**(标题不能译,原因见下)。 + +分支名:`compaction-cn` | 文件:`compaction-cn.ts` + +--- + +## 一、安装 + +源码真源 / 本机生效 / WSL 生效三份同步(MD5 必须一致): + +``` +源码真源 : D:\桌面\源码\oc_session\compaction-cn\compaction-cn.ts +本机生效 : C:\Users\ypshidifu\.config\opencode\plugins\compaction-cn.ts +WSL 生效 : /root/.config/opencode/plugins/compaction-cn.ts +``` + +插件在 **session 初始化时**加载(不是进程启动时),装好后**新建一个会话**即可生效, +已开会话不会热加载。 + +--- + +## 二、为什么只能这样干预(2026-09-25 实测定稿) + +| 候选干预点 | 结论 | +| --- | --- | +| V1 hooks(`default` 对象上的字段,含官方文档的 `experimental.session.compacting`) | ❌ 2.0.15 V2 运行时**零消费者**,探针 24 次 setup 全成功、`A.*` 日志全空;且 named export 返回 `hooks` 对象会被当 cleanup 调用 → `TypeError: Ae is not a function` 整批插件崩 | +| `opencode.config.compaction` | ❌ schema 只有 `{auto, buffer, keep.tokens}`,没有 prompt 覆盖字段 | +| `ctx.session.hook("model.request", cb)` | ❌ payload 是 `[sessionID, agent, model, kind, baseURL, headers]`,**没有 messages**,只能识别(`kind="compaction"`)不能改写内容 | +| `ctx.session.hook("compaction", cb)` | ✅ **唯一可行**。payload = `[sessionID, model, system, messages, options, agent, tools]`,`system` 会带进最终压缩请求 | + +压缩请求时序(二进制逆向): + +``` +compaction hook(prep) → [...messages, 模板 Zt.user(z$(...))] → model.request(kind="compaction") +``` + +模板是在钩子**之后**才追加进 messages 的,所以钩子改不到模板本身,只能改 `system`。 + +### 关键约束:小节标题绝对不能翻译 + +输出校验 `ww = (e) => e.split("\n").some(a => U$.includes(a.trim()))`, +`U$` 是模板按行过滤出的英文标题行。摘要里必须**整行命中**这些英文标题,否则**压缩直接失败**: + +``` +## Objective / ## Requirements / ## Decisions / ## Work State / +### Completed / ### Active / ### Blocked / +## Next Move / ## Relevant Files / ## Important Context +``` + +所以本插件只加规则「**内容中文、标题原样英文**」,外加路径/代码/URL/报错原文不翻译。 + +--- + +## 三、工作方式 + +`setup(ctx)` 里向 `ctx.session.hook("compaction", cb)` 注册一个回调,往 `g.system` +数组 push 一段带 `` 标记的文本。带标记是为了**幂等判重**—— +多 location、多次 reload 可能重复注册钩子,已注入过就不再加。 + +日志(不进 stdout,避免污染 TUI): + +``` +C:\Users\ypshidifu\.config\opencode\compaction-cn.log # Windows +/root/.config/opencode/compaction-cn.log # WSL +``` + +正常会看到: + +``` +compaction 钩子注册完成 registration=[...] +已注入中文输出指令 sessionID="ses_xxx" system段=5 messages=N +``` + +--- + +## 四、V2 插件契约(与 ai-mcp 同款,勿破坏) + +- `default` 导出 **必须**给 `{ id, setup }`(缺了报 `Plugin must export a default definition...`) +- **`setup` 必须返回 void**,绝不抛异常(返回 `hooks`/truthy 对象 → `Ae is not a function` 整批崩) +- named export 用逗号表达式安全形态:`export const X: any = async (_ctx: any) => (await ({} as any), undefined as any)` +- 本插件所有异常都在内部吞掉并落日志,绝不影响同批其它插件 + +--- + +## 五、效果对比 + +压缩前(原生英文输出): + +``` +## Objective +- Respond to the user with "收到". +``` + +装插件后(e2e 实测,`has_en_headers=True`、英文标题原样保留、正文全中文): + +``` +## Objective +- 按用户要求回复"收到"。 +## Requirements +- 回复内容必须为"收到"。 +... +``` diff --git a/compaction-cn.ts b/compaction-cn.ts new file mode 100644 index 0000000..4ae6324 --- /dev/null +++ b/compaction-cn.ts @@ -0,0 +1,122 @@ +// compaction-cn —— OC 对话压缩摘要中文化 server 插件 +// +// 背景与动机: +// OC 原生压缩(SessionCompaction)提示词硬编码在 opencode.exe 二进制内, +// opencode.config.compaction 配置 schema 只有 {auto, buffer, keep.tokens},无 prompt 覆盖字段; +// V1 插件 hooks(含官方文档的 experimental.session.compacting)在 OC 2.0.15 V2 运行时 +// 【没有任何消费者】(实测:default 对象上的 V1 hook 字段零调用,且 named export 返回 +// hooks 对象会被当 cleanup 调用 → TypeError: Ae is not a function 整批崩)。 +// 于是压缩摘要默认产出纯英文,后续任务容易被前面的英文摘要带偏。 +// +// 干预点(2026-09-25 实测定稿): +// ctx.session.hook("compaction", cb) —— 对应 SessionCompaction.prepare 阶段, +// payload = [sessionID, model, system, messages, options, agent, tools],含 system 与 messages。 +// 时序:compaction hook(prep) → 追加压缩模板 Zt.user(z$(...)) → model.request(kind="compaction") +// ⇒ 模板在钩子【之后】才拼进 messages,钩子改不到模板本身; +// model.request 虽在模板之后但 payload 无 messages,只能识别不能改写。 +// ⇒ 唯一可行干预:向 g.system 追加中文输出指令(system 会带进最终压缩请求)。 +// +// 关键约束(不能违反): +// 输出校验 ww = (e) => e.split("\n").some(a => U$.includes(a.trim())) +// 其中 U$ = 模板按行过滤出的英文标题(## Objective / ## Requirements / ## Decisions / +// ## Work State / ### Completed / ### Active / ### Blocked / ## Next Move / +// ## Relevant Files / ## Important Context)。 +// ⇒ 摘要回复必须含【整行英文标题】,翻译标题 → 校验失败 → 压缩失败。 +// ⇒ 故只能要求「内容用中文、标题原样英文」,绝不能翻译标题。 +// +// 与 ai-mcp 同款的 V2 插件契约(勿破坏): +// - named export 必须是逗号表达式安全形态(返回 hooks 对象会崩) +// - default 导出 { id, setup },setup 必须返回 void、绝不抛 +// +// 部署(三份同步 + node --check): +// 源码真源 : D:\桌面\源码\oc_session\compaction-cn\compaction-cn.ts +// 本机生效 : C:\Users\ypshidifu\.config\opencode\plugins\compaction-cn.ts +// WSL 生效 : /root/.config/opencode/plugins/compaction-cn.ts + +import { appendFileSync, existsSync, mkdirSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +const LOG_DIR = join(homedir(), ".config", "opencode") +const LOG_FILE = join(LOG_DIR, "compaction-cn.log") + +// 标记串:幂等判重用(多 location / 多次 reload 可能重复注册钩子) +const MARK = "" + +const CN_RULE = `${MARK} +以下为本次会话压缩(compaction)摘要的输出语言要求,优先级高于模板中的其他说明: + +1. 除第 2 条外,摘要的全部内容必须使用简体中文撰写,包括各小节的条目正文、说明与结论。 +2. 所有小节标题必须原样保留英文、不得翻译或改动大小写,包括: + ## Objective / ## Requirements / ## Decisions / ## Work State / + ### Completed / ### Active / ### Blocked / + ## Next Move / ## Relevant Files / ## Important Context + 这些标题行是压缩结果的格式校验依据,翻译或改写会导致本次压缩失败。 +3. 文件路径、代码、命令、URL、报错原文、标识符以及用户原话引用保持原样,不要翻译。 +4. 严格遵守压缩模板中的全部格式规则(单行条目、不超过 15 个相关文件、不要提及压缩过程等)。 +5. 若本次为更新模式(已有 checkpoint 要求重写),各节内容同样按上述语言要求用简体中文重写。 +${MARK}` + +function fileLog(msg: string) { + try { + if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true }) + appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`) + } catch { + // 日志失败绝不影响主流程 + } +} + +// system 段可能是 {type:"text", text} 或纯字符串,取其文本 +function systemText(s: any): string { + if (typeof s === "string") return s + if (s && typeof s === "object") return typeof s.text === "string" ? s.text : "" + return "" +} + +// 命名导出:ai-mcp 实验矩阵 ② 的逗号表达式安全形态(返回 hooks 对象会触发 Ae is not a function) +export const CompactionCn: any = async (_ctx: any) => (await ({} as any), undefined as any) + +export default { + id: "compaction-cn", + async setup(ctx: any): Promise { + try { + const session = ctx && ctx.session + if (!session || typeof session.hook !== "function") { + fileLog("session.hook 不可用(keys=" + JSON.stringify(Object.keys(session || {})) + "),未注册") + return + } + + const registration = await session.hook("compaction", (g: any) => { + try { + if (!g) return + const sys = g.system + if (!Array.isArray(sys)) { + fileLog("compaction payload 无 system 数组(type=" + typeof sys + "),跳过") + return + } + // 幂等:已注入过就不再加(防多 location / 重复注册叠加) + if (sys.some((s: any) => systemText(s).indexOf(MARK) >= 0)) { + return + } + sys.push({ type: "text", text: CN_RULE }) + fileLog( + "已注入中文输出指令 sessionID=" + + JSON.stringify(g.sessionID) + + " system段=" + + sys.length + + " messages=" + + (Array.isArray(g.messages) ? g.messages.length : "?"), + ) + } catch (e: any) { + fileLog("compaction 钩子异常(已吞掉): " + (e && e.message ? e.message : String(e))) + } + }) + + fileLog("compaction 钩子注册完成 registration=" + JSON.stringify(registration && typeof registration === "object" ? Object.keys(registration) : String(registration))) + } catch (e: any) { + fileLog("setup 异常(已吞掉,不影响其它插件): " + (e && e.message ? e.message : String(e))) + } + // setup 必须返回 void:返回 hooks 对象会触发 Ae is not a function 整批崩溃 + return + }, +}