rcs-context 0.0.0-stage → 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3139 @@
1
+ // Copyright (c) 2026 ScreamingMaggot. This source code is licensed under the MIT License.
2
+ // contextinjector.mjs — CONTEXTinjector v1 注入侧插件
3
+ //
4
+ // F+ 事件序列注入架构(主设计 设计_事件序列注入方案.md §一/§8.1 v1.1 裁决)。
5
+ // 与 LOGcompiler(编译侧,StateCompiler/LOGcompiler/logcompiler.mjs)配对:
6
+ // LOGcompiler —— 订阅 session/event,把原始事件纯规则转录为五类行落盘(编译/观测用)
7
+ // CONTEXTinjector —— 本插件。在 round 边界把"已闭合前缀"经官方 replace 通道折叠成
8
+ // 一条单调在场的转录消息,使真实请求 = [system]+[转录]+[本轮U]
9
+ //
10
+ // 折叠算法与通道用法直接复用实证通过的 replace-probe(实证实验-Fplus/scripts/replace-probe.mjs,
11
+ // P1 fold 通过校验 / P2 折叠消息字节在场 / P3 881→443B 裁剪 / S 折叠后任务语义延续)。
12
+ // 设计依据:CONTEXTinjector/版本说明文档/v1-提案.md(触发调度 / 防自转录 / 断代衔接)。
13
+ //
14
+ // 折叠纪律(主设计 §8.1):
15
+ // 1 只折已闭合前缀;2 新折叠消息 = 旧折叠消息 + 纯拼接增量(单调在场);
16
+ // 3 触发点仅 round 边界;4 自身节点 source.kind='plugin:CONTEXTinjector'(防自转录;v1.10.27 起 v4 形态,读侧双形态兼容);5 禁工具正文。
17
+ // v1.5 condense 档(off/single/double,默认 off;红线勘误见主设计 §8.2):
18
+ // 折叠增量内新 [A] 前向浓缩(single:语义浓缩;double:双提取=语义+载荷直抄),
19
+ // [U]/[T]/[ROUND] 与旧 transcript 不动绝不回写;失败自动回退无损;
20
+ // raw 全量存证 <STATE_DIR>/.ctxinjector/raw/。无损转录(§8.1)仍为默认档与底线。
21
+ // v1.8 浓缩模型通道(可移植性改造):浓缩不再依赖本地 Ollama,改为调用 **DSH 已注册的模型**
22
+ // (ctx.llm 流式服务):默认跟随会话当前 provider/model(session.requestHeader().config),
23
+ // 可用 env 或 control.json 显式指定;DSH 无 llm 服务时回退无损折叠,绝不中断。
24
+ // 本机实验若要走本地 4B,显式设 DSH_CONTEXTINJECTOR_LOSSY_OLLAMA=<url> 启用直连通道。
25
+ //
26
+ // 启用门控(缺省全关,防误伤;v1.2 起支持持久化白名单,见 v1-提案 §控制模型):
27
+ // 优先级:TEST_ROUND(回归) > DSH_CONTEXTINJECTOR_ENABLED=1(env 直通) >
28
+ // control.json(enabled && 当前 session ∈ whitelist) > off(默认)
29
+ // DSH_CONTEXTINJECTOR_STATE_DIR=<dir> 状态目录(默认 $DSH_HOME/state-compiler,DSH_HOME 缺省 ~/.dsh;不依赖任何本机前缀)
30
+ // DSH_CONTEXTINJECTOR_OUT=<path> 观测 jsonl(未设则不落盘)
31
+ // DSH_CONTEXTINJECTOR_THRESHOLD=<pct> 断代窗口阈值(默认 70;v1 仅记录不执行)
32
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33
+ import { createHash, randomUUID } from 'node:crypto'
34
+ import { homedir } from 'node:os'
35
+ import { join, dirname } from 'node:path'
36
+ // A档:结构化工具折叠(纯规则 [T]+[V],见 toolfold/)。默认 OFF,仅在 control.toolfoldStructured 开启时启用。
37
+ import { foldOne as foldToolResult } from '../toolfold/toolfold.mjs'
38
+ // §13 消息来源分型(condense/ 共享纯模块,仿 ../toolfold 顶层兄弟目录惯例):
39
+ // 注入器侧转录 ROUND 仅计真人轮(source.kind==='user'),subagent 回传等不再漏进 [U]。
40
+ // 与 LOGcompiler §13 修复对仗。设计见 docs/v2-优化设计.md §13。
41
+ import { classifyMessageSource, subagentIdOf, classifyCondenseError, chunkForCompressor, INJECTED_USER_PREFIXES, pluginIdOf, auxFallbackRoutes, isRouteDead } from '../condense/chunk.mjs'
42
+ import { ivrLoad, ivrRecord } from '../condense/ivr.mjs' // #5 IVR 注入经济计量簿记(仅 node:fs)
43
+
44
+ const ENABLED = process.env.DSH_CONTEXTINJECTOR_ENABLED === '1'
45
+ const OUT_FILE = process.env.DSH_CONTEXTINJECTOR_OUT || ''
46
+ const THRESHOLD = Number(process.env.DSH_CONTEXTINJECTOR_THRESHOLD ?? 70)
47
+ // 回归通道(缺省关,不破坏纪律 3):headless CLI 每次只跑单轮,无第二个 user turn,
48
+ // round 边界折叠永不触发。DSH_CONTEXTINJECTOR_TEST_ROUND=1 允许"闭合前缀即折"
49
+ // (等价 replace-probe 的 FOLD_PROBE 已实证路径);TRACE=1 记录每步请求中折叠消息字节/hash,
50
+ // 用于验证折叠消息进入 deriveMessages 后逐字节稳定在场。
51
+ const TEST_ROUND = process.env.DSH_CONTEXTINJECTOR_TEST_ROUND === '1'
52
+ const TRACE = process.env.DSH_CONTEXTINJECTOR_TRACE === '1'
53
+
54
+ // v1.10.1 = v1.10 + keepInject 头部补录(foldHeadTranscript:注入节点之前的首条 user 不再从转录里消失)。
55
+ // 注意:**不占用 v1.11–v1.14 号**——那些号已被 RCS 后继分支(v1.14.1,另一条 lineage)使用,避免版本号撞车。
56
+ const PLUGIN_VERSION = 'v1.10.75 (原生压缩 ⇒ 卷封存 + 轮次继承 + 边界缀增(用户定的最小改法):①原生 compaction/end(非自身、无错误)时把当前卷的转录原文封存到 .ctxinjector/sealed/<sid>/v<N>.json,索引 <sid>.json 记 baseRound/resumeAfterSeq/volumes[],进程重启后照样生效;封存卷不再被追加、不再进上下文。②轮次继承——新卷的 [ROUND] 从封存时的 foldRound 续号(不重头从 1 开始)。③从摘要边界缀增——新卷只缀增原生摘要覆盖范围(compaction/summary.shadowedSeqs 的最大 seq,回退 surface 上最后一条宿主检查点)之后的节点;仍留在表面的旧卷原地冻结(不遮蔽、不重转录)。④seedFoldState 跳过封存卷(seq ≤ resumeAfterSeq 或文本 hash 命中封存卷)——堵住"原生保留窗口正好落在旧卷上 ⇒ 重启 seed 把旧卷又灌回来"。⑤A/B:control.resetAfterNativeCompaction=false / env DSH_CONTEXTINJECTOR_NATIVE_RESET=0 回到旧行为。回归 tests/native-compaction-reset.test.mjs) 内嵌 v1.10.74 (原生压缩后不复活旧转录:①非自身 compaction/end ⇒ 清空折叠基线并允许重新 seed——此前 seedFoldState 只在 foldText 为空时跑,原生压缩把检查点总结掉后 st.foldText 仍是压缩前旧值,下一折按 transcript=foldText+新段把整份旧转录又拼回去 ⇒ 上下文立刻回到压缩前大小 ⇒ 下一轮再次越线、再次原生压缩(control.resetAfterNativeCompaction=false / env DSH_CONTEXTINJECTOR_NATIVE_RESET=0 可关,A/B 用);②宿主原生压缩写的 compact-checkpoint 当压缩边界——转录分型把它记 opaque、不产行,折进遮蔽区等于把 summary 从模型视野里删掉,现在折叠从它之后开始、它始终留在表面;回归 tests/native-compaction-reset.test.mjs) 内嵌 v1.10.73 (DSH 0.2.2-rc.2 兼容:runtime-context 快照从裸文本改成 <system-reminder>The following information describes your current session: ...</system-reminder>,旧前缀(Current runtime context)整类失配 ⇒ 尾部快照不再被判为注入 ⇒ 被遮蔽后 DSH 逐轮补发;现在 source.kind 优先(runtime-context 进 DSH_INJECTED_KINDS),前缀匹配前剥 reminder 包裹;回归 tests/dsh-injected-rc2.test.mjs;rc.2 的 session 格式 v4 schema 与 compaction 协议逐字节未变,append 仅新增批量重载、单条形态保留) 内嵌 v1.10.72 (未落盘的 seq 不再冒充业务失败:raw append 是异步的,[T] 行渲染时该 seq 可能还没 flush,回执里的 seq-not-in-raw 只是「稍后再取」。v1.10.67 把它当业务失败提成 FAIL(seq-not-in-raw),真机上一条 probe 的正文带该回执,[T] 行就标成 FAIL,而同一个 seq 稍后展开实得 6662B ⇒ 模型据此判「工具坏了」。现在 RETRYABLE_CODES 里的码记中性状态 PENDING,行仍只挂 raw=<seq>,不与 FAIL 同权重;readRawEntry 的该回执补 pending:true + hint,工具描述里写明 PENDING 的语义;PENDING 行也不再内联小回执预览(判定列已是「稍后再取」,别复述回执)。方案 2(渲染前 await 一次 raw flush)未采纳:给每个 [T] 行加一次 flush 等待,收益只是省掉一次重试) 内嵌 v1.10.71 (注册即被回收根治:真机"register 不抛错、工具却始终不在可见表里"的根因不是时机、不是 allow 白名单——是每处注册后紧跟的 ctx.effect(() => { dispose?.() }) 把它当场注销了:cordis 的 effect(execute) 会立即执行 execute 并把返回值当 disposer,于是"持有"变成了"立即释放"。隔离实测(tests/probe-effect-dispose.mjs):naive 写法 registered=false,generator 写法 registered=true。插件内三处(ctx.tools 直挂 / ctx.inject 延迟挂 / 补挂器)统一改走 holdToolRegistration 的 generator 持有形。v1.10.69 的 inject:["tools"]、v1.10.70 的补挂与落盘诊断全部保留,作为无害兜底。模型侧因此第一次真正拿到 transcript_expand 的定义,[T] 行的 raw=<seq> 才成为可点入口) 内嵌 v1.10.70 (工具可见性:注册调用与 memory.mjs 完全一致(插件级 inject:["tools"] + apply 内 ctx.tools.register),但隔离实例实测 register 不抛错、工具就是进不了可见表——apply 时 n=1、1s 后同一实例 n=92/99(含 memory_*/MCP)里却没有它,2s/5s/10s 补挂同样不落。已把"何时丢、丢时 table 里有什么"写进 state-compiler/tools/transcript-expand.json 供线上复查(probe 缺 photoshop 等 bundle,结论不能直接外推到 live)。期间先加三处兜底:pre-step 与工具表变化时补挂、每步检查一次;同时转录行内联 FAIL(错误码) 保证不依赖该工具也能判真伪) 内嵌 v1.10.69 (工具可见性根因探查:工具注册的结论不再只进 console——真机 v1.10.67 的 armed/visible= 只打在用户 cmd 窗口里,重启后无从判读"加载了但模型看不到"。现在 register 成功/失败/可见性探测结果连同 tools/change 复检写进 STATE_DIR/tools/transcript-expand.json,并优先走 ctx.tools 直挂(与 memory.mjs 等已证明可见的插件同路径),tools 未注入才退回 ctx.inject 延迟挂载) 内嵌 v1.10.67 (转录可逆面三修:①工具用法写进注册表——transcript_expand 此前无 description,模型不知道 raw=<seq> 可点、更不知道判定真伪该点它(真机连着三轮回执 ok:false 仍凭 [T] 行尾 OK 下结论);②业务层判定——行尾 OK/ERR 只判传输层,回执里的 {"ok":false,"error":...} 现在提成 FAIL(错误码),错误码与体积无关、永远显示;③注册后自检——register 没抛错≠模型看得见,armed 行现在回读可见定义表印 visible=yes/NO 并单列一条 error;体积门槛:只有被遮蔽正文 ≥ rawPtrMinBytes(缺省 1024,control/env 热调、0=永远挂指针退回旧行为)才挂 raw=<seq>,小结果里**失败行与提问答复**把有界预览(≤160 字)内联进同一行 [T]、成功的小回执保持精简 —— 真机回放 71 条:2670B→3382B(+27%),换来 4 条业务失败可见原因、且不再需要回取) 内嵌 v1.10.66 (错误面收口:档位探测失败(off 被拒→去掉档位重试成功)不再进 errs,改记 effortDowngraded;condenseErrResolved 改按 本折成文段>0 且 fallbackReasons.llmErr=0 判定 ⇒ noShrink/tooShort 这类质量性回退不再把成功折叠标成失败。真机 18:53:aCount=6、aFolded=4、noShrink=2、无 llmErr,面板却挂红字) 内嵌 v1.10.65 (折叠侧路由降级:上游摘掉模型或宿主没配该 id 时,按 压缩路由→配置回退→本机 ollama 换路重试,并把实际服务的路由写进 last-fold 的 condenseRoute(via=fallback);此前折叠侧完全没有降级,模型一死整折无损回退而本机 ollama 明明在跑) 内嵌 v1.10.64 (折叠记录可写性根治:last-fold 字面量曾引用 if 块内的 ioc/condenseErrResolved,两次导致 ioc is not defined ⇒ 整折 append-failed;已改函数级 let,并有结构性测试禁止 last-fold 引用块内变量。显式 max_tokens 不再二次收敛(maxTokensSent 可核验)。面板:已恢复的折叠不显示红字,标记移到「实际使用」行尾) 内嵌 v1.10.61 (错误回执分型:出过错但每一段都折叠成功时 last-fold 记 condenseErrResolved,WebUI 降级为灰字提示而非红字报错——真机 folded=1/1、fallback=0、salvaged=1 的面板仍显示 UNSUPPORTED_REASONING_EFFORT) 内嵌 v1.10.60 (辅助调用会话身份:折叠浓缩/记忆编译/整理三处 purpose=compaction 调用现在都带 sessionId(宿主 dsh-compaction-basic 与 dsh-session-title-llm 一直这么做,适配器靠它生成 x-opencode-session;缺失时 opencode-go 直接 400 MissingSessionID——这是档位回退之后撞到的第二层,实测被 v1.10.59 的档位回退救到的那次重试全部死在这里)) 内嵌 v1.10.59 (推理档位回退:宿主对没有 off 档的模型直接 finish=error UNSUPPORTED_REASONING_EFFORT(实测 opencode-go/deepseek-v4.1-flash),此前整条 [A] 无损回退、记忆编译静默失败;现在被拒时不传 reasoningEffort 再试一次,让正文以默认思考模式参与,并把原因记进 io.errs/llmLast;control.condenseReasoningEffort 置空时终于能表达「完全不传」(此前被真值判断当成未设置)) 内嵌 v1.10.58 (修复异步 turn-end fold 的 compaction turn 归属:调用方未显式给 turn 时,从当前会话事件回溯仍打开的 turn,避免落到下一轮时写 turn:null 触发 v4 日志 "compaction/start does not match the open turn";内嵌 v1.10.40 (自绘折叠/记忆状态:隐藏宿主「上下文已压缩」行,输入框左侧状态胶囊显示「本折 x% · 累计 y%」,折叠/记忆编译/原生压缩各有琥珀脉冲;记忆编译与原生 compaction 的在飞标记落状态目录,结束即删。内嵌 v1.10.38 (检查点去重 + 微折门槛:同一会话复用同一 compactionId(聊天区只维护一行「上下文已压缩」,不再每轮追加);摘要改为「本折 x.xx · 累计 y.yy」两个比值(<1 为省);新原料不足 foldMinShadowBytes(缺省 2048,0=关)不折,等后续轮累积。内嵌 v1.10.37 (折叠触发减法:删除 agent/turn-stopping(本栈 0 次有效折叠),turn/end 成为唯一生产触发点并折掉刚结束的轮;pre-step 退回仅 join(foldJoinTimeoutMs 超时),next() 全路径恰好一次;移除 protectLatestRound 结构性死触发——实测一次 748B 浓缩 60.1s 挡在下一轮前的阻塞路径消失。内嵌 v1.10.36 (代码结构大纲升级为轻量词法抽取:多行签名拼接、箭头函数/IIFE 区分、类/impl 上下文前缀、按语言分规则、注释与单行函数体剔除;仍为 8 项/320 字上限,纯规则零模型。内嵌 v1.10.35 (read 折叠保留有界正文预览:首 24 行 + 末 8 行 + 代码结构大纲(≤320 字),完整原文仍由 raw=<seq> 承载;edit/write 增加 del/ins 行数统计;均不调用模型。内嵌 v1.10.34 (折叠可见性修复:提交改走宿主 compaction 协议 start→summary→compact-checkpoint 替换→end,UI 显示“上下文已压缩”标记且助手原文仍留在人类转录;turn-stopping/turn-end 触发保护最近一轮,不再当场遮掉刚流出的正文;[S] 快照行不再进折叠转录并在续传时清理旧残留;transcript_expand 改 ctx.inject 延迟挂载。内嵌 v1.10.33 (计量口径两处修正:①applyCondense 内部也认 off 档——此前只靠调用方判断,任何新调用点漏判就会在 off 档照样发起付费浓缩(实测真会调模型);②`stat.calls` 改为**真实发起的调用次数**(llmText 里计数),预算预约数另存 callsReserved——旧口径把短行/回本闸提前返回的「预约」也算成调用,实测 tooShort 行 calls=1 而 auxCalls=0。内嵌 v1.10.32 (折叠账本幂等:同一区间被两个触发点各提交一次时不再重复记账(旧实现每笔写两行 ⇒ 按行求和的 raw/appended 总账翻倍);monotonic 由硬编码 true 改为按 cumLines 与遮蔽 seq 实算;sanitizeSid 空串回退 unknown,杜绝 .jsonl 无名文件被多会话共用。内嵌 v1.10.31 (DSH session-format v4 兼容:④source.kind=plugin 属退役形态⇒写侧全部改 producer-owned kind=plugin:X(与官方 v3→v4 迁移同形态;v3 宿主对 kind 无枚举校验,单形态双宿主通吃);读侧(防自转录/注入避让/分类器)统一走 pluginIdOf 双形态识别;⑤session.events 公开数组 rc.3+ 已移除,枚举改 ownEvents/snapshotEvents 兜底,杜绝空表静默降级。内嵌 v1.10.26 (DSH 0.1.5 兼容:①session.events[seq] → session.eventAt(seq),0.1.5 已移除 events 公开数组,旧写法在 resume-seed/折叠时抛 "Cannot read properties of undefined (reading \'<seq>\')";②surfaceOp replace 字段 start/end → startSeq/endSeq,0.1.5 按三键定长校验,旧字段名致 "carries an invalid replace surfaceOp";③遮蔽集排除 role===\'system\' 节点,0.1.5 新增"seq 0 的 system prompt 只能被 system/message 且仅覆盖该节点"保护,旧行为使折叠范围从 0 起 ⇒ "node 0 holds the system prompt" 被拒。三处均保留旧版回退路径,同一文件可跑 0.1.1-rc.2 与 0.1.5-rc.2。含 v1.10.25 及此前全部修复;⑪漂移#11:0.1.7 把 tool-call 从"助手消息内容块"改为**独立 tool/call 事件**⇒ transcribeIncremental 的 pending 永不登记 ⇒ [T] 的工具名/目标整列退化为 ?(真机 118/118);改以 callId→{name,arguments} 的**事件索引**(callIndexFromEvents)为权威源,块级缺失时回查;老宿主块内自带 name ⇒ 回查不命中、行为逐字不变;⑪b 主症状修复:0.1.7 的工具结果是 {role:tool, toolCallId, isError, content:[text]} **没有 tool-result 块** ⇒ 旧块级分支整类跳过 ⇒ 真机 205 节点折叠后 [T] 行数=0(模型看不到任何工具痕迹)。转录改双形态识别(isToolResultMsg/toolResultCallId,callId 与 isError 回落消息层),并新增**覆盖守卫**:增量内每个工具结果必须产出一行 [T],少一行即拒绝本次折叠、本轮按原文发送);⑫当前轮保护:折叠不得吃掉仍在飞的那一轮——closedPrefixEnd 在"所有 call 都已闭合"时返回表面末端,于是 pre-step 折发生在"本轮第 2 步"时会把本轮 user 消息与刚产出的助手回复一并折进转录,表面尾部变成 user 角色节点 ⇒ 宿主视其为未回答的用户轮 ⇒ 再跑一步 ⇒ 同一条用户消息被答两次(真机 session-de4959d2 回合 2 连续两条回答)。新增 openRoundClampEnd:轮起点之后无 turn/end ⇒ 遮蔽终点钳到该起点之前;已结束的轮不受影响;事件不可得则退回旧行为);⑬压缩比口径修正:旧 shrink = transcriptBytes(累计转录)/shadowedBytes(本折遮蔽) 是**混合口径**,增量折叠上结构性 >1(真机 659%/917%/382400%)⇒ 抽出纯函数 foldShrinkOf 统一为**本折**口径(appendedBytes/incrementalRawBytes),三处引用与面板同步;累计量 transcriptBytes 原样保留;v1.10.40 首折 head 同口径:原样保留的 head 段不计入本折占比(真机 101%→18.5%))'
57
+ // 持久化门控与状态(供 WebUI 面板读写/展示;只写纯标量 JSON,无内部活体对象)
58
+ // v1.8 可移植:不再硬编码本机绝对路径。DSH_HOME 由启动器注入(start-dsh-web.cmd);
59
+ // 缺省回落到 ~/.dsh,使插件在任何人的机器上开箱可用(显式 env 仍最高优先)。
60
+ const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
61
+ const STATE_DIR = process.env.DSH_CONTEXTINJECTOR_STATE_DIR || join(DSH_HOME, 'state-compiler')
62
+ const CONTROL_FILE = join(STATE_DIR, 'control.json') // { enabled, sessions[], condense, updatedAt } —— UI 开关 + session 白名单 + 压缩档
63
+ const RUNTIME_FILE = join(STATE_DIR, 'runtime.json') // 运行参数/最近折叠摘要(UI 展示用)
64
+ const LAST_FOLD_FILE = join(STATE_DIR, 'last-fold.json') // 最近一次折叠详情(含注入内容预览)
65
+
66
+ // —— condense 档(v1.5,取代 v1.4 布尔 lossy):off | single | double ——
67
+ // off = 无损转录折叠(默认档与底线,§8.1)
68
+ // single = 增量内新 [A] 走 4B 前向语义浓缩(留结论/浓缩推理)——v1.4 lossy 原行为
69
+ // double = 双提取:语义浓缩 + 载荷直抄(p1c 架构;孤立载荷值额外清单并入 [A] 行,保真最高、调用更多)
70
+ // [U]/[T]/[ROUND] 与旧 transcript(foldText)绝不触碰/绝不回写(只拼新浓缩行,monotonic 前缀保持)。
71
+ // 浓缩失败(ollama 离线/超时/返回空/未缩短)自动回退无损:该 [A] 行保留原文,折叠不中断。
72
+ // 底气:raw 权威存证——被遮蔽节点原文全量追加 <STATE_DIR>/.ctxinjector/raw/<sid>.jsonl,随时可回放/审计。
73
+ // v1.7 增补:per-session 档位 control.json.sessionModes = { <sid>: 'off'|'single'|'double' },
74
+ // 由 composer「本会话压缩模式」(首轮发送前即可选)写入,只作用于该会话。
75
+ // v1.9 修订(2026-09-06,档位权威收口):顶部面板「AI 输出压缩档」已删除——每会话已有独立档位,
76
+ // 全局默认(control.json.condense / 旧 lossy 布尔)**不再参与裁决**(字段留作惰性残余,不读写)。
77
+ // 档位来源优先级:env DSH_CONTEXTINJECTOR_LOSSY(0/off/single/double,1=兼容 single)>
78
+ // control.json.sessionModes[<sid>](本会话档,v1.7,唯一 UI 档位来源)>
79
+ // (gate=test/env 无 UI 部署:默认无损 off 兜底,保 headless/回归通道)
80
+ // gate=control 时若会话无本会话档 ⇒ **不折叠**(白名单仅门控残余,档位以 sessionModes 为准)。
81
+ const ENV_MODE = String(process.env.DSH_CONTEXTINJECTOR_LOSSY ?? '').toLowerCase().trim()
82
+ const LOSSY_MODEL = process.env.DSH_CONTEXTINJECTOR_LOSSY_MODEL || 'qwen3-8b-q4:latest'
83
+ // v1.8:Ollama 直连降级为**可选**通道 —— 仅当显式设置该 env 才启用(保留本机本地 4B 实验路径)。
84
+ // 未设置时浓缩一律走 DSH 已注册模型(见 llmText/resolveCondenseRoute),无本地模型的机器也可用。
85
+ const LOSSY_OLLAMA = process.env.DSH_CONTEXTINJECTOR_LOSSY_OLLAMA || ''
86
+ const LOSSY_MIN_BYTES = Number(process.env.DSH_CONTEXTINJECTOR_LOSSY_MIN ?? 240) // [A] 正文 < 此长度不浓缩(不值得)
87
+ // 【取自 RCS-0.1.0 返回包】minBytes 热读:`control.condenseMinBytes`(int)可覆盖内置 240,
88
+ // 免重启生效(面板改 control.json 即生效,与 #7 condenseMaxTokens 同机制)。
89
+ // 缺省仍是 240 ⇒ 不配置时行为与旧版完全一致。注意:调低 = 更多 [A] 会各触发一次浓缩模型调用。
90
+ function minBytesOf(io) {
91
+ const n = io && io.minBytes
92
+ return Number.isFinite(n) && n >= 0 ? n : LOSSY_MIN_BYTES
93
+ }
94
+ // 【v1.10.19 · P1 修复(用户 2026-09-12 实证报告)】默认上限 700 → 2048:
95
+ // 实测单次浓缩在 700 下大量 `finish=max-tokens`(double 8/12 运行、single 5/12 运行),
96
+ // 截断 ⇒ 视为失败 ⇒ 整条 [A] 无损回退(命中率仅 5–7%)。700 对"要写一段压缩后正文"的任务
97
+ // 本就偏紧(尤其是更长的 [A] 或中文),而调大到 8096 后截断归零(命中率 18–20%)。
98
+ // 但"一律 8096"会让小输入也按大额度生成、白等延迟(实测单次折叠 2.5s→7.7s),
99
+ // 故改为:**上限 2048 + 每次调用按输入长度自适应**(见 effectiveMaxTokens)。
100
+ const LOSSY_MAX_TOKENS = Number(process.env.DSH_CONTEXTINJECTOR_LOSSY_MAXTOK ?? 2048)
101
+ const LOSSY_TIMEOUT_MS = Number(process.env.DSH_CONTEXTINJECTOR_LOSSY_TIMEOUT ?? 90000)
102
+ // 【v1.10.20 · 经济性 P0】浓缩/载荷调用的推理档位,缺省 `off`(禁用思维链)。
103
+ // 为什么这是本版最大的一笔经济性修复:`reasoningEffort` 是 dsh-llm 的一等请求项,
104
+ // deepseek 适配器把 'off' 解析为 `{thinking:{type:'disabled'}}`(dsh-llm-deepseek:34);
105
+ // 而**推理 token 与正文 token 共同占用 maxTokens、且一并按输出计费**。
106
+ // 本插件此前只传 `purpose:'compaction'`,而未像 harness 的辅助调用(`purpose:'session-title'`,
107
+ // 见 dsh-llm-deepseek:31)那样关掉思维链 ⇒ 模型把整个输出额度烧在推理上。
108
+ // 实测(1801 字 [A] @maxTokens=768):reasoningTokens=768 / 输出正文 0 字 / finish=max-tokens
109
+ // ⇒ 整条 [A] 判失败、无损回退:**既付了钱,又没换来任何压缩**。
110
+ // 而"压缩"本质是纯删除+逐字摘抄任务,推理无收益。关闭后实测:成功率 3/9 → 9/9、
111
+ // 压缩率约 2 倍、单次调用耗时降为 1/3~1/4(见 tools/.probe 证据)。
112
+ // 允许值:''(不传,恢复旧行为)| 'off' | 'low' | 'high' | 'max';非法值一律回落 'off'。
113
+ const LOSSY_EFFORTS = new Set(['', 'off', 'low', 'high', 'max'])
114
+ const LOSSY_EFFORT = (() => {
115
+ const s = String(process.env.DSH_CONTEXTINJECTOR_LOSSY_EFFORT ?? 'off').trim().toLowerCase()
116
+ return LOSSY_EFFORTS.has(s) ? s : 'off'
117
+ })()
118
+ function effortOf(io) {
119
+ const s = String((io && io.reasoningEffort) ?? LOSSY_EFFORT).trim().toLowerCase()
120
+ return LOSSY_EFFORTS.has(s) ? s : 'off'
121
+ }
122
+ // 【v1.10.20 · 经济性 P2】截断抢救(salvage):`finish=max-tokens` 且正文已明显缩短时,
123
+ // **保留已生成的部分**而不是把这条 [A] 整条判失败。
124
+ // 理由:一条被截断的输出,我们已经为输入 token + 已生成的输出 token 全额付过费;
125
+ // 整条丢弃 ⇒ 钱花了、上下文一点没省(这正是 P1 观测到的 `aFallback 8/8` 的浪费形态)。
126
+ // 改为接受"尾部少了几个要点"的部分结果,换来"这条 [A] 真的变短了"——两者相较,后者才是在跑压缩。
127
+ // 两道闸(缺一不可,宁可回退也不污染上下文):
128
+ // ① 缩短必须达标:`outLen <= inLen * SALVAGE_MAX_RATIO`(只短一点点不值得冒截断风险);
129
+ // ② 不得残留**半截占位符**(见 DANGLING_PLACEHOLDER_RE)。
130
+ const SALVAGE_MAX_RATIO = 0.85
131
+ // guardText 把 uuid/hex/路径换成了 `⟦ID0⟧` 这样的占位符,`restoreText` 之后会规则回填成真值。
132
+ // · 完整占位符 `⟦ID0⟧` 在浓缩结果里是**合法**的(回填后即真值);
133
+ // · 但截断恰好切在占位符中间会留下 `⟦ID` / `⟦ID0` 这种**半截**,`restoreText` 匹配不上 ⇒
134
+ // 原样漏进 transcript,既污染上下文、又可能被后续轮次当成真实值。这种必须拒收。
135
+ const DANGLING_PLACEHOLDER_RE = /⟦(?![A-Z]+\d+⟧)/
136
+ const RAW_DIR = join(STATE_DIR, '.ctxinjector', 'raw')
137
+ mkdirSync(RAW_DIR, { recursive: true })
138
+ // #4 per-session 最近折叠:除全局 last-fold.json,另按会话存一份,供注入面板"按当前会话"展示
139
+ const LAST_FOLD_PER = join(STATE_DIR, '.ctxinjector', 'last-fold')
140
+ mkdirSync(LAST_FOLD_PER, { recursive: true })
141
+ // 【在飞折叠标记】面板「上一轮输出压缩中…」的数据源(用户报:后台折叠期间面板一片空白,以为没折)。
142
+ // 目录/命名与上面 per-session 副本**同款口径**(bare sid → 非法字符换 _ → 截断 120),
143
+ // 内容 { sessionId, trigger, startedAt, step, round? }。
144
+ // 【v1.10.2 修复 2026-09-10(用户实测报障)】旧实现只在 scheduleFold 的排队路径写标记、
145
+ // 在它那个 Promise 落定时删;而当时的 pre-step 路径是**直接调用 foldAttempt**、完全绕过 scheduleFold
146
+ // ⇒ 只有排队折才有标记。用户会话实际走 pre-step(last-fold.json: trigger=pre-step)⇒
147
+ // /status.foldInflight 恒为 null ⇒ 会话页「压缩中」小标真机上永不出现。槽位/位置/CSS 都是对的
148
+ // (localStorage.ciFoldChipForce='1' 强制可见即证),坏的是数据侧——本文件。
149
+ // 现在生命周期搬进**折叠主体**:foldAttempt 入口写、finally 删 ⇒ 任何调用点(轮末后台 / 回归直折)
150
+ // 一律自动覆盖,不存在"某条触发路径忘了写"的余地(单点收口,不再依赖调用方自觉)。
151
+ // UX(本修复的重点,不是副作用):pre-step 折叠的"压缩中"窗口 = **下一轮已开始、模型还没开始流式
152
+ // 输出的那几秒**——这正是用户盯着屏幕等回复的时刻,是最该显示小标的位置;而旧实现只在轮末排队折
153
+ // 显示,那一刻用户正在读上一条回复、并不在等,等于把指示器放在没人看的窗口里。
154
+ // per-sid 语义:一会话一枚标记;同 sid 重入/并发(如 join 超时后 pre-step 又自折一次)用计数只清最后一次。
155
+ // 陈旧标记(进程崩溃残留)不由本侧清理——host /status 有 10 分钟陈旧闸兜底(见 webui/lib/index.mjs)。
156
+ // ⚠ 读写全部 try/catch:标记只是观测面,**绝不允许**影响折叠本身。
157
+ const FOLD_INFLIGHT_DIR = join(STATE_DIR, '.ctxinjector', 'fold-inflight')
158
+ mkdirSync(FOLD_INFLIGHT_DIR, { recursive: true })
159
+ // v1.10.39:宿主原生 compaction 的在飞标记(WebUI 自绘「原生压缩中」脉冲;宿主自带的压缩行已被插件隐藏)。
160
+ const NATIVE_COMPACTION_DIR = join(STATE_DIR, '.ctxinjector', 'native-compaction')
161
+ // 【v1.10.75】封存卷(seal)。契约(用户定的最小改法,见 tests/native-compaction-reset.test.mjs):
162
+ // 原生压缩发生后,它写的 compact-checkpoint 就是**压缩前历史的权威总结**;我们那份覆盖同一区间的
163
+ // 转录(LOG 表)如果继续被追加/重新拼进去,等于把宿主刚压掉的内容又灌回上下文(真机:压缩后
164
+ // 请求立刻回到压缩前大小 ⇒ 下一轮再越线 ⇒ 再压缩,逐轮循环)。
165
+ // 故:旧卷**封存**(原文落盘、不再进上下文、不再被追加),新卷①继承轮次②只从原生摘要边界之后缀增。
166
+ // 目录布局:sealed/<sid>.json = 索引(baseRound / resumeAfterSeq / volumes[]);
167
+ // sealed/<sid>/v<N>.json = 该卷全文 + 元数据(rounds 区间、seq 区间、bytes、sha256)。
168
+ const SEALED_DIR = join(STATE_DIR, '.ctxinjector', 'sealed')
169
+ // 【v1.10.5 ★必须-1(协作:TESTBRANCH 规格方 LogCompiler K)】折叠索引:seq 区间 ↔ 转录**累计行号**。
170
+ // 为什么不用 last-fold.json:它会被后续折叠覆盖,不能做溯源锚点。本文件**只追加**(NDJSON)。
171
+ // 为什么用累计行号而不是段内行号:转录是单调前缀(foldText 只追加),段内行号在 1MB 轮转下
172
+ // 会歧义(每文件从 1 起还是跨文件连续?),累计行号自带不变量
173
+ // cumLines.before(后一条) ≥ cumLines.after(前一条),且 bytes.transcript 单调不减可做第二重校验。
174
+ const FOLD_INDEX_DIR = join(STATE_DIR, '.ctxinjector', 'fold-index')
175
+ // rotate.index = 该会话转录当前已轮转出的段数(logcompiler 每超 ROTATE_BYTES 切一份 <sid>.log.N)
176
+ function foldRotateIndex(sid) {
177
+ try {
178
+ const bare = String(sid ?? '').replace(/^session-?/i, '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80)
179
+ const dir = process.env.LOGCOMPILER_OUT ? dirname(process.env.LOGCOMPILER_OUT) : join(STATE_DIR, 'transcripts')
180
+ let n = 0
181
+ while (existsSync(join(dir, bare + '.log.' + (n + 1)))) n += 1
182
+ return n
183
+ } catch { return 0 }
184
+ }
185
+ // 只在折叠**提交成功后**调用(失败/延后如 pair-split 不写);写失败绝不影响折叠。
186
+ function writeFoldIndex(rec) {
187
+ try {
188
+ mkdirSync(FOLD_INDEX_DIR, { recursive: true })
189
+ const bare = String(rec.sid ?? '').replace(/^session-?/i, '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80)
190
+ const fp = join(FOLD_INDEX_DIR, bare + '.jsonl')
191
+ // 【v1.10.32 · 幂等记账】同一区间可能因重试/并发被提交两次(0.2 真机曾因两个触发点各折一次,
192
+ // 每笔折叠被写两行,除时间戳外逐字相同,间隔 1.7–1.9s)。
193
+ // 旧实现各写一行 ⇒ 任何「按行求和」的总账(raw / appended / 压缩倍数)翻倍。
194
+ // 这里比对最后一行:同一 foldSeq 再来一次即视为同一笔,跳过记账(读数以先到的那次为准)。
195
+ try {
196
+ const prevText = readFileSync(fp, 'utf8')
197
+ const lastLine = prevText.trim().split('\n').pop()
198
+ if (lastLine) { const prev = JSON.parse(lastLine); if (prev && prev.foldSeq === rec.foldSeq) return }
199
+ } catch { /* 文件尚不存在或不可读 ⇒ 正常写入 */ }
200
+ // 【v1.10.32 · monotonic 实算】旧值是硬编码 true(自证字段);改为按遮蔽 seq 与累计行号实算,
201
+ // 免得下游把「机器没验过」当成「已验证」(与 22.4× 那次教训同类)。
202
+ const seqs = Array.isArray(rec.shadowedSeqs) ? rec.shadowedSeqs.map(Number) : []
203
+ const seqMono = seqs.every((v, i) => i === 0 || v > seqs[i - 1])
204
+ const cumMono = !(Number.isFinite(rec.cumBefore) && Number.isFinite(rec.cumAfter)) || rec.cumAfter >= rec.cumBefore
205
+ const line = JSON.stringify({
206
+ t: Date.now(), sessionId: rec.sid, foldSeq: rec.foldSeq, shadowedSeqs: rec.shadowedSeqs ?? [],
207
+ cumLines: { before: rec.cumBefore, after: rec.cumAfter },
208
+ bytes: { shadowed: rec.shadowedBytes, transcript: rec.transcriptBytes },
209
+ // v1.10.25 经济性口径(附加字段,旧消费方零改动):live = 提交后真实请求体量;
210
+ // raw = 本折新原料字节;appended = 本折新增转录字节(foldRatio = appended/raw)
211
+ ...(Number.isFinite(rec.liveBytes) ? { live: rec.liveBytes } : {}),
212
+ ...(Number.isFinite(rec.incrementalRawBytes) ? { raw: rec.incrementalRawBytes } : {}),
213
+ ...(Number.isFinite(rec.appendedBytes) ? { appended: rec.appendedBytes } : {}),
214
+ monotonic: seqMono && cumMono, rotate: { file: 'transcripts', index: foldRotateIndex(rec.sid) }, trigger: rec.trigger,
215
+ // v1.10.13 多段转录:本次折叠同时遮蔽的 head 段 seq(附加字段,cumLines 语义不变 ⇒ 消费方无需改)
216
+ ...(Array.isArray(rec.headSeqs) && rec.headSeqs.length > 0 ? { headSeqs: rec.headSeqs } : {}),
217
+ })
218
+ appendFileSync(fp, line + '\n', 'utf8')
219
+ } catch (err) { console.error('[contextinjector] fold-index failed:', err?.message ?? err) }
220
+ }
221
+ function foldInflightFile(sid) {
222
+ return join(FOLD_INFLIGHT_DIR, sanitizeSid(String(sid ?? '').replace(/^session-?/i, '')) + '.json')
223
+ }
224
+ function writeFoldInflight(args) {
225
+ try {
226
+ const fp = foldInflightFile(args?.sid)
227
+ try { rmSync(fp, { force: true }) } catch { /* 旧残留删不掉也不阻断写入(force 已忽略不存在) */ }
228
+ const meta = {
229
+ sessionId: args?.sid ?? null,
230
+ trigger: args?.trigger ?? null,
231
+ startedAt: Date.now(),
232
+ step: Number.isFinite(args?.stepNo) ? args.stepNo : 0,
233
+ }
234
+ if (Number.isFinite(args?.round)) meta.round = args.round // 调用方已知轮号时带上(JSON.stringify 会省略 undefined)
235
+ writeFileSync(fp, JSON.stringify(meta, null, 2) + '\n', 'utf8')
236
+ } catch (err) { console.error('[contextinjector] fold-inflight write failed:', err?.message ?? err) }
237
+ }
238
+ function clearFoldInflight(sid) {
239
+ try { rmSync(foldInflightFile(sid), { force: true }) } catch (err) {
240
+ console.error('[contextinjector] fold-inflight clear failed:', err?.message ?? err)
241
+ }
242
+ }
243
+ // 在飞折叠深度(per-sid):**只清最后一次**。可能真并发的唯一场景是 join 超时——
244
+ // pre-step join 一个有超时闸的后台折叠,超时后 pre-step 自己又折一次(同一 sid 两次在飞)。
245
+ // 若各自 finally 都无条件删标记,先落定的那个会把仍在飞的那个的标记删掉(小标提前熄灭、界面撒谎)。
246
+ // 故:入口 +1,出口 -1,减到 0 才删文件;重入时 writeFoldInflight 覆盖写(startedAt 刷新为本次的真实起点)。
247
+ const foldInflightDepth = new Map() // sid -> 在飞折叠次数
248
+ // 入口(foldAttempt 的第一件事)。**绝不抛**:标记坏了也不能让折叠消失。
249
+ function enterFoldInflight(args) {
250
+ try {
251
+ const sid = args?.sid
252
+ foldInflightDepth.set(sid, (foldInflightDepth.get(sid) ?? 0) + 1)
253
+ writeFoldInflight(args) // 覆盖写:startedAt 取本次折叠的真实起点,不留上一次的旧时间戳
254
+ } catch (err) { console.error('[contextinjector] fold-inflight enter failed:', err?.message ?? err) }
255
+ }
256
+ // 出口(foldAttempt 的 finally)。成功 / 失败 / 提前 return / 异常一律走到这里。**绝不抛**。
257
+ function exitFoldInflight(sid) {
258
+ try {
259
+ const n = (foldInflightDepth.get(sid) ?? 0) - 1
260
+ if (n > 0) { foldInflightDepth.set(sid, n); return } // 同 sid 还有折叠在飞 ⇒ 标记必须留着
261
+ foldInflightDepth.delete(sid) // 计数缺失(n<0)也会走到这里 ⇒ 失败方向是"删掉",绝不留"永久压缩中"
262
+ clearFoldInflight(sid)
263
+ } catch (err) { console.error('[contextinjector] fold-inflight exit failed:', err?.message ?? err) }
264
+ }
265
+
266
+ // v1.7:本会话档(sessionModes)查询——命中则优先于全局默认档;键与运行时 id 都做前缀归一比较
267
+ function perSessionMode(ctrl, sid) {
268
+ const sm = ctrl?.sessionModes
269
+ if (!sid || !sm || typeof sm !== 'object') return null
270
+ const n = normSessionId(sid)
271
+ for (const k of Object.keys(sm)) {
272
+ if (normSessionId(k) === n) {
273
+ const v = sm[k]
274
+ if (v === 'double' || v === 'single' || v === 'off') return v
275
+ }
276
+ }
277
+ return null
278
+ }
279
+ function condenseMode(gate, ctrl, sid) {
280
+ if (gate === 'off') return 'off'
281
+ if (ENV_MODE === 'double') return 'double'
282
+ if (ENV_MODE === 'single' || ENV_MODE === '1') return 'single'
283
+ if (ENV_MODE && ENV_MODE !== 'off' && ENV_MODE !== '0') return 'single' // 其它非空值按 single 保守处理
284
+ const per = perSessionMode(ctrl, sid)
285
+ if (per) return per
286
+ // v1.9:全局默认档(control.condense / lossy 布尔)已废止、不再参与裁决。
287
+ // test/env 直通(headless/回归/operator 部署)无本会话档 → 无损 off 兜底;
288
+ // control 门控下无本会话档 → null(调用方判"不折叠",无隐式默认)。
289
+ if (gate === 'test' || gate === 'env') return 'off'
290
+ return null
291
+ }
292
+ // —— v1.6 keepInject 档(注入节点避让折叠,见版本说明文档 v1.6)——
293
+ // false = v1.5 旧路径:遮蔽整段(含 DSH 注入节点)→ 每轮 policy 重注入(~492B/轮)
294
+ // true = v1.6 路径:遮蔽候选截断在最后一个 DSH 注入节点之后 → 快照始终在场 → 零补发
295
+ // 【默认值翻转(取自 RCS-0.1.0)2026-09-10】默认改为 **true**:RCS 实测"关闭时折叠把 DSH 的
296
+ // runtime 快照 / skill 目录节点折掉 → DSH 每轮补发一份(8 次折叠 → 8 次补发,9 轮出现 9 份快照
297
+ // + 9 份目录)";本机 2026-09-10 在测试分支复现同一现象(control.json 无 keepInject 字段的实例
298
+ // 最新转录里 [S] 快照行出现 4 次)。故与 RCS 对齐:默认避让,改由显式值关闭。
299
+ // 档位来源优先级:env=1 强制 ON / env=0 强制 OFF > control.json.keepInject===false 关闭 > **默认 ON**
300
+ const KEEP_INJECT_ENV = process.env.DSH_CONTEXTINJECTOR_KEEPINJECT
301
+ function keepInjectOn(gate, ctrl) {
302
+ if (gate === 'off') return false
303
+ if (KEEP_INJECT_ENV === '1') return true
304
+ if (KEEP_INJECT_ENV === '0') return false
305
+ if (ctrl?.keepInject === false) return false
306
+ return true // 0.2.3/RCS 起默认 ON(旧行为可用 DSH_CONTEXTINJECTOR_KEEPINJECT=0 或 control.keepInject=false 恢复)
307
+ }
308
+
309
+ // —— #6 折叠时机(取自 RCS-0.1.0 返回包)——
310
+ // 用户诉求:"一旦压缩完成就先进入 LOG 并且渲染在 WEB,这样用户能及时得到指令"。
311
+ // 旧实现只在**下一轮** agent/pre-step 折叠 ⇒ 本轮回复读完到下一轮发请求之间才压缩,
312
+ // 面板上的「最近一次折叠」与 last-fold.json 永远慢一拍。现增加 turn 结束即折(后台),
313
+ // pre-step 只做 join,保证下一轮请求看到的仍是已折叠 surface(语义与旧实现等价)。
314
+ const FOLD_AT_TURN_END_ENV = process.env.DSH_CONTEXTINJECTOR_FOLD_AT_TURN_END
315
+ // 默认**开**(用户明确要求)。turn/end 是唯一的生产折叠触发点,故显式 control.foldAtTurnEnd=false
316
+ // 或 env=0 ⇒ **完全不折**(应急闸);pre-step 只剩 join,没有别的触发点可回退。
317
+ function foldAtTurnEndOn(ctrl, env = FOLD_AT_TURN_END_ENV) {
318
+ const e = String(env ?? '').trim()
319
+ if (e === '0') return false
320
+ if (e === '1') return true
321
+ return ctrl?.foldAtTurnEnd !== false
322
+ }
323
+ // join 超时(**安全闸**):turn 结束的后台折叠可能要调浓缩模型(本地 4B 约 7–18s)。
324
+ // 若下一轮 pre-step 无限期等它,用户的下一轮就会被一个慢模型卡住(这是本项目一直不敢开 turn-end 折的原因)。
325
+ // 故 join 必须有上限:超时即放弃等待、照旧走"本轮要用的 surface"(折叠结果晚一点落,等价于今天的旧行为),
326
+ // 并只记一条 fold-join-timeout(不刷屏)。缺省 3000ms;非法值/负数一律回落缺省。
327
+ const FOLD_JOIN_TIMEOUT_MS = Number(process.env.DSH_CONTEXTINJECTOR_FOLD_JOIN_MS ?? 3000)
328
+ function foldJoinTimeoutMs(ctrl, fallback = FOLD_JOIN_TIMEOUT_MS) {
329
+ const fb = Number.isFinite(fallback) && fallback > 0 ? fallback : 3000
330
+ const n = ctrl?.foldJoinTimeoutMs
331
+ if (typeof n === 'number' && Number.isFinite(n) && n > 0) return Math.floor(n)
332
+ return fb
333
+ }
334
+ // 微折门槛(v1.10.38):新原料不足此字节就不折,等后续轮累积。缺省 2048,control=0 关闭。
335
+ // 动机:turn-end 成为唯一触发点后,每轮(包括只发一句“wtf”的微轮)都会提交一个检查点,
336
+ // 聊天区会不断出现「上下文已压缩」标记;小轮折了也省不了多少,反而刷屏 + 可能触发一次浓缩调用。
337
+ const FOLD_MIN_SHADOW_BYTES = 2048
338
+ function foldMinShadowBytesOf(ctrl) {
339
+ const n = ctrl?.foldMinShadowBytes
340
+ if (typeof n === 'number' && Number.isFinite(n) && n >= 0) return Math.floor(n)
341
+ return FOLD_MIN_SHADOW_BYTES
342
+ }
343
+ // 原生压缩后重置折叠基线(v1.10.74):缺省 **开**。
344
+ // control.resetAfterNativeCompaction=false 或 env DSH_CONTEXTINJECTOR_NATIVE_RESET=0 可关(A/B 用)。
345
+ // 动机见 compaction/end 分支的长注释:不重置 ⇒ 下一次折叠把宿主已经总结掉的整份旧转录再灌回去。
346
+ const NATIVE_RESET_ENV = process.env.DSH_CONTEXTINJECTOR_NATIVE_RESET
347
+ function nativeCompactionResetOn(ctrl, env = NATIVE_RESET_ENV) {
348
+ const e = String(env ?? '').trim()
349
+ if (e === '0' || e === 'false') return false
350
+ if (e === '1' || e === 'true') return true
351
+ return ctrl?.resetAfterNativeCompaction !== false
352
+ }
353
+ // raw 指针门槛(v1.10.67):[T] 行只在**被遮蔽结果正文 ≥ 此字节数**时才挂 raw=<seq>。
354
+ // 动机(用户定档):raw= 是回取入口,回取要一次工具往返;小结果(一次 ok 回执、一次 404、
355
+ // 一份几百字的 grep 命中)用往返换回来不划算,且模型容易为一条 OK 去展开。故:
356
+ // · 正文 ≥ 门槛 ⇒ 只挂 raw=<seq>(可展开,正文不进转录);
357
+ // · 正文 < 门槛 ⇒ 不挂指针,把有界预览(≤RAW_PREVIEW_CHARS 字)内联在**同一行** [T] 里,
358
+ // 信息不丢、往返也省了。control.rawPtrMinBytes / env 可热调(0 = 永远挂指针,退回旧行为)。
359
+ const RAW_PTR_MIN_BYTES = 1024
360
+ const RAW_PREVIEW_CHARS = 160
361
+ function rawPtrMinBytesOf(ctrl) {
362
+ const n = ctrl?.rawPtrMinBytes
363
+ if (typeof n === 'number' && Number.isFinite(n) && n >= 0) return Math.floor(n)
364
+ const e = Number(process.env.DSH_CONTEXTINJECTOR_RAW_PTR_MIN)
365
+ if (Number.isFinite(e) && e >= 0) return Math.floor(e)
366
+ return RAW_PTR_MIN_BYTES
367
+ }
368
+ // 折叠门控(env 为最高优先,与 keepInject 同款语义):
369
+ // env=1 强制(无视白名单,供真机实验)> env=0 强制关 > gate !== 'off'(白名单/回归通道)
370
+ function foldGateOpen(ctrl, gate, env = FOLD_AT_TURN_END_ENV) {
371
+ const e = String(env ?? '').trim()
372
+ if (e === '1') return true
373
+ if (e === '0') return false
374
+ return gate !== 'off'
375
+ }
376
+
377
+ mkdirSync(STATE_DIR, { recursive: true })
378
+ if (OUT_FILE) mkdirSync(dirname(OUT_FILE), { recursive: true })
379
+
380
+ function readControl() {
381
+ try { return JSON.parse(readFileSync(CONTROL_FILE, 'utf8')) } catch { return null }
382
+ }
383
+ function sessionIdOf(session) {
384
+ return session?.id ?? session?.sessionId ?? session?.goalId ?? session?.name ?? null
385
+ }
386
+ // 目录名可能是 session-<uuid>,运行时 session.id 可能是同值或去前缀的 uuid —— 归一比较
387
+ const normSessionId = (s) => String(s ?? '').replace(/^session-?/i, '')
388
+ // 【RCS 接入 2026-09-09,取自 RCS-0.1.0 返回包】子代理会话**不参与折叠**:
389
+ // 子代理的价值恰恰是它读过的原始内容,折成 `[T] read | 文件 | OK` 之后它会"忘掉读过什么"
390
+ // 并重新读;而且每次折叠还要多调一次浓缩模型。这直接旁路,语义上不折。
391
+ // 判据:DSH 写进会话 header 的 `origin === 'subagent'`(dsh-subagent childSessionMeta;
392
+ // 持久化读法见 list-children)。取不到时返回 false ⇒ 退化为"不旁路",不会误伤主会话。
393
+ function isSubagentSession(session) {
394
+ try {
395
+ return session?.header?.origin === 'subagent' || session?.meta?.origin === 'subagent'
396
+ } catch { return false }
397
+ }
398
+ function gateFor(sid, control, session) {
399
+ if (isSubagentSession(session)) return { gate: 'off', subagent: true } // 最高优先级:先于 TEST_ROUND/ENABLED
400
+ if (TEST_ROUND) return { gate: 'test' }
401
+ if (ENABLED) return { gate: 'env' }
402
+ const n = normSessionId(sid)
403
+ if (control && control.enabled === true && sid &&
404
+ Array.isArray(control.sessions) && control.sessions.some((s) => normSessionId(s) === n)) return { gate: 'control' }
405
+ return { gate: 'off' }
406
+ }
407
+ function persistLastFold(meta) {
408
+ try {
409
+ writeFileSync(LAST_FOLD_FILE, JSON.stringify(meta, null, 2) + '\n', 'utf8')
410
+ // #4 per-session 副本:注入面板按当前会话读自己的最近折叠,而非串到别的会话
411
+ if (meta && meta.sessionId) {
412
+ const bare = String(meta.sessionId).replace(/^session-?/i, '')
413
+ writeFileSync(join(LAST_FOLD_PER, sanitizeSid(bare) + '.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8')
414
+ }
415
+ } catch (err) { console.error('[contextinjector] lastFold persist failed:', err?.message ?? err) }
416
+ }
417
+
418
+ const hash256 = (s) => createHash('sha256').update(String(s)).digest('hex')
419
+
420
+ // #5 压缩器IO比(2026-09-10 口径重写,取自 RCS-0.1.0):**不再需要 S**(固定注入前缀净字节)。
421
+ // ivrRecord 只用 shadowedBytes / transcriptBytes 两个数算 inBytes/outBytes/ratio;
422
+ // 旧的 _sysBytesCache + ivrSystemBytes()(每会话取 requestHeader().system 并缓存)已删除——
423
+ // 它唯一的用途就是把 S 喂给旧 IVR 公式(S 只会把读数推向 1,在 RCS 架构下测量的是"空气")。
424
+ // `ivrEnsure(stateDir, sid, S)` 的 S 形参保留以兼容旧调用方,函数体内显式忽略。
425
+
426
+ function blocksOf(m) { return Array.isArray(m?.content) ? m.content : [] }
427
+ // §1.2 content-only:reasoning/thinking 块恒剥离(注入器不转录思维链)。
428
+ // 实测 deepseek-v4-flash 无独立 reasoning 块(内容以 text 到);qwen/ollama 类以 thinking 块到则在此剥掉,
429
+ // 使 [A] 只收真实正文 —— 降注入前缀 / 压缩器输入,避免旧思维链回流诱导。
430
+ function isReasoningBlock(b) { return b?.type === 'reasoning' || b?.type === 'thinking' }
431
+ function inline(s) { return String(s ?? '').replace(/[\r\n]+/g, ' ').trim() }
432
+
433
+ function log(tag, row = {}) {
434
+ if (!OUT_FILE) return
435
+ try {
436
+ appendFileSync(OUT_FILE, JSON.stringify({ t: Date.now(), enabled: ENABLED ? 1 : 0, tag, ...row }) + '\n', 'utf8')
437
+ } catch (err) {
438
+ console.error('[contextinjector] log failed:', err?.message ?? err)
439
+ }
440
+ }
441
+
442
+ // 【v1.10.25 · 计量修复(★经济性口径的根因)】块文本提取。
443
+ // 为什么必须有它:`tool-result` 的正文在 `content` 字段里,而它的**实际形状是块数组**
444
+ // (真机日志实证:`{"type":"tool-result","toolCallId":"call_…","content":[{"type":"text","text":"…"}]}`)。
445
+ // 旧实现写的是 `tr:${b.name}:${b.content}` —— 对数组做模板字符串 ⇒ 一律得到 `[object Object]`,
446
+ // 于是**工具结果正文(真机上占会话字节的大头,实测 159KB/185KB)在计量里只算 ~30B**。
447
+ // 后果(2026-09-14 付费跑批 A01-off-1 实证):主指标 `shrink = transcriptBytes/shadowedBytes`
448
+ // 长期停在 0.998 附近 ⇒ 从文件层看"折叠几乎什么都没省",而被折掉的恰恰是工具正文。
449
+ // 这不是显示问题:`shadowedBytes` 是**经济性指标**,计量盲区会让"折叠有没有收益"无法从文件层
450
+ // 判断——协作方据此得出了与事实相反的结论(详见 docs/DESIGN-prompt-cost-accounting.md)。
451
+ function blockTextOf(c) {
452
+ if (c === null || c === undefined) return ''
453
+ if (typeof c === 'string') return c
454
+ if (Array.isArray(c)) return c.map(blockTextOf).join('')
455
+ if (typeof c === 'object') {
456
+ if (typeof c.text === 'string') return c.text
457
+ if (c.content !== undefined) return blockTextOf(c.content)
458
+ try { return JSON.stringify(c) } catch { return '' }
459
+ }
460
+ return String(c)
461
+ }
462
+ function messageBytes(m) {
463
+ const parts = blocksOf(m).map((b) => {
464
+ switch (b.type) {
465
+ case 'text': return `text:${b.text ?? ''}`
466
+ case 'tool-call': return `tc:${b.name ?? ''}:${b.arguments ?? ''}`
467
+ // tool-result:正文按块结构取全文(v1.10.25 修复);name 缺失时退回 toolCallId,便于辨识
468
+ case 'tool-result': return `tr:${b.name ?? b.toolCallId ?? ''}:${blockTextOf(b.content)}`
469
+ default: return `${b.type}:${JSON.stringify(b).slice(0, 120)}`
470
+ }
471
+ })
472
+ return `${m?.role ?? '?'}\u0001${parts.join('\u0001')}`
473
+ }
474
+ function summarize(m) {
475
+ const bytes = messageBytes(m)
476
+ return { role: m?.role ?? '?', src: m?.source?.kind, bytes: bytes.length, hash: hash256(bytes).slice(0, 16) }
477
+ }
478
+ function snapshotOf(session) {
479
+ try { return session.deriveMessages().map((m) => summarize(m)) } catch { return null }
480
+ }
481
+ // 折叠消息视图:只挑 CONTEXTinjector 自己的折叠消息(runtime-context 等注入面也带
482
+ // plugin 族 source,但标识不同;v1.10.27 起双形态——旧 kind:'plugin'+plugin 字段 / 新 kind:'plugin:X'),
483
+ // 验证其进入真实请求后逐字节在场
484
+ function transcriptNodesOf(msgs) {
485
+ return (msgs ?? [])
486
+ .filter((m) => pluginIdOf(m?.source) === 'CONTEXTinjector')
487
+ .map((m) => { const b = messageBytes(m); return { bytes: b.length, hash: hash256(b).slice(0, 16) } })
488
+ }
489
+
490
+ function pathOf(argsJson) {
491
+ try {
492
+ const a = JSON.parse(argsJson ?? '{}')
493
+ for (const k of ['file_path', 'path', 'directory']) if (typeof a[k] === 'string' && a[k].trim()) return a[k].trim()
494
+ } catch { /* not json */ }
495
+ return '-'
496
+ }
497
+
498
+ // 可折叠前缀端点(#4 修复 2026-09-10,取自 RCS-0.1.0 返回包:配对安全钳制):
499
+ // 原实现两缺口:a) 每消息只取首个 tool-result(.find)→ 并行工具/多结果消息漏配、pending 残留;
500
+ // b) 仅以"最后一条已配对结果"为端点,未校验"call 在区间内、其 result 在区间外"的切分
501
+ // → 遮蔽区间切出孤儿 tool-result → INVALID_REQUEST(与 §13 分类改动同源风险区)。
502
+ // 现:① 收集全部 call/result(多结果消息不漏);② 候选端点后做配对钳制——任何"call 在内 result
503
+ // 在外/缺失"或"result 在内 call 在外/缺失"的配对都把端点回退到该配对之前,迭代至稳定,
504
+ // 保证区间内每个 tool-call↔tool-result 配对完整(永不切 pair)。
505
+ // 【v1.10.17】中止一轮留下的**悬空 tool-call**:call 在 surface 上、结果永远不会来(用户按了停止)。
506
+ // 判据:该 call 所属的那一轮**已经结束**(其节点 seq 之后存在 `turn/end` 事件)⇒ 结果确定不会再来。
507
+ // 这类 call 允许被跨越;**轮未结束**(工具在飞、结果可能马上到)时绝不跨越 —— 否则结果晚到会成
508
+ // 孤儿 tool-result(v1.6 的 INVALID_REQUEST 血债)。拿不准一律返回空集(保守)。
509
+ // 【当前轮保护 · 2026-09-30 真机 session-de4959d2 修复】
510
+ // 病灶:pre-step 折若发生在"本轮第 2 步"(本轮首个 tool-call 已配到 result),closedPrefixEnd 会因
511
+ // "所有 call 都已闭合"而返回**表面末端** ⇒ 本轮的 user 消息与刚产出的助手回复一起被折进转录,
512
+ // 表面尾部变成一条 **user 角色**的转录 ⇒ 宿主视其为"未回答的用户轮" ⇒ 再跑一步、同一条用户消息
513
+ // 被答两次(真机 turn=2:连续两条对同一问题的不同回答)。
514
+ // 判据与既有纪律一致——只折叠"已经彻底结束"的内容:某轮结束 ⇔ 其起点之后存在 turn/end 事件
515
+ // (与 collectDeadCalls 的 dead-call 判定同源)。查不到事件时**不钳制**,退回旧行为,
516
+ // 以免因查询失败让折叠永久停摆。纯函数,可单测。
517
+ // 【0.1.27 · 压缩比口径】本折压缩后占比 = 本次新增转录 ÷ 本次被转写的新原料。
518
+ // 为什么必须"本折 ÷ 本折":旧写法是 transcriptBytes(累计转录) / shadowedBytes(本折遮蔽),
519
+ // 首次折叠时二者同源才显得正常,增量折叠上结构性 >1(真机 5914%、917%、3824 倍)。
520
+ // 【0.1.38 · head 段同口径】首折转录里有一段原样保留的 head(首次折叠前的首轮消息):
521
+ // 它折叠前后都是同一批字节(raw 1756B → transcript 1756B),字节中性,既不该进分子也不该进分母。
522
+ // 旧实现分子含 head、分母只算新原料 ⇒ 真机 2150/2132=101%(新段实际 393B=18.5%)。
523
+ // 缺增量数据的旧记录(v1.10.25 之前)回退旧算式,保证字段恒为数值。纯函数,可单测。
524
+ export function foldShrinkOf({ appendedBytes, incrementalRawBytes, headBytes = 0, transcriptBytes, shadowedBytes }) {
525
+ const ap = Number(appendedBytes)
526
+ const raw = Number(incrementalRawBytes)
527
+ if (Number.isFinite(ap) && Number.isFinite(raw) && raw > 0) {
528
+ const head = Number(headBytes)
529
+ const retained = Number.isFinite(head) && head > 0 ? head : 0
530
+ return Number((Math.max(0, ap - retained) / raw).toFixed(4))
531
+ }
532
+ const tr = Number(transcriptBytes), sh = Number(shadowedBytes)
533
+ if (Number.isFinite(tr) && Number.isFinite(sh)) return Number((tr / Math.max(1, sh)).toFixed(4))
534
+ return null
535
+ }
536
+
537
+ export function openRoundClampEnd(entries, session, end) {
538
+ try {
539
+ if (!(end >= 0)) return end
540
+ let openIdx = -1
541
+ for (let i = Math.min(end, (entries?.length ?? 0) - 1); i >= 0; i--) {
542
+ const m = entries[i]?.msg
543
+ if (!m || m.role !== 'user') continue
544
+ let cls = null
545
+ try { cls = classifyMessageSource(m.source)?.cls ?? null } catch { cls = null }
546
+ if (cls === 'user') { openIdx = i; break }
547
+ }
548
+ if (openIdx < 0) return end // 没有真人轮起点(异常形态)⇒ 不钳制
549
+ const seq = entries[openIdx]?.seq
550
+ if (!Number.isFinite(seq)) return end
551
+ const evs = allEvents(session)
552
+ let ended = false
553
+ for (let s = seq + 1; s < evs.length; s++) { if (evs[s]?.type === 'turn/end') { ended = true; break } }
554
+ if (ended) return end // 该轮已结束 ⇒ 允许折叠它(轮末折的主路径)
555
+ return Math.min(end, openIdx - 1) // 仍在飞 ⇒ 遮蔽终点钳到该轮起点之前
556
+ } catch { return end } // 任何异常 ⇒ 不钳制(保守回退到旧行为)
557
+ }
558
+
559
+ export function collectDeadCalls(entries, session) {
560
+ try {
561
+ const calls = new Map() // id -> 在 entries 中的下标
562
+ const results = new Set()
563
+ for (let i = 0; i < (entries?.length ?? 0); i++) {
564
+ for (const b of blocksOf(entries[i]?.msg)) {
565
+ if (b.type === 'tool-call') { const id = b.id ?? b.toolCallId; if (id && !calls.has(id)) calls.set(id, i) }
566
+ else if (b.type === 'tool-result') { const id = b.toolCallId ?? b.callId ?? b.id; if (id) results.add(id) }
567
+ }
568
+ }
569
+ const dead = new Set()
570
+ if (calls.size === 0) return dead
571
+ const evs = allEvents(session)
572
+ for (const [id, i] of calls) {
573
+ if (results.has(id)) continue
574
+ const seq = entries[i]?.seq
575
+ if (!Number.isFinite(seq)) continue
576
+ for (let s = seq + 1; s < evs.length; s++) {
577
+ if (evs[s]?.type === 'turn/end') { dead.add(id); break }
578
+ }
579
+ }
580
+ return dead
581
+ } catch { return new Set() }
582
+ }
583
+ // opts.deadCalls:已确定不会有结果的 call id 集合(见 collectDeadCalls)—— 它们不算"未闭合"。
584
+ export function closedPrefixEnd(msgs, opts = {}) {
585
+ const dead = opts?.deadCalls instanceof Set ? opts.deadCalls : null
586
+ const isDead = (id) => !!(dead && dead.has(id))
587
+ const callIdx = new Map() // id -> 首个 call 下标
588
+ const resultIdx = new Map() // id -> 首个 result 下标
589
+ let lastClosed = -1
590
+ for (let i = 0; i < msgs.length; i++) {
591
+ for (const b of blocksOf(msgs[i])) {
592
+ if (b.type === 'tool-call') {
593
+ const id = b.id ?? b.toolCallId ?? ''
594
+ if (id && !callIdx.has(id)) callIdx.set(id, i)
595
+ } else if (b.type === 'tool-result') {
596
+ const id = b.toolCallId ?? b.callId ?? ''
597
+ if (id) {
598
+ if (!resultIdx.has(id)) resultIdx.set(id, i)
599
+ if (callIdx.has(id)) lastClosed = Math.max(lastClosed, i)
600
+ }
601
+ }
602
+ }
603
+ }
604
+ if (msgs.length === 0) return -1
605
+ // 悬空但已定死的 call 视同闭合;仍在飞的不算(保守)
606
+ const allClosed = [...callIdx.keys()].every((id) => resultIdx.has(id) || isDead(id))
607
+ let end = allClosed ? msgs.length - 1 : lastClosed
608
+ if (end < 0) return -1
609
+ // 配对安全钳制(迭代至稳定)
610
+ for (let guard = 0; guard < msgs.length + 2; guard++) {
611
+ let bad = -1
612
+ for (const [id, ci] of callIdx) {
613
+ if (isDead(id) && !resultIdx.has(id)) continue // 已定死的悬空 call:允许被跨越
614
+ const ri = resultIdx.has(id) ? resultIdx.get(id) : Number.POSITIVE_INFINITY
615
+ if (ci <= end && !(ri <= end)) bad = Math.max(bad, ci) // call 在内、result 在外/缺失
616
+ }
617
+ for (const [id, ri] of resultIdx) {
618
+ const ci = callIdx.has(id) ? callIdx.get(id) : -1
619
+ if (ri <= end && !(ci >= 0 && ci <= end)) bad = Math.max(bad, ri) // result 在内、call 在外/缺失
620
+ }
621
+ if (bad < 0) break
622
+ end = bad - 1
623
+ if (end < 0) return -1
624
+ }
625
+ return end
626
+ }
627
+
628
+ // 【#4 修复 2026-09-10,取自 RCS-0.1.0 返回包】跨界配对守卫:返回"被折叠边界切分"的配对 id(空 = 安全)。
629
+ // 场景:遮蔽集(将被 replace 掉的 [start..end])与尾部(保留在 surface)之间若存在
630
+ // "call 在遮蔽集、result 留在尾部"或反之 → surface 出现孤儿 tool 消息 → INVALID_REQUEST
631
+ // (本机 2026-09-08 会话 7a5301d7 并行 subagent 场景实测崩溃,与 §13 分类改动同源风险区;
632
+ // ki 前截断 / 注入节点剔除 / 边界计算都可能造成)。
633
+ // 用法:折叠提交前调用;非空则延后本次折叠(defer),绝不提交切分配对。
634
+ export function pairSplitIds(insideMsgs, outsideMsgs) {
635
+ const collect = (msgs) => {
636
+ const calls = new Set()
637
+ const results = new Set()
638
+ for (const m of msgs) {
639
+ for (const b of blocksOf(m)) {
640
+ if (b.type === 'tool-call') {
641
+ const id = b.id ?? b.toolCallId ?? ''
642
+ if (id) calls.add(id)
643
+ } else if (b.type === 'tool-result') {
644
+ const id = b.toolCallId ?? b.callId ?? ''
645
+ if (id) results.add(id)
646
+ }
647
+ }
648
+ }
649
+ return { calls, results }
650
+ }
651
+ const a = collect(insideMsgs)
652
+ const b = collect(outsideMsgs)
653
+ const bad = []
654
+ for (const id of a.calls) if (b.results.has(id) && !a.results.has(id)) bad.push(id) // call 在内、result 在外
655
+ for (const id of a.results) if (b.calls.has(id) && !a.calls.has(id)) bad.push(id) // result 在内、call 在外
656
+ return bad
657
+ }
658
+
659
+ // 转录簿记:text 块 / tool-call↔tool-result 配对,产出五类行(主设计 §2 v1 定稿)。
660
+ // 防自转录/防污染:真实用户输入 = 非 plugin 且非 DSH 自动注入快照。
661
+ // DSH 以 user 角色注入的 runtime/policy 快照(Current runtime context / Current DSH file policy /
662
+ // sandbox:policy …)常不带 plugin source,若被记成 [U] 会污染转录 → 主 AI 复述时出现"伪历史"。
663
+ // 跳过:plugin source / [ROUND] 开头(自身折叠消息重放兜底)/ runtime 与 file-policy 快照 / sandbox: 前缀。
664
+ // 【#2 修复(取自 RCS-0.1.0 返回包)】INJECTED_USER_PREFIXES 已提为共享单源(condense/chunk.mjs),
665
+ // 由本文件与 LOGcompiler 共同导入 —— 旧实现两处各写一套前缀,source 缺失时轮号会分叉。
666
+ // 注入型 user 节点:DSH runtime/policy 快照(dsh-system-prompt 每 step 追加)。
667
+ // v1.6 归因定稿(2026-09-06,skiphead/splitfold 行为实验;取代 v1.5"replace 触发、不可修"错误结论):
668
+ // 触发源 = 折叠把 runtime context 节点遮蔽掉 → surface 缺 policy 快照 → DSH 补发(~492B/轮)。
669
+ // 证据:fold2 遮蔽含 runtime → 折叠后 runtime 消失 1 步 → 下一步前重注入;
670
+ // skiphead 遮蔽段避开注入节点 → 折叠后 8 step runtimeCount 恒 1、supersedes 零新增、任务完成;
671
+ // splitfold 同一步两次 surfaceOp replace 均 ok:true(注入节点穿插时可分段)。
672
+ // 对策(v1.6 keepInject):遮蔽区间永不包含 DSH 注入节点 → 快照始终在场 → 零补发。
673
+ // isDshInjected(v1.6):DSH 注入判定精确化——**自身转录消息(plugin 标识='CONTEXTinjector')不算注入**,
674
+ // 必须可遮蔽(新转录以旧文本为前缀,遮蔽换新安全);其余 plugin 族与 runtime/policy 前缀 user 才算注入。
675
+ // v1.10.27:plugin 族判定走 pluginIdOf(双形态兼容 v4 的 kind:'plugin:X')。
676
+ function isOwnTranscript(m) {
677
+ return pluginIdOf(m?.source) === 'CONTEXTinjector'
678
+ }
679
+ // 【v1.10.74】宿主原生压缩写的检查点:kind='compact-checkpoint' 且**没有** plugin 标识。
680
+ // 它是 DSH 对压缩前历史的唯一权威总结,折叠必须把它当边界(见 foldBody 的长注释):
681
+ // 吞掉它 = 把 summarize 掉的正文再丢一次,而且转录侧分型为 opaque、不会产行。
682
+ function isHostCheckpointMsg(m) {
683
+ return m?.source?.kind === 'compact-checkpoint' && pluginIdOf(m.source) === null
684
+ }
685
+ // ── v1.10.75 · 封存卷索引(纯读;写侧在 apply 里,因为要改会话状态)────────────────
686
+ // sid 归一与 raw/fold-index 同款(去掉 session- 前缀 + 只留 [A-Za-z0-9._-])。
687
+ function sealedIndexPath(sid) {
688
+ return join(SEALED_DIR, sanitizeSid(String(sid ?? '').replace(/^session-?/i, '')) + '.json')
689
+ }
690
+ function sealedVolumePath(sid, v) {
691
+ return join(SEALED_DIR, sanitizeSid(String(sid ?? '').replace(/^session-?/i, '')), 'v' + v + '.json')
692
+ }
693
+ // 读不到 / 解析失败一律当"没有封存"(绝不因为索引坏了改变折叠语义)。
694
+ export function readSealIndex(sid) {
695
+ try {
696
+ const f = sealedIndexPath(sid)
697
+ if (!existsSync(f)) return null
698
+ const j = JSON.parse(readFileSync(f, 'utf8'))
699
+ return (j && typeof j === 'object') ? j : null
700
+ } catch { return null }
701
+ }
702
+ // 写索引:失败返回 false(调用方据此决定"不清基线",宁可重复注入一次也不静默丢历史)。
703
+ function writeSealIndex(sid, idx) {
704
+ try {
705
+ mkdirSync(SEALED_DIR, { recursive: true })
706
+ writeFileSync(sealedIndexPath(sid), JSON.stringify(idx, null, 2) + '\n', 'utf8')
707
+ return true
708
+ } catch (err) {
709
+ console.error('[contextinjector] seal-index write failed:', err?.message ?? err)
710
+ return false
711
+ }
712
+ }
713
+ // 原生摘要的覆盖边界。返回 `{ seq, source }`:
714
+ // source='summary' ⇒ seq = `compaction/summary.shadowedSeqs` 的最大 seq(或 shadowedRange.end)。
715
+ // 这是**单调**边界:边界之后的节点 seq 都更大,可以直接拿它做 `seq > boundary` 的过滤。
716
+ // source='checkpoint' ⇒ seq = surface 上最后一条宿主检查点的 seq。它只是**位置**边界
717
+ // (检查点的事件 seq 在日志尾部,而它后面留在表面的节点 seq 更小)⇒ 调用方不得用它做 seq 过滤,
718
+ // 只能按位置切。
719
+ // source='none' ⇒ seq=-1(保守:不据它封存)。
720
+ export function nativeBoundaryOf(session, compactionId = null) {
721
+ let best = -1
722
+ try {
723
+ for (const e of allEvents(session) ?? []) {
724
+ if (!e || e.type !== 'compaction/summary') continue
725
+ const d = e.data ?? {}
726
+ if (compactionId && d.compactionId && d.compactionId !== compactionId) continue
727
+ const seqs = Array.isArray(d.shadowedSeqs) ? d.shadowedSeqs.map(Number).filter((n) => Number.isFinite(n)) : []
728
+ const cand = Math.max(seqs.length ? Math.max(...seqs) : -1, Number.isFinite(d?.shadowedRange?.end) ? Number(d.shadowedRange.end) : -1)
729
+ if (cand > best) best = cand
730
+ }
731
+ } catch { /* 观测面 */ }
732
+ if (best >= 0) return { seq: best, source: 'summary' }
733
+ try {
734
+ const nodes = [...(session?.surface?.nodes ?? [])]
735
+ for (let i = nodes.length - 1; i >= 0; i--) {
736
+ if (isHostCheckpointMsg(session.deriveEventMessage(evAt(session, nodes[i])))) return { seq: Number(nodes[i]), source: 'checkpoint' }
737
+ }
738
+ } catch { /* 观测面 */ }
739
+ return { seq: -1, source: 'none' }
740
+ }
741
+ // 转录文本里的轮号区间(封存卷元数据用;无 [ROUND] ⇒ [null, null])。
742
+ export function roundRangeOf(text) {
743
+ const ns = [...String(text ?? '').matchAll(/^\[ROUND\]\s*(\d+)/gm)].map((m) => Number(m[1])).filter((n) => Number.isFinite(n))
744
+ return ns.length ? [Math.min(...ns), Math.max(...ns)] : [null, null]
745
+ }
746
+ // 【取自 RCS-0.1.0】DSH 以非 plugin 形式注入的节点 kind 白名单(实测 skill 目录节点:
747
+ // kind==='skill-catalog'、文本以 <system-reminder> 开头)——不判为注入的话它会被折进遮蔽区,
748
+ // DSH 随即补发一份目录 ⇒ 遮蔽边界更保守地"视为注入、留在表面"。
749
+ const DSH_INJECTED_KINDS = new Set(['skill-catalog', 'runtime-context'])
750
+ // 【DSH 0.2.2-rc.2 兼容】runtime-context 快照从裸文本改成
751
+ // <system-reminder>The following information describes your current session: ...</system-reminder>,
752
+ // 旧前缀('Current runtime context')整类失配 ⇒ 尾部快照不再被判为注入 ⇒ 被遮蔽 ⇒ DSH 逐轮补发。
753
+ // 两道保险:①按 source.kind 判定(与文本形态无关);②前缀匹配前剥掉 reminder 包裹。
754
+ function stripReminderWrapper(text) {
755
+ const m = /^<system-reminder>\s*([\s\S]*?)\s*<\/system-reminder>$/u.exec(text)
756
+ return m ? m[1] : text
757
+ }
758
+ function isDshInjected(m) {
759
+ if (!m || m.role !== 'user') return false
760
+ // 【extreason 接入(取自 RCS-0.1.0)】外置推理简报节点(plugin 标识==='EXTREASON')**不算 DSH 注入**:
761
+ // 它是易失量(每轮由插件重发),必须可被折叠吞掉;否则会被 keepInject 钉在 surface 上、逐轮累积。
762
+ const pid = pluginIdOf(m.source)
763
+ if (pid === 'EXTREASON') return false
764
+ if (pid !== null) return !isOwnTranscript(m)
765
+ if (DSH_INJECTED_KINDS.has(m.source?.kind)) return true
766
+ const text = stripReminderWrapper(blocksOf(m)
767
+ .filter((b) => b.type === 'text').map((b) => b.text ?? '').join('')
768
+ .replace(/[\r\n]+/g, ' ').trim())
769
+ return INJECTED_USER_PREFIXES.some((p) => text.startsWith(p))
770
+ }
771
+ // v1.5 兼容语义:任何 plugin user(含自身转录消息)都视为注入 —— 用于 keepInject=false 的末端剔除(保持旧行为)。
772
+ // ⚠ 注意:此处**必须保持旧版语义**,勿把 subagent/system 按 §13 算作注入。
773
+ // 它决定折叠遮蔽边界(keepInject=false 时 `while end` 往回跳注入)。把 subagent(kind 非 plugin)
774
+ // 判为注入会改变遮蔽区间 → 在并行 subagent 的 tool-result 与其 assistant(tool_calls) 之间切出
775
+ // 孤儿 tool 消息 → INVALID_REQUEST(A/B 真机实测:§13 改此处即崩溃,回退即恢复)。
776
+ // subagent 转录不进 [U] 已由 transcribeIncremental 的 §13 分型单独处理,与本函数无关。
777
+ function isInjectedUser(m) {
778
+ if (!m || m.role !== 'user') return false
779
+ // 【extreason 接入(取自 RCS-0.1.0)】同上:简报节点在 keepInject 关闭的旧语义下也必须可折叠
780
+ const pid = pluginIdOf(m.source)
781
+ if (pid === 'EXTREASON') return false
782
+ if (pid !== null) return true
783
+ if (DSH_INJECTED_KINDS.has(m.source?.kind)) return true
784
+ const text = stripReminderWrapper(blocksOf(m)
785
+ .filter((b) => b.type === 'text').map((b) => b.text ?? '').join('')
786
+ .replace(/[\r\n]+/g, ' ').trim())
787
+ return INJECTED_USER_PREFIXES.some((p) => text.startsWith(p))
788
+ }
789
+ // v1.10 重启续传:DSH 装载会话不重放历史事件,重启后 foldText/foldSeq/foldRound 归零,
790
+ // 但自身折叠转录节点已持久化在 surface。首次(本进程)接触该会话且要折叠时,
791
+ // 找到最后一个自身折叠转录节点,回填 foldText/foldSeq/foldRound → 后续折叠仍是"旧转录+增量"单调拼接,
792
+ // 不丢旧轮注入历史(否则会用"仅尾部"的转录 replace 掉整段旧折叠)。
793
+ // 【v1.10.13 多段转录(control.segments,默认关闭)·读取侧】
794
+ // v1.10 旧实现只取**最后一个**自身转录节点 ⇒ surface 上存在多段时(standalone 的旧段、
795
+ // 多段契约的 head 段/历史段)前面的段在重启后静默消失,后续折叠也就丢了历史。
796
+ // 现改为:**按 seq 升序拼接全部自身转录节点** —— 单段会话结果与旧版逐字相同(只有一个文本),
797
+ // 多段会话才体现差异。seq 取**最后一段**的起点(后续增量判据 `seq > foldSeq` 依赖它)。
798
+ // 纯函数(可单测):items = surface 顺序的 { seq, text }。
799
+ export function mergeOwnSegments(items) {
800
+ const segs = (items ?? []).filter((it) => it && typeof it.text === 'string' && it.text !== '')
801
+ if (segs.length === 0) return { text: '', seq: -1, round: 0, segCount: 0, segs: [] }
802
+ let mx = 0
803
+ for (const it of segs) for (const m of it.text.matchAll(/^\[ROUND\]\s*(\d+)/gm)) { const n = Number(m[1]); if (n > mx) mx = n }
804
+ return {
805
+ text: segs.map((it) => it.text).join('\n'),
806
+ seq: segs[segs.length - 1].seq,
807
+ round: mx,
808
+ segCount: segs.length,
809
+ segs: segs.map((it) => parseSegHeader(it.text, it.seq)),
810
+ }
811
+ }
812
+ // 段文本装配(多段转录的写入侧,纯函数):**只有行,没有任何机器可读头**。
813
+ // 【v1.10.16 修正(用户实测报障 2026-09-11)】0.1.9–0.1.11 曾在首行插一行
814
+ // `# SC-SEG <segIndex> <baseCumLines> <prevSegSeq>`,当时按"每段约 40B"当作可接受代价。
815
+ // 用户实际看到它出现在**注入的上下文里**(会话转录首行 `# SC-SEG 4 16 5383`)⇒ 机器元数据
816
+ // 不该进模型上下文:那既是噪声、又会让模型对"这是什么格式"产生无谓猜测。
817
+ // 现在段信息**只**落两处(都不进上下文):
818
+ // · `last-fold.segments[]`(本次折叠各段的 segIndex/kind/seq/cumLines/bytes)
819
+ // · `.ctxinjector/fold-index/<sid>.jsonl`(每次折叠的 cumLines 前后值与 seq 集合)
820
+ // 顺序不依赖文本自证:surface 节点列表本身是有序的(续传 seed 就按它拼接),
821
+ // `verify-fold-integrity.mjs` 仍可用 last-fold.segments + fold-index 复核。
822
+ // 只算不提交 —— 调用方"先算全部、再依次 append"。
823
+ export function segmentText(rows) {
824
+ return (rows ?? []).join('\n')
825
+ }
826
+ // 兼容读取:0.1.9–0.1.11 落过段头的会话,其文本里仍留着那一行(历史不改写)。
827
+ // 本函数仍可解析出来供审计;无段头(0.1.12 起的新契约)⇒ 三个字段记 null,绝不抛。
828
+ export function parseSegHeader(text, seq = -1) {
829
+ const s = String(text ?? '')
830
+ const m = /^# SC-SEG (\d+) (\d+) (-|\d+)\s*$/.exec(s.split('\n', 1)[0])
831
+ return {
832
+ seq,
833
+ segIndex: m ? Number(m[1]) : null,
834
+ baseCumLines: m ? Number(m[2]) : null,
835
+ prevSegSeq: m && m[3] !== '-' ? Number(m[3]) : null,
836
+ bytes: s.length,
837
+ }
838
+ }
839
+ // v1.10.26 (RCS↔DSH 0.1.5 compat): DSH 0.1.5 refuses any replace range whose
840
+ // first node is the system prompt (surface node 0 must be rewritten only by a
841
+ // system/message over exactly that node). Keep system nodes on the surface so a
842
+ // fold range never starts at seq 0.
843
+ function isSystemNode(m) {
844
+ return m?.role === 'system'
845
+ }
846
+ // v1.10.26 (RCS↔DSH 0.1.5 compat): DSH 0.1.5 validates a replace surfaceOp as
847
+ // exactly {op,startSeq,endSeq}, while <=0.1.1 used {op,start,end}. Emit the new
848
+ // shape; the three-key exact-length check forbids sending both spellings.
849
+ function replaceOp(startSeq, endSeq) {
850
+ return { op: 'replace', startSeq, endSeq }
851
+ }
852
+ // v1.10.26 (RCS↔DSH 0.1.5 compat): DSH 0.1.5 dropped the public `session.events`
853
+ // array and exposes `eventAt(seq)` instead. Keep both paths so one file runs on
854
+ // 0.1.1-rc.2 and 0.1.5-rc.2 alike.
855
+ function evAt(session, seq) {
856
+ if (typeof session?.eventAt === 'function') return session.eventAt(seq)
857
+ return session?.events?.[seq]
858
+ }
859
+ // v1.10.27 (DSH rc.3+/0.1.7 compat): the public `session.events` array is gone there;
860
+ // enumerate via ownEvents() when present. `?? []` alone was a silent-degrade trap —
861
+ // empty event lists read as "no dead calls / no transcript" without any error.
862
+ function allEvents(session) {
863
+ if (typeof session?.ownEvents === 'function') return session.ownEvents()
864
+ const snap = session?.snapshotEvents
865
+ if (typeof snap === 'function') { try { return snap.call(session) } catch { return [] } }
866
+ return session?.events ?? []
867
+ }
868
+ // 【v1.10.28 · 漂移 #11】0.1.7 把工具调用从"助手消息内的 tool-call 内容块"改成**独立 tool/call 事件**
869
+ // (载荷 data:{callId,name,arguments},arguments 为 JSON 字符串)。派生的 tool-result 块仍在,但
870
+ // transcribeIncremental 的 `pending` 再无登记 ⇒ 每条 [T] 落进兜底 `{op:'?',path:'-'}` ⇒ 118/118 全成
871
+ // `?|?|-|OK`,工具名与目标**整列丢失**(转录仍可回放,但"每条断言可指回原文"的记录价值归零)。
872
+ // 修法:以事件层为权威源建 callId → {op,path,args} 索引,块级取不到时回查。老宿主块里有 name,
873
+ // 回查分支不会命中 ⇒ 同一份代码双宿主通吃(与 source.kind 的 pluginIdOf 同思路)。
874
+ function callIndexFromEvents(session) {
875
+ const map = new Map()
876
+ try {
877
+ for (const e of allEvents(session) ?? []) {
878
+ const ty = e?.type
879
+ if (ty !== 'tool/call' && ty !== 'tool-call' && ty !== 'toolCall') continue
880
+ const d = e?.data ?? e
881
+ const id = d?.callId ?? d?.toolCallId ?? d?.id
882
+ if (id == null || id === '') continue
883
+ map.set(String(id), {
884
+ op: d?.name ?? d?.toolName ?? '?',
885
+ path: pathOf(d?.arguments),
886
+ args: argsOf(d?.arguments),
887
+ })
888
+ }
889
+ } catch { /* best-effort:索引失败退回旧行为 */ }
890
+ return map
891
+ }
892
+ // 累计原料字节:从折叠账本(只追加)求和,用于面板/检查点的“累计压缩比”。
893
+ // 账本里的 raw 是每次真正被转写的新原料字节(排除自身旧转录节点);同一 foldSeq 只算一次。
894
+ function seedCumulativeRaw(sid) {
895
+ try {
896
+ const bare = String(sid ?? '').replace(/^session-?/i, '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80)
897
+ const fp = join(FOLD_INDEX_DIR, bare + '.jsonl')
898
+ if (!existsSync(fp)) return 0
899
+ const seen = new Set()
900
+ let sum = 0
901
+ for (const line of readFileSync(fp, 'utf8').split('\n')) {
902
+ if (!line.trim()) continue
903
+ let r = null
904
+ try { r = JSON.parse(line) } catch { continue }
905
+ const n = Number(r?.raw)
906
+ if (!Number.isFinite(n) || n < 0) continue
907
+ if (r?.foldSeq !== undefined) {
908
+ if (seen.has(r.foldSeq)) continue
909
+ seen.add(r.foldSeq)
910
+ }
911
+ sum += n
912
+ }
913
+ return sum
914
+ } catch { return 0 }
915
+ }
916
+ function seedFoldState(session, surface, st, sid) {
917
+ try {
918
+ const nodes = [...(surface?.nodes ?? [])]
919
+ const entries = nodes.map((seq) => ({ seq, msg: session.deriveEventMessage(evAt(session, seq)) }))
920
+ // 【v1.10.75】封存卷绝不复活:重启后 surface 上仍可能有已封存的旧卷(原生压缩的保留窗口
921
+ // 正好落在旧卷之后 ⇒ 旧卷没被总结掉、只是没被压进 summary)。判据两条:
922
+ // ① 位置:seq ≤ resumeAfterSeq(在原生摘要覆盖范围之内);
923
+ // ② 内容:文本 hash 逐字等于某个封存卷(边界之后仍留在表面的旧卷)。
924
+ // 跳过 ≠ 丢弃:原文在 sealed/ 里,轮次由 baseRound 继承。
925
+ const idx = readSealIndex(sid)
926
+ const floorSeq = Number.isFinite(idx?.resumeAfterSeq) ? Number(idx.resumeAfterSeq) : -1
927
+ const sealedHashes = new Set((Array.isArray(idx?.volumes) ? idx.volumes : []).map((v) => v?.sha256).filter(Boolean))
928
+ const baseRound = Number(idx?.baseRound) || 0
929
+ const own = []
930
+ const sealedSeqs = []
931
+ let lastCompactionId = null
932
+ for (const e of entries) {
933
+ if (!e.msg || !isOwnTranscript(e.msg)) continue
934
+ const text = blocksOf(e.msg).filter((b) => b.type === 'text').map((b) => b.text ?? '').join('')
935
+ if (!text) continue
936
+ if ((floorSeq >= 0 && e.seq <= floorSeq) || sealedHashes.has(hash256(stripSnapshotRows(text)))) {
937
+ sealedSeqs.push(e.seq)
938
+ continue
939
+ }
940
+ own.push({ seq: e.seq, text })
941
+ if (typeof e.msg.source?.compactionId === 'string' && e.msg.source.compactionId) lastCompactionId = e.msg.source.compactionId
942
+ }
943
+ if (own.length === 0) {
944
+ // 表面上只剩封存卷(或什么都没有):仍然把"轮次继承 + 起点边界"恢复出来,
945
+ // 新卷从边界之后缀增,绝不把封存内容重新灌回上下文。
946
+ if (baseRound > 0 || floorSeq >= 0 || sealedSeqs.length > 0) {
947
+ st.foldText = ''
948
+ st.foldSeq = floorSeq
949
+ st.lastSegSeq = floorSeq
950
+ st.segCount = 0
951
+ st.cumRawBytes = 0
952
+ st.compactionId = null // 新卷 → 新的 UI 检查点行
953
+ st.foldRound = Math.max(Number(st.foldRound) || 0, baseRound)
954
+ st.resumeAfterSeq = floorSeq
955
+ st.freshVolume = true
956
+ st.seeded = true
957
+ console.log(`[contextinjector] resume-seed: 自身转录节点全属已封存卷(seq=${sealedSeqs.join(',') || 'none'})⇒ 新卷从 seq=${floorSeq} 之后缀增,轮次继承 ${st.foldRound}`)
958
+ return true
959
+ }
960
+ return false
961
+ }
962
+ const merged = mergeOwnSegments(own) // v1.10.13:拼接全部段(旧版只取最后一个 ⇒ 多段会丢前面的段)
963
+ st.foldText = stripSnapshotRows(merged.text)
964
+ st.foldSeq = merged.seq
965
+ st.foldRound = Math.max(merged.round, baseRound) // 封存卷的轮次只作为下限:绝不倒退
966
+ st.segCount = merged.segCount
967
+ st.lastSegSeq = merged.seq
968
+ st.resumeAfterSeq = floorSeq
969
+ st.freshVolume = sealedSeqs.length > 0
970
+ if (lastCompactionId) st.compactionId = lastCompactionId // 复用同一个 UI 标记(不每轮新增一行)
971
+ st.cumRawBytes = seedCumulativeRaw(sid)
972
+ st.seeded = true
973
+ console.log(`[contextinjector] resume-seed seq=${merged.seq} round=${st.foldRound} segments=${merged.segCount} foldBytes=${merged.text.length} cumRaw=${st.cumRawBytes} sealedSkipped=${sealedSeqs.length} (process-restart resume)`)
974
+ return true
975
+ } catch (err) {
976
+ console.error('[contextinjector] resume-seed failed:', err?.message ?? err)
977
+ return false
978
+ }
979
+ }
980
+ function argsOf(argsJson) {
981
+ try { const a = JSON.parse(argsJson ?? '{}'); return (a && typeof a === 'object') ? a : {} } catch { return {} }
982
+ }
983
+ const TOOL_DOMAIN = {
984
+ read: 'fs', read_image: 'fs', write: 'fs', edit: 'fs', str_replace: 'fs',
985
+ glob: 'fs-search', grep: 'fs-search', pwsh: 'shell', bash: 'shell',
986
+ create_goal: 'goal', get_goal: 'goal', update_goal: 'goal',
987
+ job_list: 'jobs', job_output: 'jobs', job_kill: 'jobs',
988
+ ask_user_question: 'ask-user', web_search: 'web', web_fetch: 'web',
989
+ todo_write: 'todo', subagent: 'subagent',
990
+ }
991
+ function toolDomainOf(op) { return TOOL_DOMAIN[op] || op }
992
+ // 收集工具结果的可读正文(text 块 + tool-result 的 content)——结构化档用来算 head/tail/err 原文/指纹;
993
+ // 旧档不调用,仍不回流正文。
994
+ function toolResultText(m, resultBlock) {
995
+ const parts = []
996
+ for (const x of blocksOf(m)) if (x && x.type === 'text' && x.text) parts.push(x.text)
997
+ if (resultBlock && typeof resultBlock.content === 'string' && resultBlock.content) parts.push(resultBlock.content)
998
+ return parts.join('\n')
999
+ }
1000
+ // 【漂移 #11】工具结果消息的双形态判定:
1001
+ // 0.1.5 及更早:消息 content 内含 {type:'tool-result', toolCallId, isError} 块;
1002
+ // 0.1.7 起:结果消息是 {role:'tool', toolCallId, isError, source:{kind:'tool',callId}, content:[{type:'text'}]}
1003
+ // —— **没有** tool-result 块。只认块级会让整类结果在转录里凭空消失。
1004
+ // 【业务层判定 · v1.10.67】行尾 OK/ERR 只判**传输层**(工具进程有没有报错)。很多工具用
1005
+ // JSON 回执表达**业务失败**:{"ok":false,"error":"need-explicit-user-intent"}、write 冲突、
1006
+ // edit 未命中、web_fetch 拿不到正文……传输层全是成功 ⇒ 旧行写 `OK`,模型据此判定"成功了"
1007
+ // (真机:memory_correct 连续三轮回执 ok:false,[T] 行却三次 OK,我照着 OK 往下走)。
1008
+ // 现在把回执里的业务失败提成 `FAIL(<错误码>)`:错误码只有十几个字节,与体积无关,永远显示。
1009
+ const BIZ_FAIL_RE = /"ok"\s*:\s*false|\{\s*"error"\s*:|^error:/i
1010
+ const BIZ_CODE_RE = /"error"\s*:\s*"([^"\n]{1,64})"/
1011
+ // 【v1.10.72】"还没落盘/不在 raw 里"不是业务失败。转录行里把这类回执写成 FAIL(seq-not-in-raw)
1012
+ // 等于告诉模型"工具坏了",真机就是这样误判的:一条 probe 的正文里带该回执,[T] 行标 FAIL,
1013
+ // 而同一个 seq 稍后展开实得 6662B(只是 raw append 还没 flush)。这类行只保留 raw=<seq>
1014
+ // 指针,判定列写中性状态 PENDING,不与业务 FAIL 同权重。
1015
+ const RETRYABLE_CODES = new Set(['seq-not-in-raw'])
1016
+ function toolVerdictTag(isErr, text) {
1017
+ if (isErr) return 'ERR'
1018
+ const t = String(text ?? '')
1019
+ if (!t || !BIZ_FAIL_RE.test(t)) return 'OK'
1020
+ const m = t.match(BIZ_CODE_RE)
1021
+ const code = m ? m[1].replace(/[\s|]+/g, '-').slice(0, 40) : ''
1022
+ if (code && RETRYABLE_CODES.has(code)) return 'PENDING'
1023
+ return code ? `FAIL(${code})` : 'FAIL'
1024
+ }
1025
+ // 小结果的内联预览:首行、压掉连续空白、截断到 RAW_PREVIEW_CHARS,避免把换行带进转录。
1026
+ function boundedPreview(text, cap = RAW_PREVIEW_CHARS) {
1027
+ const s = String(text ?? '').replace(/\s+/g, ' ').trim()
1028
+ if (!s) return ''
1029
+ return s.length > cap ? s.slice(0, cap) + '…' : s
1030
+ }
1031
+ // 载荷必须留痕的工具:ask_user_question 的回执是**用户决定**(选项/自定义答复),
1032
+ // 它体积很小、成功态(OK)也不该被"小结果不内联"的省 token 规则吃掉——真机回放里
1033
+ // 正是这一行会退化成光秃秃的 `[T] ask_user_question | - | OK`,把用户选了什么抹掉。
1034
+ // 这类工具永远挂指针,够小的时候再加一行预览。
1035
+ const PAYLOAD_KEEP = new Set(['ask_user_question'])
1036
+ function isToolResultMsg(m) { if (!m) return false
1037
+ if (m.role === 'tool' || m?.source?.kind === 'tool') return true
1038
+ return blocksOf(m).some((b) => b?.type === 'tool-result')
1039
+ }
1040
+ function toolResultCallId(m, block) {
1041
+ return String(block?.toolCallId ?? block?.callId ?? m?.toolCallId ?? m?.callId ?? m?.source?.callId ?? '')
1042
+ }
1043
+ // 历史转录里可能残留旧版写入的 [S] 快照摘要行。快照每 step 重注入、内容只是 section 名,
1044
+ // 进入折叠转录只会逐轮累积噪声;新写入已不再产生 [S],读取/续传时再清一次旧残留。
1045
+ export function stripSnapshotRows(text) {
1046
+ const s = String(text ?? '')
1047
+ if (!s.includes('[S] ')) return s
1048
+ return s.split('\n').filter((line) => !line.startsWith('[S] ')).join('\n')
1049
+ }
1050
+ function seqMapOf(entries) { const map = new Map()
1051
+ for (const e of entries ?? []) {
1052
+ const n = Number(e?.seq)
1053
+ if (e?.msg && Number.isFinite(n)) map.set(e.msg, n)
1054
+ }
1055
+ return map
1056
+ }
1057
+ function transcribeIncremental(msgs, roundState, opts = {}) {
1058
+ const rows = []
1059
+ const pending = new Map() // callId -> {op, path, args}
1060
+ const structured = !!(opts && opts.structured)
1061
+ const calls = (opts && opts.calls) instanceof Map ? opts.calls : null // 漂移 #11:事件层索引
1062
+ const seqOf = (() => {
1063
+ const s = opts && opts.seqOf
1064
+ if (s instanceof Map) return (m) => s.get(m)
1065
+ if (typeof s === 'function') return s
1066
+ return (m) => m?.seq
1067
+ })()
1068
+ // 旧宿主把 tool-call 放在助手消息块里:记住 callId → 该消息 seq,
1069
+ // 供 write/edit/str_replace 行输出 call=<seq>(展开即得实际 patch 参数)。
1070
+ const callSeq = new Map()
1071
+ for (const m of msgs) {
1072
+ const n = Number(seqOf(m))
1073
+ if (!Number.isFinite(n)) continue
1074
+ for (const b of blocksOf(m)) {
1075
+ if (b?.type !== 'tool-call') continue
1076
+ const id = b.id ?? b.toolCallId ?? b.callId
1077
+ if (id != null && id !== '') callSeq.set(String(id), n)
1078
+ }
1079
+ }
1080
+ // 一行工具记录(块级与消息级两条路径共用,保证输出逐字同形)
1081
+ function emitToolRow(callId, isErr, m, block) {
1082
+ const rec = pending.get(String(callId)) ?? calls?.get(String(callId))
1083
+ ?? { op: block?.name ?? m?.toolName ?? '?', path: '-', args: {} }
1084
+ const rawSeq = Number(seqOf(m))
1085
+ const callSq = callSeq.get(String(callId))
1086
+ const text = toolResultText(m, block)
1087
+ const payloadBytes = text.length
1088
+ // v1.10.67 体积门槛:只有够大的结果才值得一次回取往返 ⇒ 只有它挂 raw=<seq>。
1089
+ // 小结果不挂指针(省 token、免掉无谓展开);其中**非 OK** 的把有界预览内联在同一行,
1090
+ // 失败原因看得到,成功的小回执(ok:true / "No matches found")正文没有信息量、不内联。
1091
+ const pointerMin = Number.isFinite(opts?.rawPtrMinBytes) ? opts.rawPtrMinBytes : RAW_PTR_MIN_BYTES
1092
+ const keepPayload = PAYLOAD_KEEP.has(String(rec.op))
1093
+ const hasPointer = Number.isFinite(rawSeq) && (payloadBytes >= pointerMin || keepPayload)
1094
+ const extra = []
1095
+ if (hasPointer) extra.push('raw=' + rawSeq)
1096
+ if (Number.isFinite(callSq) && /^(?:write|edit|str_replace|str_replace_editor)$/.test(rec.op)) extra.push('call=' + callSq)
1097
+ const verdict = toolVerdictTag(isErr, text)
1098
+ if (structured) {
1099
+ const { t, vs } = foldToolResult({ action: rec.op, args: rec.args || {}, text, isError: verdict !== 'OK', fs: { readFileSync }, tool: toolDomainOf(rec.op) })
1100
+ for (const v of vs) rows.push(v)
1101
+ rows.push(t + extra.map((x) => '|' + x).join(''))
1102
+ } else {
1103
+ // 小结果把预览内联在**同一行**(不再新开 [V] 行):转录里 [V] 的既有语义是 subagent 正文/
1104
+ // 错误补充(memory.mjs 会按该语义解析),工具预览混进去会被误配到相邻记录上。
1105
+ // PENDING(未落盘/稍后再取)不算失败,也不内联:那一行只该留 raw=<seq>,别再复述回执。
1106
+ const failish = verdict === 'ERR' || verdict.startsWith('FAIL')
1107
+ const inline = payloadBytes > 0 && payloadBytes < pointerMin && (failish || keepPayload) ? boundedPreview(text) : ''
1108
+ rows.push(`[T] ${rec.op} | ${rec.path} | ${verdict}${inline ? ' | ' + inline : ''}${extra.map((x) => ' | ' + x).join('')}`)
1109
+ }
1110
+ }
1111
+ for (const m of msgs) {
1112
+ const role = m?.role
1113
+ const src = m?.source?.kind
1114
+ // 漂移 #11:0.1.7 形态的结果消息没有 tool-result 块 ⇒ 走消息级路径,并**跳过**块循环
1115
+ if ((role === 'tool' || src === 'tool') && !blocksOf(m).some((b) => b?.type === 'tool-result')) {
1116
+ emitToolRow(toolResultCallId(m), m?.isError === true, m, null)
1117
+ continue
1118
+ }
1119
+ for (const b of blocksOf(m)) {
1120
+ if (isReasoningBlock(b)) continue // §1.2 content-only:reasoning 块剥离(不转录)
1121
+ // 注:subagent relay/settled 正文实测以 text 块到达(deepseek-v4-flash),不受此影响;
1122
+ // 若未来某 provider 把 relay 正文放 reasoning 块,需在 relay 分支单独透传(见报告 §4-B)。
1123
+ if (b.type === 'text') {
1124
+ const txt = inline(b.text)
1125
+ if (!txt) continue
1126
+ if (role === 'user') {
1127
+ // §13:按 source 分型——ROUND 仅真人轮(kind==='user');subagent 回传/系统注入
1128
+ // 不再漏进 [U]。旧判 src==='plugin' + 文本前缀抓不到 subagent-report/settled(kind 非 plugin)。
1129
+ const cls = classifyMessageSource(m?.source)
1130
+ if (cls.cls === 'opaque' && !m?.source) {
1131
+ // source 缺失(异常)⇒ 回退旧文本前缀启发式
1132
+ if (INJECTED_USER_PREFIXES.some((p) => stripReminderWrapper(txt).startsWith(p))) continue
1133
+ roundState.n += 1
1134
+ rows.push(`[ROUND] ${roundState.n}`)
1135
+ rows.push(`[U] ${txt}`)
1136
+ continue
1137
+ }
1138
+ switch (cls.cls) {
1139
+ case 'user': // 仅真人输入递增 ROUND
1140
+ roundState.n += 1
1141
+ rows.push(`[ROUND] ${roundState.n}`)
1142
+ rows.push(`[U] ${txt}`)
1143
+ break
1144
+ case 'catalog': // skill 目录,不计 ROUND
1145
+ rows.push(`[C] ${txt}`)
1146
+ break
1147
+ case 'snapshot': // runtime 快照每 step 重注入,且宿主始终把最新一份留在表面
1148
+ // (keepInject 不遮蔽注入节点)⇒ 转录它们只会把同一行 section 名逐轮累积成噪声。
1149
+ // 不写 [S]:模型侧仍有最新的运行时快照,历史快照本来就是易失信息。
1150
+ break
1151
+ case 'notice': // job 通知 / 审批变更 / subagent 结束,不计 ROUND
1152
+ rows.push(`[N] ${cls.sub} | ${txt}`)
1153
+ break
1154
+ case 'relay': // subagent 报告:走 [T]/[V] 带锚点,内容保留但不计 ROUND
1155
+ rows.push(`[T] subagent | ${subagentIdOf(txt) ?? cls.agentId ?? '?'} | OK`)
1156
+ rows.push(`[V] ${txt}`)
1157
+ break
1158
+ case 'extreason': { // 【extreason 接入(取自 RCS-0.1.0)】外置推理简报:文本本身已是 `[R] …` 行
1159
+ // —— 必须用 b.text 原始行,不能用 inline 后的 txt(inline 把换行压成空格、丢行结构)
1160
+ for (const line of String(b.text ?? '').split('\n')) {
1161
+ const s = line.trim()
1162
+ if (/^\[R\] /.test(s)) rows.push(s)
1163
+ }
1164
+ break
1165
+ }
1166
+ default:
1167
+ break // self(本插件折叠注入)/ opaque ⇒ 不转录
1168
+ }
1169
+ } else if (role === 'assistant') {
1170
+ rows.push(`[A] ${txt}`)
1171
+ }
1172
+ continue
1173
+ }
1174
+ if (b.type === 'tool-call') {
1175
+ const id = b.id ?? b.toolCallId ?? ''
1176
+ const ev = (id && calls && calls.get(String(id))) || null
1177
+ const op = b.name ?? b.toolName ?? ev?.op ?? '?'
1178
+ if (id) pending.set(String(id), { op, path: (b.arguments != null ? pathOf(b.arguments) : (ev?.path ?? '-')), args: (b.arguments != null ? argsOf(b.arguments) : (ev?.args ?? {})) })
1179
+ continue
1180
+ }
1181
+ if (b.type === 'tool-result') {
1182
+ // 块级形态(老宿主):isError 优先取块,缺失时回落消息层
1183
+ emitToolRow(toolResultCallId(m, b), (b.isError !== undefined ? b.isError === true : m?.isError === true), m, b)
1184
+ }
1185
+ }
1186
+ }
1187
+ return rows
1188
+ }
1189
+
1190
+ // ========== v1.10.1 keepInject 头部补录(修复"折叠转录缺第 1 轮")==========
1191
+ // 病根(2026-09-10 用户实测 session-2ae3f3f9「晚上吃什么→偏清淡→那就鲈鱼」):DSH 的 runtime/policy
1192
+ // 快照节点(user 角色 / source.plugin='@deepseek-ai/dsh-system-prompt')实测**紧跟首条真人 user**
1193
+ // (该会话 seq8=user「晚上吃什么」/ seq9=runtime 快照 / seq10=EXTREASON 简报 / seq268=首条 [A])。
1194
+ // keepInject ON 的遮蔽段从"最后一个注入节点之后"起算 ⇒ 注入**之前**的普通节点(=首条 user)
1195
+ // 永远进不了遮蔽集,于是也永远没被转录:last-fold.full 以孤儿行 `[R]…[A]…` 开头,
1196
+ // 首个 `[ROUND]` 已是第二轮「偏清淡」,而面板 parseTranscript 丢弃 `[ROUND]` 之前的行
1197
+ // ⇒ 用户看到"第一轮(首条 user + 首条 AI 回复)整体消失"。
1198
+ // 修法(最小正确):把"注入节点之前、因而永远无法遮蔽"的那段节点**只转录、不遮蔽**,且只在
1199
+ // **本会话第一次折叠**(foldText === '')时补录一次 ⇒ 转录恢复为完整记录(ROUND 1 = 首条 user),
1200
+ // 而 keepInject 的保证分毫未动:注入节点既不进遮蔽区间、也不进 head(绝不遮蔽 ⇒ 零快照补发)。
1201
+ // 代价(实测极小):这段文本在请求里出现两次(原节点仍在表面 + 转录行)——本例 ≈ 一条 user 文本 +
1202
+ // 一行 `[ROUND] 1` ≈ 30B;换来转录/面板的记录完整性。旧转录(foldText 非空,含重启 seedFoldState)
1203
+ // 一律不补录:把 head 插到转录中段会破坏 monotonic 前缀纪律(既有历史会话保持现状,新会话起生效)。
1204
+ // ⚠ 纯函数(可单测):entries = surface 顺序的 {seq,msg}(msg 已过滤 null)、hasPriorTranscript =
1205
+ // foldText !== '';roundState 会被**就地推进**(head 里的真人轮先占号,随后的增量行轮号连续)。
1206
+ export function foldHeadTranscript(entries, hasPriorTranscript, roundState, opts = {}) {
1207
+ let lastInject = -1
1208
+ for (let i = 0; i < entries.length; i++) if (isDshInjected(entries[i].msg)) lastInject = i
1209
+ if (lastInject < 0) return { head: [], tail: entries, hadInject: false, rows: [] } // 无注入节点 ⇒ 与旧版逐字同行为
1210
+ const head = hasPriorTranscript ? [] : entries.slice(0, lastInject) // 已存在转录 ⇒ 不补录(幂等)
1211
+ const tail = entries.slice(lastInject + 1)
1212
+ const rows = head.length > 0 ? transcribeIncremental(head.map((e) => e.msg), roundState, { ...opts, seqOf: seqMapOf(head) }) : []
1213
+ return { head, tail, hadInject: true, rows }
1214
+ }
1215
+
1216
+ // ================= condense 档(v1.5):增量 [A] 前向浓缩 =================
1217
+ // 只处理增量内"新 [A]"行的正文;[ROUND]/[U]/[T] 原样通过;结果只拼接到 foldText 之后,
1218
+ // 旧 transcript 从不重新浓缩(monotonic 前缀由调用方保证)。
1219
+ // 【v1.10.18 默认提示词重写(用户提"两个压缩器默认提示词不够好")· A/B 实测见 docs/PROD-RCS-0.1.14-NOTES.txt】
1220
+ // 旧版(v1.10.17 及以前)的病根:把要求写成"必须保留:结论、做出的决定、任务目标与完成状态、
1221
+ // 关键数字/文件名/路径…"这样一份**清单** ⇒ 4B 级模型会为凑满清单而**编造**任务目标/完成状态/文件路径
1222
+ // (真机实证:`核心文件路径为:/memory/architecture/cerebral-memory-v1.2.yaml`,原文里根本没有)。
1223
+ // 新版改成"**只删不改**"的约束式写法 + 点名禁止那几句模板,并在 A/B 上用真机数据验证(12 条真实 [A] 原文):
1224
+ // · 旧提示词:编造状态 5/12 条、编造值 3/12 条,中位耗时 17.0s
1225
+ // · 新提示词:编造状态 **0/12**、编造值 2/12,中位压缩比 0.464(旧 0.437 ≈ 同级),中位耗时 **4.5s**
1226
+ // 【v1.10.20 · 经济性 P1 提示词收紧(A/B 实测)】
1227
+ // 上一版只写了"目标不超过原文的四分之一"这一条**相对**约束。问题:对"原文本身就是流水"的 [A],
1228
+ // 模型会把它理解成"只要比原文短就行",于是贴着上限写 ⇒ 输出 token 高、压缩率差。
1229
+ // 这一版只**追加一句绝对字数锚点**("原文约 1000 字时输出控制在 150 字以内"),不动其余任何约束
1230
+ // (铁律一/二、取舍顺序、形式全部逐字保留 ⇒ 不引入新的编造风险)。
1231
+ // 真机 A/B(同一组 3 条真实 [A],均 1758 字,reasoningEffort=off):
1232
+ // · maxTokens=768 :压缩比 0.319 → **0.258**,输出 token 298 → **232**(-22%)
1233
+ // · maxTokens=1536:压缩比 0.336 → **0.273**,输出 token 311 → **250**(-20%)
1234
+ // · 对照组 terse(整段重写的另一版提示词):0.311 / 0.303 ⇒ 并不优于原版,被弃用
1235
+ // 两档预算下一致更优 ⇒ 收益来自提示词本身,而非预算。证据:tools/.probe/summary-e3.json。
1236
+ const CONDENSE_SYS = '你是 AI 输出的有损压缩器。把给定的一条 AI 消息压成一段明显更短的文本,供长期携带上下文使用。\n\n铁律一(只删不改):原文里没有的路径、文件名、版本号、数字、ID、hash、状态词一律不得出现;原文里的具体值必须逐字照抄,不得改写、缩写、翻译、补全或猜测;不确定就省略。\n铁律二(不许编结论/进度):禁止写原文没有的"已完成/已交付/已通过/已验证/已修复/已上线"等说法;禁止写"任务目标:…""完成状态:…""关键文件路径:…"这类模板句,除非原文本身就有对应内容。\n\n取舍顺序:先保结论与决定、真实状态与卡点、未决问题与下一步,以及所有具体值(数字/路径/文件名/ID/链接);这些比措辞重要——为了给它们腾地方,优先删掉修饰语、过程叙述、客套、重复与无关举例。原文若是过程流水,就只留"做了什么 + 结果 + 待办",不要替它总结出原文没有的结论。\n\n形式:单段纯文本,要点之间用";"连接;不加标题、编号、标签、前缀或解释;使用与原文相同的语言。\n长度:目标不超过原文的四分之一;原文本身已是简短结论时,基本原样保留。硬约束:无论原文多长,输出都必须明显短于原文——原文约 1000 字时输出控制在 150 字以内,更要紧的是**不要复述原文**,只留结论、状态、卡点、下一步与具体值。'
1237
+ // double 档第二轮:载荷直抄(离线 p1c [A·K] 思想,收敛版)。本输入无 ⟦ 占位符,只收孤立名词性值;
1238
+ // 含谓词的整句/步骤编号一律剔(p1c naive→v4 实测教训),输出行级去重。
1239
+ // double 档第二轮:载荷直抄(离线 p1c [A·K] 思想,收敛版)。本输入无 ⟦ 占位符,只收孤立名词性值;
1240
+ // 含谓词的整句/步骤编号一律剔(p1c naive→v4 实测教训),输出行级去重。
1241
+ // 【v1.10.18 重写 · A/B 实测】旧版列了 4 条"禁止事项",但没把"只许逐字摘抄、原文没有的绝不许出现、
1242
+ // 找不到值就输出空"立成硬规则 ⇒ 真机实测 10 条原文里 **5 条出现非逐字行**(共 148 行,多为把
1243
+ // `_xchg\FROM-….md` 之类规范化/补全)。新版把这些立成铁律后:**非逐字行 0**、输出行 962→756
1244
+ // (召回 243→201,即"宁可漏抄不可改字"——正是本插件既定原则"错误载荷比丢失更糟")。
1245
+ const KEYS_SYS = '你是载荷值提取器:从给定原文里,把"未来可能被引用的具体值"逐字摘抄成清单,一行一个。\n\n只允许摘抄,不允许改写:每个值都必须与原文逐字节一致;不得翻译、缩写、补全、换算或猜测。原文里没有的值,绝不许出现在输出里;一个合格的值都找不到时,输出空(不要为凑输出而编)。\n\n合格的值:孤立的数值(结果 24、比率 0.31、端口 8787、行号 200)、短 ID 或 hash 片段(e7b40c73、DC364B19)、标记词(CLEANUP_OK)、文件名或目录名(summary.txt、branch1)、版本号(v1.10.17)、链接(http://…)。\n不合格:整句、从句、动宾短语、对话正文、段落残余、步骤标题或编号(Step 1、步骤2、操作:pwsh)、占位符(⟦ID0⟧ 及其碎片)。含谓词(是/请/提供/执行/需要/无法/完成/确认)的行一律不算值。\n\n输出:每行一个值,不加编号、项目符号、引号或任何解释;重复的值只留一次。'
1246
+ // #10:把内置默认压缩提示词暴露给 WebUI(runtime.json.defaultPrompts),供用户在自定义前查看/对照。
1247
+ // 单一来源——不重复到 host,只在此写一次;host /status 已读 runtime.json。
1248
+ try {
1249
+ let _cur = null
1250
+ try { _cur = JSON.parse(readFileSync(RUNTIME_FILE, 'utf8')) } catch { _cur = {} }
1251
+ _cur.defaultPrompts = { single: CONDENSE_SYS, keys: KEYS_SYS }
1252
+ writeFileSync(RUNTIME_FILE, JSON.stringify(_cur, null, 2) + '\n', 'utf8')
1253
+ } catch { /* runtime.json 不可写则忽略,折叠时再补 */ }
1254
+ // —— [A] 浓缩管道 guard(p1b 实证复用):结构可识别载荷先占位、浓缩后再规则回填——弱模型从不接触真值,
1255
+ // 无从抄错/截断/幻觉(e2e 实测 4B 把 branchID=e7b40c73 写成 e7b4073、幻觉 e7b40c77)。——
1256
+ const G_SLOT_RE = /⟦([A-Z]+)(\d+)⟧/g
1257
+ function guardText(text) {
1258
+ const slots = []
1259
+ const hit = (m, tag) => { slots.push(m); return `⟦${tag}${slots.length - 1}⟧` }
1260
+ let s = String(text)
1261
+ const p = (m) => hit(m, 'P'), id = (m) => hit(m, 'ID'), hx = (m) => hit(m, 'HX')
1262
+ s = s.replace(/[A-Za-z]:\\[^\s"'<>,。;⟦]+/g, p) // PATH 先抽:路径内 ID/hex 只进一个占位,防嵌套
1263
+ s = s.replace(/(?:session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, id)
1264
+ s = s.replace(/[0-9a-f]{6,}/gi, hx) // 8 位短 id / 长 hash 全占位(e7b40c73 在此被保护)
1265
+ return { text: s, slots }
1266
+ }
1267
+ function restoreText(text, slots) {
1268
+ const unfold = (s) => s.replace(G_SLOT_RE, (_, __, i) => slots[Number(i)] ?? '')
1269
+ let s = String(text)
1270
+ let out = unfold(s)
1271
+ while (out !== s) { s = out; out = unfold(s) } // 递归展开,防嵌套残留
1272
+ return out
1273
+ }
1274
+ const PREDICATE_RE = /是|请|提供|执行|需要|无法|完成|确认|正在|已经|应该|可以|让我/
1275
+ const dedupeKeys = (raw) => {
1276
+ const seen = new Set(); const lines = []
1277
+ for (const rawLine of String(raw).split('\n')) {
1278
+ const ln = rawLine.trim().replace(/^[((【\[][^))】\]]{0,12}[))】\]]\s*/, '') // 剥前缀标签
1279
+ if (!ln || ln.length > 80 || PREDICATE_RE.test(ln)) continue
1280
+ if (seen.has(ln)) continue
1281
+ seen.add(ln); lines.push(ln)
1282
+ }
1283
+ return lines.join('\n')
1284
+ }
1285
+ // ================= v1.8 浓缩模型通道:DSH 已注册模型(ctx.llm) =================
1286
+ // 为什么:v1.5–v1.7 只走本地 Ollama,没有本地部署的机器上 single/double 档等同不可用。
1287
+ // 现在调用 DSH 自身注册的 provider/model(官方 deepseek 或用户配置的任意 provider),零额外部署。
1288
+ // 为什么软取:硬 inject:['llm'] 在服务缺失时让插件停在 PENDING 且**不报错**(手册 §4.4),
1289
+ // 那会连无损折叠一起废掉。故一律软取;取不到就回退无损,纪律不变。
1290
+ function llmOf(ctx) {
1291
+ try {
1292
+ const viaGet = ctx && typeof ctx.get === 'function' ? ctx.get('llm') : undefined
1293
+ return viaGet ?? ctx?.llm ?? null
1294
+ } catch { return null }
1295
+ }
1296
+ // 会话当前模型路由(与官方 dsh-session-title-llm 同源:request/header 快照)
1297
+ function routeFromSession(session) {
1298
+ try {
1299
+ const h = typeof session?.requestHeader === 'function' ? session.requestHeader() : undefined
1300
+ const c = h?.config
1301
+ if (c && typeof c.provider === 'string' && typeof c.model === 'string' && c.provider && c.model) {
1302
+ return { provider: c.provider, model: c.model, via: 'session' }
1303
+ }
1304
+ } catch { /* best-effort */ }
1305
+ return null
1306
+ }
1307
+ // 兜底:注册表首个 provider 的首个模型(会话尚无 header 时,如首轮之前)
1308
+ async function routeFromRegistry(llm) {
1309
+ try {
1310
+ const ps = llm.listProviders?.() ?? []
1311
+ for (const p of ps) {
1312
+ const id = typeof p === 'string' ? p : p?.id
1313
+ if (!id) continue
1314
+ const ms = await llm.listModels?.(id).catch(() => null) ?? []
1315
+ const first = ms?.[0]
1316
+ const mid = typeof first === 'string' ? first : first?.id
1317
+ return mid ? { provider: id, model: mid, via: 'registry' } : null
1318
+ }
1319
+ } catch { /* best-effort */ }
1320
+ return null
1321
+ }
1322
+ // 路由优先级:env > control.json > 会话当前模型 > 注册表首个
1323
+ async function resolveCondenseRoute(ctx, session, ctrl, llmFixed) {
1324
+ const envP = process.env.DSH_CONTEXTINJECTOR_CONDENSE_PROVIDER
1325
+ const envM = process.env.DSH_CONTEXTINJECTOR_CONDENSE_MODEL
1326
+ if (envP && envM) return { provider: envP, model: envM, via: 'env' }
1327
+ const cp = ctrl?.condenseProvider
1328
+ const cm = ctrl?.condenseModel
1329
+ if (typeof cp === 'string' && typeof cm === 'string' && cp && cm) return { provider: cp, model: cm, via: 'control' }
1330
+ const llm = llmFixed ?? llmOf(ctx) // v1.10.19:优先用上层固化的句柄(作用域可能已经/即将被回收)
1331
+ if (!llm) return null
1332
+ return routeFromSession(session) ?? await routeFromRegistry(llm)
1333
+ }
1334
+ // user message 构造:优先 dsh-llm 的规范构造器(动态 import;解析失败则退回等价手写值)
1335
+ let _msgCtor = null
1336
+ let _msgTried = false
1337
+ async function userMessage(text) {
1338
+ if (!_msgTried) {
1339
+ _msgTried = true
1340
+ try { _msgCtor = (await import('@deepseek-ai/dsh-llm')).createUserMessage ?? null } catch { _msgCtor = null }
1341
+ }
1342
+ const content = [{ type: 'text', text }]
1343
+ const source = { kind: 'plugin:CONTEXTinjector' }
1344
+ if (_msgCtor) {
1345
+ try { return _msgCtor({ content, source }) } catch { /* 退回手写值 */ }
1346
+ }
1347
+ return { id: 'msg-' + randomUUID(), role: 'user', content, source }
1348
+ }
1349
+ // 一次流式旁路调用;非 stop 结束(错误/中止/截断)一律视为失败 → 调用方回退无损。
1350
+ // v1.9:可选 sink(reason) 收集失败原因(llm 缺失/路由缺失/流错误/非 stop finish),
1351
+ // 供折叠把真实错误上浮到 lastFold.condenseErr → WebUI 面板展示(此前错误只隐没在 aFallback 计数里)。
1352
+ // 【v1.10.59 · 档位回退】reasoningEffort 是优化,不是前提。实测 opencode-go/deepseek-v4.1-flash
1353
+ // 根本没有 off 档,宿主直接 finish=error UNSUPPORTED_REASONING_EFFORT ⇒ 整条 [A] 判失败、
1354
+ // 无损回退,压缩收益归零。这里在被拒时**不传该字段再试一次**,让正文以模型默认(思考)模式参与。
1355
+ // 失败载荷若与档位无关(额度、网络、空输出)则不重试,语义与旧版逐字一致。
1356
+ const EFFORT_REJECTED_RE = /UNSUPPORTED_REASONING_EFFORT|reasoning\s*effort/i
1357
+
1358
+ // 辅助调用必须带会话身份:宿主自己的压缩/标题调用都传 sessionId
1359
+ // (dsh-compaction-basic/lib/index.js:319 `sessionId: agent.session.id, purpose:"compaction"`;
1360
+ // dsh-session-title-llm/lib/index.js:195 同形),适配器用它生成 provider 要求的会话头
1361
+ // (@ebony-vinyl/dsh-our-free-model/src/adapter.js:389/396:缺失时回落 'global' 且只警告一次)。
1362
+ // 本插件此前只传 purpose:'compaction' ⇒ opencode-go 上游直接 400 MissingSessionID ——
1363
+ // 推理档位回退之后撞上的就是这一层。
1364
+ function auxSessionId(sid) {
1365
+ const t = String(sid ?? '').trim()
1366
+ if (!t) return undefined
1367
+ return t.startsWith('session-') ? t : 'session-' + t
1368
+ }
1369
+
1370
+ async function llmText(ctx, route, system, user, maxTokens, sink, llmFixed, opts) {
1371
+ const eff = opts && opts.effort !== undefined ? String(opts.effort).trim().toLowerCase() : LOSSY_EFFORT
1372
+ const meta = (opts && opts.meta) || null
1373
+ const io = (opts && opts.io) || null
1374
+ // 档位探测的失败**不进 errs**:off 被拒 → 去掉档位重试成功,是一次成功的调用,不是错误。
1375
+ // 真机:多段折叠每段都先撞一次 UNSUPPORTED,面板于是长期挂着"压缩调用错误"而折叠其实成了。
1376
+ // 过程仍可审计:io.downgrades 计数 + meta.effortFallback + last-fold。
1377
+ const buf = []
1378
+ const sinkB = (m) => { if (m) buf.push(String(m)) }
1379
+ let r = await llmTextOnce(ctx, route, system, user, maxTokens, sinkB, llmFixed, opts, eff)
1380
+ if (r.ok) return r.text
1381
+ if (eff !== '' && r.effortRejected) {
1382
+ r = await llmTextOnce(ctx, route, system, user, maxTokens, sinkB, llmFixed, opts, '')
1383
+ if (meta) meta.effortFallback = eff
1384
+ if (r.ok) {
1385
+ if (io) io.downgrades = (io.downgrades ?? 0) + 1
1386
+ return r.text
1387
+ }
1388
+ }
1389
+ // 只有真正失败才把过程抛给调用方(errs → last-fold.condenseErr)
1390
+ for (const m of buf) sink?.(m)
1391
+ // 路由级不可用(上游下线 / 宿主没配这个 id)重试同一条没有意义:换路由才有产出。
1392
+ // 真机教训:折叠侧此前完全没有降级,模型一死整折就无损回退,而本机 ollama 明明在跑。
1393
+ if (io && Array.isArray(io.fallbackRoutes) && isRouteDead(r.error)) {
1394
+ for (const alt of io.fallbackRoutes) {
1395
+ if (alt.provider === route.provider && alt.model === route.model) continue
1396
+ sink?.(`route-dead(${route.provider}/${route.model}); trying ${alt.provider}/${alt.model}`)
1397
+ buf.length = 0
1398
+ let rr = await llmTextOnce(ctx, alt, system, user, maxTokens, sinkB, llmFixed, opts, eff)
1399
+ if (!rr.ok && rr.effortRejected && eff !== '') rr = await llmTextOnce(ctx, alt, system, user, maxTokens, sinkB, llmFixed, opts, '')
1400
+ if (rr.ok) {
1401
+ io.fallbackRoute = alt
1402
+ if (meta) meta.fallbackRoute = alt.provider + '/' + alt.model
1403
+ return rr.text
1404
+ }
1405
+ }
1406
+ }
1407
+ return null
1408
+ }
1409
+
1410
+ async function llmTextOnce(ctx, route, system, user, maxTokens, sink, llmFixed, opts, eff) {
1411
+ // 【v1.10.19 · P3】优先用调用方固化的句柄(折叠开始时取到的那一个),取不到才实时解析。
1412
+ const llm = llmFixed ?? llmOf(ctx)
1413
+ if (!llm) { sink?.('llm service unavailable'); return { ok: false, effortRejected: false, error: 'llm service unavailable' } }
1414
+ if (!route?.provider || !route?.model) { sink?.('condense route missing'); return { ok: false, effortRejected: false, error: 'condense route missing' } }
1415
+ // 【v1.10.32 · 计量口径】真实**发起**的调用次数(含随后失败/被拒的):记在 io.attempts 上。
1416
+ // 修的是"预约 ≠ 调用":applyCondense 里 `foldCalls += 1` 是预算预约,短行/回本闸会在
1417
+ // condenseText 内部**提前返回**、一次请求都没发,但 last-fold 的 `calls` 已经 +1 —— 于是
1418
+ // "本次折叠发起了几次付费调用"被虚高(实测:tooShort 行 calls=1 而 auxCalls=0)。
1419
+ if (opts && opts.io) opts.io.attempts = (opts.io.attempts ?? 0) + 1
1420
+ // 记下**实际发出去的** max_tokens:truncation 到底是不是被我们自己压出来的,只能靠这个数说话。
1421
+ if (opts && opts.io && Number.isFinite(maxTokens)) opts.io.mtSeen = Math.max(opts.io.mtSeen ?? 0, Math.floor(maxTokens))
1422
+ const meta = (opts && opts.meta) || null // 可选:回填 finish 详情,供调用方做截断抢救判定
1423
+ const messages = [await userMessage(user)]
1424
+ let text = ''
1425
+ let finish = null
1426
+ let usage = null
1427
+ const req = {
1428
+ provider: route.provider,
1429
+ model: route.model,
1430
+ messages,
1431
+ system,
1432
+ temperature: 0,
1433
+ maxTokens,
1434
+ purpose: 'compaction',
1435
+ }
1436
+ // 只在显式取值时下发 reasoningEffort;'' 表示"完全不传"(保留旧行为,便于对照实验/回滚)。
1437
+ if (LOSSY_EFFORTS.has(eff) && eff !== '') req.reasoningEffort = eff
1438
+ // 会话身份与推理档位无关,任何 provider 都该带;缺了它,需要会话路由头的上游会整条拒收。
1439
+ const auxSid = opts && opts.io ? auxSessionId(opts.io.sessionId) : undefined
1440
+ if (auxSid) req.sessionId = auxSid
1441
+ // 只要发生了一次真实调用,钱就花了 —— 因此**无论成功/失败/被拒绝**都要计量(幂等:只记一次)。
1442
+ let usageRecorded = false
1443
+ const recordUsage = () => {
1444
+ if (usageRecorded || !usage) return
1445
+ usageRecorded = true
1446
+ if (meta) {
1447
+ meta.inTokens = usage.inputTokens ?? 0
1448
+ meta.cacheReadTokens = usage.cacheReadTokens ?? 0
1449
+ meta.outTokens = usage.outputTokens ?? 0
1450
+ }
1451
+ const sink2 = opts && opts.usageSink
1452
+ if (sink2) {
1453
+ sink2.calls += 1
1454
+ sink2.in += usage.inputTokens ?? 0
1455
+ sink2.cacheRead += usage.cacheReadTokens ?? 0
1456
+ sink2.out += usage.outputTokens ?? 0
1457
+ }
1458
+ }
1459
+ try {
1460
+ for await (const chunk of llm.stream(req)) {
1461
+ if (!chunk) continue
1462
+ if (chunk.type === 'text-delta') text += chunk.text ?? ''
1463
+ // 【v1.10.23 · 经济性计量】过去只认 text-delta/finish,把 **usage 分块整个丢掉** ——
1464
+ // 于是辅助调用(浓缩/K)花了多少 token 在插件侧完全不可见:bench 的 billed 只统计会话里的
1465
+ // assistant 事件,也不含这次调用。结果是"压缩到底花了多少钱"只能靠估。
1466
+ // 现在把真实 usage 记进 opts.usageSink(由调用方聚合到 io.aux),并回填 meta 供日志核验。
1467
+ else if (chunk.type === 'usage') usage = chunk.usage ?? chunk
1468
+ else if (chunk.type === 'finish') finish = chunk.reason
1469
+ }
1470
+ } catch (err) {
1471
+ recordUsage()
1472
+ const msg = 'stream error: ' + String(err?.message ?? err)
1473
+ sink?.(msg)
1474
+ return { ok: false, effortRejected: EFFORT_REJECTED_RE.test(msg), error: msg }
1475
+ }
1476
+ recordUsage()
1477
+ if (!finish) { sink?.('no finish chunk'); return { ok: false, effortRejected: false, error: 'no finish chunk' } }
1478
+ const kind = String(finish.kind ?? '')
1479
+ if (meta) { meta.finishKind = kind; meta.truncated = kind !== 'stop' && /max.?tokens|length|truncat/i.test(kind) }
1480
+ // 【v1.10.20 · P2】截断抢救:仅当调用方显式开启 opts.salvage 且确为"额度用尽"型结束,
1481
+ // 才把**已生成的部分正文**交回调用方(由它做缩短幅度/占位符完整性两道闸)。
1482
+ // 其余非 stop(error/aborted/…)依旧一律失败 ⇒ 无损回退,语义不变。
1483
+ if (kind !== 'stop') {
1484
+ const salvageable = !!(opts && opts.salvage) && meta && meta.truncated && text.trim().length > 0
1485
+ // 【v1.10.21 · 诊断】过去这里只记 kind,把真正的失败载荷(failure.code/message)丢掉了 ——
1486
+ // 导致"K 通道 keysErr=12/12"到底是**调用失败**还是**返回了但没可用值**无法区分。
1487
+ // 现在把 payload 一并带出(截断到 160 字符)。分型正则仍只看 `finish=<kind>` 前缀,语义不变。
1488
+ const pay = finish.failure ? ' ' + String(finish.failure.code ?? '') + ':' + String(finish.failure.message ?? '').slice(0, 160) : ''
1489
+ const rejected = EFFORT_REJECTED_RE.test(String(finish.failure?.code ?? '') + ' ' + String(finish.failure?.message ?? ''))
1490
+ if (!salvageable) { sink?.('finish=' + kind + pay); return { ok: false, effortRejected: rejected, error: 'finish=' + kind + pay } }
1491
+ sink?.('salvage=' + kind)
1492
+ }
1493
+ if (!text.trim()) { sink?.('empty output'); return { ok: false, effortRejected: false, error: 'empty output' } } // P5:空输出过去是"静默回退",连原因都不留
1494
+ return { ok: true, text: text.trim() }
1495
+ }
1496
+
1497
+ // 串行链:多会话并发折叠时对模型调用排队,避免打爆本地/远端推理
1498
+ let _condChain = Promise.resolve()
1499
+ // v1.9:io.errs(可选数组)收集模型调用失败原因 → 上浮到 lastFold.condenseErr
1500
+ function pushErr(io, reason) {
1501
+ if (io && Array.isArray(io.errs) && reason) io.errs.push(String(reason))
1502
+ }
1503
+ // 【v1.10.19 · P1】每次调用的输出上限:以 io 给出的**上限**为天花板,按输入长度自适应。
1504
+ // 为什么自适应:压缩目标本身就是"比原文短得多",给一条 800 字的 [A] 开 2048 token 的额度纯属浪费
1505
+ // (模型会写满、延迟上去了、压缩率还变差);给一条 8000 字的 [A] 只开 700 又会截断。
1506
+ // 经验式:≈ 输入的 1/2 token 足够让模型把"压缩后正文"写完并正常 stop(压到 1/4 字符 ≈ 1/6 token,
1507
+ // 这里留 3 倍余量);下限 768(短输入也要写完一整段),上限取传入的天花板。
1508
+ export function effectiveMaxTokens(bodyChars, ceiling, opts = null) {
1509
+ const cap = Number.isFinite(ceiling) && ceiling > 0 ? Math.floor(ceiling) : LOSSY_MAX_TOKENS
1510
+ // 显式配置(control.condenseMaxTokens)就是预算,不再按输入长度二次收敛。
1511
+ // max_tokens 是**上限**而不是计费:写大不会多花钱,写小只会让"思考 + 正文"共享的额度
1512
+ // 更快撞顶(真机:一次 1/1 成功的折叠仍 salvage=max-tokens)。
1513
+ if (opts && opts.explicit) return cap
1514
+ const n = Number.isFinite(bodyChars) ? Math.max(0, Math.floor(bodyChars)) : 0
1515
+ // 先保底(短输入也要够写完一段),再受天花板约束 —— 顺序反了会让 control 显式调小失效
1516
+ return Math.min(cap, Math.max(768, Math.ceil(n / 2)))
1517
+ }
1518
+ // =====================================================================================
1519
+ // 【v1.10.23 · 经济性准入闸(payback gate)】——把"字节门槛"升级为"回本门槛"
1520
+ // =====================================================================================
1521
+ // 为什么必须加这道闸(实测标定见 tools/agg-cost.mjs,n=56 条带真实 usage 的探针记录):
1522
+ // 折一条 [A] 要花**一次付费调用**,而它省下的 token 在后续回合里是**缓存读**——只值 D 倍。
1523
+ // 于是产生一个致命不对称:**今天按 1.0 全额付费,换取明天每回合 1/30 的折扣**。
1524
+ //
1525
+ // 计费等价(billed-equivalent token):
1526
+ // 成本(一次性) = 正文 token [未缓存,1.0]
1527
+ // + 被缓存的提示词前缀 × D [实测中位 cacheRead=640 tok]
1528
+ // + 输出 token × pOut [输出与输入同价计,偏保守]
1529
+ // 收益(每后续回合)= (正文 token − 输出 token) × D
1530
+ // ⇒ 回本回合 m*(S) = cost(S) / benefitPerTurn(S)
1531
+ //
1532
+ // 由此得到一个**渐进下界**:m* → (1+pOut·r) / ((1−r)·D)。只要 D<1,m* 就永远大于 1——
1533
+ // 也就是说"折了就赚"是不成立的,任何一次折叠都必须用**后续回合数**来摊还。
1534
+ // 当前 minBytes=240B 对应的 m* 远大于这些会话的实际剩余回合 ⇒ 折了注定回不了本(旧的纯字节闸的盲区)。
1535
+ // 故缺省要求"预计 8 回合内回本",否则**不发起调用**(不花钱),记 `payback` 并走无损折叠。
1536
+ //
1537
+ // 旋钮:
1538
+ // DSH_CONTEXTINJECTOR_LOSSY_PAYBACK=<n> 允许的回本视界(回合);**缺省 0 = 关闭该闸**(行为不变)
1539
+ // DSH_CONTEXTINJECTOR_CACHE_DISCOUNT=<f> 缓存读折扣 D(默认 1/30,按当前价目表;旧 0.25 为经验值)
1540
+ // DSH_CONTEXTINJECTOR_CACHED_PREFIX_TOKENS=<n> 辅助调用被缓存的提示词前缀(默认 640,实测中位)
1541
+ //
1542
+ // ★为什么缺省**关**:下面那组"字符→token / 输出比"常数是从探针语料反推的,而该语料在 token 拆分上
1543
+ // 自相矛盾(同一批记录里输入约 5 字符/token、输出约 2 字符/token),**不足以支撑一个会改变线上
1544
+ // 行为的硬闸**——若按它设 horizon=8,则任何规模都通不过(估计器自身的渐进下界就是 16 回合),
1545
+ // 等于**悄悄把有损档整体停用**。那是产品决策,不该藏在一个估错的常数里。
1546
+ // 故本版只做两件事:①把辅助调用的**真实 usage 计量落实**(此前 usage 分块被整块丢弃);
1547
+ // ②把"预估回本"与"实测回本"并列落盘。等实测数据回来、模型被真值校准后,再把闸默认打开。
1548
+ const PB_HORIZON = (() => {
1549
+ const raw = process.env.DSH_CONTEXTINJECTOR_LOSSY_PAYBACK
1550
+ if (raw === undefined || raw === '') return 0
1551
+ const n = Number(raw)
1552
+ return Number.isFinite(n) && n >= 0 ? n : 0
1553
+ })()
1554
+ const PB_CACHE_DISCOUNT = (() => {
1555
+ const n = Number(process.env.DSH_CONTEXTINJECTOR_CACHE_DISCOUNT)
1556
+ // 缓存读价按**当前价目表**取 1/30(未命中输入价的 0.0333…);旧值 0.25 只是 bench 的
1557
+ // BENCH_D 经验值,从未按价目表独立取数,会把回本回合数低估约 7.5 倍。换 provider 时用 env 覆盖。
1558
+ return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 1 / 30
1559
+ })()
1560
+ const PB_CACHED_PREFIX_TOKENS = (() => {
1561
+ const n = Number(process.env.DSH_CONTEXTINJECTOR_CACHED_PREFIX_TOKENS)
1562
+ return Number.isFinite(n) && n > 0 ? n : 640
1563
+ })()
1564
+ // 字符→token 折算(中英混排经验值)与输出/输入 token 比。两者都取**偏保守**(宁可高估成本),
1565
+ // 因为闸的失效方向不对称:误折一次 = 实打实花钱;误跳一次 = 只是没省到。
1566
+ const PB_CHARS_PER_TOKEN = 2.5
1567
+ const PB_OUT_TOKEN_RATIO = 0.6
1568
+ // 预估一次折叠的成本/收益/回本回合(单位统一为计费等价 token)
1569
+ function paybackOf(bodyChars) {
1570
+ const bodyTok = Math.max(1, (Number(bodyChars) || 0) / PB_CHARS_PER_TOKEN)
1571
+ const outTok = bodyTok * PB_OUT_TOKEN_RATIO
1572
+ const cost = bodyTok + PB_CACHED_PREFIX_TOKENS * PB_CACHE_DISCOUNT + outTok
1573
+ const benefitPerTurn = Math.max(0, bodyTok - outTok) * PB_CACHE_DISCOUNT
1574
+ return { bodyTok, outTok, cost, benefitPerTurn, turns: benefitPerTurn > 0 ? cost / benefitPerTurn : Infinity }
1575
+ }
1576
+ // 实测回本回合(用真实 usage 反算;无 usage 时返回 null)。用于把"预估"与"实付"对照写进 last-fold。
1577
+ function paybackMeasured(aux, bodyChars, outChars) {
1578
+ if (!aux || !aux.calls) return null
1579
+ const cost = aux.in + aux.cacheRead * PB_CACHE_DISCOUNT + aux.out
1580
+ const savedTok = Math.max(0, (Number(bodyChars) || 0) - (Number(outChars) || 0)) / PB_CHARS_PER_TOKEN
1581
+ const benefitPerTurn = savedTok * PB_CACHE_DISCOUNT
1582
+ return { cost, benefitPerTurn, turns: benefitPerTurn > 0 ? cost / benefitPerTurn : Infinity }
1583
+ }
1584
+ // 【v1.10.19 · P5】回退原因分型计数:过去只统计"总共回退了多少条",不区分原因(显式失败/空输出/
1585
+ // 没变短/预算到顶/值保真闸拒收),导致"大量静默回退"无从排查(用户实证报告 P5)。现分五类计数,
1586
+ // 随 stat 一起写进 last-fold(`fallbackReasons`)与折叠日志。
1587
+ function fbBump(io, kind) {
1588
+ try { if (io && io.fb) io.fb[kind] = (io.fb[kind] ?? 0) + 1 } catch { /* ignore */ }
1589
+ }
1590
+ // 【v1.10.21 · 诊断】K 通道失败归因:`keysErr` 一个计数混了两种完全不同的病因 ——
1591
+ // (a) 调用本身失败(无服务/路由/凭证/超时) (b) 调用成功但 dedupeKeys 认为"没有一个合格值"。
1592
+ // 两者修复方向相反(修路由 vs 修提示词/闸门),故必须分开记,否则会一直误诊。
1593
+ function kd(io, kind, extra) {
1594
+ try { if (io && Array.isArray(io.kd)) io.kd.push(extra == null ? kind : kind + ':' + extra) } catch { /* ignore */ }
1595
+ }
1596
+ // v1.8:优先 DSH 已注册模型(io.route),其次显式配置的 Ollama 直连,都不行 → null(回退无损)
1597
+ // 【v1.10.19 · P3 修复】`io.llm` = 折叠开始时**固化**的 llm 服务句柄(见 foldBody)。为什么必须固化:
1598
+ // 后台折叠(turn/end 触发的异步任务)跑到一半时,请求作用域的 ctx 可能已被回收 ⇒
1599
+ // 每次调用都重新 `llmOf(ctx)` 会越跑越取不到服务(实测:single 每条 [A] 1 次调用 1/12 失败,
1600
+ // double 每条 [A] 2 次调用 **12/12** 失败,且调用越慢失败率越高 —— 典型的"句柄在折叠进行中失效")。
1601
+ // 固化后:优先用句柄;句柄为空才回退到实时解析(行为不变,只是不再随作用域回收而丢失)。
1602
+ async function condenseText(text, io) {
1603
+ const body = String(text).trim()
1604
+ if (body.length < minBytesOf(io)) { fbBump(io, 'tooShort'); return null }
1605
+ // 【v1.10.23 · 经济性准入】字节门槛之上再加一道**回本**闸:预计回本回合 m* 超过允许视界就
1606
+ // **不发起调用**(一分钱不花),记 `payback` 走无损折叠。这是**外生**判定(花钱之前,用预估成本);
1607
+ // 实际花费由 paybackMeasured 事后核验并写进 last-fold,便于把"预估 vs 实付"对上。
1608
+ const horizon = Number.isFinite(io?.paybackHorizon) ? io.paybackHorizon : PB_HORIZON
1609
+ const pbEst = paybackOf(body.length)
1610
+ if (io) io.pbEst = pbEst
1611
+ if (horizon > 0 && pbEst.turns > horizon) {
1612
+ fbBump(io, 'payback')
1613
+ if (io) { io.pbSkip = (io.pbSkip ?? 0) + 1; io.pbHorizon = horizon }
1614
+ return null
1615
+ }
1616
+ const run = _condChain.then(async () => {
1617
+ const mx = effectiveMaxTokens(body.length, io && io.maxTokens, { explicit: !!(io && io.maxTokensExplicit) })
1618
+ const condenseSys = (io && io.condenseSys) || CONDENSE_SYS // #10 可自定义提示词
1619
+ const meta = {}
1620
+ const a0 = io?.aux ? { ...io.aux } : null
1621
+ const viaDsh = await llmText(io?.ctx, io?.route, condenseSys, '原文:\n' + body + '\n\n——\n浓缩:', mx, (r) => pushErr(io, r), io?.llm, { effort: effortOf(io), salvage: true, meta, usageSink: io?.aux, io })
1622
+ // 事后核验:本次调用真实花了多少(含失败——失败也付费),以及据此算出的实测回本回合
1623
+ if (io && a0 && io.aux && io.aux.calls > a0.calls) {
1624
+ const d = { calls: io.aux.calls - a0.calls, in: io.aux.in - a0.in, cacheRead: io.aux.cacheRead - a0.cacheRead, out: io.aux.out - a0.out }
1625
+ io.pbLast = { ...d, ...paybackMeasured(d, body.length, viaDsh ? viaDsh.length : 0), horizon, split: '1x' }
1626
+ }
1627
+ if (viaDsh) {
1628
+ const c = viaDsh.trim()
1629
+ // 回退判据不变:空输出 / 未缩短(弱模型偶尔复读原文)→ 视为浓缩失败,走无损
1630
+ if (!c) fbBump(io, 'empty')
1631
+ else if (c.length >= body.length) fbBump(io, 'noShrink')
1632
+ else if (meta.truncated) {
1633
+ // 【v1.10.20 · P2】截断抢救的两道闸:缩短达标 + 无半截占位符。任一不过 ⇒ 记 truncated 并回退无损。
1634
+ if (c.length > body.length * SALVAGE_MAX_RATIO || DANGLING_PLACEHOLDER_RE.test(c)) fbBump(io, 'truncated')
1635
+ else { fbBump(io, 'salvaged'); return c }
1636
+ } else return c
1637
+ } else {
1638
+ // P5 分型:把"为什么没拿到浓缩结果"记清楚,别都堆成一句 llmErr。
1639
+ // · finish=max-tokens ⇒ `truncated`(P1 的症状:额度不够、被截断)
1640
+ // · empty output ⇒ `empty`(模型正常 stop 但没吐字)
1641
+ // · 其余(无服务/路由/流错误/超时)⇒ `llmErr`
1642
+ const last = String((io?.errs ?? [])[(io?.errs ?? []).length - 1] ?? '')
1643
+ fbBump(io, /max-tokens/.test(last) ? 'truncated' : (/empty output/.test(last) ? 'empty' : 'llmErr'))
1644
+ }
1645
+ if (!LOSSY_OLLAMA) return null // 未显式配置直连通道 → 到此为止,无损回退
1646
+ const r = await fetch(LOSSY_OLLAMA, {
1647
+ method: 'POST',
1648
+ headers: { 'Content-Type': 'application/json' },
1649
+ body: JSON.stringify({
1650
+ model: LOSSY_MODEL,
1651
+ messages: [{ role: 'system', content: ((io && io.condenseSys) || CONDENSE_SYS) }, { role: 'user', content: '原文:\n' + body + '\n\n——\n浓缩:' }],
1652
+ stream: false, temperature: 0,
1653
+ options: { num_ctx: 16384 }, max_tokens: mx,
1654
+ }),
1655
+ signal: AbortSignal.timeout(LOSSY_TIMEOUT_MS),
1656
+ })
1657
+ if (!r.ok) return null
1658
+ const j = await r.json().catch(() => null)
1659
+ if (!j) return null
1660
+ const c = (j.choices?.[0]?.message?.content ?? '').trim()
1661
+ if (!c || c.length >= body.length) return null
1662
+ return c
1663
+ }).catch(() => null) // 模型不可用/超时 → 回退无损,折叠不中断
1664
+ _condChain = run.then(() => {}, () => {})
1665
+ return run
1666
+ }
1667
+ // double 档第二轮:载荷直抄清单(null = 无载荷/失败,不并入)
1668
+ // 【v1.10.22 · 并发双通道】opts.parallel=true ⇒ **不排队、立即发起**。
1669
+ // 为什么这是关键:`extractKeys` 消费的是**原文**(不是浓缩产物),与浓缩无数据依赖;
1670
+ // 而"折叠内第 2 次调用必失败(NO_ADAPTER)"的根因是两次调用之间作用域被回收。
1671
+ // 把 K 与同一条消息的第 1 次浓缩调用**在同一存活窗口内并发发起**,即可结构性绕开该约束。
1672
+ async function extractKeys(text, io, opts) {
1673
+ const body = String(text).trim()
1674
+ if (body.length < minBytesOf(io)) return null
1675
+ const work = async () => {
1676
+ const mx = effectiveMaxTokens(body.length, io && io.maxTokens, { explicit: !!(io && io.maxTokensExplicit) })
1677
+ const keysSys = (io && io.keysSys) || KEYS_SYS // #10 可自定义载荷提示词
1678
+ // 【v1.10.20 · P0】K 通道同样关掉思维链(它和浓缩是同一类"只删不改/逐字摘抄"任务,推理无收益)。
1679
+ // 【P2】但 K 通道**不开**截断抢救:截断会切在某个值中间,产出一个"半个值",
1680
+ // 而本插件的既定原则是"错误载荷比丢失更糟" ⇒ K 通道截断仍整条判失败。
1681
+ const viaDsh = await llmText(io?.ctx, io?.route, keysSys, '原文:\n' + body + '\n\n——\n载荷清单,一行一个:', mx, (r) => pushErr(io, r), io?.llm, { effort: effortOf(io), usageSink: io?.aux, io })
1682
+ if (viaDsh) {
1683
+ const lines = dedupeKeys(viaDsh)
1684
+ if (lines) { kd(io, 'ok', viaDsh.length + '->' + lines.split('\n').length); return lines }
1685
+ kd(io, 'noValues', viaDsh.length) // 调用成功、但闸门判定无合格值(≠ 调用失败)
1686
+ } else {
1687
+ kd(io, 'callFailed', String((io?.errs ?? [])[(io?.errs ?? []).length - 1] ?? '').slice(0, 120))
1688
+ }
1689
+ // 【v1.10.19 · P2/P5】K 通道失败单独计数:真机上 `keys` 恒为 0 却看不出原因(用户实证报告 P2)——
1690
+ // 现在它会明确记成 `keysErr`,与"整条 [A] 回退"分开,便于定位(P3 修好后本计数应回到 0)。
1691
+ fbBump(io, 'keysErr')
1692
+ if (!LOSSY_OLLAMA) return null
1693
+ const r = await fetch(LOSSY_OLLAMA, {
1694
+ method: 'POST',
1695
+ headers: { 'Content-Type': 'application/json' },
1696
+ body: JSON.stringify({
1697
+ model: LOSSY_MODEL,
1698
+ messages: [{ role: 'system', content: ((io && io.keysSys) || KEYS_SYS) }, { role: 'user', content: '原文:\n' + body + '\n\n——\n载荷清单,一行一个:' }],
1699
+ stream: false, temperature: 0,
1700
+ options: { num_ctx: 16384 }, max_tokens: mx,
1701
+ }),
1702
+ signal: AbortSignal.timeout(LOSSY_TIMEOUT_MS),
1703
+ })
1704
+ if (!r.ok) return null
1705
+ const j = await r.json().catch(() => null)
1706
+ if (!j) return null
1707
+ const lines = dedupeKeys(j.choices?.[0]?.message?.content ?? '')
1708
+ return lines || null
1709
+ }
1710
+ // parallel:立即发起,**不**占用 _condChain(否则会排在浓缩之后,等于没并发);也不改写链尾。
1711
+ const par = !!(opts && opts.parallel)
1712
+ const run = (par ? Promise.resolve().then(work) : _condChain.then(work)).catch(() => null)
1713
+ if (!par) _condChain = run.then(() => {}, () => {})
1714
+ return run
1715
+ }
1716
+ // 值保真闸(e2e double 实测教训):弱 4B 会把 branchID=e7b40c73 截断成 e7b4073、并幻觉 e7b40c77。
1717
+ // 错误载荷比丢失更糟——规则级校验,不依赖模型自觉:浓缩/直抄中的"承重 token"若在原文不存在,
1718
+ // 判定为改值/幻觉 → 该 [A] 整体回退无损(宁不压缩,不可传错值)。
1719
+ const PAYLOAD_CHECK = [
1720
+ /[0-9a-f]{6,}/gi, // uuid 段 / 长 hex / 8 位短 id(e7b4073、e7b40c73 均命中)
1721
+ /\b[A-Za-z0-9_.-]+\.(?:txt|json|md|log|yml|yaml|jsonl|zstd|mjs|js|ts|csv|png|jpg|zstd)\b/gi, // 文件名
1722
+ /\b[A-Z][A-Z0-9_]{3,}\b/g, // 大写状态/标记词(CLEANUP_OK)
1723
+ /https?:\/\/[^\s]+/gi, // 链接
1724
+ /\b\d{3,}(?:\.\d+)?\b/g, // 端口/大数/比率(8787、0.31;2 位小计数不核对,降误报)
1725
+ ]
1726
+ // 整词出现校验:token 必须在原文以"完整值"出现(两侧不是可延续字符)——
1727
+ // 否则 token 可能是被截断的前缀(port=878 是 port=8787 的前缀),判假。
1728
+ function occursWhole(source, token) {
1729
+ const src = String(source ?? '').toLowerCase()
1730
+ const k = String(token ?? '').toLowerCase()
1731
+ if (!k) return false
1732
+ let i = 0
1733
+ while ((i = src.indexOf(k, i)) !== -1) {
1734
+ const before = src[i - 1] || ''
1735
+ const after = src[i + k.length] || ''
1736
+ if (!/[a-z0-9_.@]/.test(before) && !/[a-z0-9_.@]/.test(after)) return true
1737
+ i += k.length
1738
+ }
1739
+ return false
1740
+ }
1741
+ function verifyAgainstSource(condensed, source) {
1742
+ for (const re of PAYLOAD_CHECK) {
1743
+ for (const m of String(condensed ?? '').match(re) || []) {
1744
+ if (!occursWhole(source, m)) return false
1745
+ }
1746
+ }
1747
+ return true
1748
+ }
1749
+ // 处理增量行:仅 [A] 行浓缩;失败回退原文。
1750
+ // single = 语义浓缩;double = 语义浓缩 + 载荷直抄(去重并入 [A] 行,不新增行类,保五类行纪律)
1751
+ // 弱模型只接触 guard 化文本(值→占位符),浓缩后规则回填 → 值不可能被改/截断/幻觉。
1752
+ const A_LINE_RE = /^\[A\] /
1753
+ async function applyCondense(rows, mode = 'single', io = null) {
1754
+ const stat = { a: 0, folded: 0, fallback: 0, keys: 0, rawBytes: 0, outBytes: 0 }
1755
+ // 【v1.10.32】off 档在函数**内部**也认:此前只靠调用方 `if (mode !== 'off')` 防护 —— 任何新调用点
1756
+ // 漏掉那个判断,off 档就会照样发起付费浓缩(实测:直接 applyCondense(rows,'off',io) 真的会调模型、
1757
+ // 真花钱、真改行)。契约下沉到函数内部,比靠每个调用点自觉可靠。
1758
+ if (mode === 'off') return { rows: Array.isArray(rows) ? [...rows] : [], stat: { ...stat, calls: 0, callsReserved: 0, ms: 0, budgetHit: false } }
1759
+ // 真实发起次数基线(见 llmText 的 io.attempts):用于把 "calls" 从"预算预约数"改成"真发起的请求数"。
1760
+ const attemptsBase = Number.isFinite(io && io.attempts) ? io.attempts : 0
1761
+ // 【默认与 RCS-0.1.0 对齐 · 2026-09-10】值保真闸 `verifyAgainstSource` 默认**关闭**:
1762
+ // RCS 实测三类误杀(`README.md → 伪 token README`;URL 贪婪吞中文标点;`0.111.0 → 伪 token 111.0`)
1763
+ // 会让整条 [A] 白退化成无损回退、白花一次模型调用。
1764
+ // 本仓库保留这个安全网,但改为**按 control 显式开启**:`control.payloadCheck === true` 时恢复
1765
+ // 旧严格语义(弱模型改值 ⇒ 整段回退)。guardText/restoreText 占位符化与 occursWhole 关键值过滤
1766
+ // 在任何档位下都照旧生效,故"值被改坏"仍有第一道防线。
1767
+ const pc = !!(io && io.payloadCheck === true)
1768
+ // 【v1.10.3 硬预算】一次折叠必须有上限:本地小模型下 double 档每条 [A] 要两次调用、长行还会分块,
1769
+ // 实测出现过单次折叠 243 秒把用户的下一轮请求整个堵住。这里给浓缩阶段两道硬闸:
1770
+ // · condenseBudgetMs:墙钟预算(默认 15000ms),超时后**不再发起新调用**,剩余 [A] 走无损回退;
1771
+ // · condenseMaxCallsPerFold:调用次数上限(默认 12),防止单次调用极慢时仍然堆叠。
1772
+ // 两者都由 control 热读,折叠照常提交(转录保持单调),只是没来得及浓缩的行原样保留。
1773
+ const budgetMs = Number.isFinite(io && io.budgetMs) && io.budgetMs >= 0 ? io.budgetMs : 15000
1774
+ const maxCalls = Number.isFinite(io && io.maxCalls) && io.maxCalls >= 0 ? io.maxCalls : 12
1775
+ const t0 = Date.now()
1776
+ let foldCalls = 0
1777
+ let budgetHit = false
1778
+ const out = []
1779
+ for (const line of rows) {
1780
+ if (!A_LINE_RE.test(line)) { out.push(line); continue }
1781
+ stat.a++
1782
+ const body = line.slice(4)
1783
+ // 硬预算:超时或超次数 ⇒ 本行不再送模型,按无损路径保留原文(与浓缩失败同一出口)
1784
+ if (foldCalls >= maxCalls || (budgetMs > 0 && Date.now() - t0 > budgetMs)) {
1785
+ budgetHit = true
1786
+ fbBump(io, foldCalls >= maxCalls ? 'maxCalls' : 'budgetMs')
1787
+ out.push(line); stat.fallback++; stat.outBytes += body.length; continue
1788
+ }
1789
+ stat.rawBytes += body.length
1790
+ const g = guardText(body) // guard:uuid/hex/路径 → 占位符,不入模型上下文
1791
+ // 【v1.10.22 · 并发双通道(double 重设计)】K 通道消费的是**原文** g.text,与浓缩产物无数据依赖,
1792
+ // 故在此处(本行第一次调用发起之前)就把 K 调用**并发发出**:两者落在同一个作用域存活窗口内。
1793
+ // 这直接针对实测根因——串行时 K 总是"折叠内第 2 次调用",而第 2 次调用 12/12 以
1794
+ // `NO_ADAPTER: no adapter registered` 失败(两次调用之间作用域已回收)。
1795
+ // 代价:放弃 _condChain 的全局排队(K 不再等浓缩),换来 K 通道可用。
1796
+ let pKeys = null
1797
+ if (mode === 'double') {
1798
+ // 只在**本行确实会发起调用**时才占用名额:minBytes 未过 ⇒ extractKeys 内部会立即返回 null,
1799
+ // 若仍 `foldCalls += 1`,就会让"免付费的短行"白吃 maxCalls 预算并虚高 `calls` 统计。
1800
+ if (g.text.length < minBytesOf(io)) { /* 短行:K 与浓缩都不会调用,不记账 */ }
1801
+ // 预留 2 个名额(本条浓缩 + 本条 K),保证并发之后 `calls` 仍不会越过 maxCalls 硬闸。
1802
+ else if (foldCalls + 2 > maxCalls) fbBump(io, 'keysSkippedBudget')
1803
+ else { foldCalls += 1; pKeys = extractKeys(g.text, io, { parallel: true }) }
1804
+ }
1805
+ // #8 分块:单块输入超预算时按语义断点切块、逐块浓缩再拼接(chunkForCompressor,纯规则)。
1806
+ // 预算 maxChars ≈ io.maxTokens×4 字符(token→字符粗略系数;守卫只防"一段塞爆窗口",非精确)。
1807
+ const maxChars = Math.max(512, (io && io.maxTokens ? io.maxTokens : LOSSY_MAX_TOKENS) * 4)
1808
+ let text = null
1809
+ if (g.text.length > maxChars) {
1810
+ const chunks = chunkForCompressor(g.text, maxChars, { allowHardCut: true })
1811
+ const parts = []
1812
+ let ok = true
1813
+ for (const ch of chunks) {
1814
+ foldCalls += 1
1815
+ const cRaw = await condenseText(ch, io)
1816
+ const c = cRaw ? restoreText(cRaw, g.slots) : null
1817
+ if (!c || (pc && !verifyAgainstSource(c, body))) { ok = false; if (c && pc) fbBump(io, 'payloadGate'); break }
1818
+ parts.push(c)
1819
+ }
1820
+ if (ok && parts.length) text = parts.join('\n')
1821
+ } else {
1822
+ foldCalls += 1
1823
+ const acRaw = await condenseText(g.text, io)
1824
+ const ac = acRaw ? restoreText(acRaw, g.slots) : null
1825
+ if (ac && (!pc || verifyAgainstSource(ac, body))) text = ac
1826
+ else if (ac && pc) fbBump(io, 'payloadGate')
1827
+ }
1828
+ if (text == null) { out.push(line); stat.fallback++; stat.outBytes += body.length; continue } // 浓缩失败/非规则值被改 → 无损回退
1829
+ stat.folded++
1830
+ // 【v1.10.22】K 调用已在上面并发发出(pKeys);此处只做**等待 + 收编**,不再发起新调用。
1831
+ // 旧实现(串行):`if (foldCalls >= maxCalls) skip else { foldCalls+=1; await extractKeys(...) }`
1832
+ // —— 那正是"第 2 次调用"的成因。次数与预算记账已在发起处完成,语义不变。
1833
+ if (mode === 'double' && pKeys) {
1834
+ const keys = await pKeys
1835
+ if (keys) {
1836
+ // 模型若抄占位符行(违背 prompt)整行弃;直抄值必须以整词出现在原文(剔幻觉/截断/前缀)
1837
+ const ks = keys.split('\n').map((x) => x.trim()).filter(Boolean)
1838
+ .filter((k) => !/⟦[A-Z]+\d+⟧/.test(k))
1839
+ .filter((k) => occursWhole(body, k))
1840
+ .filter((k) => !text.includes(k))
1841
+ if (ks.length) { text = text + ' [关键值] ' + ks.join(';'); stat.keys += ks.length }
1842
+ }
1843
+ }
1844
+ out.push('[A] ' + text)
1845
+ stat.outBytes += text.length
1846
+ }
1847
+ // 【v1.10.32】`calls` = **真实发起的调用次数**(llmText 里计数,含随后失败的);预算预约数另存
1848
+ // callsReserved 备查。修的是"预约≠调用":短行/回本闸会在 condenseText 内部提前返回、一次请求
1849
+ // 都没发,旧口径却已经 +1(实测 tooShort 行 calls=1 而 auxCalls=0)。
1850
+ const attempts = (Number.isFinite(io && io.attempts) ? io.attempts : 0) - attemptsBase
1851
+ stat.calls = attempts
1852
+ if (attempts !== foldCalls) stat.callsReserved = foldCalls
1853
+ stat.ms = Date.now() - t0; stat.budgetHit = budgetHit
1854
+ // 【v1.10.19 · P5】回退原因分型(只统计非零项,避免污染 last-fold)
1855
+ // 【v1.10.20 · P2】`salvaged` 单列进 stat:它是**成功**(换了条路拿到可用的浓缩结果),
1856
+ // 不是回退,混进 fallbackReasons 会让人误读成"又回退了几条"。
1857
+ if (io && io.fb) {
1858
+ const fb = {}
1859
+ for (const [k, v] of Object.entries(io.fb)) if (v > 0 && k !== 'salvaged') fb[k] = v
1860
+ if (Object.keys(fb).length) stat.fallbackReasons = fb
1861
+ if (io.fb.salvaged > 0) stat.salvaged = io.fb.salvaged
1862
+ }
1863
+ // 生效推理档位随 stat 一起上浮:persistLastFold 的两处字面量位于声明 `ioc` 的那个块**之外**,
1864
+ // 在那里直接引用 ioc 会抛 `ioc is not defined` 并让**整次折叠**失败(已踩过一次)。
1865
+ // 挂在 stat 上既保证作用域正确,也保证"写进日志的档位"与"实际下发的档位"同源。
1866
+ stat.condenseReasoningEffort = effortOf(io)
1867
+ // 【v1.10.21】K 通道归因随 stat 上浮(同 condenseReasoningEffort 的理由:persistLastFold 在 ioc 作用域之外)
1868
+ if (io && Array.isArray(io.kd) && io.kd.length) stat.keysDiag = io.kd.slice(0, 12)
1869
+ // 【v1.10.23 · 经济性计量】把辅助调用**真实用量**与**回本回合**上浮到 last-fold。
1870
+ // 此前"压缩花了多少钱"在插件侧完全不可见(usage 分块被丢掉、bench 的 billed 也只统计会话内
1871
+ // assistant 事件),只能靠估;现在预估(paybackEst)与实测(paybackMeasured)**并列落盘**,
1872
+ // 便于验证估值模型本身准不准。
1873
+ if (io && io.aux && io.aux.calls) {
1874
+ stat.auxCalls = io.aux.calls
1875
+ stat.auxTokens = { in: io.aux.in, cacheRead: io.aux.cacheRead, out: io.aux.out }
1876
+ stat.auxBilled = Math.round(io.aux.in + io.aux.cacheRead * PB_CACHE_DISCOUNT + io.aux.out)
1877
+ }
1878
+ if (io && Number.isFinite(io.paybackHorizon)) stat.paybackHorizon = io.paybackHorizon
1879
+ if (io && io.pbSkip) stat.paybackSkipped = io.pbSkip
1880
+ if (io && io.pbEst) stat.paybackEst = Math.round(io.pbEst.turns * 10) / 10
1881
+ if (io && io.pbLast && Number.isFinite(io.pbLast.turns)) stat.paybackMeasured = Math.round(io.pbLast.turns * 10) / 10
1882
+ return { rows: out, stat }
1883
+ }
1884
+ // raw 权威存证:被遮蔽节点(本次 fold 的增量段)原文全量追加落盘,供回放/审计/有损后的无损恢复
1885
+ // 【v1.10.32】空串与纯空白必须回退成 'unknown':旧实现 String(s ?? 'unknown') 对 '' 仍得 '',
1886
+ // 于是 fold-index / last-fold / raw 的文件名变成 `.jsonl` / `.json` / `.jsonl`——同一目录下
1887
+ // 所有「sid 为空」的会话共用同名文件 ⇒ 账本互相混写。
1888
+ const sanitizeSid = (s) => String(s ?? '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120) || 'unknown'
1889
+ function rawAppend(sid, entries) {
1890
+ try {
1891
+ if (!entries || entries.length === 0) return
1892
+ // 【v1.10.25】只存**原料**:单段模式下"上一版转录节点"的事件 seq 落在日志尾部 ⇒ 必然落在本次增量里
1893
+ // (见增量集合处的注释),于是它的**全文(数十 KB)每次折叠都被当成"被遮蔽原文"再存一遍**。
1894
+ // 实测(付费跑批 A01-off-1):raw 存证 2,350,121B 里 1,908,405B 是**我们自己的转录**(62 行/63 折),
1895
+ // 真正的原料只有 223,038B ⇒ 存证体积 ~10× 虚高,且任何"把 raw 当原文来统计"的分析都会
1896
+ // 把转录重复计入 62 次(这与"重复注入 29 次"的误判同源)。转录是 raw 的**派生物**,不该回灌自己。
1897
+ const material = entries.filter((e) => !(e?.msg && isOwnTranscript(e.msg)))
1898
+ if (material.length === 0) return
1899
+ const fp = join(RAW_DIR, sanitizeSid(sid) + '.jsonl')
1900
+ const body = material.map((e) => JSON.stringify({ t: Date.now(), seq: e.seq, msg: e.msg })).join('\n')
1901
+ appendFileSync(fp, body + '\n', 'utf8')
1902
+ } catch (err) {
1903
+ console.error('[contextinjector] raw append failed:', err?.message ?? err)
1904
+ }
1905
+ }
1906
+
1907
+ // ================= raw 回取(折叠的可逆面) =================
1908
+ // [T] 只保留有界摘要;被遮蔽的原文全量在 raw/<sid>.jsonl。**够大的**结果(≥ rawPtrMinBytes,
1909
+ // 缺省 1024)才挂 raw=<seq> 供按需展开;小结果不挂指针,把有界预览内联在同一行 [T] 里
1910
+ // (回取一次工具往返的成本高于收益 —— 见 rawPtrMinBytesOf 与 v1.10.67 说明)。
1911
+ // 路径用 sanitizeSid 清洗,只读、不写库。
1912
+ function rawMessageText(m) {
1913
+ const parts = []
1914
+ for (const b of blocksOf(m)) {
1915
+ if (b?.type === 'text') parts.push(b.text ?? '')
1916
+ else if (b?.type === 'tool-result') parts.push(blockTextOf(b.content))
1917
+ else if (b?.type === 'tool-call') parts.push(`[tool-call ${b.name ?? '?'}] ${b.arguments ?? ''}`)
1918
+ }
1919
+ if (!parts.length) parts.push(blockTextOf(m?.content))
1920
+ return parts.filter(Boolean).join('\n')
1921
+ }
1922
+
1923
+ export function readRawEntry(rawDir, sid, seq, maxChars = 4000) {
1924
+ const n = Number(seq)
1925
+ if (!Number.isFinite(n)) return { ok: false, error: 'bad-seq' }
1926
+ const s = sanitizeSid(String(sid ?? '').replace(/^session-?/i, ''))
1927
+ const candidates = [join(rawDir, 'session-' + s + '.jsonl'), join(rawDir, s + '.jsonl')]
1928
+ const file = candidates.find((f) => existsSync(f))
1929
+ if (!file) return { ok: false, error: 'no-raw-file', tried: candidates }
1930
+ let rec = null
1931
+ try {
1932
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
1933
+ if (!line.trim()) continue
1934
+ let row = null
1935
+ try { row = JSON.parse(line) } catch { continue }
1936
+ if (Number(row?.seq) === n) { rec = row; break }
1937
+ }
1938
+ } catch (err) {
1939
+ return { ok: false, error: 'read-failed', detail: String(err?.message ?? err) }
1940
+ }
1941
+ // 中性状态,不是失败:该 seq 可能只是还没 flush 进 raw(折叠落盘是异步的),
1942
+ // 或在 raw 覆盖范围之外(当前轮/未折叠的步骤)。行内记 PENDING,稍后重试即可。
1943
+ if (!rec) return { ok: false, error: 'seq-not-in-raw', pending: true, file, hint: '尚未落盘或不在 raw 范围内;稍后重试(本轮回合结束后再展开)' }
1944
+ const text = rawMessageText(rec.msg)
1945
+ const capN = Math.max(200, Math.min(20000, Number(maxChars) || 4000))
1946
+ return {
1947
+ ok: true, sid: s, seq: n, file,
1948
+ bytes: text.length, truncated: text.length > capN,
1949
+ text: text.length > capN ? text.slice(0, capN) : text,
1950
+ }
1951
+ }
1952
+
1953
+ function sidFromExec(exec) {
1954
+ const s = exec?.agent?.session ?? exec?.session ?? exec?.agent ?? null
1955
+ return s?.id ?? s?.sessionId ?? s?.goalId ?? s?.name ?? ''
1956
+ }
1957
+
1958
+ const SELF_COMPACTION_IDS = new Set()
1959
+ export function isSelfCompactionId(id) { return SELF_COMPACTION_IDS.has(String(id ?? '')) }
1960
+
1961
+ // 宿主 v4 日志要求 compaction/start.turn 等于“当时仍打开的 turn”(无打开 turn 才可为 null)。
1962
+ // turn-end 触发的 fold 是异步的:压缩完成时下一轮可能已经 turn/start。调用方传 null 时
1963
+ // 必须以**提交时刻**的会话事件为准回溯,否则会写出 turn:null + 开轮,重载时
1964
+ // SessionFormatError: compaction/start does not match the open turn(真机 session-64963da1)。
1965
+ export function openTurnOf(session) {
1966
+ try {
1967
+ const evs = session?.ownEvents?.() ?? session?.snapshotEvents?.() ?? session?.events ?? []
1968
+ if (!Array.isArray(evs)) return null
1969
+ for (let i = evs.length - 1; i >= 0; i--) {
1970
+ const e = evs[i]
1971
+ if (!e || typeof e.type !== 'string') continue
1972
+ if (e.type === 'turn/end') return null
1973
+ if (e.type === 'turn/start') {
1974
+ const n = Number(e.data?.turn)
1975
+ return Number.isFinite(n) ? n : null
1976
+ }
1977
+ }
1978
+ } catch { /* 观测/兼容:拿不到事件时保守按无开轮回退 */ }
1979
+ return null
1980
+ }
1981
+
1982
+ // 折叠提交:走宿主的 compaction 协议,而不是裸 surfaceOp replace。
1983
+ // 宿主的对话 UI 只把 append 来源当人类转录、把 replace 副本当模型专用;裸替换会让
1984
+ // 用户眼前的助手正文消失。compaction 检查点被 UI 识别为“上下文已压缩”标记,
1985
+ // 原始 append 消息仍留在人类转录里,模型侧只看到检查点 + 保留节点。
1986
+ export function appendFoldCheckpoint(session, {
1987
+ transcript,
1988
+ shadowedSeqs,
1989
+ turn = null,
1990
+ shadowedBytes = 0,
1991
+ compactionId = randomUUID(),
1992
+ summaryText = null,
1993
+ } = {}) {
1994
+ if (!session || typeof session.append !== 'function') throw new Error('appendFoldCheckpoint: session.append required')
1995
+ const seqs = [...new Set((shadowedSeqs ?? []).filter((s) => Number.isFinite(s)))]
1996
+ if (seqs.length === 0) throw new Error('appendFoldCheckpoint: shadowedSeqs required')
1997
+ const text = String(transcript ?? '')
1998
+ const start = seqs[0]
1999
+ const end = seqs[seqs.length - 1]
2000
+ const lifecycleTurn = Number.isFinite(turn) ? turn : openTurnOf(session)
2001
+ const lifecycle = { compactionId, turn: lifecycleTurn }
2002
+ SELF_COMPACTION_IDS.add(compactionId)
2003
+ try {
2004
+ const startEvent = session.append('compaction/start', lifecycle)
2005
+ const shadowedTokenCount = Math.max(1, Math.ceil((Number(shadowedBytes) > 0 ? Number(shadowedBytes) : text.length) / 4))
2006
+ const summary = summaryText ?? `规则折叠 ${seqs.length} 条历史(原文保留在会话日志,模型使用规则转录)。`
2007
+ const summaryEvent = session.append('compaction/summary', {
2008
+ compactionId,
2009
+ summary: [{ type: 'text', text: summary }],
2010
+ shadowedRange: { start, end },
2011
+ shadowedSeqs: seqs,
2012
+ shadowedTokenCount,
2013
+ provider: 'CONTEXTinjector',
2014
+ model: 'rule-fold',
2015
+ })
2016
+ const replacementEvent = session.append('user/message', {
2017
+ id: randomUUID(),
2018
+ role: 'user',
2019
+ content: [{ type: 'text', text }],
2020
+ // kind 必须是宿主认识的 compact-checkpoint;plugin 字段留给 RCS 自己识别“这是自身转录”。
2021
+ source: { kind: 'compact-checkpoint', compactionId, plugin: 'CONTEXTinjector' },
2022
+ }, {
2023
+ surfaceOp: replaceOp(start, end),
2024
+ sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...seqs],
2025
+ })
2026
+ const endEvent = session.append('compaction/end', lifecycle)
2027
+ return {
2028
+ compactionId,
2029
+ turn: lifecycleTurn,
2030
+ startSeq: startEvent.seq,
2031
+ summarySeq: summaryEvent.seq,
2032
+ replaceSeq: replacementEvent.seq,
2033
+ endSeq: endEvent.seq,
2034
+ shadowedSeqs: seqs,
2035
+ shadowedTokenCount,
2036
+ }
2037
+ } finally {
2038
+ SELF_COMPACTION_IDS.delete(compactionId)
2039
+ }
2040
+ }
2041
+
2042
+ export function makeTranscriptExpandTool({ rawDir = RAW_DIR } = {}) {
2043
+ return {
2044
+ name: 'transcript_expand',
2045
+ // 【工具用法 · v1.10.67】用法规约就该写在注册表里:模型看得到的是工具目录,
2046
+ // 看不到插件源码与设计笔记。此前本工具无 description,模型即使拿到了 raw=<seq>
2047
+ // 也不知道它可点、更不知道该在"判定真伪/需要精确载荷"时点它(真机:连续三轮
2048
+ // 凭 [T] 行尾 OK 下结论,从未想到回取)。下面这些句子就是给模型的使用说明。
2049
+ description:
2050
+ '展开被折叠转录遮蔽的工具结果原文(只读)。' +
2051
+ '[T] 行尾的 raw=<seq> 就是这里的 seq。' +
2052
+ '何时用:要判定某一步是否真的成功(行尾 OK/ERR 只是传输层,业务失败记作 FAIL(错误码))、' +
2053
+ '需要精确的错误码/报错全文/文件内容/命令输出,或摘要太短不足以决策时,调它取原文,不要凭摘要猜。' +
2054
+ '何时不必用:小结果(<rawPtrMinBytes)里,失败行与提问答复的预览已内联在 [T] 行、成功的小回执无需正文;' +
2055
+ '当前轮与尚未折叠的步骤不在 raw 中,会返回 seq-not-in-raw([T] 行里记作 PENDING,不是失败)——' +
2056
+ '那些内容仍在你的上下文里,不必查询。',
2057
+ parameters: {
2058
+ type: 'object',
2059
+ properties: {
2060
+ seq: { type: 'integer', description: 'The raw=<seq> value from a [T] row.' },
2061
+ sid: { type: 'string', description: 'Session id; defaults to the current session.' },
2062
+ maxChars: { type: 'integer', minimum: 200, maximum: 20000, description: 'Preview cap (default 4000).' },
2063
+ },
2064
+ required: ['seq'],
2065
+ additionalProperties: false,
2066
+ },
2067
+ output: {
2068
+ schema: { type: 'object', properties: { ok: { type: 'boolean' }, text: { type: 'string' } }, additionalProperties: true },
2069
+ render(args, value) {
2070
+ if (value?.ok) {
2071
+ const head = `raw seq=${value.seq} bytes=${value.bytes}${value.truncated ? ' (truncated)' : ''}\n`
2072
+ return [{ type: 'text', text: head + value.text }]
2073
+ }
2074
+ return [{ type: 'text', text: JSON.stringify(value) }]
2075
+ },
2076
+ },
2077
+ timeoutMs: 5000,
2078
+ isConcurrencySafe: () => true,
2079
+ async execute(args, exec) {
2080
+ const sid = sanitizeSid(String(args?.sid || sidFromExec(exec) || '').replace(/^session-?/i, ''))
2081
+ if (!sid || sid === 'unknown') return { ok: false, error: 'no-session-scope' }
2082
+ return readRawEntry(rawDir, sid, args?.seq, args?.maxChars)
2083
+ },
2084
+ }
2085
+ }
2086
+
2087
+ // 【注册诊断落盘 · v1.10.68】register 抛没抛错、进没进可见表,此前只进 console:
2088
+ // 用户从 cmd 窗口启动 web 实例时没人留存那几行,2026-11-10 就卡在"插件明明 armed、
2089
+ // 模型工具表里却没有 transcript_expand"上无从判断。状态同时写进
2090
+ // STATE_DIR/tools/transcript-expand.json,重启后直接读文件即可(不必再问控制台)。
2091
+ // 【注册即被回收 · v1.10.71】cordis 的 ctx.effect(execute) 会**立即执行** execute,
2092
+ // 并把它的返回值当作 disposer。所以写成 ctx.effect(() => { dispose?.() }) 等于
2093
+ // "注册完立刻注销":register 不抛错、日志照打,工具却永远进不了可见表
2094
+ // (真机:tools.get('transcript_expand') 恒 false,补挂 9 次都一样)。
2095
+ // 隔离实测(tests/probe-effect-dispose.mjs):naive 写法 registered=false,
2096
+ // generator 写法 registered=true。持有 disposer 只有一个正确形状:
2097
+ // ctx.effect(function* () { yield () => dispose() })。
2098
+ function holdToolRegistration(effectCtx, disposer, label) {
2099
+ if (typeof disposer !== 'function') return
2100
+ try {
2101
+ if (typeof effectCtx?.effect === 'function') {
2102
+ effectCtx.effect(function* () { yield () => { try { disposer() } catch { /* ignore */ } } }, label)
2103
+ } else if (typeof effectCtx?.on === 'function') {
2104
+ effectCtx.on('dispose', () => { try { disposer() } catch { /* ignore */ } })
2105
+ }
2106
+ } catch { /* 持有失败不影响工具本身 */ }
2107
+ }
2108
+
2109
+ const TOOL_STATUS_DIR = join(STATE_DIR, 'tools')
2110
+ const TOOL_STATUS_FILE = join(TOOL_STATUS_DIR, 'transcript-expand.json')
2111
+ let TRANSCRIPT_EXPAND_STATUS = {
2112
+ tool: 'transcript_expand',
2113
+ state: 'not-attempted',
2114
+ at: null,
2115
+ }
2116
+ function recordTranscriptExpandStatus(patch) {
2117
+ TRANSCRIPT_EXPAND_STATUS = {
2118
+ ...TRANSCRIPT_EXPAND_STATUS,
2119
+ ...patch,
2120
+ pluginVersion: String(PLUGIN_VERSION).split(' ')[0],
2121
+ at: new Date().toISOString(),
2122
+ }
2123
+ try {
2124
+ mkdirSync(TOOL_STATUS_DIR, { recursive: true })
2125
+ writeFileSync(TOOL_STATUS_FILE, JSON.stringify(TRANSCRIPT_EXPAND_STATUS, null, 2), 'utf8')
2126
+ } catch { /* 诊断写不进去不影响工具本身 */ }
2127
+ }
2128
+
2129
+ export default {
2130
+ name: 'CONTEXTinjector',
2131
+ // 【根因 · v1.10.69】必须声明 inject: ['tools'],让插件在 tools 服务就绪后才 apply。
2132
+ // 此前用 ctx.inject(['tools'], cb) 延迟挂载:cb 里 register 成功、日志也打了 armed,
2133
+ // 但那个回调上下文是一次性的(fork),注册随其作用域结束被回收 —— 真机证据:
2134
+ // state-compiler/tools/transcript-expand.json 里 state=registered 而
2135
+ // tools/change 复检 visible=NO (registry n=92)。memory.mjs 的 memory_* 一直可见,
2136
+ // 差别就在它写了 inject: ['tools'] 并在 apply 里同步 ctx.tools.register()。
2137
+ inject: ['tools'],
2138
+ apply(ctx) {
2139
+ console.log(`[CONTEXTinjector] armed (${PLUGIN_VERSION}; threshold=${THRESHOLD}%; out=${OUT_FILE || '-'}; stateDir=${STATE_DIR})`)
2140
+ // raw 回取工具:让 [T] 的 raw=<seq> 可展开为被遮蔽原文(只读)。
2141
+ // tools 是可选宿主服务,必须走 ctx.inject 延迟挂载;直接 ctx.get('tools') 在服务尚未
2142
+ // 注入时会被 cordis 拒绝(实测 “cannot get property "tools" without inject”)。
2143
+ const probeVisible = (tools) => {
2144
+ // 只读回读:全局视图里在不在。注意"全局可见"≠"模型可见"(作用域限制/执行器选择
2145
+ // 都能再遮一层),所以这是下限证据,不是结论。
2146
+ try {
2147
+ const defs = typeof tools.definitions === 'function' ? tools.definitions() : null
2148
+ if (!Array.isArray(defs)) return { visible: 'unknown', registry: -1 }
2149
+ const all = defs.map((d) => String(d?.name ?? '')).sort()
2150
+ let resolved = null
2151
+ try { resolved = typeof tools.get === 'function' ? !!tools.get('transcript_expand') : null } catch { resolved = 'get-failed' }
2152
+ return {
2153
+ visible: all.includes('transcript_expand') ? 'yes' : 'NO',
2154
+ resolved,
2155
+ registry: defs.length,
2156
+ // 诊断:把当次可见名字写进状态文件,便于判断是"整类工具被换掉"还是"只丢我们一个"。
2157
+ sample: all.filter((n) => /transcript|memory_|pwsh|read/.test(n)).slice(0, 12),
2158
+ hasTranscript: all.some((n) => n.includes('transcript')),
2159
+ }
2160
+ } catch (err) {
2161
+ return { visible: 'probe-failed:' + String(err?.message ?? err).slice(0, 80), registry: -1 }
2162
+ }
2163
+ }
2164
+ const armTranscriptExpand = (toolsCtx) => {
2165
+ try {
2166
+ const tools = toolsCtx?.tools ?? (typeof toolsCtx?.get === 'function' ? toolsCtx.get('tools') : null)
2167
+ if (tools && typeof tools.register === 'function') {
2168
+ const dispose = tools.register(makeTranscriptExpandTool())
2169
+ const probe = probeVisible(tools)
2170
+ recordTranscriptExpandStatus({ state: 'registered', ...probe, via: 'ctx.inject' })
2171
+ console.log(`[CONTEXTinjector] transcript_expand armed (raw seq lookup); visible=${probe.visible} (registry n=${probe.registry})`)
2172
+ if (probe.visible === 'NO') console.error('[CONTEXTinjector] transcript_expand registered but NOT in the visible registry — models cannot call it')
2173
+ // tools/change 后再回读一次:别的插件注册/限制可能改变可见性,落盘留痕。
2174
+ try {
2175
+ if (typeof toolsCtx.on === 'function') toolsCtx.on('tools/change', () => {
2176
+ try { recordTranscriptExpandStatus({ state: 'registered', ...probeVisible(tools), via: 'tools/change' }) } catch { /* ignore */ }
2177
+ })
2178
+ } catch { /* 事件挂不上不影响工具 */ }
2179
+ try { if (typeof toolsCtx.effect === 'function') holdToolRegistration(toolsCtx, dispose, 'ctx.inject') } catch { /* ignore */ }
2180
+ } else {
2181
+ recordTranscriptExpandStatus({ state: 'tools-service-absent', via: 'ctx.inject' })
2182
+ console.log('[CONTEXTinjector] transcript_expand unavailable: tools service absent')
2183
+ }
2184
+ } catch (err) {
2185
+ const message = String(err?.message ?? err)
2186
+ recordTranscriptExpandStatus({ state: 'register-failed', error: message, via: 'ctx.inject' })
2187
+ console.error('[CONTEXTinjector] transcript_expand registration failed:', message)
2188
+ }
2189
+ }
2190
+ // 补挂器(自愈):在 apply 作用域里声明,供 tools/change 与 pre-step 共同调用。
2191
+ let ensureTranscriptExpandRegistered = () => {}
2192
+ // 【活取服务 · v1.10.71】不要把 tools 实例闭包住:宿主重启服务 fiber 后是新实例,
2193
+ // 往旧实例上补挂多少次都不会出现在模型看到的表里(真机 rearmCount=21 次全 NO)。
2194
+ const resolveTools = () => {
2195
+ try { const t = ctx.tools; if (t && typeof t.register === 'function') return t } catch { /* 未注入 */ }
2196
+ try { const t = typeof ctx.get === 'function' ? ctx.get('tools') : null; if (t && typeof t.register === 'function') return t } catch { /* ignore */ }
2197
+ return null
2198
+ }
2199
+ try {
2200
+ // 优先走 ctx.tools 直挂(与 memory.mjs 等已证明可见的插件同一条路径);
2201
+ // tools 尚未注入时 cordis 会拒绝 get,再退回 ctx.inject 延迟挂载。
2202
+ let armedDirectly = false
2203
+ try {
2204
+ const tools = resolveTools()
2205
+ if (tools) {
2206
+ const dispose = tools.register(makeTranscriptExpandTool())
2207
+ const probe = probeVisible(tools)
2208
+ recordTranscriptExpandStatus({ state: 'registered', ...probe, via: 'ctx.tools' })
2209
+ console.log(`[CONTEXTinjector] transcript_expand armed via ctx.tools; visible=${probe.visible} (registry n=${probe.registry})`)
2210
+ if (probe.visible === 'NO') console.error('[CONTEXTinjector] transcript_expand registered but NOT in the visible registry — models cannot call it')
2211
+ try {
2212
+ if (typeof ctx.on === 'function') ctx.on('tools/change', () => {
2213
+ try { recordTranscriptExpandStatus({ state: 'registered', ...probeVisible(resolveTools() ?? tools), via: 'tools/change' }) } catch { /* ignore */ }
2214
+ })
2215
+ } catch { /* 事件挂不上不影响工具 */ }
2216
+ armedDirectly = true
2217
+ // 【自愈重挂 · v1.10.69e / v1.10.71】每次补挂都**活取**服务实例,
2218
+ // 并以"可见表里有没有"(而不是"register 有没有抛错")为判据。
2219
+ let rearmCount = 0
2220
+ ensureTranscriptExpandRegistered = (why) => {
2221
+ try {
2222
+ if (rearmCount > 20) return
2223
+ const live = resolveTools()
2224
+ if (!live) return
2225
+ const seen = typeof live.get === 'function' ? live.get('transcript_expand') : undefined
2226
+ if (seen) return
2227
+ rearmCount += 1
2228
+ const d = live.register(makeTranscriptExpandTool())
2229
+ holdToolRegistration(ctx, d, 'transcript_expand')
2230
+ recordTranscriptExpandStatus({ state: 're-registered', ...probeVisible(live), via: why, rearmCount })
2231
+ console.log(`[CONTEXTinjector] transcript_expand re-registered (${why}) #${rearmCount}`)
2232
+ } catch (err) {
2233
+ recordTranscriptExpandStatus({ state: 're-register-failed', error: String(err?.message ?? err), via: why, rearmCount })
2234
+ }
2235
+ }
2236
+ // 【延时补挂 · v1.10.69f】启动早期注册可能被随后的层重建冲掉:启动后
2237
+ // 2s/5s/10s 各复查一次(此时已是修好的持有形,正常情况下第一次就够)。
2238
+ for (const delayMs of [2000, 5000, 10000]) {
2239
+ try { const t = setTimeout(() => { try { ensureTranscriptExpandRegistered('timer+' + delayMs) } catch { /* ignore */ } }, delayMs); t.unref?.() } catch { /* ignore */ }
2240
+ }
2241
+ holdToolRegistration(ctx, dispose, 'ctx.tools')
2242
+ }
2243
+ } catch { /* tools 未注入:走下面的 inject 路径 */ }
2244
+ if (!armedDirectly) {
2245
+ if (typeof ctx.inject === 'function') ctx.inject(['tools'], armTranscriptExpand)
2246
+ else armTranscriptExpand(ctx)
2247
+ }
2248
+ } catch (err) {
2249
+ const message = String(err?.message ?? err)
2250
+ recordTranscriptExpandStatus({ state: 'register-failed', error: message, via: 'ctx.inject-arm' })
2251
+ console.error('[CONTEXTinjector] transcript_expand registration failed:', message)
2252
+ }
2253
+ // 诊断行显示**生效值**而非原始字段(2026-09-10 默认翻转后,旧文案会谎报 "default OFF")
2254
+ const _kiCtrl = readControl()
2255
+ const _kiEff = keepInjectOn('control', _kiCtrl)
2256
+ console.log(`[CONTEXTinjector] keepInject(v1.6): env=${KEEP_INJECT_ENV ?? '-'} control=${_kiCtrl?.keepInject ?? '-'} ⇒ ${_kiEff ? 'ON(避让 DSH 注入节点 ⇒ 快照零补发)' : 'OFF(v1.5 路径:会遮蔽注入节点 ⇒ DSH 每轮补发快照)'} [默认 ON;env=0 或 control.keepInject=false 可关闭]`)
2257
+ console.log(`[CONTEXTinjector] gate priority: test=${TEST_ROUND ? 1 : 0} env=${ENABLED ? 1 : 0} else ${CONTROL_FILE} whitelist (default OFF)`)
2258
+ // #6 折叠时机:**不缓存生效值**——与本模块其它 control 项(keepInject/condenseMinBytes/prompts)同规矩,
2259
+ // 每次触发现读 control.json,改档无需重启(生效值的时点在这里无法断言 gate,故不谎报 ON/OFF)。
2260
+ console.log(`[CONTEXTinjector] fold timing(#6): turn-end(唯一后台折叠触发点,缺省 ON) + pre-step(仅 join,超时 ${FOLD_JOIN_TIMEOUT_MS}ms) | env DSH_CONTEXTINJECTOR_FOLD_AT_TURN_END=${FOLD_AT_TURN_END_ENV ?? '-'} / DSH_CONTEXTINJECTOR_FOLD_JOIN_MS=${process.env.DSH_CONTEXTINJECTOR_FOLD_JOIN_MS ?? '-'} | control.foldAtTurnEnd=false / env=0 ⇒ 不折(应急闸) | control.foldJoinTimeoutMs 热读`)
2261
+ const ctrl0 = readControl()
2262
+ console.log(`[CONTEXTinjector] condense modes: off|single|double; env=${ENV_MODE || '-'} (v1.9: no global default — sessionModes authoritative; control-gated sessions without a mode do NOT fold); minBytes=${LOSSY_MIN_BYTES}(default; control.condenseMinBytes overrides live); rawDir=${RAW_DIR}`)
2263
+ // v1.8 浓缩模型通道自检(软取,不影响装载):无 llm 服务时 single/double 自动回退无损折叠
2264
+ const forced0 = ctrl0?.condenseProvider && ctrl0?.condenseModel ? `${ctrl0.condenseProvider}/${ctrl0.condenseModel}` : '-'
2265
+ const logCondenseModel = (llmNow) => {
2266
+ console.log(`[CONTEXTinjector] condense model(v1.8): llm-service=${llmNow ? 'available' : 'ABSENT'} route=${forced0 === '-' ? 'follow-session' : forced0} ollama-direct=${LOSSY_OLLAMA ? 'on' : 'off'}`)
2267
+ }
2268
+ const llm0 = llmOf(ctx)
2269
+ if (llm0) logCondenseModel(llm0)
2270
+ else {
2271
+ // 0.2.1 起 llm 服务可能在本插件 apply 之后才挂载:1s 后复查,避免把"尚未挂载"误报成 ABSENT。
2272
+ // 运行时每次折叠仍会重新 llmOf(ctx)(见 resolveCondenseRoute),此处只影响诊断行。
2273
+ const t = setTimeout(() => { try { logCondenseModel(llmOf(ctx)) } catch { /* best-effort */ } }, 1000)
2274
+ if (typeof t?.unref === 'function') t.unref()
2275
+ }
2276
+ // 注意:不再提前 return——钩子始终注册,启停由运行时门控逐 step 判定(支持 WebUI 开关/session 白名单,无需重启)
2277
+
2278
+ // 折叠状态必须按会话隔离:真实 WebUI 一个进程同时服务多个会话,
2279
+ // 进程级单例会把某会话的 foldText 接进另一会话的转录(触发不稳定 + 伪历史)。
2280
+ const sessionStates = new Map() // sid -> { foldSeq, foldText, foldRound, foldedOnce, segCount, lastSegSeq }
2281
+ function stateOf(sid) {
2282
+ const key = sid ?? '__unknown__'
2283
+ let s = sessionStates.get(key)
2284
+ // segCount/lastSegSeq(v1.10.13 多段转录):段号与上一段起点 seq(段头据此编号、标明承接关系)
2285
+ if (!s) { s = { foldSeq: -1, foldText: '', foldRound: 0, foldedOnce: false, segCount: 0, lastSegSeq: -1, compactionId: null, cumRawBytes: 0, vol: 1, freshVolume: false, resumeAfterSeq: -1, sealEpoch: 0 }; sessionStates.set(key, s) }
2286
+ return s
2287
+ }
2288
+
2289
+ // ── 折叠主体(turn/end 后台任务;TEST_ROUND 回归通道也会直接调用)───────────────
2290
+ // 边界计算(closedPrefixEnd)/ 注入避让(keepInject)/ pairSplitIds 跨界守卫 /
2291
+ // extreason 转录 / 浓缩调用都收在这里。返回 {folded, reason, ...}。
2292
+ // ⚠ 契约:本函数**绝不抛**(内部已 try/catch),调用方无需再包一层;**不调用** next()。
2293
+ //
2294
+ // 【在飞标记生命周期收口】外层 foldAttempt 只做一件事:入口写标记、finally 删标记,然后转交 foldBody。
2295
+ // 放这里则任何调用点、任何提前 return / 抛错 / 被 abort 都自动覆盖,调用方无需(也**不得**)自己写删。
2296
+ // 标记始终是 best-effort:enterFoldInflight / exitFoldInflight 内部全 try/catch,绝不影响折叠本身。
2297
+ async function foldAttempt(args) {
2298
+ enterFoldInflight(args) // 入口即"压缩中":含真实 trigger / step / startedAt
2299
+ try {
2300
+ return await foldBody(args)
2301
+ } catch (err) {
2302
+ // 【v1.10.7(协作 r9 ★必须-4 建议)】foldBody 抛错过去**只经 finally 冒泡**:既不落痕,又让两个
2303
+ // 轮末折整体停摆——v1.10.5 的 TDZ 正是这样让折叠静默死掉。
2304
+ // 现在一律记账并降级为"本次不折",绝不静默:reason=error 且带 err 文本。
2305
+ const msg = String(err?.message ?? err)
2306
+ console.error(`[contextinjector] fold-attempt-error trigger=${args?.trigger ?? '-'} sid=${args?.sid ?? '-'} err=${msg}`)
2307
+ // ★必须-9(协作 r11):log() 在不配 OUT_FILE 时是空操作,故异常降级必须另落**可审计文件**。
2308
+ try {
2309
+ const errDir = join(STATE_DIR, '.ctxinjector', 'fold-errors')
2310
+ mkdirSync(errDir, { recursive: true })
2311
+ appendFileSync(join(errDir, sanitizeSid(String(args?.sid ?? 'unknown').replace(/^session-?/i, '')) + '.jsonl'), JSON.stringify({ t: Date.now(), trigger: args?.trigger ?? null, sid: args?.sid ?? null, step: args?.stepNo ?? null, err: msg }) + '\n', 'utf8')
2312
+ } catch { /* ignore */ }
2313
+ try { log('fold-attempt-error', { trigger: args?.trigger ?? null, sid: args?.sid ?? null, err: msg }) } catch { /* ignore */ }
2314
+ return { folded: false, reason: 'error', error: msg }
2315
+ } finally {
2316
+ exitFoldInflight(args?.sid) // 成功 / 失败 / 提前 return / 异常:一律在这里熄灭
2317
+ }
2318
+ }
2319
+ // 折叠主体(语义与旧 foldAttempt 逐字一致,仅改名以免与上面那层混)
2320
+ async function foldBody({ session, surface, sid, ctrl, gate, stepNo, turn, claimed, trigger }) {
2321
+ // 仅测试:让 harness 能走一次真实异常路径(验证 ★必须-9 的错误账本确实落盘)
2322
+ if (process.env.DSH_CONTEXTINJECTOR_THROW_TEST === '1') throw new Error('harness-injected-fold-error')
2323
+ const st = stateOf(sid)
2324
+ // v1.10 重启续传:本进程首见该会话且未折叠过 → 尝试从 surface 的自身折叠节点回填状态
2325
+ if (gate !== 'off' && st.foldText === '' && st.seedChecked !== true) {
2326
+ st.seedChecked = true
2327
+ seedFoldState(session, surface, st, sid)
2328
+ }
2329
+ // 【v1.10.6(协作 r7 §1 纠正)】本次折叠**前**的转录累计行数——必须
2330
+ // ① 在 `st` 取到之后(此前引用 foldText 会落进 TDZ:`let foldText` 在下方几行才声明,
2331
+ // 抛 ReferenceError,而 node --check 只查语法、抓不到这类运行时错误;foldAttempt 只有
2332
+ // finally 没有 catch ⇒ 异常冒泡 ⇒ **每一次折叠都会死**,轮末折整体停摆);
2333
+ // ② 在 seedFoldState **之后**(seed 会改写 st.foldText;回填后的值才是本次折叠据以增量的基线,
2334
+ // 也才满足"foldText 是每会话单调前缀状态"的语义);权威来源是 `st.foldText` 而不是局部 `foldText`。
2335
+ const cumLinesBeforeEntry = st.foldText === '' ? 0 : st.foldText.split('\n').length
2336
+ // handler 局部副本:每次触发从该会话状态载入;折叠成功后写回(多会话不串扰)
2337
+ let foldSeq = st.foldSeq, foldText = stripSnapshotRows(st.foldText), foldRound = st.foldRound, foldedOnce = st.foldedOnce
2338
+ // 【v1.10.75】在飞期间发生过原生压缩(封存/换卷)⇒ 本次折叠的遮蔽区间与基线都已过期:
2339
+ // 提交会写出指向已被宿主总结掉的 seq 的 replace,或把旧卷拼回去。记下 epoch,提交前比对。
2340
+ const sealEpochAtEntry = Number(st.sealEpoch) || 0
2341
+ if (TEST_ROUND && foldedOnce) return { folded: false, reason: 'test-once' } // 回归模式单次折叠(实证 P/S 同语义)
2342
+
2343
+ // —— round 边界(或回归模式闭合前缀):把已闭合前缀折叠为转录消息 ——
2344
+ const nodes = [...surface.nodes] // surface.nodes = seq 列表
2345
+ const entries = nodes.map((seq) => ({ seq, msg: session.deriveEventMessage(evAt(session, seq)) }))
2346
+ // 【v1.10.17】中止一轮会留下"悬空 tool-call"(call 在 surface、结果永不到来)。旧行为把它当未闭合
2347
+ // ⇒ 该轮及其后所有内容都进不了遮蔽集 ⇒ **整个会话的折叠冻结**(真机复现 + abort-turn.test 第 3 例:
2348
+ // 用户报"终止过 AI 输出,再发消息,这一轮的大段输出也不压缩了")。
2349
+ // 现按"该轮是否已结束(其后有 turn/end)"判定:已结束 ⇒ 结果确定不会来 ⇒ 允许跨越;
2350
+ // 仍在飞 ⇒ 保持保守(结果晚到就成孤儿 tool-result,会 INVALID_REQUEST —— v1.6 的事故)。
2351
+ const deadCalls = collectDeadCalls(entries, session)
2352
+ let end = closedPrefixEnd(entries.map((e) => e.msg), { deadCalls })
2353
+ if (end < 0) return { folded: false, reason: 'no-closed-prefix' } // 尚无闭合前缀(首轮未产生 assistant↔tool 闭合),留给下一轮
2354
+ // 【当前轮保护】仍在飞的那一轮不进遮蔽集(否则表面尾部会变成 user 角色的转录 ⇒ 宿主再跑一步 ⇒ 重复回答)。
2355
+ // 轮末(turn/end)触发时,刚结束的轮已带 turn/end 事件 ⇒ 允许折叠;唯一的生产触发点就是它。
2356
+ {
2357
+ const clamped = openRoundClampEnd(entries, session, end)
2358
+ if (clamped !== end) {
2359
+ console.log(`[contextinjector] fold-clamped-to-open-round trigger=${trigger} step=${stepNo} sid=${sid} end=${end}→${clamped} (当前轮仍在飞,本轮不折它)`)
2360
+ end = clamped
2361
+ if (end < 0) return { folded: false, reason: 'open-round' }
2362
+ }
2363
+ }
2364
+ const callsIdx = callIndexFromEvents(session) // 漂移 #11:一次建索引,head 补录与增量共用
2365
+ const ki = keepInjectOn(gate, ctrl) // v1.6 注入节点避让:快照始终在场 → 零 policy 补发
2366
+ // 遮蔽区间排除末端注入型节点(最新 runtime/policy 快照保留在 surface)——保转录纯度;
2367
+ // v1.6(ki)用精确判定(自身转录消息可遮蔽、只剔 DSH 注入);v1.5 兼容用旧语义(任何 plugin user)
2368
+ const injTest = ki ? isDshInjected : isInjectedUser
2369
+ while (end >= 0 && injTest(entries[end].msg)) end -= 1
2370
+ if (end < 0) return { folded: false, reason: 'tail-injected' }
2371
+
2372
+ // 【v1.10.74 · 宿主检查点是压缩边界】`kind='compact-checkpoint'` 且没有 plugin 标识的消息是
2373
+ // DSH 原生压缩写下的前文总结。转录分型把它记作 opaque(不产行),所以一旦被折进遮蔽区,
2374
+ // 它的正文就从模型视野里消失(实测:原生压缩后第一折把 summary 吞掉、上下文里只剩新一行的 [U]/[A])。
2375
+ // 故折叠**从它之后**开始,它自己永远留在表面;紧随其后的新轮照常折叠。
2376
+ // 判据用**位置**(开头的连续段)而不是 seq:surface 顺序才是权威,seq 只用于记账。
2377
+ // 【v1.10.75】边界不一定在开头:原生保留窗口若正好落在插件旧卷之后,检查点会出现在表面中段。
2378
+ // 位置仍是权威 —— 取**最后一条**宿主检查点之后,它自己永不进遮蔽区。
2379
+ let leadCut = 0
2380
+ {
2381
+ let i = 0
2382
+ while (i < entries.length && (!entries[i].msg || isSystemNode(entries[i].msg))) i += 1
2383
+ while (i < entries.length && isHostCheckpointMsg(entries[i].msg)) i += 1
2384
+ leadCut = i
2385
+ for (let j = entries.length - 1; j >= 0; j--) {
2386
+ if (entries[j].msg && isHostCheckpointMsg(entries[j].msg)) { if (j + 1 > leadCut) leadCut = j + 1; break }
2387
+ }
2388
+ }
2389
+ // 【v1.10.75 · 新卷】封存后的第一次折叠:边界之前的内容(宿主总结 + 已封存卷)一律不碰。
2390
+ // 已封存卷若仍留在表面(它在摘要边界之后、没被总结掉),就**原地冻结**:不遮蔽、不重转录,
2391
+ // 新卷从它之后缀增 —— 这正是"封存这之前的 LOG 表,从边界开始缀增"。
2392
+ const freshVol = st.freshVolume === true
2393
+ if (freshVol) {
2394
+ let lastOwn = -1
2395
+ for (let i = 0; i < entries.length; i++) if (entries[i].msg && isOwnTranscript(entries[i].msg)) lastOwn = i
2396
+ if (lastOwn + 1 > leadCut) leadCut = lastOwn + 1
2397
+ // 注入节点永不遮蔽:边界之后若还有 runtime/policy 快照,从最后一条之后开始
2398
+ for (let i = leadCut; i < entries.length; i++) if (entries[i].msg && injTest(entries[i].msg)) leadCut = i + 1
2399
+ if (leadCut > end) return { folded: false, reason: 'sealed-tail' } // 边界之后还没有可折的闭合前缀
2400
+ }
2401
+
2402
+ let shadowed = entries.slice(leadCut, end + 1).filter((e) => e.msg !== null && !isSystemNode(e.msg))
2403
+ // 轮号状态必须在 head 转录**之前**建立:head(首轮 user)先占 [ROUND] 1,增量行随后续号。
2404
+ // 【v1.10.75】新卷的 n = 继承来的 foldRound(封存时的最后轮号)⇒ 首条新行 = foldRound+1,不重头从 1。
2405
+ const roundState = { n: foldRound }
2406
+ let headRows = []
2407
+ let headSeqs = []
2408
+ let headEntries = [] // v1.10.13:head 原始节点(多段转录下 head 也要被遮蔽,需要它们的原始字节计量)
2409
+ if (ki) {
2410
+ // 遮蔽候选截断在"最后一个 DSH 注入节点之后":注入节点保持原位 seq(永不遮蔽),
2411
+ // 只遮蔽其后普通节点(含自身旧转录消息)→ runtime 快照始终在场 → DSH 不补发。
2412
+ // 首个 runtime 注入通常紧跟首条 user;修复后 DSH 不再新增注入 → 截断起点跨轮稳定,
2413
+ // 遮蔽内容 ≈ 几乎全部历史。
2414
+ // 【v1.10.1 修复】被截掉的那段(注入**之前**的普通节点 = 首条 user)过去既不被遮蔽、也不被
2415
+ // 转录 ⇒ 转录从第 2 轮起(面板显示 ROUND 1 = 第二条消息)。现按 foldHeadTranscript 的契约
2416
+ // 在首次折叠时"只转录不遮蔽"补录一次;注入节点本身仍绝不进 head/tail(keepInject 不变)。
2417
+ // 【v1.10.75】新卷不做 head 补录:卷的起点是原生摘要边界,之前的内容已由宿主总结覆盖,
2418
+ // 补录会把它再抄一遍(并且 head 的 tail 切法会把宿主检查点圈进遮蔽区)。
2419
+ const ht = freshVol
2420
+ ? { head: [], tail: shadowed, hadInject: false, rows: [] }
2421
+ : foldHeadTranscript(shadowed, foldText !== '', roundState, { structured: ctrl?.toolfoldStructured === true, calls: callsIdx, rawPtrMinBytes: rawPtrMinBytesOf(ctrl) })
2422
+ headRows = ht.rows
2423
+ headSeqs = ht.head.map((e) => e.seq)
2424
+ headEntries = ht.head
2425
+ if (ht.hadInject) {
2426
+ shadowed = ht.tail
2427
+ if (shadowed.length === 0) return { folded: false, reason: 'nothing-after-inject' } // 注入之后无普通节点可折(留给下一轮)
2428
+ }
2429
+ }
2430
+ // 【v1.10.14 · 多段转录(control.segments,默认关)】开关必须在这里就读出来:下面的增量集合算法要分叉。
2431
+ const segmentsOn = !!(ctrl && ctrl.segments === true)
2432
+ // 增量集合 = "上次折叠之后要转录的节点":
2433
+ // 单段(默认):按 seq 判(`seq > foldSeq`)—— 旧转录节点的事件 seq 在日志**尾部**,
2434
+ // 必然被圈进来;旧行为正需要如此(它会被本次 replace 覆盖成"旧文本+新行"的累加转录)。
2435
+ // 多段(segments):**必须按位置判**("最后一个自身段节点之后")。原因(真机实测事故,见下):
2436
+ // 段节点若被圈进增量,本次 replace 就会**把上一段整段吃掉**。
2437
+ // 【真机证据 2026-09-10 session-215290cd,包 0.1.9】折叠 1 落两段(head@7 + 段1@9),
2438
+ // 段 1 的事件 seq = 74(日志尾部);折叠 2 的增量 `seq > foldSeq(9)` ⇒ 圈进了 74,
2439
+ // replace 区间 = [74..139] ⇒ 段 1 从 surface 消失 ⇒ 表面只剩 head+段2(760B),
2440
+ // 而 foldText = head+段1+段2(1192B)⇒ 漂移守卫连拒四次(fold-errors: ownBytes=760 foldBytes=1192),
2441
+ // 该会话折叠停摆。⇒ 判据必须是**位置**,不是 seq。
2442
+ let incremental = shadowed.filter((e) => e.seq > foldSeq) // 单调在场:只转录上次折叠后的新节点
2443
+ if (segmentsOn) {
2444
+ let lastOwnIdx = -1
2445
+ for (let i = 0; i < shadowed.length; i++) if (shadowed[i].msg && isOwnTranscript(shadowed[i].msg)) lastOwnIdx = i
2446
+ incremental = shadowed.slice(lastOwnIdx + 1) // 只覆盖"最后一个段节点之后":既有段全部留在表面
2447
+ }
2448
+ if (shadowed.length === 0 || incremental.length === 0) return { folded: false, reason: 'no-increment' }
2449
+ // 【v1.10.38 · 微折门槛】新原料不足门槛就留给后续轮,不提交检查点。
2450
+ // 放在转录/浓缩之前:小轮不花模型调用,也不在聊天区新增/更新「上下文已压缩」标记。
2451
+ // 回归通道(TEST_ROUND)不受限:headless 单轮测试的夹具本来就很小。
2452
+ const rawNewBytes = incremental.reduce((a, e) => a + (isOwnTranscript(e.msg) ? 0 : messageBytes(e.msg).length), 0)
2453
+ const minShadowBytes = foldMinShadowBytesOf(ctrl)
2454
+ if (!TEST_ROUND && minShadowBytes > 0 && rawNewBytes < minShadowBytes) {
2455
+ try { log('fold-skip', { trigger, step: stepNo, turn, sid, reason: 'below-min-shadow', raw: rawNewBytes, min: minShadowBytes }) } catch { /* ignore */ }
2456
+ console.log(`[contextinjector] fold-skip trigger=${trigger} step=${stepNo} sid=${sid} reason=below-min-shadow raw=${rawNewBytes}B<${minShadowBytes}B(累积到门槛再折)`)
2457
+ return { folded: false, reason: 'below-min-shadow', incrementalRawBytes: rawNewBytes, minShadowBytes }
2458
+ }
2459
+ // 【#4 守卫(取自 RCS-0.1.0 返回包)】跨界配对检查:遮蔽集与尾部之间不得切分
2460
+ // tool-call↔tool-result 配对,否则 surface 出现孤儿 tool 消息 → INVALID_REQUEST
2461
+ // (与 §13 同源风险区)。不安全 → 延后本次折叠(下一轮再试),绝不提交切分配对。
2462
+ try {
2463
+ const tailMsgs = entries.slice(end + 1).map((e) => e.msg).filter(Boolean)
2464
+ const split = pairSplitIds(shadowed.map((e) => e.msg), tailMsgs)
2465
+ if (split.length > 0) {
2466
+ console.log(`[contextinjector] fold-deferred-orphan trigger=${trigger} step=${stepNo} turn=${turn} ids=${split.slice(0, 3).join(',')} n=${split.length} (pair split; defer to next round)`)
2467
+ return { folded: false, reason: 'pair-split' }
2468
+ }
2469
+ } catch { /* 守卫失败不阻断主链 */ }
2470
+
2471
+ const newRows = transcribeIncremental(incremental.map((e) => e.msg), roundState, { structured: ctrl?.toolfoldStructured === true, calls: callsIdx, seqOf: seqMapOf(incremental), rawPtrMinBytes: rawPtrMinBytesOf(ctrl) })
2472
+ if (newRows.length === 0) return { folded: false, reason: 'no-rows' }
2473
+ // 【覆盖守卫 · 漂移 #11 的教训】增量里每一个工具结果消息都必须在转录里留下一行 [T]。
2474
+ // 少一行 ⇒ 某一类节点被整类跳过(形状漂移的典型症状,真机表现为 205 节点折成 0 行工具记录)。
2475
+ // 这时**拒绝提交**、本轮按原文发送:宁可这一轮不省,也绝不带着"看起来完整、其实缺整段"的历史继续。
2476
+ // 旧的五项校验比的是字节与关键值,对"少产出行"不敏感——本守卫补的正是这个盲区。
2477
+ {
2478
+ const wantTool = incremental.filter((e) => isToolResultMsg(e.msg)).length
2479
+ const gotTool = newRows.reduce((n, r) => n + (/^\[T\] /.test(r) ? 1 : 0), 0)
2480
+ if (gotTool < wantTool) {
2481
+ console.log(`[contextinjector] fold-rejected-coverage trigger=${trigger} step=${stepNo} sid=${sid} want=${wantTool} got=${gotTool} (工具结果未逐条进转录 ⇒ 本轮不折叠)`)
2482
+ return { folded: false, reason: 'tool-coverage', wantTool, gotTool }
2483
+ }
2484
+ }
2485
+ // condense 档:mode=off|single|double。只对增量内新 [A] 浓缩([U]/[T]/[ROUND] 不动),
2486
+ // 失败回退无损。旧 transcript 绝不回写:condense 只作用于本次增量。
2487
+ const mode = condenseMode(gate, ctrl, sid)
2488
+ if (mode === null) {
2489
+ // v1.9:全局默认档已废止——control 门控会话未设本会话档(sessionModes)⇒ 不折叠。
2490
+ // 经 composer 选档(off/single/double)后才会折叠;none = 移出白名单并清档。
2491
+ console.log(`[contextinjector] no-mode skip trigger=${trigger} step=${stepNo} turn=${turn} sid=${sid} (whitelisted but no per-session mode; no global default)`)
2492
+ return { folded: false, reason: 'no-mode' }
2493
+ }
2494
+ let stat = null
2495
+ let finalRows = newRows
2496
+ let condenseRoute = null // v1.8:本次折叠实际使用的浓缩模型路由(记入 last-fold 供核验)
2497
+ let condenseErr = null // v1.9:浓缩模型调用失败原因(上浮 last-fold → WebUI 展示;质量性回退不在此列)
2498
+ let condenseErrKind = null // #9:对 condenseErr 分型(enum),供 WebUI 如实展示错误类别
2499
+ // 必须与 condenseErr 同级声明:写 last-fold 的代码在本 if 块之外,块内 const 出不去
2500
+ // (v1.10.61 就踩了这个:每次折叠在写记录时抛 "condenseErrResolved is not defined" ⇒ 整折失败)。
2501
+ let condenseErrResolved = false
2502
+ // 同理:last-fold 在 if 块之外写,实际发出的 max_tokens 必须先落到函数级变量。
2503
+ let mtSent = 0
2504
+ let downgrades = 0 // 档位被拒后重试成功的次数(过程,不是错误)
2505
+ if (mode !== 'off') {
2506
+ // 【v1.10.19 · P3 修复】在**最早的时刻**固化 llm 句柄(此刻 ctx 一定还活着:本函数由 turn/end
2507
+ // handler 起头,handler 返回后作用域才可能被回收)。后台折叠跑到一半时请求作用域可能已被
2508
+ // 回收 ⇒ 每次重新 llmOf(ctx) 会越跑越取不到服务(实证:double 每条 [A] 的第 2 次调用 12/12 失败,
2509
+ // 而第 1 次成功;调用越慢失败率越高)。固化后随 ioc 一路带下去,不再随作用域回收而丢失。
2510
+ const llmFixed = llmOf(ctx)
2511
+ // v1.8:浓缩模型路由在折叠点解析(跟随会话当前模型,或 env/control 显式指定)
2512
+ const route = await resolveCondenseRoute(ctx, session, ctrl, llmFixed)
2513
+ condenseRoute = route ? { provider: route.provider, model: route.model, via: route.via } : null
2514
+ if (!condenseRoute) condenseErr = 'no condense route (llm service absent / no session model / empty registry)'
2515
+ const ioc = { ctx, route, llm: llmFixed, errs: [], fb: {}, kd: [], aux: { calls: 0, in: 0, cacheRead: 0, out: 0 }, mtSeen: 0, sessionId: sid, fallbackRoutes: auxFallbackRoutes(ctrl || {}) } // v1.9 errs / v1.10.19 fb=回退原因分型 / v1.10.21 kd=K 通道归因 / v1.10.23 aux=辅助调用真实用量 / v1.10.60 sessionId=辅助调用会话身份
2516
+ // #7 maxtokens:control.condenseMaxTokens(int)或 env 覆盖;无则内置 LOSSY_MAX_TOKENS(2048)
2517
+ // 实际每次调用还会按输入长度自适应收敛(effectiveMaxTokens),此处是**上限**。
2518
+ // #7 maxtokens **上限**:control.condenseMaxTokens(int)或 env 覆盖;无则内置 LOSSY_MAX_TOKENS(2048)。
2519
+ // 每次调用再按输入长度自适应收敛(effectiveMaxTokens);此处只定天花板。
2520
+ const ctlMT = ctrl && typeof ctrl.condenseMaxTokens === 'number' ? Math.max(64, Math.floor(ctrl.condenseMaxTokens)) : NaN
2521
+ ioc.maxTokens = Number.isFinite(ctlMT) ? ctlMT : LOSSY_MAX_TOKENS
2522
+ // 显式设了天花板就按天花板发:max_tokens 是上限不是计费,压低只会让思考型和正文抢额度。
2523
+ ioc.maxTokensExplicit = Number.isFinite(ctlMT)
2524
+ // 【v1.10.20 · 经济性 P0】浓缩调用的推理档位:control.condenseReasoningEffort(string,需为
2525
+ // '' | off | low | high | max)或 env DSH_CONTEXTINJECTOR_LOSSY_EFFORT 覆盖;缺省 'off'。
2526
+ // 事故说明见 LOSSY_EFFORT 定义处:不关思维链时,推理 token 吃满整个输出额度 ⇒ 正文 0 字、
2527
+ // finish=max-tokens ⇒ 整条 [A] 判失败并无损回退,输入与输出 token **全额付费却零压缩收益**。
2528
+ // 非法值不写入(回落 effortOf 的缺省),避免把控制端的手误变成一次线上行为变更。
2529
+ const ctlEff = ctrl && typeof ctrl.condenseReasoningEffort === 'string'
2530
+ ? ctrl.condenseReasoningEffort.trim().toLowerCase() : null
2531
+ // '' 是合法值(=完全不传该字段),此前用真值判断把它当"没设置",控制端无法表达默认思考档。
2532
+ if (ctlEff !== null && LOSSY_EFFORTS.has(ctlEff)) ioc.reasoningEffort = ctlEff
2533
+ // 硬预算(v1.10.3):墙钟 + 调用次数。次数上限对 double 是**隐性减半**(每条 [A] 要两次调用),
2534
+ // 实证报告 P4 记录在案;这里把生效值记进 ioc,供日志/last-fold 如实展示。
2535
+ ioc.maxCalls = Number.isFinite(ctrl?.condenseMaxCallsPerFold) ? Math.max(0, Math.floor(ctrl.condenseMaxCallsPerFold)) : undefined
2536
+ ioc.budgetMs = Number.isFinite(ctrl?.condenseBudgetMs) ? Math.max(0, Math.floor(ctrl.condenseBudgetMs)) : undefined
2537
+ // 【取自 RCS-0.1.0 返回包】minBytes 热读:control.condenseMinBytes 覆盖内置 240(缺省不变)
2538
+ const ctlMB = ctrl && typeof ctrl.condenseMinBytes === 'number' ? Math.max(0, Math.floor(ctrl.condenseMinBytes)) : NaN
2539
+ if (Number.isFinite(ctlMB)) ioc.minBytes = ctlMB
2540
+ // 【v1.10.23 · 经济性准入】回本视界(回合):control.condensePaybackTurns 覆盖内置 8;0=关闭回本闸。
2541
+ const ctlPB = ctrl && typeof ctrl.condensePaybackTurns === 'number' ? Math.max(0, Math.floor(ctrl.condensePaybackTurns)) : NaN
2542
+ ioc.paybackHorizon = Number.isFinite(ctlPB) ? ctlPB : PB_HORIZON
2543
+ // 值保真闸:默认关(与 RCS 对齐),control.payloadCheck===true 时恢复严格模式
2544
+ if (ctrl && ctrl.payloadCheck === true) ioc.payloadCheck = true
2545
+ // #10 自定义提示词:control.prompts.single/keys(null/空=内置默认)
2546
+ if (ctrl && ctrl.prompts && typeof ctrl.prompts === 'object') {
2547
+ const pp = ctrl.prompts
2548
+ if (typeof pp.single === 'string' && pp.single.trim()) ioc.condenseSys = pp.single.trim()
2549
+ if (typeof pp.keys === 'string' && pp.keys.trim()) ioc.keysSys = pp.keys.trim()
2550
+ }
2551
+ const res = await applyCondense(newRows, mode, ioc)
2552
+ finalRows = res.rows
2553
+ stat = res.stat
2554
+ // 实际服务的是回退路由时,记录如实指向它(面板/日志都读 condenseRoute)
2555
+ if (ioc.fallbackRoute) condenseRoute = { provider: ioc.fallbackRoute.provider, model: ioc.fallbackRoute.model, via: 'fallback' }
2556
+ if (!condenseErr && ioc.errs.length) condenseErr = ioc.errs.slice(0, 3).join(' | ')
2557
+ condenseErrKind = condenseErr ? classifyCondenseError(condenseErr) : null // #9 分型
2558
+ // 出过错但每一段都折叠成功(实测:档位被拒 → 不传档位重试 → 输出撞 max-tokens →
2559
+ // 抢救成功,folded=1/1、fallback=0)。这不是失败,面板不能再按错误展示;
2560
+ // 原始文本仍留在 condenseErr 里供核验。
2561
+ condenseErrResolved = !!(condenseErr && stat && stat.folded > 0 && ((stat.fallbackReasons && stat.fallbackReasons.llmErr) || 0) === 0)
2562
+ mtSent = ioc.mtSeen || 0
2563
+ downgrades = ioc.downgrades || 0
2564
+ if (stat.a > 0) console.log(`[contextinjector] condense[${mode}] trigger=${trigger} round=${roundState.n}: [A] ${stat.folded}/${stat.a} folded, ${stat.fallback} fallback${Object.keys(stat.fallbackReasons ?? {}).length ? '(' + Object.entries(stat.fallbackReasons).map(([k, v]) => k + '=' + v).join(',') + ')' : ''}, keys=${stat.keys}${stat.keysDiag ? '(' + stat.keysDiag.join('|') + ')' : ''}${stat.salvaged ? ', salvaged=' + stat.salvaged : ''}, calls=${stat.calls}/${ioc.maxCalls ?? '?'} ms=${stat.ms}${stat.budgetHit ? ' BUDGET-HIT' : ''}, ${stat.rawBytes}B->${stat.outBytes}B via=${route ? route.provider + '/' + route.model + '(' + route.via + ')' : 'none'}${condenseErr ? ' err=' + condenseErr : ''}${condenseErrKind ? ' kind=' + condenseErrKind : ''}${condenseErrResolved ? ' (recovered)' : ''}${ioc.mtSeen ? ' mt=' + ioc.mtSeen : ''}`)
2565
+ }
2566
+ // head 行只可能在 foldText === '' 时非空(补录只做一次),且其节点 seq 更早 ⇒ 必须排在增量行**之前**;
2567
+ // 之后每次折叠都只是向后追加 ⇒ monotonic 前缀纪律不变(transcript.startsWith(foldText) 仍成立)。
2568
+ // 【v1.10.10 · standalone 独立片段(默认关闭,control.standalone === true 才启用)】
2569
+ // 背景(取自 RCS-0.1.0 D13):本会话的"注入节点排在自身旧转录之后"时,旧实现会连注入一起折
2570
+ // → 注入被替换 → DSH 立刻补发 → 自我抵消。standalone 只折**注入之后**的增量、**不带旧前缀**,
2571
+ // 让旧转录留在 surface 上(不被本次 replace 覆盖)。
2572
+ // 安全前提:上面刚做完的 `foldText` 漂移守卫(v1.10.9)——若 surface 上的旧转录与 foldText 不一致,
2573
+ // 本次折叠已被拒绝,不会走到这里。
2574
+ // 代价(如实记录):本段转录不含旧前缀 ⇒ last-fold 的 `monotonic` 记 `'segment'`;
2575
+ // 续传语义依赖 `seedFoldState`(它取最后一个自身转录节点),故本项**默认关闭**、需实机 A/B。
2576
+ // 【v1.10.13 · 多段转录(control.segments,默认关闭)】
2577
+ // 把"转录"从**一个**节点改成**有序段集合**,一举解决两条已知妥协:
2578
+ // ① head 重复:注入节点之前那段(v1.10.1 起"只转录不遮蔽")过去既在 surface 上、又在转录文本里
2579
+ // ⇒ 请求里出现两遍。本项让 head **独立成段且被遮蔽**(它不含注入节点,遮蔽它不违反 keepInject)
2580
+ // ⇒ 零重复。
2581
+ // ② standalone 的隐蔽风险:旧 standalone 的新段不带旧前缀,而 `seedFoldState` 只取最后一个
2582
+ // 自身转录节点 ⇒ 旧段在重启/面板里静默消失。本项下每个段**各自**留在 surface 上,
2583
+ // 完整转录 = 全部段按 seq 升序拼接(读取侧见 mergeOwnSegments),故续传不丢段。
2584
+ // 承载方式:新段**只装本次增量**(不覆盖既有段节点 —— 替换区间从"最后一个自身段节点之后"起算),
2585
+ // 段文本**只有转录行、不含任何机器可读头**(v1.10.16 修正:早期版本在首行插过
2586
+ // `# SC-SEG …`,用户实测它进了注入的上下文 ⇒ 已移除;段信息只落 last-fold / fold-index)。
2587
+ // 提交纪律:两段**先算全部、再依次 append**(head 段在前,seq 升序);任一段失败 ⇒ catch 清空会话
2588
+ // 状态、下次折叠重新 seed(从表面真值重建 foldText),绝不留下"状态指向已不存在的文本"。
2589
+ // 默认关闭 ⇒ 关闭时下方 transcript 组装与 `monotonic` 逐字不变(需实机 A/B 后再谈默认值)。
2590
+ // ⚠ 开关本体在**增量集合**那段就已读出(segmentsOn);这里只做段规划。
2591
+ let segHeadPlan = null // { text, seqs, startSeq, endSeq }
2592
+ let segNewText = ''
2593
+ let segMeta = null // last-fold.segments(每段的 segIndex/seq/cumLines/bytes)
2594
+ let standaloneSeg = false
2595
+ if (!segmentsOn && ctrl && ctrl.standalone === true && foldText !== '') {
2596
+ let lastInj = -1
2597
+ let lastOwn = -1
2598
+ const cand = entries.slice(0, end + 1)
2599
+ for (let i = 0; i < cand.length; i++) {
2600
+ if (!cand[i].msg) continue
2601
+ if (isDshInjected(cand[i].msg)) lastInj = i
2602
+ else if (isOwnTranscript(cand[i].msg)) lastOwn = i
2603
+ }
2604
+ standaloneSeg = lastOwn >= 0 && lastInj > lastOwn
2605
+ if (standaloneSeg) console.log(`[contextinjector] standalone 独立片段:注入@${lastInj} 在旧转录@${lastOwn} 之后 ⇒ 本次转录不带旧前缀(旧转录留在表面)`)
2606
+ }
2607
+ if (segmentsOn) {
2608
+ const segBase = Number.isFinite(st.segCount) ? st.segCount : 0
2609
+ const headOn = headRows.length > 0 && headSeqs.length > 0
2610
+ let baseCum = cumLinesBeforeEntry
2611
+ const metas = []
2612
+ if (headOn) {
2613
+ // 【v1.10.16】段文本不含任何机器可读头(用户实测:头行会进入注入的上下文)
2614
+ const headText = segmentText(headRows)
2615
+ segHeadPlan = { text: headText, seqs: [...headSeqs], startSeq: headSeqs[0], endSeq: headSeqs[headSeqs.length - 1] }
2616
+ metas.push({ segIndex: segBase, kind: 'head', seq: segHeadPlan.startSeq, cumLines: cumLinesBeforeEntry, bytes: headText.length, seqs: [...headSeqs] })
2617
+ baseCum = cumLinesBeforeEntry + headText.split('\n').length
2618
+ }
2619
+ const newIndex = segBase + (headOn ? 1 : 0)
2620
+ segNewText = segmentText(finalRows)
2621
+ metas.push({ segIndex: newIndex, kind: 'new', seq: incremental[0].seq, cumLines: baseCum, bytes: segNewText.length })
2622
+ segMeta = metas
2623
+ }
2624
+ const transcript = segmentsOn
2625
+ ? (foldText === ''
2626
+ ? (segHeadPlan ? segHeadPlan.text + '\n' + segNewText : segNewText)
2627
+ : foldText + '\n' + segNewText)
2628
+ : (foldText === ''
2629
+ ? headRows.concat(finalRows).join('\n')
2630
+ : (standaloneSeg ? finalRows.join('\n') : foldText + '\n' + finalRows.join('\n')))
2631
+
2632
+ // 折叠检查点消息由 appendFoldCheckpoint 统一构造(compaction 生命周期的承载者)。
2633
+ // 【v1.10.13】多段转录:本段只承载"本次增量",替换区间从"最后一个自身段节点之后"起算
2634
+ // (旧段节点 seq === foldSeq ⇒ 不在增量内 ⇒ 不被覆盖、留在表面)。关闭时 = 旧行为(全部被遮蔽 seq)。
2635
+ const shadowedSeqs = segmentsOn ? incremental.map((e) => e.seq) : shadowed.map((e) => e.seq)
2636
+ // 多段转录下 head 段也被遮蔽 ⇒ 其原始字节计入遮蔽;旧段节点(seq ≤ foldSeq)不被遮蔽 ⇒ 不计。
2637
+ const headShadowBytes = segmentsOn ? headEntries.reduce((a, e) => a + messageBytes(e.msg).length, 0) : 0
2638
+ const shadowedBytes = shadowed.reduce((a, e) => a + ((segmentsOn && e.seq <= foldSeq) ? 0 : messageBytes(e.msg).length), 0) + headShadowBytes
2639
+ // v1.10.1:head(只转录不遮蔽)单独计量——它不进遮蔽集/不进 surfaceOp,故不计入 shadowedBytes;
2640
+ // 单列出来便于核验"补录代价"(这段文本在请求里出现两次)。
2641
+ // 【v1.10.13】多段转录开启后 head **已被遮蔽** ⇒ 这段文本不再出现两次,headBytes 改为"该段字节"。
2642
+ const headBytes = headRows.length > 0 ? headRows.join('\n').length : 0
2643
+ // 【v1.10.9 · standalone 的前置守卫(协作 r10 要求)】提交前对账:我们保留的 `foldText`
2644
+ // 必须与 surface 上"最后一个自身转录节点"的实际文本一致(或互为前缀)。
2645
+ // 【v1.10.12 收紧(实测事故)】原判定把**任何**非前缀差异都当拒折理由,结果白名单会话
2646
+ // `1c59c181` 的折叠自 v1.10.9 起**每次都被拒**(转录仍在写、折叠零产出,而拒折当时只在 console
2647
+ // 留痕 ⇒ 从文件侧完全看不出来)。现改为**只在真丢内容时拒折**:surface 自身转录节点的文本里
2648
+ // 连 `foldText` 的**前 200 字**都找不到 ⇒ 旧转录确实被改写/丢失(继续折会静默丢内容)。
2649
+ // 其余差异(空白/换行/尾部差异等)记为 advisory `foldtext-drift-minor`,**继续折叠**、不再阻断。
2650
+ // 【v1.10.13】多段转录下 surface 上有**多个**自身转录节点 ⇒ 对账对象必须是"全部段按 seq 升序拼接"
2651
+ // (单段会话只有一个文本 ⇒ 与旧版逐字相同)。否则"末段 vs 整份 foldText"的必然差异会被误判成漂移、
2652
+ // 把多段会话的折叠直接拒绝掉。
2653
+ const ownTexts = []
2654
+ for (const e of entries) {
2655
+ if (e.msg && isOwnTranscript(e.msg)) {
2656
+ const t = blocksOf(e.msg).filter((b) => b.type === 'text').map((b) => b.text ?? '').join('')
2657
+ if (t) ownTexts.push(t)
2658
+ }
2659
+ }
2660
+ const ownNodeText = ownTexts.length > 0 ? stripSnapshotRows(ownTexts.join('\n')) : null
2661
+ const head200 = foldText.slice(0, 200)
2662
+ const foldTextDrift = ownNodeText !== null && foldText !== '' &&
2663
+ !(ownNodeText === foldText || ownNodeText.startsWith(foldText) || foldText.startsWith(ownNodeText)) &&
2664
+ !ownNodeText.includes(head200)
2665
+ const foldTextDriftMinor = ownNodeText !== null && foldText !== '' && !foldTextDrift &&
2666
+ !(ownNodeText === foldText || ownNodeText.startsWith(foldText) || foldText.startsWith(ownNodeText))
2667
+ if (foldTextDriftMinor) {
2668
+ try { log('foldtext-drift-minor', { sid, trigger, step: stepNo, ownBytes: ownNodeText.length, foldBytes: foldText.length }) } catch { /* ignore */ }
2669
+ try { console.log('[contextinjector] foldtext-drift-minor sid=' + sid + ' own=' + ownNodeText.length + 'B fold=' + foldText.length + 'B (非致命差异:继续折叠)') } catch { /* ignore */ }
2670
+ }
2671
+ if (foldTextDrift) {
2672
+ try { log('foldtext-drift', { sid, trigger, step: stepNo, ownBytes: ownNodeText.length, foldBytes: foldText.length }) } catch { /* ignore */ }
2673
+ // 【v1.10.11】拒折也要留痕:否则"漂移拒折"与"该会话根本没在折叠"在文件层面完全一样、无法区分。
2674
+ try {
2675
+ const dDir = join(STATE_DIR, '.ctxinjector', 'fold-errors')
2676
+ mkdirSync(dDir, { recursive: true })
2677
+ appendFileSync(join(dDir, sanitizeSid(String(sid ?? 'unknown').replace(/^session-?/i, '')) + '.jsonl'), JSON.stringify({ t: Date.now(), kind: 'foldtext-drift', trigger, step: stepNo, ownBytes: ownNodeText.length, foldBytes: foldText.length }) + '\n', 'utf8')
2678
+ } catch { /* ignore */ }
2679
+ console.log(`[contextinjector] foldtext-drift sid=${sid} own=${ownNodeText.length}B fold=${foldText.length}B (surface 旧转录与 foldText 不一致 ⇒ 本次不折,避免旧内容静默丢失)`)
2680
+ return { folded: false, reason: 'foldtext-drift' }
2681
+ }
2682
+ const transcriptBytes = transcript.length
2683
+ // 【v1.10.25 · 经济性口径】四个"能对外讲的量"(此前只有 shrink,而它在单段模式下**结构上必然≈1**:
2684
+ // 被替换集合里含"上一版转录节点本身" ⇒ 等于拿转录比自己。实测 0.998 就是这么来的:
2685
+ // 它既不是"折叠只省了 0.2%",也不是"折叠有效",它只是**不含信息**)。
2686
+ // liveBytes 提交后**真实请求里**的消息字节合计(真正在上下文里的量)
2687
+ // incrementalRawBytes 本次真正被转写的**新**原料字节(排除自身旧转录节点)
2688
+ // appendedBytes 本次追加进转录的字节(单段模式=全文重写,但新增只有这么多)
2689
+ // foldRatio appendedBytes / incrementalRawBytes(<1 才是省)
2690
+ const prevTranscriptBytes = (foldText === '' || standaloneSeg) ? 0 : foldText.length
2691
+ const incrementalRawBytes = rawNewBytes // 与微折门槛同源,避免两处口径漂移
2692
+ const appendedBytes = Math.max(0, transcriptBytes - prevTranscriptBytes)
2693
+ // 首折的 head 段原样保留(raw → transcript 同字节),字节中性:从分子剔除,分母本就不含它。
2694
+ const headRetainedBytes = foldText === '' ? headBytes : 0
2695
+ const foldRatio = incrementalRawBytes > 0
2696
+ ? Number((Math.max(0, appendedBytes - headRetainedBytes) / incrementalRawBytes).toFixed(4))
2697
+ : null
2698
+ // 【v1.10.38】同一个会话复用同一个 compactionId:宿主聊天区按 id 建标记节点,
2699
+ // 复用 ⇒ 只有一行「上下文已压缩」在原地更新,而不是每折一次追加一行。
2700
+ // 摘要只放两个比值(<1 表示这次/累计真的省了),不再写“规则折叠 N 条…”的长句。
2701
+ if (!st.compactionId) st.compactionId = randomUUID()
2702
+ const cumRawAfter = (Number.isFinite(st.cumRawBytes) ? st.cumRawBytes : 0) + (segHeadPlan ? headShadowBytes : 0) + incrementalRawBytes
2703
+ const cumRatio = cumRawAfter > 0 ? Number((transcriptBytes / cumRawAfter).toFixed(4)) : null
2704
+ const foldSummary = `本折 ${foldRatio === null ? '—' : foldRatio.toFixed(2)} · 累计 ${cumRatio === null ? '—' : cumRatio.toFixed(2)}`
2705
+ // 【0.1.27 · 口径统一】`shrink` 改为**本折**口径(与 foldRatio 同源):
2706
+ // 旧值 transcriptBytes/shadowedBytes 是"累计转录 ÷ 本折遮蔽",首次折叠时二者同源才显得正常,
2707
+ // 增量折叠上结构性 >1(真机 5914%)——那是混合口径,不是压缩比。
2708
+ // 累计量仍以 transcriptBytes 原样保留;缺增量数据(老记录)时回退旧算式,保证字段恒为数值。
2709
+ const foldShrink = foldShrinkOf({ appendedBytes, incrementalRawBytes, headBytes: headRetainedBytes, transcriptBytes, shadowedBytes })
2710
+ log('fold-attempt', {
2711
+ trigger, step: stepNo, turn, claimed, end, keepInject: ki ? 1 : 0,
2712
+ shadowedCount: shadowedSeqs.length, shadowedSeqs,
2713
+ headCount: headSeqs.length, headSeqs, headBytes, headRetainedBytes,
2714
+ incrementalCount: incremental.length, shadowedBytes,
2715
+ transcriptBytes, shrink: foldShrink, foldShrinkScope: 'this-fold(appended/incrementalRaw)',
2716
+ // v1.10.25 经济性口径(见上方注释):真实请求体量 / 本次新原料 / 本次新增转录 / 本折压缩比
2717
+ prevTranscriptBytes, incrementalRawBytes, appendedBytes, foldRatio, cumRawAfter: cumRawAfter, cumRatio,
2718
+ transcriptHash: hash256(transcript).slice(0, 16),
2719
+ monotonic: standaloneSeg ? 'segment' : (foldSeq >= 0 ? transcript.startsWith(foldText) : 'first'),
2720
+ lossy: mode !== 'off' ? 1 : 0, mode, aCount: stat?.a ?? 0, aFolded: stat?.folded ?? 0, aFallback: stat?.fallback ?? 0, aKeys: stat?.keys ?? 0,
2721
+ calls: stat?.calls ?? 0, ms: stat?.ms ?? 0, budgetHit: stat?.budgetHit === true, // v1.10.5 预算法统计(r9 ★必须-5:直接入字面量)
2722
+ fallbackReasons: stat?.fallbackReasons, maxCallsUsed: stat?.calls ?? 0, // v1.10.19 P5:回退原因分型(只含非零项)
2723
+ salvaged: stat?.salvaged, condenseReasoningEffort: stat?.condenseReasoningEffort, // v1.10.20:截断抢救条数 + 生效推理档位(便于事后归因)
2724
+ keysDiag: stat?.keysDiag, // v1.10.21:K 通道归因(ok / noValues / callFailed[:原因])
2725
+ // v1.10.23 经济性:辅助调用真实用量 + 预估/实测回本回合 + 因回本不划算而**未发起**的条数
2726
+ auxCalls: stat?.auxCalls, auxTokens: stat?.auxTokens, auxBilled: stat?.auxBilled,
2727
+ paybackHorizon: stat?.paybackHorizon, paybackEst: stat?.paybackEst, paybackMeasured: stat?.paybackMeasured, paybackSkipped: stat?.paybackSkipped,
2728
+ })
2729
+ console.log(`[contextinjector] fold-attempt trigger=${trigger} step=${stepNo} turn=${turn} end=${end} shadowed=${shadowedSeqs.length} head=${headSeqs.length}${headSeqs.length ? '(' + headSeqs.join(',') + ')' : ''} +inc=${incremental.length} ${incrementalRawBytes}B->${appendedBytes}B ratio=${foldRatio === null ? '-' : foldRatio.toFixed(2)}/${cumRatio === null ? '-' : cumRatio.toFixed(2)} [累计转录 ${transcriptBytes}B] condense=${mode} monotonic=${foldSeq >= 0 ? transcript.startsWith(foldText) : 'first'}`)
2730
+ // 【v1.10.20 · 真机事故】提交前的最后一道让路检查(覆盖**所有**触发路径:pre-step 直折不经 scheduleFold):
2731
+ // DSH compaction 在飞行中时提交 `surfaceOp replace` 会让它的总结作废
2732
+ // (`compaction: session surface changed during summarization`)⇒ 本轮不折,下一轮再折,内容不丢。
2733
+ if (isCompacting(sid)) {
2734
+ try { log('fold-skip', { trigger, step: stepNo, turn, sid, reason: 'compacting-at-commit' }) } catch { /* ignore */ }
2735
+ console.log(`[contextinjector] fold-skip trigger=${trigger} step=${stepNo} reason=compacting-at-commit(DSH 正在 compaction ⇒ 本次不提交,避免作废它的总结)`)
2736
+ return { folded: false, reason: 'compacting' }
2737
+ }
2738
+ // 【v1.10.75】在飞期间被原生压缩换过卷 ⇒ 本次折叠作废(下一轮按新卷基线重新折)。
2739
+ // 判据是 epoch 而不是 _compacting:压缩可能已经在本次折叠的 await 期间 start→end 走完。
2740
+ if ((Number(st.sealEpoch) || 0) !== sealEpochAtEntry) {
2741
+ try { log('fold-skip', { trigger, step: stepNo, turn, sid, reason: 'sealed-midflight' }) } catch { /* ignore */ }
2742
+ console.log(`[contextinjector] fold-skip trigger=${trigger} step=${stepNo} reason=sealed-midflight(折叠在飞期间原生压缩换卷 ⇒ 本次不提交)`)
2743
+ return { folded: false, reason: 'sealed-midflight' }
2744
+ }
2745
+ try {
2746
+ // 【v1.10.13 多段转录】head 段**先**提交(seq 升序),再提交新段 —— 两段互不覆盖:
2747
+ // head 段的区间只覆盖注入节点之前的节点(不含注入节点 ⇒ keepInject 分毫未动),
2748
+ // 新段的区间从"最后一个段节点之后"起算(既有段留在表面 ⇒ 拼起来才是完整转录)。
2749
+ // 中间状态必须先写回 st:若第二段失败,catch 会清空状态强制重新 seed(从表面真值重建)。
2750
+ if (segHeadPlan) {
2751
+ appendFoldCheckpoint(session, {
2752
+ transcript: segHeadPlan.text,
2753
+ shadowedSeqs: segHeadPlan.seqs,
2754
+ turn: trigger === 'pre-step' ? turn : null,
2755
+ shadowedBytes: headShadowBytes,
2756
+ compactionId: st.compactionId,
2757
+ summaryText: foldSummary,
2758
+ })
2759
+ foldSeq = segHeadPlan.startSeq
2760
+ foldText = segHeadPlan.text
2761
+ st.foldSeq = foldSeq; st.foldText = foldText
2762
+ st.lastSegSeq = foldSeq
2763
+ console.log(`[contextinjector] segments head 段已提交 seq=${segHeadPlan.startSeq}..${segHeadPlan.endSeq} ${segHeadPlan.text.length}B(首轮补录同时遮蔽 ⇒ 该段文本在请求里不再出现两遍)`)
2764
+ }
2765
+ appendFoldCheckpoint(session, {
2766
+ transcript: segmentsOn ? segNewText : transcript,
2767
+ shadowedSeqs,
2768
+ turn: trigger === 'pre-step' ? turn : null,
2769
+ shadowedBytes,
2770
+ compactionId: st.compactionId,
2771
+ summaryText: foldSummary,
2772
+ })
2773
+ st.cumRawBytes = cumRawAfter
2774
+ foldSeq = shadowedSeqs[0] // 折叠消息占据 start seq;其后 append 的节点归下次增量
2775
+ foldText = transcript
2776
+ foldRound = roundState.n
2777
+ if (TEST_ROUND) foldedOnce = true
2778
+ // 多段转录:段号与"最后一段起点 seq"(段头编号与承接关系据此,重启 seed 也按此恢复)
2779
+ if (segmentsOn) {
2780
+ st.segCount = (Number.isFinite(st.segCount) ? st.segCount : 0) + (segHeadPlan ? 1 : 0) + 1
2781
+ st.lastSegSeq = foldSeq
2782
+ }
2783
+ // 写回 per-session 状态(同一 web 进程多会话互不串扰)
2784
+ st.foldSeq = foldSeq; st.foldText = foldText; st.foldRound = foldRound; st.foldedOnce = foldedOnce
2785
+ // 【v1.10.75】新卷已落第一条检查点 ⇒ 卷边界生效,后续折叠恢复"边界之后"的常规增量语义
2786
+ if (freshVol) { st.freshVolume = false; st.vol = Number(st.vol) || 1 }
2787
+ const after = snapshotOf(session)
2788
+ // v1.10.25:liveBytes = 提交后**真实请求**里各条消息的实际字节合计("上下文里到底有多大")。
2789
+ // 它与"日志里历史事件体的总和"是两个完全不同的量:后者随折叠次数线性累加(每次重写整份转录
2790
+ // 都会在追加式日志里留下一份当时全文),把后者当成"重复注入的份数"会得出 ~29× 的假象。
2791
+ // 本字段与 tools/measure-prompt-surface.mjs 的不变量判据互为交叉校验。
2792
+ const liveBytes = Array.isArray(after) ? after.reduce((a, e) => a + (Number(e.bytes) || 0), 0) : null
2793
+ rawAppend(sid, incremental) // raw 权威存证:每次成功折叠把被遮蔽增量原文全量落盘(无论是否 lossy)
2794
+ log('fold-committed', { trigger, step: stepNo, after, liveBytes, incrementalRawBytes, appendedBytes, headRetainedBytes, foldRatio, cumRatio })
2795
+ console.log(`[contextinjector] fold trigger=${trigger} @step${stepNo}: ${incrementalRawBytes}->${appendedBytes} B (${(1 / Math.max(1e-9, foldShrink)).toFixed(2)}x), round=${foldRound}${mode !== 'off' ? ` condense[${mode}]:[A]${stat?.folded ?? 0}/${stat?.a ?? 0}` : ''}`)
2796
+ // 【v1.10.5 ★必须-1】折叠索引(只追加,供 LogCompiler K 做 seq ↔ 转录累计行号双向溯源)
2797
+
2798
+ try {
2799
+ // v1.10.13:多段转录下 head 段同属本次折叠的遮蔽范围 ⇒ 一并记入索引行(headSeqs 为附加字段);
2800
+ // shadowedSeqs 仍是"本次 replace 覆盖的 seq"(= 增量),cumLines 语义**不变**(全局累计行号)。
2801
+ writeFoldIndex({ sid, trigger, foldSeq: shadowedSeqs[0], shadowedSeqs, headSeqs: segmentsOn ? headSeqs : [], cumBefore: cumLinesBeforeEntry, cumAfter: transcript.split('\n').length, shadowedBytes, transcriptBytes, liveBytes, appendedBytes, incrementalRawBytes })
2802
+
2803
+ } catch (err) { console.error('[contextinjector] fold-index call failed:', err?.message ?? err) }
2804
+ persistLastFold({
2805
+ t: Date.now(), plugin: PLUGIN_VERSION, sessionId: sid, gate, keepInject: ki ? 1 : 0, mode, trigger, step: stepNo, round: foldRound,
2806
+ // v1.10.75:卷号 / 本轮是否新卷的第一折 / 原生摘要边界(新卷起点的 seq)——面板与审计都据此区分"卷"
2807
+ vol: Number(st.vol) || 1, resumedFromBoundary: freshVol ? 1 : undefined, resumeAfterSeq: Number.isFinite(st.resumeAfterSeq) ? st.resumeAfterSeq : undefined,
2808
+ condenseRoute, // v1.8:实际浓缩模型(provider/model/来源),便于核验"用了谁的模型"
2809
+ condenseErr, // v1.9:本次折叠浓缩模型调用失败原因(无则 undefined,JSON 省略)
2810
+ condenseErrKind, // #9:condenseErr 的分型 enum(供 WebUI 如实展示错误类别)
2811
+ condenseErrResolved: condenseErrResolved || undefined, // 出过错但全段折叠成功 ⇒ 面板降级为提示而非报错
2812
+ shadowedBytes, transcriptBytes, shrink: foldShrink, foldShrinkScope: 'this-fold((appended-headRetained)/incrementalRaw)',
2813
+ // 【v1.10.25】`shrink` 保留(面板/历史消费方零改动),但它**不是收益指标**:单段模式下
2814
+ // 被替换集含上一版转录节点本身 ⇒ 结构上恒 ≈1(实测 0.998)。真正的收益看下面四个量:
2815
+ liveBytes, incrementalRawBytes, appendedBytes, headRetainedBytes, foldRatio, cumRatio,
2816
+ headSeqs, headBytes, // v1.10.1:本次折叠补录的头部节点;v1.10.13 起多段转录下该段**同时被遮蔽**(不再重复)
2817
+ transcriptHash: hash256(transcript).slice(0, 16),
2818
+ // v1.10.13 多段转录:新增 `segments`/`segCount`(`full` 仍 = 全部段按序拼接 ⇒ 面板与协作方零改动)
2819
+ segments: segMeta ?? undefined, segCount: segMeta ? segMeta.length : undefined,
2820
+ monotonic: standaloneSeg ? 'segment' : (foldSeq >= 0 ? transcript.startsWith(foldText) : 'first'),
2821
+ lossy: mode !== 'off' ? 1 : 0, aCount: stat?.a ?? 0, aFolded: stat?.folded ?? 0, aFallback: stat?.fallback ?? 0, aKeys: stat?.keys ?? 0,
2822
+ calls: stat?.calls ?? 0, ms: stat?.ms ?? 0, budgetHit: stat?.budgetHit === true, // v1.10.7 预算法统计(此处是第二个 persistLastFold 字面量,v1.10.5 的补丁只落在第一个)
2823
+ fallbackReasons: stat?.fallbackReasons, // v1.10.19 P5:本折回退原因分型(compact/short/empty/noShrink/llmErr/payloadGate/maxCalls/budgetMs)
2824
+ salvaged: stat?.salvaged, condenseReasoningEffort: stat?.condenseReasoningEffort, maxTokensSent: mtSent || undefined, effortDowngraded: downgrades || undefined, // v1.10.20:截断抢救条数 + 生效推理档位;v1.10.64 实际发出的 max_tokens
2825
+ keysDiag: stat?.keysDiag, // v1.10.21:K 通道归因(ok / noValues / callFailed[:原因])
2826
+ // v1.10.23 经济性:与第一个 persist 字面量保持同源(历史上这里漏打过补丁,故两处都写)
2827
+ auxCalls: stat?.auxCalls, auxTokens: stat?.auxTokens, auxBilled: stat?.auxBilled,
2828
+ paybackHorizon: stat?.paybackHorizon, paybackEst: stat?.paybackEst, paybackMeasured: stat?.paybackMeasured, paybackSkipped: stat?.paybackSkipped,
2829
+ preview: transcript.slice(0, 400), // 注入内容预览(前 400 字符,供 WebUI 展示;完整消息在会话 surface 内)
2830
+ tailPreview: transcript.slice(-300), // 尾部增量预览:用于确认折叠持续更新到最新轮
2831
+ afterNodes: after, // 折叠后请求的消息结构(role/src/bytes/hash)——核验"该看的在、不该看的无"
2832
+ full: transcript, // 转录全文(供面板结构化渲染;单条消息量级小)
2833
+ })
2834
+ // #5 压缩器IO比(2026-09-10 口径重写):折叠成功后记一笔 inBytes/outBytes/ratio(不再传 S)。
2835
+ try {
2836
+ ivrRecord(STATE_DIR, sid, {
2837
+ n: foldRound, step: stepNo, shadowedBytes, transcriptBytes, ts: Date.now(),
2838
+ inBytes: incrementalRawBytes, // 本折真正喂进压缩器的原料(单折量;旧版用 shadowedBytes−累计转录,会钳成 0)
2839
+ })
2840
+ } catch (err) { console.error('[contextinjector] ivrRecord failed:', err?.message ?? err) }
2841
+ } catch (err) {
2842
+ log('fold-rejected', { trigger, step: stepNo, error: String(err?.message ?? err) })
2843
+ console.error(`[contextinjector] fold-rejected trigger=${trigger} step=${stepNo}: ${String(err?.message ?? err)}`)
2844
+ // 【v1.10.13】多段转录一次折叠要提交两段 ⇒ 第一段落面、第二段失败时"状态 vs 表面"不再保证一致。
2845
+ // 处理:清空本会话状态并允许重新 seed —— seed 按 seq 拼接**全部**段节点(表面真值)⇒ 下轮自动重建。
2846
+ // 绝不留"foldText 指向表面已不存在的文本"这种静默错误状态。
2847
+ if (segmentsOn) {
2848
+ try { st.foldText = ''; st.foldSeq = -1; st.seedChecked = false; st.segCount = 0; st.lastSegSeq = -1; st.cumRawBytes = 0 } catch { /* ignore */ }
2849
+ console.log('[contextinjector] segments 提交失败 ⇒ 已清空会话状态,下次折叠重新 seed(从表面真值重建 foldText)')
2850
+ }
2851
+ return { folded: false, reason: 'append-failed' }
2852
+ }
2853
+ return { folded: true, mode, trigger, round: foldRound, shadowedBytes, transcriptBytes, stat }
2854
+ }
2855
+
2856
+ // ── 在飞折叠登记(turn/end 排队 → pre-step join)【取自 RCS-0.1.0】──────────────
2857
+ // 为什么后台折:浓缩模型(本地 4B 约 7–18s,实测一次 60.1s)绝不能挡在用户下一轮前面。
2858
+ // 故轮末只登记后台任务,pre-step 在真正发请求前 join(**带超时**,见 joinFoldInFlight),
2859
+ // 保证请求看到的一定是已折叠 surface,且永不被慢模型卡住。
2860
+ const foldInFlight = new Map() // sid -> Promise
2861
+ // 同一会话已有折叠在飞 → 复用(不重复排队;下一轮/下一轮结束会再试)
2862
+ function scheduleFold(args) {
2863
+ const sid = args.sid
2864
+ const prev = foldInFlight.get(sid)
2865
+ if (prev) return prev
2866
+ // 【v1.10.20】DSH 正在做 compaction ⇒ 让路(第一道:登记时)。折叠延后到下一轮,内容不丢。
2867
+ if (isCompacting(sid)) {
2868
+ logFoldSkip(args.trigger, 'compacting', sid)
2869
+ return null
2870
+ }
2871
+ // 【在飞标记】不在这里写/删了(v1.10.2):改由 foldAttempt 入口写、finally 删 —— 那条路径覆盖
2872
+ // 所有调用点(轮末排队 / TEST_ROUND 直折),而本函数只覆盖前者(这正是真机 bug)。
2873
+ // 本处也不再需要"落定即删":foldAttempt 的 finally 就是唯一的删除点;而上面 prev 去重分支**不会**
2874
+ // 写标记(它复用别人已登记的 Promise,那个折叠自己的 finally 负责删)⇒ 不存在留下"永久压缩中"的路径。
2875
+ const p = (async () => {
2876
+ try { return await foldAttempt(args) }
2877
+ catch (err) { log('handler-error', { trigger: args.trigger, error: String(err?.message ?? err) }); return null }
2878
+ })()
2879
+ p.then(() => {}, () => {}) // 兜底:绝不让 Promise 拒绝外泄
2880
+ p.then(() => { if (foldInFlight.get(sid) === p) foldInFlight.delete(sid) })
2881
+ foldInFlight.set(sid, p)
2882
+ return p
2883
+ }
2884
+ // 【安全闸】join 必须**有超时**:下一轮 pre-step 绝不能无限期等一个慢/卡死的浓缩模型。
2885
+ // 超时 ⇒ 只记一条 fold-join-timeout 并继续(下一轮请求用当前 surface;该次折叠结果稍后落地,
2886
+ // 等价于#6 之前的旧行为)。超时**不**取消后台任务:让它自然跑完,不打断 in-flight 的 session.append。
2887
+ async function joinFoldInFlight(sid, timeoutMs, stepNo) {
2888
+ const p = foldInFlight.get(sid)
2889
+ if (!p || typeof p.then !== 'function') return { joined: false, timedOut: false, waitMs: 0 }
2890
+ const ms = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 3000
2891
+ const t0 = Date.now()
2892
+ let timer = null
2893
+ const timeout = new Promise((resolve) => {
2894
+ // 不 unref:该计时器就是 join 的**安全闸**,必须保证它一定会触发
2895
+ //(unref 后若事件循环恰好只剩这个计时器,进程会先退出/TLA 报警,超时反而没人兜底)。
2896
+ // 正常路径(后台折叠先完成)会 clearTimeout,不会留下悬挂计时器。
2897
+ timer = setTimeout(() => resolve({ joined: false, timedOut: true, waitMs: Date.now() - t0 }), ms)
2898
+ })
2899
+ const settled = Promise.resolve(p).then(() => ({ joined: true, timedOut: false, waitMs: Date.now() - t0 }), () => ({ joined: true, timedOut: false, waitMs: Date.now() - t0 }))
2900
+ const out = await Promise.race([settled, timeout])
2901
+ if (timer) clearTimeout(timer)
2902
+ if (out.timedOut) console.log(`[contextinjector] fold-join-timeout step=${stepNo} sid=${sid} wait=${out.waitMs}ms>=${ms}ms (后台折叠仍在飞;本轮用当前 surface,折叠结果稍后落地)`)
2903
+ return out
2904
+ }
2905
+ // 轮末的廉价预检:surface 上还有"上次折叠之后"的节点才值得排队(否则纯属浪费一次调度)
2906
+ function hasFoldCandidate(session, surface, sid) {
2907
+ try {
2908
+ const st = stateOf(sid)
2909
+ const nodes = [...(surface?.nodes ?? [])]
2910
+ if (nodes.length === 0) return false
2911
+ return st.foldSeq < 0 ? (st.foldText === '') : nodes.some((seq) => seq > st.foldSeq)
2912
+ } catch { return false }
2913
+ }
2914
+
2915
+ // #6 折叠时机**不缓存生效值**:与本模块其它 control 项(keepInject / condenseMinBytes / prompts)同规矩,
2916
+ // 每次触发现读 control.json(改档无需重启);gate 在时点未知,故此处不对 ON/OFF 作断言。
2917
+
2918
+ // 触发点被挡下时留一行可诊断日志(同一 sid+原因只记一次,不刷屏)。
2919
+ const _foldSkipLogged = new Set()
2920
+ const logFoldSkip = (where, reason, sid, extra) => {
2921
+ try {
2922
+ const k = where + '|' + reason + '|' + String(sid ?? '-')
2923
+ if (_foldSkipLogged.has(k)) return
2924
+ _foldSkipLogged.add(k)
2925
+ console.log(`[contextinjector] ${where}-skip reason=${reason} sid=${sid ?? '-'}${extra ? ' ' + extra : ''}`)
2926
+ } catch { /* 诊断失败绝不影响折叠 */ }
2927
+ }
2928
+
2929
+ // ── 触发点 1:round 边界 —— **只 join,绝不折叠** ────────────────────────────
2930
+ // 实测(2026-10-22 session-3655c2fe):旧实现在 next() 之后同步 await foldAttempt,
2931
+ // 一次 748B 的浓缩跑了 60.1s,用户发消息后要等它结束才开始回答。折叠的唯一生产者是
2932
+ // 轮末(turn/end)后台任务;这里只做两件事:join 在飞折叠(带超时)、原样放行请求。
2933
+ // next() 必须在**所有路径**上恰好调用一次(包括 join 抛错)——故放到 try 之外。
2934
+ ctx.on('agent/pre-step', async ({ agent, messages, turn, step: stepNo }, next) => {
2935
+ // 每步开头补挂一次(无害:已存在即返回),躲开启动早期的层重建。
2936
+ try { ensureTranscriptExpandRegistered('pre-step') } catch { /* ignore */ }
2937
+ const claimed = messages?.length ?? 0
2938
+ let testFold = null
2939
+ try {
2940
+ const session = agent?.session
2941
+ const surface = session?.surface
2942
+ if (!session || !surface) {
2943
+ logFoldSkip('pre-step', 'no-session-or-surface', session ? sessionIdOf(session) : null)
2944
+ } else {
2945
+ const sid = sessionIdOf(session)
2946
+ const ctrl = readControl()
2947
+ const gate = gateFor(sid, ctrl, session).gate
2948
+ // 轮级诊断(claimed>0 每轮一条):web 终端可见 —— 用于定位 pre-step 是否到达 / sid 与白名单是否匹配
2949
+ if (claimed > 0) console.log(`[contextinjector] round step=${stepNo} turn=${turn} sid=${sid} gate=${gate} enabled=${ctrl?.enabled === true} whitelist=${JSON.stringify(ctrl?.sessions ?? [])}`)
2950
+ if (gate !== 'off') {
2951
+ if (TRACE) {
2952
+ try { log('trace', { step: stepNo, turn, claimed, transcript: transcriptNodesOf(session.deriveMessages()) }) } catch { /* best-effort */ }
2953
+ }
2954
+ // 【v1.10.56 · 阻塞 join 回归】用户开压缩就是为了吃增益,新消息必须等压缩完再发。
2955
+ // 旧实现(v1.10.53):非阻塞 join,新消息不等压缩完 ⇒ 本轮吃不到增益,压缩白做。
2956
+ // 现改回 await:pre-step 等压缩完成(带超时闸)再放行请求,保证每轮都吃到压缩增益。
2957
+ await joinFoldInFlight(sid, foldJoinTimeoutMs(ctrl), stepNo)
2958
+ // 回归通道(缺省关):headless 单轮没有 turn/end 事件,仍允许在 pre-step 同步折一次。
2959
+ // 必须在 next() **之后**折(那时本轮 [A] 才在 surface 上);生产路径不会走到这里。
2960
+ if (TEST_ROUND) testFold = { session, surface, sid, ctrl, gate, stepNo, turn, claimed }
2961
+ }
2962
+ }
2963
+ } catch (err) {
2964
+ log('handler-error', { error: String(err?.message ?? err) })
2965
+ }
2966
+ const decision = await next()
2967
+ if (testFold) {
2968
+ try { await foldAttempt({ ...testFold, trigger: 'pre-step' }) } catch (err) { log('handler-error', { error: String(err?.message ?? err) }) }
2969
+ }
2970
+ return decision
2971
+ })
2972
+
2973
+ // ── 触发点 2:turn/end 后台折(**唯一的生产折叠触发点**)──────────────────────
2974
+ // turn/end 是必然发生的会话事件。debounce 400ms 等 surface 落定后登记后台折叠;
2975
+ // 折叠结果落 last-fold.json → 面板与 LOG 立即刷新,下一轮 pre-step 只需 join。
2976
+ // 旧的 agent/turn-stopping 触发点已删除:本栈实测它 0 次有效折叠(EXTREASON 的 next-step
2977
+ // 让它几乎不派发),留着只会制造第二套并行状态。现在同一时刻最多一个后台折叠。
2978
+ const _turnEndTimers = new Map() // sid -> timer
2979
+ // 【v1.10.20 · 真机事故 2026-09-12(session-0cea2d8f,会话被撑到 830K token 后 DSH 触发自带 compaction)】
2980
+ // DSH 的 compaction 会先 snapshot surface、再让模型总结,结束时校验"surface 没变";而我们的折叠
2981
+ // 在同一时间提交了一个 `surfaceOp replace` ⇒ DSH 报
2982
+ // `compaction: session surface changed during summarization` 并**放弃压缩**,下一轮再撞上下文上限、
2983
+ // 再触发 compaction、再被我们打断……形成死循环(日志实测连续两轮同样报错)。
2984
+ // 对策:跟踪每个会话的 compaction 在飞状态(DSH 会发 `compaction/start` / `compaction/end` 事件),
2985
+ // 在折叠**登记时**与**提交前**各查一次 ⇒ 让路给 DSH,等下一轮再折。折叠本身不丢内容,只是延后。
2986
+ const _compacting = new Set() // sid:DSH 正在对它会话做 compaction
2987
+ // 【v1.10.75 · 封存当前卷】原生压缩结束后调用一次(非自身、非错误)。
2988
+ // 做三件事,顺序不能反:
2989
+ // ① 先把**当前卷**的转录原文落盘(sealed/<sid>/v<N>.json + 索引),这是"封存"的实体;
2990
+ // ② 记 baseRound(继承轮次的下限)与 resumeAfterSeq(原生摘要覆盖到的最后 seq);
2991
+ // ③ 再把会话基线切到新卷:foldText 清空、foldSeq = resumeAfterSeq、foldRound 保留(续号)。
2992
+ // 失败语义:落盘失败 ⇒ 不封存、不清基线(宁可重复注入一次,也不丢历史且不知道丢在哪)。
2993
+ function sealVolume(sid, st, session, compactionId) {
2994
+ const b = nativeBoundaryOf(session, compactionId)
2995
+ // 只有"摘要给出"的边界是单调的,才能拿来做 `seq > boundary` 过滤;检查点边界只用于位置切分。
2996
+ const resumeAfterSeq = b.source === 'summary' ? b.seq : -1
2997
+ const boundary = b.seq
2998
+ const prev = readSealIndex(sid) ?? { sessionId: sid, baseRound: 0, resumeAfterSeq: -1, volumes: [] }
2999
+ const vols = Array.isArray(prev.volumes) ? [...prev.volumes] : []
3000
+ const v = vols.length + 1
3001
+ const text = stripSnapshotRows(st.foldText)
3002
+ let rec = null
3003
+ if (text !== '') {
3004
+ const fromSeq = Number.isFinite(st.foldSeq) && st.foldSeq >= 0 ? st.foldSeq : (Number.isFinite(prev.resumeAfterSeq) ? Number(prev.resumeAfterSeq) : -1)
3005
+ const meta = {
3006
+ v,
3007
+ sealedAt: Date.now(),
3008
+ nativeCompactionId: compactionId ?? null,
3009
+ rounds: roundRangeOf(text),
3010
+ seq: [fromSeq, boundary],
3011
+ bytes: text.length,
3012
+ sha256: hash256(text),
3013
+ }
3014
+ rec = meta
3015
+ try {
3016
+ mkdirSync(dirname(sealedVolumePath(sid, v)), { recursive: true })
3017
+ writeFileSync(sealedVolumePath(sid, v), JSON.stringify({ ...meta, text }, null, 2) + '\n', 'utf8')
3018
+ vols.push(meta)
3019
+ } catch (err) {
3020
+ console.error('[contextinjector] seal-volume write failed:', err?.message ?? err)
3021
+ return { sealed: false, reason: 'write-failed', v, boundary, baseRound: Number(st.foldRound) || 0 }
3022
+ }
3023
+ }
3024
+ const baseRound = Math.max(Number(prev.baseRound) || 0, Number(st.foldRound) || 0, Number(rec?.rounds?.[1]) || 0)
3025
+ const wrote = writeSealIndex(sid, {
3026
+ sessionId: sid,
3027
+ baseRound,
3028
+ resumeAfterSeq,
3029
+ boundarySeq: boundary,
3030
+ boundaryFrom: b.source,
3031
+ volumes: vols.slice(-50),
3032
+ updatedAt: Date.now(),
3033
+ })
3034
+ // 索引没落盘 ⇒ 不切基线:重启后会拿不到"哪些是封存卷",那就会把旧卷再灌回来。
3035
+ // 宁可这次重复注入,也不要进入"知道丢了但不知道丢在哪"的状态。
3036
+ if (!wrote) return { sealed: false, reason: 'index-write-failed', v, boundary, boundaryFrom: b.source, resumeAfterSeq, baseRound }
3037
+ // 新卷(顺序:先把落盘做成功,再动内存基线)
3038
+ st.foldText = ''
3039
+ st.foldSeq = resumeAfterSeq
3040
+ st.lastSegSeq = resumeAfterSeq
3041
+ st.segCount = 0
3042
+ st.cumRawBytes = 0
3043
+ st.compactionId = null // 新卷 = 新的 UI 检查点行(旧行已被宿主总结或因封存而失效)
3044
+ st.seedChecked = false
3045
+ st.seeded = false
3046
+ st.freshVolume = true
3047
+ st.vol = v
3048
+ st.resumeAfterSeq = resumeAfterSeq
3049
+ st.boundarySeq = boundary
3050
+ st.sealEpoch = (Number(st.sealEpoch) || 0) + 1 // 让在飞折叠作废(见 foldBody 提交前的比对)
3051
+ st.foldRound = baseRound // ① 轮次继承 ② 新卷起点
3052
+ return { sealed: rec !== null, v, boundary, boundaryFrom: b.source, resumeAfterSeq, bytes: rec ? rec.bytes : 0, rounds: rec ? rec.rounds : null, baseRound }
3053
+ }
3054
+ ctx.on('session/event', (a, b) => {
3055
+ try {
3056
+ const ev = (b && typeof b === 'object' && typeof b.type === 'string') ? b
3057
+ : (a && typeof a === 'object' && typeof a.type === 'string' ? a : null)
3058
+ if (!ev) return
3059
+ if (ev.type !== 'compaction/start' && ev.type !== 'compaction/end') return
3060
+ if (isSelfCompactionId(ev?.data?.compactionId)) return // 自己提交的折叠生命周期,不需要让路
3061
+ const session = (a && (a.id || a.sessionId || a.surface)) ? a : (ev.session ?? a?.session ?? null)
3062
+ const sid = sessionIdOf(session) ?? ev?.data?.sessionId ?? null
3063
+ if (!sid) return
3064
+ const bare = sanitizeSid(String(sid).replace(/^session-?/i, ''))
3065
+ const nativeFile = join(NATIVE_COMPACTION_DIR, bare + '.json')
3066
+ if (ev.type === 'compaction/start') {
3067
+ _compacting.add(sid)
3068
+ try {
3069
+ mkdirSync(NATIVE_COMPACTION_DIR, { recursive: true })
3070
+ writeFileSync(nativeFile, JSON.stringify({ sessionId: sid, startedAt: Date.now() }) + '\n', 'utf8')
3071
+ } catch { /* 观测面 */ }
3072
+ console.log(`[contextinjector] DSH compaction 开始(sid=${sid})⇒ 让路:本次不提交折叠,避免 "surface changed during summarization"`)
3073
+ } else {
3074
+ _compacting.delete(sid)
3075
+ try { rmSync(nativeFile, { force: true }) } catch { /* 观测面 */ }
3076
+ console.log(`[contextinjector] DSH compaction 结束(sid=${sid}${ev?.data?.error ? ' err=' + String(ev.data.error).slice(0, 80) : ''})`)
3077
+ // 【v1.10.75 · 卷封存(取代 v1.10.74 的"清基线")】
3078
+ // 实测(2026-11-11,session-64963da1 / spark 131072 窗口):DSH 原生压缩把插件的折叠
3079
+ // 检查点一起总结掉之后,旧基线仍指向压缩前的整份转录,下一折不把宿主已总结的内容重新灌回
3080
+ // 上下文(v1.10.74 修的问题),而且旧卷在原生摘要覆盖范围里已无信息增量。
3081
+ // 现在把旧卷**封存**(原文落盘、不再进上下文),新卷继承轮次、只从原生摘要边界之后缀增。
3082
+ // 原生压缩报错(surface 未变)时不动基线:没有新总结可以接管,封存只会白白切断连续转录。
3083
+ if (nativeCompactionResetOn(readControl())) {
3084
+ if (ev?.data?.error) {
3085
+ console.log('[contextinjector] 原生压缩失败 ⇒ 不封存(基线保持,等下一次成功压缩再交接)')
3086
+ } else {
3087
+ const st = stateOf(sid)
3088
+ // 本插件在该会话还没有任何卷(从未折过)⇒ 无可封存、也无可继承:不落任何文件,
3089
+ // 新卷语义由 foldBody 的 leadCut(最后一条宿主检查点之后)自然成立。
3090
+ const hasLiveVolume = st.foldText !== '' || (Number.isFinite(st.foldSeq) && st.foldSeq >= 0) || (Number(st.segCount) || 0) > 0
3091
+ if (!hasLiveVolume && !st.freshVolume) {
3092
+ try { log('native-compaction-noop', { sid, reason: 'no-live-volume' }) } catch { /* 观测面 */ }
3093
+ } else {
3094
+ const r = sealVolume(sid, st, session, ev?.data?.compactionId ?? null)
3095
+ try { log('native-compaction-seal', { sid, ...r }) } catch { /* 观测面 */ }
3096
+ if (r.sealed) {
3097
+ console.log(`[contextinjector] 原生压缩 ⇒ 封存第 ${r.v} 卷(${r.bytes}B,轮次 ${r.rounds?.[0] ?? '?'}–${r.rounds?.[1] ?? '?'},边界 seq=${r.boundary}/${r.boundaryFrom});新卷继承轮次 ${r.baseRound}、只从边界之后缀增`)
3098
+ } else {
3099
+ console.log(`[contextinjector] 原生压缩 ⇒ 无旧卷可封存(基线为空 / 落盘失败 ${r.reason ?? ''}),边界 seq=${r.boundary}/${r.boundaryFrom},新卷继承轮次 ${r.baseRound}`)
3100
+ }
3101
+ }
3102
+ }
3103
+ }
3104
+ }
3105
+ } catch { /* 观测面永不抛 */ }
3106
+ })
3107
+ // 供 foldBody 在提交前复查(登记时查一次、提交前再查一次,两道)
3108
+ const isCompacting = (sid) => !!sid && _compacting.has(sid)
3109
+ ctx.on('session/event', (a, b) => {
3110
+ try {
3111
+ const ev = (b && typeof b === 'object' && typeof b.type === 'string') ? b
3112
+ : (a && typeof a === 'object' && typeof a.type === 'string' ? a : null)
3113
+ if (!ev || ev.type !== 'turn/end') return
3114
+ // 【v1.10.5(协作 r4 O-13)】取参不能要求首参带 surface:否则下一行判 no-session-or-surface 后静默返回,
3115
+ // 表现为『既无 turn-end 也无 skip』(对方实测其会话已在白名单、gate=control)。
3116
+ const session = (a && (a.id || a.sessionId || a.surface)) ? a : (ev.session ?? a?.session ?? null)
3117
+ const surface = session?.surface ?? session?.deriveSurface?.() ?? null
3118
+ if (!session || !surface) { logFoldSkip('turn-end', 'no-session-or-surface', session ? sessionIdOf(session) : null); return }
3119
+ const sid = sessionIdOf(session)
3120
+ const ctrl = readControl()
3121
+ if (!foldAtTurnEndOn(ctrl)) { logFoldSkip('turn-end', 'switch-off', sid); return }
3122
+ const gate = gateFor(sid, ctrl, session).gate
3123
+ if (!foldGateOpen(ctrl, gate)) { logFoldSkip('turn-end', 'gate-' + gate, sid); return }
3124
+ if (!hasFoldCandidate(session, surface, sid)) { logFoldSkip('turn-end', 'no-candidate', sid); return }
3125
+ const prev = _turnEndTimers.get(sid)
3126
+ if (prev) clearTimeout(prev)
3127
+ const timer = setTimeout(() => {
3128
+ _turnEndTimers.delete(sid)
3129
+ try {
3130
+ void scheduleFold({ session, surface, sid, ctrl, gate, stepNo: 0, turn: ev?.data?.turn ?? 0, claimed: 0, trigger: 'turn-end' })
3131
+ } catch (err) { console.error('[contextinjector] turn-end schedule failed:', err?.message ?? err) }
3132
+ }, 400)
3133
+ _turnEndTimers.set(sid, timer)
3134
+ } catch (err) { console.error('[contextinjector] turn-end hook failed:', err?.message ?? err) }
3135
+ })
3136
+ },
3137
+ }
3138
+ // 测试/诊断句柄(named export 不影响 cordis 按 default 加载插件)
3139
+ export { writeFoldIndex, condenseMode, perSessionMode, condenseText, callIndexFromEvents, isToolResultMsg, toolResultCallId, toolVerdictTag, boundedPreview, rawPtrMinBytesOf, extractKeys, applyCondense, guardText, restoreText, verifyAgainstSource, occursWhole, rawAppend, sanitizeSid, llmOf, resolveCondenseRoute, llmText, minBytesOf, keepInjectOn, isDshInjected, isInjectedUser, foldAtTurnEndOn, foldGateOpen, foldJoinTimeoutMs, foldMinShadowBytesOf, nativeCompactionResetOn, transcribeIncremental, enterFoldInflight, exitFoldInflight, foldInflightFile, paybackOf, paybackMeasured, messageBytes, blockTextOf }