Files
oc_session/compaction-cn.ts
T

346 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
},
}