346 lines
19 KiB
TypeScript
346 lines
19 KiB
TypeScript
// 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 整批崩)。
|
||
// 于是压缩摘要默认产出纯英文,后续任务容易被前面的英文摘要带偏。
|
||
//
|
||
// ── OC 2.0.15 压缩链路(逆向 opencode.exe 实锤,勿重复考古)────────────────
|
||
// auto = SessionCompaction.compact(k) reason:"auto" (runStep 里 required:F 判定后调用)
|
||
// manual = SessionCompaction.compactManual(T) reason:"manual"
|
||
// 两者都进 G(execute) → g(N,msg) → N.prepare(args)
|
||
// → SessionModelRequest.prepare → trigger("session","compaction", payload)
|
||
// (trigger:顺序执行注册回调、返回【同一个 payload 对象】⇒ 对 system/messages 的原地 push 必然生效)
|
||
// → T.system / T.messages → e9({system:T.system, messages:NB(T6(T.messages))}) → 构造 request
|
||
// G 在 prepare 返回【之后】才拼压缩模板:
|
||
// be = pn.update(we.request, {messages:[...we.request.messages, Zt.user(z$(...))]})
|
||
// ⇒ 钩子里追加的消息一定排在模板【前面】。
|
||
//
|
||
// ── 2026-09-26 根因(A/B 实测,模型 agnes-2.5-flash)──────────────────────
|
||
// 注入生效但位置不对。同一模型四组对照(对话全英文、模板一致):
|
||
// A. 中文规则放 system 末尾 → cn=0 纯英文(= 插件 v1/v2 的做法)
|
||
// B. 中文指令放【模板之后】 → cn=157 中文 ✅
|
||
// C. 中文规则放 system 最前面 → cn=0 纯英文
|
||
// D. 模板之后追加、用英文措辞 → cn=123 中文 ✅
|
||
// ⇒ 模型只认「最后一条消息」里的指令;system 段无论放哪都压不住。
|
||
// ⇒ 手动压缩出中文,是因为对话本身就是中文(模型跟随对话语言),与注入无关;
|
||
// 自动压缩时上下文几乎全英文,才暴露出「指令位置不对」。
|
||
//
|
||
// ── 因此干预点分两层(v4)────────────────────────────────────────────────
|
||
// ① session.hook("compaction"):向 payload.system 追加完整中文规则(覆盖读 system 的模型)
|
||
// ② session.hook("http.request"):【关键】压缩请求出网前解析 JSON body,
|
||
// 检测到压缩模板(## Objective + Do not continue the task)后做两件事:
|
||
// ②-a 把极短英文语言规则【内联写进模板消息本身】(紧跟 Do not continue the task)
|
||
// ②-b 再在 messages 【最末尾】(即模板之后)追加一条独立指令
|
||
// 2026-09-26 实测只做 ②-b 时 4 次里有 1 次模型仍输出英文,故 v4 加 ②-a 双保险。
|
||
// 非压缩请求 / 非 JSON / 已含标记 → 原样放行,绝不改写。
|
||
//
|
||
// ── v5(2026-10-12):常驻中文引导 ──────────────────────────────────────
|
||
// 需求:模型在【每次工具调用后、思考结束、任何时候】都保持中文回复,
|
||
// 不被工具返回的英文结果/日志/网页内容带偏;模型确实不支持中文则可忽略。
|
||
// 做法:http.request 钩子扩展到【所有 kind】的 JSON 聊天请求——
|
||
// * 压缩请求(指纹命中)→ 走原 v4 双保险逻辑(不变)
|
||
// * 其它请求 → 在 messages 【最末尾】追加常驻规则 <reply-language>
|
||
// (A/B 实测:该模型只认 messages 最末尾的指令;每轮请求 hook 重新注入,
|
||
// 历史不落库,因此每轮都是新注入,天然"任何时候"生效)
|
||
// 幂等标记 LANG_MARK 与压缩标记 MARK 分开,互不干扰;
|
||
// 日志按 sessionID 60s 节流,避免刷屏。
|
||
//
|
||
// 参考:session.hook("model.request") 实测 payload keys 只有
|
||
// ["sessionID","agent","model","kind","baseURL","headers"],无 system/messages,
|
||
// 只能做诊断日志,改不了内容(保留日志便于日后核对)。
|
||
//
|
||
// 关键约束(不能违反):
|
||
// 输出校验 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)。
|
||
// ⇒ 摘要必须含【整行英文标题】,翻译标题 → 校验失败 → 压缩失败。
|
||
// ⇒ 只能要求「内容中文、标题原样英文」,绝不能翻译标题。
|
||
// compaction 钩子里若追加消息,content 必须是数组:
|
||
// prepare 链路里 T6 会在 Zt.make 之前对 content 调 .map(),给字符串会 TypeError。
|
||
//
|
||
// 与 ai-mcp 同款的 V2 插件契约(勿破坏):
|
||
// - named export 必须是逗号表达式安全形态(返回 hooks 对象会崩)
|
||
// - default 导出 { id, setup },setup 必须返回 void、绝不抛
|
||
//
|
||
// 部署(三份同步 + node --experimental-strip-types 校验):
|
||
// 源码真源 : 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 = "<compaction-language>"
|
||
|
||
// system 段注入(对部分模型有效,对只认最后一条消息的模型无效,保留但不作为唯一手段)
|
||
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 要求重写),各节内容同样按上述语言要求用简体中文重写,
|
||
不要沿用旧 checkpoint 的英文表述。
|
||
6. 禁止输出 <template>、<conversation-checkpoint>、<summary>、<recent-context> 等包裹标签,
|
||
禁止任何前言、解释或结尾说明;回复的第一行必须是 "## Objective"。
|
||
${MARK}`
|
||
|
||
// 追加在【压缩模板之后】的短指令 —— 2026-09-26 A/B 实测唯一稳定生效的位置
|
||
// 注意:实测单靠这一条仍有小概率被模型无视(4 次里 1 次英文),因此 v4 又把同一要求
|
||
// 直接写进压缩模板消息本身(紧跟 "Do not continue the task"),双保险。
|
||
const CN_DIRECTIVE = `${MARK}【压缩摘要输出语言强制指令】本条指令优先于上方模板中的其它说明:
|
||
- 摘要正文(所有条目)一律用简体中文撰写,即使对话原文是英文。
|
||
- 小节标题原样保留英文:## Objective / ## Requirements / ## Decisions / ## Work State /
|
||
### Completed / ### Active / ### Blocked / ## Next Move / ## Relevant Files / ## Important Context。
|
||
- 回复第一行必须是 "## Objective";禁止输出 <template>、<conversation-checkpoint>、
|
||
<summary>、<recent-context> 等包裹标签;禁止任何前言与解释。
|
||
- 文件路径、代码、命令、URL、报错原文保持原样。
|
||
- 不要把本条指令写进摘要内容。
|
||
Final rule: write the summary body in Simplified Chinese, keep the ## headings in English,
|
||
first line must be ## Objective, no wrapper tags, do not quote this instruction.
|
||
${MARK}`
|
||
|
||
// 写进压缩模板消息尾部的极短版(紧跟 "Do not continue the task",与模板同一条消息)
|
||
const CN_INLINE = `
|
||
|
||
${MARK}LANGUAGE RULE (overrides everything above): write the whole summary body in Simplified Chinese,
|
||
even if the conversation is English. Keep every section heading exactly in English (## Objective etc.).
|
||
First line of your reply must be "## Objective". No preamble, no explanation, do not quote this rule.
|
||
${MARK}`
|
||
|
||
// ── v5 常驻中文引导(每个非压缩请求末尾注入,防被英文工具结果带偏)──────
|
||
const LANG_MARK = "<reply-language>"
|
||
|
||
const LANG_DIRECTIVE = `${LANG_MARK}
|
||
REPLY LANGUAGE RULE — applies to EVERY reply, at ALL times, including after every tool call and mid-task:
|
||
- Always reply to the user in Simplified Chinese (简体中文).
|
||
- English appearing in tool outputs, file contents, logs, search results or web pages must NOT switch your reply language.
|
||
- Keep code, file paths, commands, URLs, error text and identifiers unchanged (do not translate them).
|
||
- If you genuinely cannot output Chinese, ignore this rule.
|
||
${LANG_MARK}`
|
||
|
||
// 压缩请求 body 的指纹(同时命中才动手,避免误改其它请求)
|
||
const FP_HEAD = "## Objective"
|
||
const FP_TAIL = "Do not continue the task"
|
||
|
||
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 ""
|
||
}
|
||
|
||
// LLM message 的 content 可能是字符串或 [{type,text}] 数组
|
||
function msgText(m: any): string {
|
||
try {
|
||
if (!m) return ""
|
||
if (typeof m.text === "string") return m.text
|
||
const c = (m as any).content
|
||
if (typeof c === "string") return c
|
||
if (Array.isArray(c)) return c.map((p: any) => systemText(p)).join("\n")
|
||
return ""
|
||
} catch {
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// 往一条 message 的 content 末尾追加文本(字符串 / 数组两种形态都兼容)
|
||
function appendToMsg(m: any, tail: string): boolean {
|
||
try {
|
||
if (!m) return false
|
||
if (typeof m.content === "string") {
|
||
m.content += tail
|
||
return true
|
||
}
|
||
if (Array.isArray(m.content)) {
|
||
for (let i = m.content.length - 1; i >= 0; i--) {
|
||
const p = m.content[i]
|
||
if (p && typeof p === "object" && typeof p.text === "string") {
|
||
p.text += tail
|
||
return true
|
||
}
|
||
}
|
||
m.content.push({ type: "text", text: tail })
|
||
return true
|
||
}
|
||
if (typeof m.text === "string") {
|
||
m.text += tail
|
||
return true
|
||
}
|
||
return false
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 命名导出: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<void> {
|
||
try {
|
||
const session = ctx && ctx.session
|
||
if (!session || typeof session.hook !== "function") {
|
||
fileLog("session.hook 不可用(keys=" + JSON.stringify(Object.keys(session || {})) + "),未注册")
|
||
return
|
||
}
|
||
|
||
// ① compaction 钩子:system 段追加中文规则(原地 push ⇒ trigger 返回同一对象,必进 request)
|
||
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 + ")keys=" + JSON.stringify(Object.keys(g)))
|
||
return
|
||
}
|
||
if (sys.some((s: any) => systemText(s).indexOf(MARK) >= 0)) return
|
||
sys.push({ type: "text", text: CN_RULE })
|
||
fileLog(
|
||
"已注入中文输出指令(system) 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)))
|
||
|
||
// ② http.request 钩子:请求出网前的最后关口
|
||
// v4:压缩请求 → 模板之后追加语言指令(A/B 实测唯一稳定生效的位置)
|
||
// v5:扩展到所有 JSON 聊天请求 → 非压缩请求在 messages 末尾注入常驻中文引导
|
||
// 日志节流:同 sessionID 60s 内最多记一条常驻注入日志
|
||
const langLogAt = new Map<string, number>()
|
||
const hr = await session.hook("http.request", async (f: any) => {
|
||
try {
|
||
if (!f || !f.request || typeof f.request.clone !== "function") return
|
||
const ct = String((f.request.headers && f.request.headers.get && f.request.headers.get("content-type")) || "")
|
||
if (ct && ct.indexOf("json") < 0) return
|
||
let raw = ""
|
||
try {
|
||
raw = await f.request.clone().text()
|
||
} catch {
|
||
return
|
||
}
|
||
if (raw.length === 0) return
|
||
const kindStr = typeof f.kind === "string" ? f.kind : ""
|
||
const isCompaction =
|
||
raw.indexOf(FP_HEAD) >= 0 && raw.indexOf(FP_TAIL) >= 0 && (kindStr === "" || kindStr === "compaction")
|
||
let obj: any
|
||
try {
|
||
obj = JSON.parse(raw)
|
||
} catch {
|
||
return
|
||
}
|
||
const msgs = obj && obj.messages
|
||
if (!Array.isArray(msgs) || msgs.length === 0) return
|
||
const lastTxt = msgText(msgs[msgs.length - 1])
|
||
|
||
// ── v5 常驻中文引导(非压缩请求):末尾不含标记才注入 ──────────
|
||
if (!isCompaction) {
|
||
if (lastTxt.indexOf(LANG_MARK) >= 0) return
|
||
msgs.push({ role: "user", content: [{ type: "text", text: LANG_DIRECTIVE }] })
|
||
const headers5 = new Headers(f.request.headers)
|
||
headers5.delete("content-length")
|
||
f.request = new Request(f.request.url, {
|
||
method: f.request.method,
|
||
headers: headers5,
|
||
body: JSON.stringify(obj),
|
||
})
|
||
const sid = String(f.sessionID || (obj && obj.sessionID) || "?")
|
||
const now = Date.now()
|
||
if (!langLogAt.has(sid) || now - (langLogAt.get(sid) as number) > 60000) {
|
||
langLogAt.set(sid, now)
|
||
fileLog("常驻中文引导已注入 session=" + sid + " kind=" + (kindStr || "?") + " messages=" + msgs.length)
|
||
}
|
||
return
|
||
}
|
||
|
||
// ── 以下为压缩请求的 v4 双保险逻辑(保持不变)────────────────
|
||
if (lastTxt.indexOf(MARK) >= 0) return
|
||
fileLog("body 顶层keys=" + JSON.stringify(Object.keys(obj)) + " temperature=" + JSON.stringify(obj.temperature) + " model=" + JSON.stringify(obj.model))
|
||
// 诊断:逐条 message 结构(role / 长度 / 开头 60 字)——只记结构不落全文,
|
||
// 全文含完整会话内容,落盘既占地方又有隐私问题,定位完成后不再需要。
|
||
try {
|
||
const brief = msgs
|
||
.map((m: any, i: number) => i + ":" + String((m && m.role) || "?") + ":" + msgText(m).length + ":" + msgText(m).slice(0, 60).replace(/\s+/g, " "))
|
||
.join(" || ")
|
||
fileLog("结构: " + brief)
|
||
} catch {}
|
||
// ②-a 把语言规则写进压缩模板消息本身(紧跟 FP_TAIL),与模板同一条消息
|
||
let inlined = false
|
||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||
const t = msgText(msgs[i])
|
||
if (t.indexOf(FP_HEAD) >= 0 && t.indexOf(FP_TAIL) >= 0) {
|
||
if (t.indexOf(MARK) >= 0) break
|
||
inlined = appendToMsg(msgs[i], CN_INLINE)
|
||
break
|
||
}
|
||
}
|
||
// ②-b 再在 messages 最末尾追加一条独立指令(A/B 实测唯一稳定生效的位置)
|
||
msgs.push({ role: "user", content: [{ type: "text", text: CN_DIRECTIVE }] })
|
||
const headers = new Headers(f.request.headers)
|
||
headers.delete("content-length")
|
||
f.request = new Request(f.request.url, { method: f.request.method, headers: headers, body: JSON.stringify(obj) })
|
||
fileLog("已在压缩模板后追加中文指令 messages=" + msgs.length + " 模板内联=" + inlined + " 原body=" + raw.length + "字节")
|
||
} catch (e: any) {
|
||
fileLog("http.request 钩子异常(已吞掉,未改写请求): " + (e && e.message ? e.message : String(e)))
|
||
}
|
||
})
|
||
fileLog("http.request 钩子注册完成 registration=" + JSON.stringify(hr && typeof hr === "object" ? Object.keys(hr) : String(hr)))
|
||
|
||
// ③ model.request 诊断:实测 payload 只有 sessionID/agent/model/kind/baseURL/headers
|
||
const mr = await session.hook("model.request", (p: any) => {
|
||
try {
|
||
if (!p || p.kind !== "compaction") return
|
||
fileLog(
|
||
"model.request(compaction) keys=" +
|
||
JSON.stringify(Object.keys(p)) +
|
||
" system=" +
|
||
(Array.isArray(p.system) ? p.system.length : typeof p.system) +
|
||
" messages=" +
|
||
(Array.isArray(p.messages) ? p.messages.length : typeof p.messages),
|
||
)
|
||
} catch (e: any) {
|
||
fileLog("model.request 钩子异常(已吞掉): " + (e && e.message ? e.message : String(e)))
|
||
}
|
||
})
|
||
fileLog("model.request 钩子注册完成 registration=" + JSON.stringify(mr && typeof mr === "object" ? Object.keys(mr) : String(mr)))
|
||
} catch (e: any) {
|
||
fileLog("setup 异常(已吞掉,不影响其它插件): " + (e && e.message ? e.message : String(e)))
|
||
}
|
||
// setup 必须返回 void:返回 hooks 对象会触发 Ae is not a function 整批崩溃
|
||
return
|
||
},
|
||
}
|