dsh-jev-prune 0.1.0

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.
package/index.js ADDED
@@ -0,0 +1,2043 @@
1
+ /**
2
+ * dsh-jev-prune —— 用 Jev 判断驱动 DSH 的上下文压缩。**两层。**
3
+ *
4
+ * ── 第一层:工具结果裁剪(接管 `ctx.toolResultPruner.pruneSession`)
5
+ * DSH 自带规则:内容超过 thresholdChars 就掐掉中间、留头尾。纯体积、零语义。
6
+ * Jev 说「还要」 → **不裁**(哪怕它很大 —— DSH 现在会误裁这类)
7
+ * Jev 说「过期了」 → **裁**(哪怕它不大 —— DSH 现在完全不碰这类,纯增量)
8
+ * 没有判定 → 退回 DSH 原来的按体积裁决(安全兜底)
9
+ * 第一层落在最近 preserveRecent 个节点里 / 永不裁剪工具 → 一律不碰
10
+ *
11
+ * ── 第二层:整对调用的「回执压缩」(接管 `ctx.compaction.summarize` + `compactRegion`)
12
+ * DSH 自带做法:让主模型读原始历史、**写一段摘要**顶替被压缩的区间。摘要会幻觉。
13
+ * 我们改成注入一段**确定性回执** —— 工具名、命令、路径、输出字符数、seq 全部由代码算出,
14
+ * **不可能包含模型推断**。代价是它不解释、只开收据(原始事件仍在会话日志里,可恢复)。
15
+ * 门控(满足全部才动):两轴相对分位取交集 + 工具白名单 + 证据守卫
16
+ * + 第二层独立的 compactPreserveRecent 最近区保护 + 文本长度门控。
17
+ *
18
+ * 判定时机:`agent/pre-step` 里**异步**预判好、缓存按 seq;
19
+ * 因为 `pruneSession(session)` 是**同步**方法,里面不能 await;
20
+ * 而 `compactRegion` 是异步的、且要求处在打开的 turn 内(`agent/pre-step` 满足)。
21
+ *
22
+ * shadow-price 协议(compaction/prune 事件 + tool/result replace)与 DSH 自带实现逐字一致,
23
+ * 保证纯消费者能按同一套账本扣减 token。第二层的完整步骤交给 `compactRegion`;
24
+ * 并行批次只有部分结果合格时,复用同一 shadow-price 协议逐结果替换正文,
25
+ * 保留 assistant/tool-call 与全部 tool/result 外壳,避免破坏工具配对。
26
+ *
27
+ * @module dsh-jev-prune
28
+ */
29
+
30
+ import { readFileSync, writeFileSync } from 'node:fs'
31
+ import { createRequire } from 'node:module'
32
+ import { dirname, join } from 'node:path'
33
+ import { fileURLToPath } from 'node:url'
34
+
35
+ import z from '@deepseek-ai/schemastery'
36
+ import { defineTool } from '@deepseek-ai/dsh-tools'
37
+
38
+ import { JevClient, estimateTokens } from './src/jev.js'
39
+ import { JEV_PRUNE_MARKER, isToolIn, parseLimit, pruneSessionWithJev } from './src/prune.js'
40
+ import {
41
+ DEFAULT_COMPACT_TOOLS,
42
+ DEFAULT_EVIDENCE_PATTERNS,
43
+ DEFAULT_FLOOR_THRESHOLD,
44
+ DEFAULT_MIN_CANDIDATES_FOR_FLOOR,
45
+ DEFAULT_MIN_CANDIDATES_FOR_RELATIVE,
46
+ DEFAULT_NEVER_COMPACT_TOOLS,
47
+ DEFAULT_NEVER_PRUNE_TOOLS,
48
+ DSH_READONLY_TOOLS,
49
+ RECEIPT_MARKER,
50
+ cachedVerdictForEvent,
51
+ computeEligibleSeqs,
52
+ renderPartialResultReceipt,
53
+ renderReceipt,
54
+ selectReceiptRanges,
55
+ } from './src/receipt.js'
56
+ import {
57
+ STATE_CONTEXT,
58
+ buildJevState,
59
+ buildToolNameIndex,
60
+ callIdOf,
61
+ eventText,
62
+ isCheckpointEvent,
63
+ probeShapes,
64
+ probeToolNames,
65
+ questionsFor,
66
+ recentGoal,
67
+ selectCandidates,
68
+ sessionEvents,
69
+ toolNameOf,
70
+ } from './src/state.js'
71
+
72
+ // ---------------------------------------------------------------- 宿主版本探测
73
+ /**
74
+ * 探测宿主 DSH 的版本号。
75
+ *
76
+ * 为什么要有这个:package.json 里写明了 testedAgainst 0.1.5-rc.2,而 DSH 0.1.x 是
77
+ * 预发布线,事件形状与服务名在 rc 之间会漂移——这是本插件最大的结构性风险。
78
+ * 靠"人肉记得升级后重跑测试"不可靠,所以在加载时把版本读出来:
79
+ * · 记进心跳与状态(可观测)
80
+ * · major.minor 与测试版本不一致时打一次 warning(不拒绝加载——也许只是字段没变)
81
+ */
82
+ const TESTED_DSH_VERSION = '0.1.5-rc.2'
83
+ const TESTED_DSH_SERIES = '0.1'
84
+
85
+ function detectDshVersion() {
86
+ const readVersionAt = (path) => {
87
+ try {
88
+ return JSON.parse(readFileSync(path, 'utf8'))?.version ?? 'unknown'
89
+ } catch {
90
+ return null
91
+ }
92
+ }
93
+ // 路径 ①:走 require.resolve(尊重 exports map)
94
+ try {
95
+ const require = createRequire(import.meta.url)
96
+ const found = readVersionAt(require.resolve('@deepseek-ai/dsh/package.json'))
97
+ if (found != null) return found
98
+ } catch { /* exports 没暴露 package.json 时走路径 ② */ }
99
+ // 路径 ②:插件通常与宿主包同级安装(<prefix>/node_modules/ 下)
100
+ try {
101
+ const here = dirname(fileURLToPath(import.meta.url))
102
+ const found = readVersionAt(join(here, '..', '@deepseek-ai', 'dsh', 'package.json'))
103
+ if (found != null) return found
104
+ } catch { /* 都找不到就如实报 unknown */ }
105
+ return 'unknown'
106
+ }
107
+
108
+ const dshVersion = detectDshVersion()
109
+ const dshVersionMatches = dshVersion === 'unknown'
110
+ ? null // 探测不到 ≠ 不匹配,不吓唬人,只如实上报
111
+ : dshVersion.split('.').slice(0, 2).join('.') === TESTED_DSH_SERIES
112
+
113
+ /**
114
+ * `freezeMessage` 来自 @deepseek-ai/dsh-llm。用**动态导入**而不是静态导入:
115
+ * 静态导入一旦解析不到,整个插件会加载失败(连带 DSH 起不来);
116
+ * 动态导入失败只退化成一个浅拷贝,插件照常工作。
117
+ */
118
+ const fallbackFreezeMessage = (message) => ({ ...message })
119
+
120
+ /**
121
+ * 解析宿主的 freezeMessage;加载器可注入,令“可选依赖不存在”的降级路径可回归测试。
122
+ * @param {() => Promise<object>} [loadModule]
123
+ * @returns {Promise<(message: object) => object>}
124
+ */
125
+ export async function resolveFreezeMessage(loadModule = () => import('@deepseek-ai/dsh-llm')) {
126
+ try {
127
+ const mod = await loadModule()
128
+ if (typeof mod?.freezeMessage === 'function') return mod.freezeMessage
129
+ } catch {
130
+ // 使用浅拷贝兜底
131
+ }
132
+ return fallbackFreezeMessage
133
+ }
134
+
135
+ export const name = 'jev-prune'
136
+
137
+ /**
138
+ * Cordis 的依赖声明。**这一步是整个插件能否生效的关键。**
139
+ *
140
+ * 实测踩过:只写 `['tools']` 时,Cordis 等到 tools 就绪就调用 apply(),
141
+ * 而那会 `ctx.get('toolResultPruner')` 返回 null —— 服务还没注册
142
+ * (pruner 由 base bundle 的 tool-result-pruner 条目提供)。结果插件加载成功、
143
+ * 配置正确、installPrunerOverride 也跑了,但**静默地没接管任何东西**。
144
+ * 这类失败在宿主日志里完全看不见,只能靠心跳文件/落盘才能发现。
145
+ *
146
+ * 把 pruner 也声明进来,Cordis 才会等它就绪再调 apply()。
147
+ */
148
+ export const inject = ['tools', 'toolResultPruner']
149
+
150
+ /** 裁剪标记与裁剪机制从 prune.js 复用(那边才能被单测覆盖)。 */
151
+ export { JEV_PRUNE_MARKER }
152
+
153
+ /**
154
+ * 已废弃键的 schema 默认值。
155
+ *
156
+ * 为什么要把默认值提出来:宿主(cordis)会先用 `Config` schema 校验用户配置、
157
+ * **把默认值填进去**,再把结果传给 `apply()`(见 cordis 的 `resolveConfig(runtime, config)`
158
+ * → `Config['~standard'].validate(config).value`)。所以到了我们这里,
159
+ * "用户没配" 与 "用户配成了默认值" 已经**不可区分** —— 两者都是 8192 / 0。
160
+ *
161
+ * ⇒ 这两个废弃键的告警只能靠"值 ≠ 默认值"来判断"用户是否真的显式改过"。
162
+ * 直接判 `!= null` 会让**每一个用户、每一次运行**都被报"你配置了废弃键"(实测确认)。
163
+ */
164
+ export const DEFAULT_VOLUME_BUDGET_THRESHOLD_CHARS = 8192
165
+ export const DEFAULT_BUDGET_MIN_CHARS = 0
166
+
167
+ export const Config = z.object({
168
+ enabled: z.boolean().default(true),
169
+ /** TypeSafe key;留空则读环境变量 TYPESAFE_API_KEY */
170
+ apiKey: z.string().default(''),
171
+ model: z.string().default('jev-latest'),
172
+ baseUrl: z.string().default('https://api.typesafe.ai/v1/systemone'),
173
+ /** P(保留) ≥ 该值 → 不裁(budget 模式下这是**保护上限**:达到即不进候选池) */
174
+ keepThreshold: z.number().min(0).max(1).default(0.5),
175
+ /** always 模式(judgeOn: 'always')没有压力信号时的固定裁剪比例:裁掉候选池这个比例的字符增益(0.5 = 一半)。pressure 模式下由缺口自动算,与此无关。 */
176
+ alwaysTrimRatio: z.number().min(0).max(1).default(0.5),
177
+ /**
178
+ * 第一层裁决模式(P0-1):
179
+ * · `budget`(默认)——"**裁多少**"由**压力缺口比例**决定(ratio = (used − threshold)/window,
180
+ * 由 judgePass 每轮自动算并缓存;预算 = ratio × 候选池总字符增益),"**裁哪些**"由 Jev 概率
181
+ * **排序**决定(从最低开始裁,裁到省够预算即停);`keepThreshold` 退居保护上限。
182
+ * · `absolute` —— 旧行为:逐节点 `prob >= keepThreshold` 判。
183
+ * 为什么必须换:实测 Jev 概率是**窄带**(真实会话 42/42 条低于 0.5、P50=0.13),
184
+ * 固定 0.5 会把所有判定过的结果都判成"可裁";而纯相对分位又会"每轮必裁固定比例"
185
+ * (不需要压缩时也在动刀,且比例与宿主需要腾多少空间无关)。两条路都不成立,
186
+ * 所以拆成正交的两件事:省多少 = 压力缺口,裁哪些 = Jev 排序。
187
+ */
188
+ keepMode: z.string().default('budget'),
189
+ /** budget 模式小样本降级用的绝对下限(口径与第二层 floorThreshold 一致) */
190
+ keepFloorThreshold: z.number().min(0).max(1).default(0.2),
191
+ /** budget 模式:候选少于此数则降级为绝对下限(小样本上排序没有意义) */
192
+ minCandidatesForBudget: z.number().min(1).default(4),
193
+ /** @deprecated 已废弃:budget 模式改为压力分位(ratio 由 judgePass 自动算),不再错定体积规则。保留仅为向后兼容。 */
194
+ volumeBudgetThresholdChars: z.number().min(0).default(DEFAULT_VOLUME_BUDGET_THRESHOLD_CHARS),
195
+ /** @deprecated 已废弃:同上,保留仅为向后兼容。 */
196
+ budgetMinChars: z.number().min(0).default(DEFAULT_BUDGET_MIN_CHARS),
197
+ /** 值得动手的最小收益(与 sliceWithBudget 的 minGain 同口径;小于它不进候选池) */
198
+ minGainChars: z.number().min(0).default(40),
199
+ /** 最近 N 个 surface 节点永不裁剪(含正在进行的工具调用) */
200
+ preserveRecent: z.number().min(0).default(4),
201
+ /** 裁到多少字符就够:留头 + 标记 + 留尾 */
202
+ headChars: z.number().min(0).default(600),
203
+ tailChars: z.number().min(0).default(200),
204
+ /** 小于该长度的结果即使 Jev 说过期也不裁(省不到东西、还丢信息) */
205
+ minCharsToPrune: z.number().min(0).default(400),
206
+ /** 何时开始判定:pressure(上下文超软阈值才判)| always */
207
+ judgeOn: z.string().default('pressure'),
208
+ softLimit: z.string().default('55%'),
209
+ /** state 预算(Jev 上限 32k) */
210
+ maxStateTokens: z.number().min(1).default(25000),
211
+ maxRequestTokens: z.number().min(1).default(30000),
212
+ textHead: z.number().min(0).default(400),
213
+ textTail: z.number().min(0).default(150),
214
+ inputChars: z.number().min(0).default(300),
215
+ /**
216
+ * state 里每个工具结果的**摘录预算**(字符,P0-2)。
217
+ * 0 = 关闭(回到旧的 `ok, N chars (内容省略)`)。
218
+ * 为什么需要:判断者此前只看得到体积、看不到内容——Claude 版的教训(256 条结果
219
+ * 无一过阈值、与假评分器打平)说明盲判≈抛硬币;我们自己的 in-vivo 实测里
220
+ * 42/42 判"过期",被误判的正是后续修 bug 要用的那条结果。
221
+ * 摘录内容 = 头 2 行 + 命中证据词的行(报错栈/失败断言**往往在中段**,恰是被掐掉的位置)。
222
+ */
223
+ resultExcerptChars: z.number().min(0).default(240),
224
+ judgeTimeoutMs: z.number().min(1).default(60000),
225
+ /**
226
+ * 单次 ask 内最多重试几次(issue #34)。只对可重试失败生效:
227
+ * 网络异常 / 超时 / 429 / 5xx。4xx 与响应形状错误立刻放弃(重试没意义)。
228
+ * 0 = 关闭重试(退化为旧行为)。
229
+ */
230
+ judgeMaxRetries: z.number().min(0).default(2),
231
+ /** 重试退避基数(ms);实际等待为 base × 2^attempt。0 = 不等待(测试用) */
232
+ judgeRetryBaseMs: z.number().min(0).default(300),
233
+ /** 只判定不裁剪,用来先观察行为 */
234
+ dryRun: z.boolean().default(false),
235
+ /** 提问措辞:goal(默认,实测区分度最高)| legacy(上游原味,几乎无区分度)| contrast | consequence */
236
+ wording: z.string().default('goal'),
237
+ /** state 历史最少保留的行数(避免为了塞进预算把上下文丢空) */
238
+ minHistoryLines: z.number().min(1).default(8),
239
+ /** 结果永不裁剪的工具。比第二层的 neverCompactTools **窄**(见 receipt.js 的说明) */
240
+ // 第一层只截断(可逆),所以默认只守"参数即内容"的写文件类工具;
241
+ // 差异型编辑工具(Edit / ApplyPatch …)第一层可裁,第二层仍守。
242
+ neverPruneTools: z.array(z.string()).default(DEFAULT_NEVER_PRUNE_TOOLS),
243
+
244
+ // ---------------------------------------------------------------- 第二层:回执压缩
245
+ /** 第二层总开关 */
246
+ compactReceipts: z.boolean().default(true),
247
+ /** 何时做整对移出:pressure(到软阈值才做)| always | off */
248
+ compactOn: z.string().default('pressure'),
249
+ /** 第二层的压力门(比第一层保守:整对删除比截断风险大) */
250
+ compactSoftLimit: z.string().default('70%'),
251
+ /** 第二层独立的最近区保护;整对移出已有两轴判定,默认只保留最后 1 个节点。 */
252
+ compactPreserveRecent: z.number().min(0).default(1),
253
+ /**
254
+ * 门控模式:relative(默认)| absolute。
255
+ * **Jev 必须用 relative** —— 实测它的两轴概率都落在 0.05~0.37 的窄带里,
256
+ * 固定阈值 0.5 会把全部候选判成"可丢"(开发期实测,数据未随仓库提交;
257
+ * 结论见 README 的设计说明。issue #13:此前出处写作 probe_effect.js,该文件不存在)。
258
+ * absolute 只留给换判断后端(例如本地分类器)时用。
259
+ */
260
+ compactMode: z.string().default('relative'),
261
+ /** relative 模式:两轴各取尾部这个比例,**取交集** */
262
+ compactQuantile: z.number().min(0).max(1).default(0.34),
263
+ /**
264
+ * relative 模式需要的最小总体规模;小于它则**降级为绝对下限模式**(不是我原本设想的"直接不做")。
265
+ *
266
+ * issue #27:只读工具在写/执行密集会话里往往只占极少数(实测只读 1/6 → 总体仅 2 条),
267
+ * 而此前低于这个数就返回空集 → **第二层在绝大多数真实会话里静默不工作**,
268
+ * 报错文案却说"需要 ≥4 个",看着像"样本确实不够"而不像 bug。
269
+ */
270
+ minCandidatesForRelative: z.number().min(2).default(DEFAULT_MIN_CANDIDATES_FOR_RELATIVE),
271
+ /** 降级模式(总体 < minCandidatesForRelative)用的绝对下限,**明显严于** compactThreshold */
272
+ floorThreshold: z.number().min(0).max(1).default(DEFAULT_FLOOR_THRESHOLD),
273
+ /** 降级模式仍要求的最低样本量;低于它连分布都谈不上,仍然不做 */
274
+ minCandidatesForFloor: z.number().min(1).default(DEFAULT_MIN_CANDIDATES_FOR_FLOOR),
275
+ /** absolute 模式用的阈值 */
276
+ compactThreshold: z.number().min(0).max(1).default(0.5),
277
+ /**
278
+ * 允许整对移出的工具(白名单)。**默认 = `DSH_READONLY_TOOLS`(只读工具集),即默认就带白名单。**
279
+ *
280
+ * 为什么默认是"只读白名单"而不是空:白名单失效的后果是"功能静默死亡"(加载成功、
281
+ * 接管成功、判定在跑,只是什么都不做),所以宁可让它默认就窄;黑名单只用来额外
282
+ * 保护改写型调用。实测教训:最初把 Claude Code 风格的 PascalCase 名字当默认白名单,
283
+ * 而真实 DSH 的工具名是 **`pwsh` / `read` / `glob`**(全小写、shell 叫 pwsh)——
284
+ * **命中 0/11,第二层静默地永不触发**。
285
+ * 比较时做归一化(小写 + 去掉 `_`/`-`),所以 `MultiEdit` 与 `multi_edit` 等价。
286
+ * 想放宽就配成 `[]`(只受 neverCompactTools 约束)——那是显式 opt-in 的不安全模式,
287
+ * shell 调用也会被整对移出。
288
+ */
289
+ compactTools: z.array(z.string()).default(DEFAULT_COMPACT_TOOLS),
290
+ /** 永不整对移出的工具(改写型调用是承重信息) */
291
+ neverCompactTools: z.array(z.string()).default(DEFAULT_NEVER_COMPACT_TOOLS),
292
+ /** 证据守卫:结果里命中这些词就不整对移出(仍允许第一层截断) */
293
+ evidenceGuard: z.boolean().default(true),
294
+ evidencePatterns: z.array(z.string()).default(DEFAULT_EVIDENCE_PATTERNS),
295
+ /**
296
+ * assistant 消息里**用户可见文本**(`text` 块)超过这个长度的步骤不整对移出——它在交代结论。
297
+ * 默认值按真实会话标定:实测 DeepSeek 每步 text 仅 0~287 字符,1200 有充分余量。
298
+ *
299
+ * ⚠️ 这个阈值**不含 `reasoning`**(issue #26:此前两者累加,导致阈值被思考草稿主导)。
300
+ * reasoning 有独立阈值 `maxStepReasoningChars`。
301
+ */
302
+ maxStepTextChars: z.number().min(0).default(1200),
303
+ /**
304
+ * assistant 消息里**思考草稿**(`reasoning` 块)超过这个长度的步骤不整对移出。
305
+ *
306
+ * 为什么单独一个键、且默认值明显更宽:`detail` 级别的会话里 reasoning 天然很长
307
+ * (实测 0~1207 字符,且会随任务复杂度溢出到数千),它是模型的草稿而不是承重结论。
308
+ * 与 text 共用一个阈值时,reasoning 只要多写几百字就会把整层压缩静默关掉——
309
+ * 这是"第二层用不到"的主因。取 4000 是给"确实想了很久、这步大概不平凡"留余地,
310
+ * 同时让绝大多数正常步骤通过。想彻底关掉这道门就配成一个很大的数。
311
+ */
312
+ maxStepReasoningChars: z.number().min(0).default(4000),
313
+ /** 一段范围至少要能省下这么多字符,才值得开一次压缩事务 */
314
+ compactMinChars: z.number().min(0).default(2000),
315
+ /** 回执必须是原内容 token 的这个比例以下才动手(服务端硬要求 <1.0,我们更严) */
316
+ receiptMaxRatio: z.number().min(0).max(1).default(0.5),
317
+ /**
318
+ * 一次 pass 最多做几次压缩事务(issue #35)。
319
+ *
320
+ * 旧默认值是 1:一次 pass 只回收一段,大上下文要靠**多轮 pre-step** 慢慢挤,
321
+ * 而每一轮都要重新走压力门、重新判定、重新选段——收敛慢且多花 Jev 调用。
322
+ * 单次 compactRegion 的成本是"一次摘要调用"(我们注入确定性回执,所以其实
323
+ * 不含模型生成),排队做 3 段与做 1 段的边际成本很低,于是默认提到 3。
324
+ *
325
+ * 上限仍是可配的:想完全回到旧行为就设成 1。
326
+ */
327
+ maxCompactionsPerPass: z.number().min(1).default(3),
328
+ /** 回执里每行入参截断到多少字符 */
329
+ receiptArgChars: z.number().min(0).default(120),
330
+ /** 回执里每步 assistant 可见文本的原文摘录上限;0 表示不写入 */
331
+ receiptTextChars: z.number().min(0).default(400),
332
+
333
+ /**
334
+ * 心跳文件路径。非空时,插件会在加载完成、每次判定 pass、每次裁剪后写一份 JSON 快照。
335
+ * 用途有两个:①运维可观测(宿主会吞掉插件的 logger 输出,只能靠落盘看状态)
336
+ * ②**验证接管是否真的发生**——这是唯一能从外部确证"插件在真实宿主里起作用"的手段。
337
+ */
338
+ heartbeatFile: z.string().default(''),
339
+ logLevel: z.string().default('info'),
340
+ })
341
+
342
+ /**
343
+ * `resolveConfig` 把越界配置的说明挂在这个键下(普通字符串键,不是 Symbol——
344
+ * 心跳要 JSON 序列化,Symbol 存不进去)。调用方按需取用:
345
+ *
346
+ * const cfg = resolveConfig(raw)
347
+ * for (const w of cfg[CONFIG_WARNINGS] ?? []) log('warn', w)
348
+ *
349
+ * 它不是 `Config` schema 的键,所以 `check.js` 里"schema 键必须有兜底"的断言不受影响。
350
+ */
351
+ export const CONFIG_WARNINGS = '__configWarnings'
352
+
353
+ /**
354
+ * 数值配置的**合法区间表**。`[min, max]`,`Infinity` 表示"上不封顶"。
355
+ *
356
+ * 为什么必须有这张表(issue #28):`Config` 的 schema 只写了 `.default()`,没有 `.min()/.max()`,
357
+ * 而 `resolveConfig` 又只做 `?? 兜底`(只挡 undefined/null)。于是**任何越界值都原样穿透**
358
+ * 到运行时,且后果往往不是报错而是**静默失效**。实测(未修前):
359
+ *
360
+ * - `preserveRecent = -5` → `lastAllowed = surface.length - 1 - (-5)` 反而**变大**,
361
+ * 最近区保护**完全失效**。这是最危险的一条:两层都会去动正在进行的工具调用。
362
+ * - `maxStepTextChars = -1` → 每一步都 `text > -1` → 第二层**永久静默失效**。
363
+ * - `compactMinChars = -100` → 该门形同不存在("省得够不够"永远为真)。
364
+ * - `receiptMaxRatio = 5` → 回执比原文大 5 倍也放行(安全门失效)。
365
+ * - `keepThreshold = 2` → 第一层所有节点都裁(`prob >= 2` 恒假)。
366
+ *
367
+ * 分档的意义:
368
+ * · `float01` —— 概率/比例,越界后果是"判据恒真或恒假",一律钳到 [0,1]。
369
+ * · `nonNeg` —— **0 是合法值**(如 headChars=0 = 不留头),只挡负数。
370
+ * · `count` —— 计数类,下界 1(配成 0 等于把该功能关掉,那是布尔开关的职责)。
371
+ * · `positive`—— 严格正数,下界取一个很小的正数而非 0(避免除零/无穷循环)。
372
+ *
373
+ * 上界为什么普遍设 1e9:这些键都是"越大越宽松"的方向(多留一点、多等一会),
374
+ * 钳住它们是在替用户做一个他没要求的决定,收益远小于风险。真正需要上界的是
375
+ * **会转化为内存/时间开销**的那几个(见各自注释与 MAX_* 常量)。
376
+ */
377
+ const CONFIG_RANGES = {
378
+ // 概率 / 比例:越界会让判据恒真或恒假
379
+ keepThreshold: [0, 1],
380
+ keepFloorThreshold: [0, 1],
381
+ alwaysTrimRatio: [0, 1],
382
+ compactQuantile: [0, 1],
383
+ compactThreshold: [0, 1],
384
+ floorThreshold: [0, 1],
385
+ // receiptMaxRatio 不只是比例,它同时是"回执不得比原文大"的安全门:
386
+ // >1 就等于允许"压缩后反而更占地方",所以上界收在 1。
387
+ // 注释里写的"服务端硬要求 <1.0,我们更严"指的是默认值 0.5,不是上界。
388
+ receiptMaxRatio: [0, 1],
389
+ // 非负(0 合法)
390
+ preserveRecent: [0, 1e9],
391
+ compactPreserveRecent: [0, 1e9],
392
+ headChars: [0, 1e9],
393
+ tailChars: [0, 1e9],
394
+ textHead: [0, 1e9],
395
+ textTail: [0, 1e9],
396
+ minCharsToPrune: [0, 1e9],
397
+ compactMinChars: [0, 1e9],
398
+ receiptArgChars: [0, 1e9],
399
+ receiptTextChars: [0, 1e9],
400
+ inputChars: [0, 1e9],
401
+ maxStepTextChars: [0, 1e9],
402
+ maxStepReasoningChars: [0, 1e9],
403
+ // P0-1 预算模式 / P0-2 摘录(0 合法:摘录 0 = 关闭;budgetMinChars 0 = 严格跟随体积规则)
404
+ volumeBudgetThresholdChars: [0, 1e9],
405
+ budgetMinChars: [0, 1e9],
406
+ minGainChars: [0, 1e9],
407
+ resultExcerptChars: [0, 1e9],
408
+ // 计数类
409
+ minHistoryLines: [1, 1e9],
410
+ minCandidatesForRelative: [2, 1e9],
411
+ minCandidatesForFloor: [1, 1e9],
412
+ minCandidatesForBudget: [1, 1e9],
413
+ maxCompactionsPerPass: [1, 1e9],
414
+ // 预算 / 超时(正数)
415
+ maxStateTokens: [1, 1e9],
416
+ maxRequestTokens: [1, 1e9],
417
+ judgeTimeoutMs: [1, 1e9],
418
+ judgeMaxRetries: [0, 1e9],
419
+ judgeRetryBaseMs: [0, 1e9],
420
+ }
421
+
422
+ /**
423
+ * `keepMode` 的白名单校验(review 修复):此前是 `z.string()` + `?? 'budget'`,
424
+ * 拼错(如 `'budgt'`)会静默穿过两层校验直达 `planTrims`——而 `planTrims` 对
425
+ * 未知模式返回 null,等于**静默退回旧行为**,与仓库"越界配置必须留痕"的约定相悖。
426
+ * schemastery 没有 enum 构造器(const/union 语义均不符),故按 #28 的
427
+ * "回落默认 + configWarnings 留痕"模式在这里做白名单。
428
+ */
429
+ export function resolveKeepMode(raw, onWarn) {
430
+ if (raw == null || raw === '') return 'budget'
431
+ if (raw === 'budget' || raw === 'absolute') return raw
432
+ onWarn?.(`配置 keepMode=${JSON.stringify(String(raw))} 不在 ['budget','absolute'] 内 → 已改为 'budget'(拼错的模式会静默退回旧行为,必须留痕)`)
433
+ return 'budget'
434
+ }
435
+
436
+ /**
437
+ * 按区间表钳制配置值;越界时通过 `onWarn` 报告**改动前后**的值。
438
+ *
439
+ * 返回 `[钳制后的值, 是否发生过钳制]`。非有限值(NaN / Infinity / 字符串)一律回落到
440
+ * `fallback`(即默认值)——因为"改了多少"在这个语义下不可解释,"打到默认"才可解释。
441
+ * 这正是 `Number.isFinite` 而不是 `typeof === 'number'` 的理由:`typeof NaN === 'number'`。
442
+ */
443
+ export function clampConfigNumber(key, value, fallback, onWarn) {
444
+ const [lo, hi] = CONFIG_RANGES[key] ?? [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY]
445
+ const warn = (kind, got) => {
446
+ onWarn?.(`配置 ${key}=${got} 非法(${kind})→ 已改为 ${fallback}(合法区间 ${lo}~${hi})`)
447
+ return fallback
448
+ }
449
+ // 值缺失(undefined/null/'')不是"非法",是"没配"→ 静默用默认值,不打扰用户
450
+ if (value == null || value === '') return [fallback, false]
451
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
452
+ return [warn('不是有限数值', JSON.stringify(value) ?? String(value)), true]
453
+ }
454
+ if (value < lo) return [warn(`低于下限 ${lo}`, value), true]
455
+ if (value > hi) return [warn(`高于上限 ${hi}`, value), true]
456
+ return [value, false]
457
+ }
458
+
459
+ export function resolveConfig(config = {}) {
460
+ const warnings = []
461
+ // 废弃键的运行时信号(review 反馈):只标 @deprecated 会让用户配了却静默空转,
462
+ // 违反仓库"静默退回必须留痕"的约定(resolveKeepMode 同理)。
463
+ //
464
+ // ⚠️ 判据必须是"值 ≠ 默认值",不能是 `!= null`:宿主已经用 schema 填过默认值,
465
+ // 到了这里 `undefined` 不可能出现(实测:空配置也会被报"配置了废弃键")。
466
+ // 代价是"显式写成默认值"不会告警 —— 那种写法本来也是空操作,不算漏报。
467
+ if (config.volumeBudgetThresholdChars != null
468
+ && config.volumeBudgetThresholdChars !== DEFAULT_VOLUME_BUDGET_THRESHOLD_CHARS) {
469
+ warnings.push('配置 volumeBudgetThresholdChars 已废弃(budget 模式改为压力分位,由 judgePass 每轮自动计算),该键不再生效')
470
+ }
471
+ if (config.budgetMinChars != null && config.budgetMinChars !== DEFAULT_BUDGET_MIN_CHARS) {
472
+ warnings.push('配置 budgetMinChars 已废弃(同上),该键不再生效')
473
+ }
474
+ return {
475
+ ...config,
476
+ enabled: config.enabled ?? true,
477
+ apiKey: config.apiKey ?? '',
478
+ model: config.model ?? 'jev-latest',
479
+ // 维护约定:Config schema 的每个 default 都必须在这里有对应兜底(本键此前遗漏;
480
+ // 影响为零是因为 JevClient 的默认参数会在 undefined 时生效,但约定不该靠下游兜底)。
481
+ baseUrl: config.baseUrl ?? 'https://api.typesafe.ai/v1/systemone',
482
+ preserveRecent: clampConfigNumber('preserveRecent', config.preserveRecent, 4, (w) => warnings.push(w))[0],
483
+ keepThreshold: clampConfigNumber('keepThreshold', config.keepThreshold, 0.5, (w) => warnings.push(w))[0],
484
+ alwaysTrimRatio: clampConfigNumber('alwaysTrimRatio', config.alwaysTrimRatio, 0.5, (w) => warnings.push(w))[0],
485
+ keepMode: resolveKeepMode(config.keepMode, (w) => warnings.push(w)),
486
+ keepFloorThreshold: clampConfigNumber('keepFloorThreshold', config.keepFloorThreshold, 0.2, (w) => warnings.push(w))[0],
487
+ minCandidatesForBudget: clampConfigNumber('minCandidatesForBudget', config.minCandidatesForBudget, 4, (w) => warnings.push(w))[0],
488
+ volumeBudgetThresholdChars: clampConfigNumber('volumeBudgetThresholdChars', config.volumeBudgetThresholdChars, DEFAULT_VOLUME_BUDGET_THRESHOLD_CHARS, (w) => warnings.push(w))[0],
489
+ budgetMinChars: clampConfigNumber('budgetMinChars', config.budgetMinChars, DEFAULT_BUDGET_MIN_CHARS, (w) => warnings.push(w))[0],
490
+ minGainChars: clampConfigNumber('minGainChars', config.minGainChars, 40, (w) => warnings.push(w))[0],
491
+ resultExcerptChars: clampConfigNumber('resultExcerptChars', config.resultExcerptChars, 240, (w) => warnings.push(w))[0],
492
+ headChars: clampConfigNumber('headChars', config.headChars, 600, (w) => warnings.push(w))[0],
493
+ tailChars: clampConfigNumber('tailChars', config.tailChars, 200, (w) => warnings.push(w))[0],
494
+ minCharsToPrune: clampConfigNumber('minCharsToPrune', config.minCharsToPrune, 400, (w) => warnings.push(w))[0],
495
+ judgeOn: config.judgeOn ?? 'pressure',
496
+ softLimit: config.softLimit ?? '55%',
497
+ neverPruneTools: config.neverPruneTools ?? DEFAULT_NEVER_PRUNE_TOOLS,
498
+ compactReceipts: config.compactReceipts ?? true,
499
+ compactOn: config.compactOn ?? 'pressure',
500
+ compactSoftLimit: config.compactSoftLimit ?? '70%',
501
+ compactPreserveRecent: clampConfigNumber('compactPreserveRecent', config.compactPreserveRecent, 1, (w) => warnings.push(w))[0],
502
+ compactMode: config.compactMode ?? 'relative',
503
+ compactQuantile: clampConfigNumber('compactQuantile', config.compactQuantile, 0.34, (w) => warnings.push(w))[0],
504
+ minCandidatesForRelative: clampConfigNumber('minCandidatesForRelative', config.minCandidatesForRelative, DEFAULT_MIN_CANDIDATES_FOR_RELATIVE, (w) => warnings.push(w))[0],
505
+ floorThreshold: clampConfigNumber('floorThreshold', config.floorThreshold, DEFAULT_FLOOR_THRESHOLD, (w) => warnings.push(w))[0],
506
+ minCandidatesForFloor: clampConfigNumber('minCandidatesForFloor', config.minCandidatesForFloor, DEFAULT_MIN_CANDIDATES_FOR_FLOOR, (w) => warnings.push(w))[0],
507
+ compactThreshold: clampConfigNumber('compactThreshold', config.compactThreshold, 0.5, (w) => warnings.push(w))[0],
508
+ compactTools: config.compactTools ?? DEFAULT_COMPACT_TOOLS,
509
+ neverCompactTools: config.neverCompactTools ?? DEFAULT_NEVER_COMPACT_TOOLS,
510
+ evidenceGuard: config.evidenceGuard ?? true,
511
+ evidencePatterns: config.evidencePatterns ?? DEFAULT_EVIDENCE_PATTERNS,
512
+ maxStepTextChars: clampConfigNumber('maxStepTextChars', config.maxStepTextChars, 1200, (w) => warnings.push(w))[0],
513
+ maxStepReasoningChars: clampConfigNumber('maxStepReasoningChars', config.maxStepReasoningChars, 4000, (w) => warnings.push(w))[0],
514
+ compactMinChars: clampConfigNumber('compactMinChars', config.compactMinChars, 2000, (w) => warnings.push(w))[0],
515
+ receiptMaxRatio: clampConfigNumber('receiptMaxRatio', config.receiptMaxRatio, 0.5, (w) => warnings.push(w))[0],
516
+ maxCompactionsPerPass: clampConfigNumber('maxCompactionsPerPass', config.maxCompactionsPerPass, 3, (w) => warnings.push(w))[0],
517
+ receiptArgChars: clampConfigNumber('receiptArgChars', config.receiptArgChars, 120, (w) => warnings.push(w))[0],
518
+ receiptTextChars: clampConfigNumber('receiptTextChars', config.receiptTextChars, 400, (w) => warnings.push(w))[0],
519
+ dryRun: config.dryRun ?? false,
520
+ wording: config.wording ?? 'goal',
521
+ minHistoryLines: clampConfigNumber('minHistoryLines', config.minHistoryLines, 8, (w) => warnings.push(w))[0],
522
+ // issue #4:这 6 个键此前只在 Config schema 里有 default,resolveConfig 漏了——
523
+ // config 未经 schemastery 归一化时(冒烟测试的 PLUGIN_CFG、被 patch 直接注入的对象),
524
+ // abridge 拿到 undefined → head+tail+40 是 NaN → 同一段文本输出两遍、state 带 "NaN"。
525
+ // 维护约定:Config schema 的每个 default 都必须在这里有对应兜底。
526
+ textHead: clampConfigNumber('textHead', config.textHead, 400, (w) => warnings.push(w))[0],
527
+ textTail: clampConfigNumber('textTail', config.textTail, 150, (w) => warnings.push(w))[0],
528
+ inputChars: clampConfigNumber('inputChars', config.inputChars, 300, (w) => warnings.push(w))[0],
529
+ maxStateTokens: clampConfigNumber('maxStateTokens', config.maxStateTokens, 25000, (w) => warnings.push(w))[0],
530
+ maxRequestTokens: clampConfigNumber('maxRequestTokens', config.maxRequestTokens, 30000, (w) => warnings.push(w))[0],
531
+ judgeTimeoutMs: clampConfigNumber('judgeTimeoutMs', config.judgeTimeoutMs, 60000, (w) => warnings.push(w))[0],
532
+ judgeMaxRetries: clampConfigNumber('judgeMaxRetries', config.judgeMaxRetries, 2, (w) => warnings.push(w))[0],
533
+ judgeRetryBaseMs: clampConfigNumber('judgeRetryBaseMs', config.judgeRetryBaseMs, 300, (w) => warnings.push(w))[0],
534
+ heartbeatFile: config.heartbeatFile ?? '',
535
+ logLevel: config.logLevel ?? 'info',
536
+ // 越界配置的**审计出口**(issue #28):钳制是静默改写用户意图的动作,
537
+ // 必须留下痕迹——否则用户配了 preserveRecent=-5 以为"更宽",实际拿到的却是默认值,
538
+ // 而他对"为什么和在文档里读到的行为不一样"完全没有线索。
539
+ [CONFIG_WARNINGS]: warnings,
540
+ }
541
+ }
542
+
543
+ /**
544
+ * 一个工具名是否**有可能**被第二层整对移出。口径必须与 `selectReceiptRanges`
545
+ * 里的工具门逐条一致,否则分位总体与实际可压缩集合不符。
546
+ *
547
+ * 为什么需要它:第一层的判定缓存复用了 `selectCandidates` 的结果,而那份候选只
548
+ * 排除 `neverPruneTools`(黑名单)——第一层没有白名单,所以 shell 之类不可整对
549
+ * 移出的调用也会正常进缓存。若第二层直接拿整份缓存当分位总体,这些节点的概率
550
+ * 会占掉 `compactQuantile` 的尾部名额,随后又被工具门全部拒绝 → **第二层静默地
551
+ * 少压缩**(尾部全被不可移出的工具占满时,表现为 0 段可压)。
552
+ *
553
+ * @param {string} tool 工具名
554
+ * @param {{compactTools:string[], neverCompactTools:string[]}} cfg
555
+ * @returns {boolean}
556
+ */
557
+ export function isCompactableTool(tool, cfg) {
558
+ if (isToolIn(cfg.neverCompactTools, tool)) return false
559
+ if (cfg.compactTools.length > 0 && !isToolIn(cfg.compactTools, tool)) return false
560
+ return true
561
+ }
562
+
563
+ // ------------------------------------------------------------------ 主插件
564
+
565
+ /**
566
+ * @param {object} ctx Cordis 上下文
567
+ * @param {object} config 插件配置(schemastery 已校验)
568
+ * @param {object} [deps] 可选的依赖注入,仅用于测试:`{ judge, loadFreezeModule }` 可替换
569
+ * 真实的 JevClient 与可选的 dsh-llm 模块加载器。
570
+ * DSH 只传前两个参数,所以加第三个是向后兼容的;但有了它,
571
+ * "预置一组概率 → 断言裁决结果"就能跑在**真实的 apply + 真实的 pruneSession 接管**上,
572
+ * 而不是另写一份模拟逻辑。
573
+ */
574
+ export function apply(ctx, config, deps = {}) {
575
+ const cfg = resolveConfig(config)
576
+ if (!cfg.enabled) return
577
+
578
+ if (dshVersionMatches === false) {
579
+ ctx.logger?.info?.(`[jev-prune] DSH ${dshVersion} 与测试版本 ${TESTED_DSH_VERSION} 不同系列 —— 事件字段可能已漂移,建议先跑 jev_probe_shapes 核对`)
580
+ }
581
+
582
+ // 每个 apply 实例持有自己的解析结果,避免一次测试/一次宿主加载污染其他实例。
583
+ // 动态导入完成前也始终有浅拷贝兜底,插件不会阻塞启动。
584
+ let freezeMessage = fallbackFreezeMessage
585
+ void resolveFreezeMessage(deps.loadFreezeModule).then((resolved) => { freezeMessage = resolved })
586
+
587
+ const envKey = typeof process !== 'undefined' ? process.env?.TYPESAFE_API_KEY : undefined
588
+ const judge = deps.judge ?? new JevClient({
589
+ apiKey: cfg.apiKey || envKey || '',
590
+ model: cfg.model,
591
+ baseUrl: cfg.baseUrl,
592
+ timeoutMs: cfg.judgeTimeoutMs,
593
+ maxRetries: cfg.judgeMaxRetries,
594
+ retryBaseMs: cfg.judgeRetryBaseMs,
595
+ })
596
+
597
+ /** session → Map(结果 seq → {keep, prob, effectProb, chars, tool}) */
598
+ const decisions = new WeakMap()
599
+ /**
600
+ * session → 压力缺口比例(0~1)。judgePass(异步,能拿到 used/window)算好存这里,
601
+ * pruneSession(同步,DSH 调)读它来决定「这一轮裁多少」。读不到 = 0 = 不裁。
602
+ */
603
+ const pressureRatios = new WeakMap()
604
+ const stats = {
605
+ judged: 0,
606
+ requests: 0,
607
+ prunedByJev: 0,
608
+ prunedByVolume: 0,
609
+ savedChars: 0,
610
+ // keep 的三个来源分开计数(issue #8):keptByJev 此前混入了最近区/黑名单保护
611
+ keptByJev: 0,
612
+ keptByTail: 0,
613
+ keptByBlacklist: 0,
614
+ /**
615
+ * P0-1:被**预算**(而不是判定)留下来的条数。
616
+ * budget 模式下"Jev 说过期但预算已经用完"是常态——不单独计数的话,
617
+ * 用户会看到"Jev 裁掉 0"却不知道为什么,也无法判断预算是不是太紧。
618
+ */
619
+ keptByBudget: 0,
620
+ /** P0-1/P0-3:最近一次裁剪的预算分析(planTrims 的返回值),心跳里可见 */
621
+ lastBudget: null,
622
+ /** P0-3:最近若干条逐节点决策(含原因),让"为什么没裁/裁了"可复核 */
623
+ decisions: [],
624
+ skipped: 0,
625
+ errors: 0,
626
+ lastNote: '',
627
+ /**
628
+ * P0-1/P0-3 遥测:keep 概率的累计分布。
629
+ * 为什么必须落盘:本插件历史上最大的问题都是"静默失效",而这个分布是判断
630
+ * "阈值与分布是否匹配"的唯一依据——真实会话实测 42/42 低于 0.5(P50=0.13),
631
+ * 固定阈值等于"把每一轮判定都读成可裁"。没有这份数据,这个问题在运行时不可见。
632
+ */
633
+ probSum: 0,
634
+ probCount: 0,
635
+ keepAboveThreshold: 0,
636
+ keepBelowThreshold: 0,
637
+ /** P0-3:最近一次判定 pass 的门控快照(used/窗口/阈值/为什么跳过) */
638
+ lastGate: null,
639
+ /**
640
+ * 判定批次失败次数(issue #34)。旧实现一处失败就冒泡、后续批次不再问,
641
+ * 已经能拿到的概率被一起丢掉;现在逐批容错,失败批数如实上报,
642
+ * 好判断"这轮少判了几批"而不是只看到一句笼统的失败。
643
+ */
644
+ judgeBatchFailures: 0,
645
+ // 第二层
646
+ compactions: 0,
647
+ compactedSeqs: 0,
648
+ compactedChars: 0,
649
+ receiptSummaries: 0,
650
+ /**
651
+ * 回执因**竞态**没能注入的次数(issue #29)。
652
+ *
653
+ * 为什么必须单独计数:竞态的表现是"回执被别处的并发压缩抢走",而结果看起来
654
+ * 只是"这次压缩用了模型摘要"——与"我们没打算压缩它"完全无法区分。
655
+ * 不复数上报的话,这个 bug 只能靠读代码发现。
656
+ */
657
+ receiptFenceMisses: 0,
658
+ compactSkipped: 0,
659
+ lastCompactNote: '',
660
+ }
661
+
662
+ /**
663
+ * P0-3:keep 概率的分布快照(最近至多 500 个样本)。
664
+ * 落盘的理由:这个插件最大的历史问题是"静默失效",而"阈值与分布是否匹配"
665
+ * 只有看到分布本身才能判断——真实会话实测 42/42 低于 0.5(P50=0.13),
666
+ * 固定阈值等于把每一轮判定都读成"可裁"。
667
+ */
668
+ const probSamples = []
669
+ function probSummary() {
670
+ if (stats.probCount === 0) return null
671
+ const sorted = [...probSamples].sort((a, b) => a - b)
672
+ const q = (p) => (sorted.length === 0 ? null : sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))])
673
+ return {
674
+ n: stats.probCount,
675
+ sampled: sorted.length,
676
+ mean: Number((stats.probSum / stats.probCount).toFixed(4)),
677
+ p10: q(0.1), p25: q(0.25), p50: q(0.5), p75: q(0.75), p90: q(0.9),
678
+ aboveKeepThreshold: stats.keepAboveThreshold,
679
+ belowKeepThreshold: stats.keepBelowThreshold,
680
+ keepThreshold: cfg.keepThreshold,
681
+ keepMode: cfg.keepMode,
682
+ }
683
+ }
684
+
685
+ const log = (level, message) => {
686
+ if (cfg.logLevel === 'silent') return
687
+ if (level === 'debug' && cfg.logLevel !== 'debug') return
688
+ const line = `[jev-prune] ${message}`
689
+ if (typeof ctx.logger?.info === 'function') ctx.logger.info(line)
690
+ else console.error(line) // 走 stderr,绝不污染 stdout
691
+ }
692
+
693
+ const decisionsOf = (session) => {
694
+ let map = decisions.get(session)
695
+ if (map == null) {
696
+ map = new Map()
697
+ decisions.set(session, map)
698
+ }
699
+ return map
700
+ }
701
+
702
+ /**
703
+ * 描述事件访问的真实形态。
704
+ *
705
+ * 为什么需要:实测活的 DSH 会话对象上 **`session.events` 是 undefined**,
706
+ * 导致之前所有 `session.events ?? []` 都拿到空数组——工具名索引建出 0 条、
707
+ * 任务目标也丢了。这里同时报"原始访问器"与"回退链解析结果"两者,
708
+ * 好一眼看清是哪一种情况。
709
+ */
710
+ function describeEvents(session) {
711
+ const raw = session?.events
712
+ const resolved = sessionEvents(session)
713
+ const first = resolved?.[0]
714
+ return {
715
+ raw: {
716
+ isArray: Array.isArray(raw),
717
+ ctor: raw == null ? 'null' : (raw.constructor?.name ?? typeof raw),
718
+ },
719
+ resolved: {
720
+ isArray: Array.isArray(resolved),
721
+ length: resolved.length,
722
+ firstKind: first == null ? 'null' : (first.type ?? typeof first),
723
+ },
724
+ hasEventAt: typeof session?.eventAt === 'function',
725
+ }
726
+ }
727
+
728
+ // ---------------------------------------------------------- 心跳(可观测性)
729
+ // 宿主会吞掉插件的 logger 输出,所以状态要落盘才能从外部观察。
730
+ // 这同时是"接管是否真的发生"的唯一外部证据。
731
+ const bootedAt = new Date().toISOString()
732
+ let takeover = { attempted: false, installed: false, reason: 'not yet' }
733
+ let summaryHook = { attempted: false, installed: false, reason: 'not yet' }
734
+ /**
735
+ * 心跳是**合并**不是覆写。
736
+ * 之前每次调用都整份重写文件,而 pre-step 里 judgePass 在 compactPass 之后跑,
737
+ * 于是 compactPass 刚写下的 lastCompact 立刻被 judgePass 的 lastJudgePass 冲掉——
738
+ * 正好把"第二层为什么没触发"这个最关键的证据丢了。现在各调用方只更新自己的键。
739
+ */
740
+ const heartbeatState = {}
741
+ function writeHeartbeat(extra = {}) {
742
+ if (!cfg.heartbeatFile) return
743
+ try {
744
+ Object.assign(heartbeatState, extra)
745
+ const payload = {
746
+ plugin: name,
747
+ bootedAt,
748
+ now: new Date().toISOString(),
749
+ pid: typeof process !== 'undefined' ? process.pid : null,
750
+ dshVersion: { version: dshVersion, testedAgainst: TESTED_DSH_VERSION, matchesTested: dshVersionMatches },
751
+ judgeReady: judge.ready !== false,
752
+ model: cfg.model,
753
+ // 越界配置被钳制的记录(issue #28)。空数组 = 配置全部合法。
754
+ // 落盘的原因是"钳制"本身就是一种静默行为差异,必须以可观测的方式留痕。
755
+ configWarnings: cfg[CONFIG_WARNINGS] ?? [],
756
+ keepThreshold: cfg.keepThreshold,
757
+ // P0-1/P0-3:第一层的裁决模式与压力分位口径(判据落盘,否则"为什么没裁"不可复核)
758
+ keep: {
759
+ mode: cfg.keepMode,
760
+ keepThreshold: cfg.keepThreshold,
761
+ floor: cfg.keepFloorThreshold,
762
+ minCandidates: cfg.minCandidatesForBudget,
763
+ // 压力缺口比例由 judgePass 每轮算好,裁剪时随 lastPrune.budget 一起落盘;
764
+ // volumeBudgetThresholdChars / budgetMinChars 已废弃(保留仅为兼容),不再出现在这里。
765
+ },
766
+ stateExcerptChars: cfg.resultExcerptChars,
767
+ preserveRecent: cfg.preserveRecent,
768
+ wording: cfg.wording,
769
+ compact: {
770
+ enabled: cfg.compactReceipts,
771
+ on: cfg.compactOn,
772
+ mode: cfg.compactMode,
773
+ quantile: cfg.compactQuantile,
774
+ preserveRecent: cfg.compactPreserveRecent,
775
+ },
776
+ takeover,
777
+ summaryHook,
778
+ stats,
779
+ ...heartbeatState,
780
+ }
781
+ writeFileSync(cfg.heartbeatFile, JSON.stringify(payload, null, 1), 'utf8')
782
+ } catch (error) {
783
+ log('debug', `心跳写入失败:${error?.message ?? String(error)}`)
784
+ }
785
+ }
786
+
787
+ // ---------------------------------------------------------- 判定 pass(异步)
788
+ // contextWindow 按 provider+model 缓存:模型信息是静态的,每轮都问 llm 是浪费。
789
+ // 方案 B(压力门移到 fresh 检查前)要求即使 fresh=0 也每轮算 ratio,所以这个缓存
790
+ // 让"每轮算"不再有额外开销。只缓存非 null 结果,失败/null 时下次重试,与未缓存行为一致。
791
+ const windowCache = new Map()
792
+ async function resolveWindow(agent) {
793
+ try {
794
+ const header = agent.session?.requestHeader?.()?.config
795
+ const provider = header?.provider || agent.options?.provider
796
+ const model = header?.model || agent.options?.model
797
+ const llm = ctx.get('llm')
798
+ if (llm == null || !provider || !model || typeof llm.resolveModelInfo !== 'function') return null
799
+ const key = `${provider}\u0000${model}`
800
+ if (windowCache.has(key)) return windowCache.get(key)
801
+ const info = await llm.resolveModelInfo(provider, model)
802
+ const windowTokens = info?.context?.contextWindow ?? null
803
+ if (windowTokens != null) windowCache.set(key, windowTokens)
804
+ return windowTokens
805
+ } catch {
806
+ return null
807
+ }
808
+ }
809
+
810
+ async function judgePass(agent, signal) {
811
+ const session = agent?.session
812
+ // P0-3:把"没判定"的原因也落盘。此前这条路径是完全静默的——judged=0 时无法区分
813
+ // "门没开"(有 lastGate)/"没有候选"(有 surface 但都不可判)/"session 形态不对",
814
+ // 而这三者的处置完全不同。
815
+ // session 真的可能不存在:宿主在会话挂上之前也会发 pre-step,而 compactPass 对同一
816
+ // 情况的处置是 `report.blocked = '没有活动会话'`——契约就是"优雅退出",不是抛错。
817
+ //
818
+ // ⚠️ 必须与下面那个分支分开判断:`WeakMap.set` 的键必须是对象,`set(undefined)` 会抛
819
+ // `TypeError: Invalid value used as weak map key`。抛出点落在 pre-step 的 try 里,
820
+ // 会被吞成 `stats.errors` / `最近(第一层): 判定失败:…`——第一层行为其实没坏,
821
+ // 只有账本在说谎(与 #29 的 startRequests 同一类缺陷)。所以守卫不能省:
822
+ // 条件写 `session?.` 却在这一行无条件 set,等于把"受支持的路径"变成"抛错路径"。
823
+ if (session == null) {
824
+ stats.judgePassSkipped = (stats.judgePassSkipped ?? 0) + 1
825
+ stats.lastJudgeSkipReason = '没有活动会话'
826
+ return
827
+ }
828
+ if (session.surface?.nodes == null || judge.ready === false) {
829
+ // 早退也刷新压力比例(review 反馈):否则沿用上一轮的值,陈旧。
830
+ pressureRatios.set(session, 0)
831
+ stats.judgePassSkipped = (stats.judgePassSkipped ?? 0) + 1
832
+ stats.lastJudgeSkipReason = judge.ready === false ? 'judge 未就绪' : 'session.surface.nodes 不可用'
833
+ return
834
+ }
835
+
836
+ const surface = [...session.surface.nodes]
837
+ const eventAt = (seq) => session.eventAt(seq)
838
+ const nameByCallId = buildToolNameIndex(sessionEvents(session))
839
+ // 判定缓存供两层共用,但两层的最近区与工具规则不同:
840
+ // · 第一层按 preserveRecent + neverPruneTools;
841
+ // · 第二层按 compactPreserveRecent + 只读白名单/neverCompactTools。
842
+ // 不能简单取两个最近区的 min 后仍套第一层工具规则,否则 pwsh 等节点会落在
843
+ // “第一层因最近区不动、第二层因工具门不动”的死区里,却仍然花钱做 Jev 判定。
844
+ const candidateInput = {
845
+ surface,
846
+ eventAt,
847
+ events: sessionEvents(session),
848
+ marker: JEV_PRUNE_MARKER,
849
+ nameByCallId,
850
+ }
851
+ const layer1Candidates = selectCandidates({
852
+ ...candidateInput,
853
+ preserveRecent: cfg.preserveRecent,
854
+ neverPruneTools: cfg.neverPruneTools,
855
+ })
856
+ const candidatesBySeq = new Map(layer1Candidates.map((candidate) => [candidate.seq, candidate]))
857
+ const layer2CandidateSeqs = new Set()
858
+ // 选择器被显式设为 0 时,第二层数学上不可能选中任何节点。不要再扩候选或
859
+ // 询问 effect 轴;第一层仍可按自己的范围正常取得 result 判定。
860
+ // compactOn='off' 不能在这里排除:它只关闭自动 pass,jev_compact_now 会用 force
861
+ // 绕过这道门;若不预取两轴,手动命令会永久缺 effect verdict、实际无法使用。
862
+ const layer2CanSelect = cfg.compactReceipts
863
+ && (cfg.compactMode === 'relative' ? cfg.compactQuantile > 0 : cfg.compactThreshold > 0)
864
+ if (layer2CanSelect) {
865
+ const layer2Candidates = selectCandidates({
866
+ ...candidateInput,
867
+ preserveRecent: cfg.compactPreserveRecent,
868
+ // 第一层黑名单不属于第二层;第二层随后按自己的白名单 + 黑名单过滤。
869
+ neverPruneTools: [],
870
+ // decisions 是内存缓存,宿主重启/会话恢复后会丢失。第一层 replacement 虽然
871
+ // 带裁剪标记,第二层仍需允许重新判定,否则这些旧节点永远无法进入回执压缩。
872
+ includePruned: true,
873
+ }).filter((candidate) => isCompactableTool(candidate.tool, cfg))
874
+ for (const candidate of layer2Candidates) {
875
+ layer2CandidateSeqs.add(candidate.seq)
876
+ candidatesBySeq.set(candidate.seq, candidate)
877
+ }
878
+ }
879
+ const candidates = [...candidatesBySeq.values()].sort((a, b) => a.index - b.index)
880
+ const cache = decisionsOf(session)
881
+ // replacement 的判定可能仍挂在 sourceEventSeqs 指向的旧 seq 上;直接查新 seq 会
882
+ // 重复付费。缓存确实丢失时(例如重启)才重新判定当前 replacement。
883
+ // “有缓存项”不等于“判定完成”:服务可能只返回 result/effect 其中一轴。
884
+ // 第一层至少需要 result(prob);第二层必须两轴都齐。缺轴的节点下轮继续问,
885
+ // 否则一次部分响应就会让它永久停在 cache 中、永远进不了第二层。
886
+ const fresh = candidates.filter((candidate) => {
887
+ const cached = cachedVerdictForEvent(cache, eventAt(candidate.seq))
888
+ if (!Number.isFinite(cached?.prob)) return true
889
+ return layer2CandidateSeqs.has(candidate.seq) && !Number.isFinite(cached?.effectProb)
890
+ })
891
+ // 压力门控:不到软阈值就不花 Jev 的钱
892
+ //
893
+ // 失败方向(issue #32):第一层与第二层的压力门必须**同向关闭**。
894
+ // 旧实现是不对称的——第二层解析不出阈值时 `return`(不做),第一层却直接
895
+ // **穿透**(照做);更隐蔽的是 meter 缺失/抛错时 `used` 恒为 0,
896
+ // 于是 `0 < threshold` 永远成立 → 判定**每一轮都跑**,压力门等于不存在。
897
+ // 对一个"省 Jev 调用钱"的门来说,"解析不出来就别花钱"才是安全方向。
898
+ //
899
+ // 但"关闭"只对**真的算不出来**的情形成立(PR #28 review 修正):
900
+ // `softLimit` 配成绝对 token 数时,threshold 由 limit.value 直接给出,
901
+ // **根本不需要 meter**。早期实现在这里无条件要求 `measured`,于是绝对阈值
902
+ // 分支也被挡掉——把"该省的钱省下来"升级成了"功能静默消失",
903
+ // 比旧行为更糟。所以只要阈值本身能定出来(`threshold != null`),
904
+ // meter 不可用就只是"压力门降级为不设防",而不是"什么都不做"。
905
+ //
906
+ // 方案 B:压力门**移到 fresh 检查之前**。这样即使 fresh=0(没有新候选),ratio 也是
907
+ // "当前"压力缺口比例,不是清成 0(会"该裁不裁")也不是沿用旧值(陈旧)。代价是
908
+ // fresh=0 的轮次也多调一次 resolveWindow(已按 provider+model 缓存)/ meter.measure
909
+ // (本地计算),可忽略。
910
+ // P0-1 修正:压力缺口比例(0~1)。judgePass 算出后缓存给 pruneSession 决定「这一轮裁多少」。
911
+ // 默认 0 = 不裁(失败方向:算不出压力就不动手)。
912
+ let pressureRatio = 0
913
+ if (cfg.judgeOn !== 'always') {
914
+ const meter = ctx.get('tokenMeter')
915
+ let used = 0
916
+ let measured = false
917
+ try {
918
+ if (typeof meter?.measure === 'function') {
919
+ const measuredTokens = meter.measure(session)?.totalTokens
920
+ if (typeof measuredTokens === 'number' && Number.isFinite(measuredTokens)) {
921
+ used = measuredTokens
922
+ measured = true
923
+ }
924
+ }
925
+ } catch {
926
+ measured = false
927
+ }
928
+ const windowTokens = await resolveWindow(agent)
929
+ const limit = parseLimit(cfg.softLimit)
930
+ const threshold = limit.kind === 'ratio'
931
+ ? (windowTokens == null ? null : Math.floor(windowTokens * limit.value))
932
+ : limit.value
933
+ // P0-3:门控快照。三道门里这是第一道(插件判定门);宿主的压缩门(compaction-basic
934
+ // 的 thresholdRatio,默认 0.8)决定了第一层有没有写入权——两者都落盘才看得清全貌。
935
+ stats.lastGate = {
936
+ used, measured, windowTokens, limitRaw: cfg.softLimit, threshold,
937
+ skip: false, reason: '', candidates: fresh.length,
938
+ }
939
+ if (threshold == null) {
940
+ // 阈值算不出来(ratio 模式 + 窗口未知)→ 与第二层同向:不做判定,不花钱
941
+ pressureRatios.set(session, 0)
942
+ stats.skipped += fresh.length
943
+ stats.lastGate = { ...stats.lastGate, skip: true, reason: '窗口未知(ratio 模式算不出阈值)' }
944
+ stats.lastNote = '解析不出上下文窗口,第一层保守跳过(与第二层同向)'
945
+ log('info', stats.lastNote)
946
+ return
947
+ }
948
+ if (!measured) {
949
+ // 阈值能定出来但拿不到用量 → 无法比较,只能不设防地继续。
950
+ // 这里**不 return**:绝对阈值分支下 meter 本来就无关,return 等于把功能关掉。
951
+ // ratio 算不出缺口 → 保持 0(不裁),由块后的统一 set 落盘。
952
+ stats.lastGate = { ...stats.lastGate, reason: 'meter 不可用,本次不设防' }
953
+ stats.lastNote = `拿不到 token 用量(meter 缺失或抛错),压力门本次不设防(阈值 ${threshold})`
954
+ log('warn', stats.lastNote)
955
+ } else if (used < threshold) {
956
+ pressureRatios.set(session, 0)
957
+ stats.skipped += fresh.length
958
+ stats.lastGate = { ...stats.lastGate, skip: true, reason: `压力不足(${used} < ${threshold})` }
959
+ return
960
+ } else {
961
+ // 通过压力门:缺口 = used - threshold(token),比例 = 缺口 / 窗口。窗口未知时保守 0。
962
+ pressureRatio = windowTokens != null && windowTokens > 0
963
+ ? Math.min(1, Math.max(0, (used - threshold) / windowTokens))
964
+ : 0
965
+ }
966
+ } else {
967
+ // always 模式没有压力信号,按配置的固定比例裁(默认 0.5 = 裁掉池子一半增益)。
968
+ pressureRatio = cfg.alwaysTrimRatio
969
+ }
970
+ pressureRatios.set(session, pressureRatio)
971
+
972
+ if (fresh.length === 0) {
973
+ // 早退②:没有新候选。ratio 已在上面每轮算好(压力门在 fresh 检查之前),
974
+ // 这里不需要再 set 0——pruneSession 拿到的是"当前"压力比例,不是陈旧的。
975
+ // P0-3:区分"没有候选"与"候选都已判定过"——前者说明筛选条件把结果全排除了
976
+ // (preserveRecent / 黑名单 / 已裁标记),需要看见 surface 规模才能判断是否配置过紧。
977
+ stats.judgePassSkipped = (stats.judgePassSkipped ?? 0) + 1
978
+ stats.lastJudgeSkipReason = candidates.length === 0
979
+ ? `无候选(surface ${surface.length} 节点:可能全落在最近区/黑名单/已裁剪)`
980
+ : `候选全部已判定(候选 ${candidates.length},缓存 ${cache.size})`
981
+ return
982
+ }
983
+
984
+ const goal = recentGoal(sessionEvents(session))
985
+ const { state, fitted, stateTokens } = buildJevState({
986
+ surface,
987
+ eventAt,
988
+ goal,
989
+ context: STATE_CONTEXT,
990
+ options: {
991
+ textHead: cfg.textHead,
992
+ textTail: cfg.textTail,
993
+ maxStateTokens: cfg.maxStateTokens,
994
+ inputChars: cfg.inputChars,
995
+ minHistoryLines: cfg.minHistoryLines,
996
+ // P0-2:结果摘录预算(0 = 关闭)。判断者能看到"里面是什么"再决定留不留。
997
+ resultExcerptChars: cfg.resultExcerptChars,
998
+ },
999
+ })
1000
+ if (!fitted) {
1001
+ log('info', `state ≈ ${stateTokens} tokens 仍超预算 ${cfg.maxStateTokens}(行数地板 ${cfg.minHistoryLines}),本批可能被服务端拒绝`)
1002
+ }
1003
+ const questions = questionsFor(fresh, cfg.wording)
1004
+ // 只询问当前候选真正缺失、且所在层需要的轴:
1005
+ // · 第一层只消费 result;
1006
+ // · 第二层同时消费 result + effect。
1007
+ // 部分响应后的重试若把已有轴再问一次,不仅浪费预算,还可能用第二次采样覆盖
1008
+ // 第一次已经得到的概率,使同一节点的裁决随重试发生无意义漂移。
1009
+ for (const candidate of fresh) {
1010
+ const cached = cachedVerdictForEvent(cache, eventAt(candidate.seq))
1011
+ if (Number.isFinite(cached?.prob)) delete questions[`result_s${candidate.seq}`]
1012
+ if (!layer2CandidateSeqs.has(candidate.seq) || Number.isFinite(cached?.effectProb)) {
1013
+ delete questions[`effect_s${candidate.seq}`]
1014
+ }
1015
+ }
1016
+ const batches = judge.batch(state, questions, {
1017
+ maxRequestTokens: cfg.maxRequestTokens,
1018
+ overheadTokens: 40,
1019
+ })
1020
+
1021
+ const bySeq = new Map(fresh.map((c) => [c.seq, c]))
1022
+ const freshSeqs = [...bySeq.keys()]
1023
+ // 本轮开始时的请求数,用于把 `stats.requests` 只加上**本轮真正发出**的部分。
1024
+ // (这里曾经漏了声明:结算行读一个不存在的 `startRequests`,在严格模式下抛
1025
+ // ReferenceError,被 pre-step 的 catch 吞成「判定失败:startRequests is not defined」。
1026
+ // 危害在于**判定结果已经写进 decisions,第一层裁剪照常执行**——功能看起来完全正常,
1027
+ // 只有 errors 计数与心跳在说谎。smoke 的 J 块现在钉住了"成功路径不得留下被吞的错误"。)
1028
+ const startRequests = judge.requests
1029
+ // 批级容错(issue #34):旧实现让第一处失败冒泡到调用方,于是**后续批次也不会再问**——
1030
+ // 已经能问出来的概率被一起丢掉。现在逐批 try:失败的批记一笔、继续问下一批。
1031
+ // 只有**所有**批都失败才把错误抛出去(那种情况确实等于整轮没判定)。
1032
+ let batchFailures = 0
1033
+ let lastBatchError = null
1034
+ let succeeded = 0
1035
+ for (const batch of batches) {
1036
+ let answers
1037
+ try {
1038
+ answers = await judge.ask(state, batch, { signal })
1039
+ } catch (error) {
1040
+ batchFailures += 1
1041
+ lastBatchError = error
1042
+ stats.judgeBatchFailures += 1
1043
+ log('info', `判定批次失败(${batchFailures}/${batches.length}):${error?.message ?? String(error)}`)
1044
+ continue
1045
+ }
1046
+ succeeded += 1
1047
+ // 遍历本批里的**候选**(一个候选有两个题号 result_sN / effect_sN)。
1048
+ // 注意:两轴可能落在**不同批**(预算小的时候每题一批),所以本批只写
1049
+ // 它带来的那一轴,另一轴留给它自己的批——合并写在下面按候选统一结算,
1050
+ // 避免"同一候选被两批各记一次"。
1051
+ const seqsInBatch = new Set()
1052
+ for (const id of Object.keys(batch)) {
1053
+ const seq = Number(id.slice(id.indexOf('_s') + 2))
1054
+ if (Number.isFinite(seq)) seqsInBatch.add(seq)
1055
+ }
1056
+ for (const seq of seqsInBatch) {
1057
+ const candidate = bySeq.get(seq)
1058
+ if (candidate == null) continue
1059
+ const prob = answers[`result_s${seq}`]
1060
+ const effectProb = answers[`effect_s${seq}`]
1061
+ // 第一轮只有一轴时,第一层可能已经把该结果替换成新 seq;补第二轴时要沿
1062
+ // sourceEventSeqs 找到旧缓存,否则会把已有轴丢掉,形成“两个 seq 各半轴”。
1063
+ const previous = cachedVerdictForEvent(cache, eventAt(candidate.seq))
1064
+ // 局部合并:本批给出的轴覆盖,未给出的轴沿用已有值
1065
+ const merged = {
1066
+ keep: typeof prob === 'number'
1067
+ ? prob >= cfg.keepThreshold
1068
+ : (previous?.keep ?? (typeof effectProb === 'number' ? effectProb >= cfg.keepThreshold : true)),
1069
+ prob: typeof prob === 'number' ? prob : (previous?.prob ?? null),
1070
+ effectProb: typeof effectProb === 'number' ? effectProb : (previous?.effectProb ?? null),
1071
+ chars: candidate.chars,
1072
+ tool: candidate.tool,
1073
+ }
1074
+ cache.set(seq, merged)
1075
+ // 补轴可能发生在第一层已经把 old seq 替换成 replacement seq 之后。
1076
+ // 将合并结果归一到当前 seq,并移除来源 seq 的旧半条缓存,避免后续统计/查找
1077
+ // 同时看到两个各自不完整的版本。
1078
+ for (const sourceSeq of eventAt(candidate.seq)?.sourceEventSeqs ?? []) {
1079
+ if (sourceSeq !== seq) cache.delete(sourceSeq)
1080
+ }
1081
+ }
1082
+ }
1083
+ // 结算本轮的判定条数:按**候选**去重后统计。第一层只需 result,第二层
1084
+ // 必须两轴齐全;部分响应不算完成,也不会在补轴后重复计数。
1085
+ // 旧实现是"每批都遍历整个 fresh,能查到旧值就再累加一次",条数按批数虚报。
1086
+ for (const seq of freshSeqs) {
1087
+ const value = cachedVerdictForEvent(cache, eventAt(seq))
1088
+ const complete = Number.isFinite(value?.prob)
1089
+ && (!layer2CandidateSeqs.has(seq) || Number.isFinite(value?.effectProb))
1090
+ if (complete) {
1091
+ stats.judged += 1
1092
+ // P0-3 遥测:把概率分布记下来(这是判断"阈值是否失配"的唯一依据)
1093
+ if (typeof value.prob === 'number') {
1094
+ stats.probSum += value.prob
1095
+ stats.probCount += 1
1096
+ if (value.prob >= cfg.keepThreshold) stats.keepAboveThreshold += 1
1097
+ else stats.keepBelowThreshold += 1
1098
+ probSamples.push(value.prob)
1099
+ if (probSamples.length > 500) probSamples.shift()
1100
+ }
1101
+ }
1102
+ }
1103
+ stats.requests += judge.requests - startRequests
1104
+ if (batches.length > 0 && succeeded === 0) {
1105
+ // 全部失败:这确实等于整轮没判定,如实抛出(调用方会记进 stats.errors)
1106
+ throw lastBatchError ?? new Error('全部判定批次失败')
1107
+ }
1108
+ stats.lastNote = `判定 ${fresh.length} 个候选,state ≈ ${estimateTokens(state)} tokens`
1109
+ + (batchFailures > 0 ? `(${batchFailures}/${batches.length} 批失败,已跳过)` : '')
1110
+ log('debug', stats.lastNote)
1111
+ writeHeartbeat({
1112
+ lastJudgePass: {
1113
+ candidates: fresh.length,
1114
+ stateTokens: estimateTokens(state),
1115
+ nameIndexSize: nameByCallId.size,
1116
+ events: describeEvents(session),
1117
+ // 逐候选明细(seq/工具/概率)——viewer 画散点、排查"为什么裁这条"用
1118
+ rows: fresh.map((c) => {
1119
+ const v = cache.get(c.seq) ?? {}
1120
+ return { seq: c.seq, tool: c.tool, chars: c.chars, prob: v.prob ?? null, effectProb: v.effectProb ?? null }
1121
+ }),
1122
+ },
1123
+ // P0-3:判定依据落盘(分布 + 门控快照),否则"阈值失配/门没开"在运行时不可见
1124
+ probSummary: probSummary(),
1125
+ // 原始样本(最近 200 个)——viewer 用它画直方图;分位数只能看形状不能看尾巴
1126
+ probSamples: probSamples.slice(-200),
1127
+ gate: stats.lastGate,
1128
+ })
1129
+ }
1130
+
1131
+ // ---------------------------------------------------------- 裁剪(同步)
1132
+ // 逐节点裁决逻辑放在 prune.js 里(纯函数 + 依赖注入),这样才能脱离 DSH 单测。
1133
+ // 这里只负责补齐它需要的依赖。
1134
+ function pruneViaJev(pruner, session) {
1135
+ const nameByCallId = buildToolNameIndex(sessionEvents(session))
1136
+ const out = pruneSessionWithJev({
1137
+ pruner,
1138
+ session,
1139
+ cache: decisions.get(session),
1140
+ cfg: { ...cfg, marker: JEV_PRUNE_MARKER, pressureRatio: pressureRatios.get(session) ?? 0 },
1141
+ stats,
1142
+ freeze: freezeMessage,
1143
+ toolNameOf: (event) => toolNameOf(event, nameByCallId),
1144
+ callIdOf,
1145
+ })
1146
+ writeHeartbeat({
1147
+ lastPrune: {
1148
+ nodes: session.surface?.nodes?.length ?? null,
1149
+ pruned: out.pruned.length,
1150
+ charsRemoved: out.charsRemoved,
1151
+ seqs: out.pruned.map((p) => p.originalSeq),
1152
+ },
1153
+ // P0-1/P0-3:预算分析与逐节点决策落盘——"裁了哪些、为什么、预算够不够"可复核
1154
+ budget: out.plan ?? null,
1155
+ decisions: out.decisions ?? [],
1156
+ })
1157
+ // 供 jev_prune_status / 心跳汇总使用(只留最近一批,避免无限增长)
1158
+ if (Array.isArray(out.decisions) && out.decisions.length > 0) stats.decisions = out.decisions.slice(0, 50)
1159
+ return out
1160
+ }
1161
+
1162
+ function installPrunerOverride() {
1163
+ if (takeover.installed) return () => {}
1164
+ takeover = { attempted: true, installed: false, reason: '' }
1165
+ const pruner = ctx.get('toolResultPruner') ?? ctx.toolResultPruner
1166
+ if (pruner == null || typeof pruner.pruneSession !== 'function') {
1167
+ takeover.reason = pruner == null
1168
+ ? 'ctx.toolResultPruner 不存在 —— 需加载 @deepseek-ai/dsh-compaction-tool-result-pruner'
1169
+ : 'pruner.pruneSession 不是函数'
1170
+ log('info', `${takeover.reason};本次不介入(若服务稍后才就绪,会在下一次 pre-step 重试)`)
1171
+ writeHeartbeat()
1172
+ return () => {}
1173
+ }
1174
+ const originalSession = pruner.pruneSession.bind(pruner)
1175
+ pruner.pruneSession = (session) => pruneViaJev(pruner, session)
1176
+ takeover = { attempted: true, installed: true, reason: 'ok' }
1177
+ log('info', `已接管 ctx.toolResultPruner.pruneSession(keepThreshold=${cfg.keepThreshold}, preserveRecent=${cfg.preserveRecent}, dryRun=${cfg.dryRun})`)
1178
+ // 越界配置必须在加载时就喊出来(issue #28):钳制后的行为与用户写下的配置不一致,
1179
+ // 若不提示,用户会一直以为"我配了但没生效"是插件的 bug。
1180
+ for (const w of cfg[CONFIG_WARNINGS] ?? []) log('warn', w)
1181
+ writeHeartbeat()
1182
+ return () => {
1183
+ pruner.pruneSession = originalSession
1184
+ }
1185
+ }
1186
+
1187
+ // ---------------------------------------------------------- 第二层:回执压缩
1188
+ //
1189
+ // 接入点:`ctx.compaction.summarize`。DSH 的压缩后端把 summarize 当**唯一的子类定制钩子**
1190
+ // (源码原话:"summarize() is the sole subclass customization hook"),且 `compactRegion`
1191
+ // 内部是通过 `this.summarize(...)` **动态派发**的 —— 所以在实例上猴补丁它就够了,
1192
+ // 不需要 fork 后端、也不需要另写一个 CompactionEngine。
1193
+ //
1194
+ // ⚠️ 竞态(issue #29):`compactRegion` 是异步的,`summarize` 的入参里**没有区间身份**
1195
+ // (只有 `input` / `agent` / `signal`,我们无法从中看出"这次摘要对应哪个区间")。
1196
+ // 所以只要按 session 存一个待用回执,在 `await compactRegion(...)` 期间**任何**别处
1197
+ // 发起的压缩(DSH 自己的自动压缩、另一条并发路径)都会调到 summarize,把回执抢走——
1198
+ // 结果是"别人那段被换成了我们的确定性回执,而我们要压的那段反而用了模型摘要"。
1199
+ //
1200
+ // 修法分两层,缺一不可:
1201
+ // ① **归属令牌(fencing token)**:每次 compactRegion 前发一个新令牌,并把它记在
1202
+ // `activeFence` 上。summarize 只在「当前 activeFence === 待用回执的令牌」时才注入——
1203
+ // 即证明"这次 summarize 是在我们那次 compactRegion 的调用栈/时序内发生的"。
1204
+ // await 期间若被别处的调用抢先,activeFence 会被对方改写,我们自然不注入。
1205
+ // ② **一次性领取(claim-once)**:回执被消费后立刻 delete,且令牌是一次性的,
1206
+ // 防止同一次压缩里 summarize 被调用多次时重复注入。
1207
+ // ③ **归属校验(owner check)**:compactRegion 返回后核对令牌是否仍属于本次调用,
1208
+ // 不属于则说明中途被打断,如实记进 action,不谎报成功。
1209
+ let fenceCounter = 0
1210
+ /**
1211
+ * 当前"活跃"的压缩令牌。每次我们要调 compactRegion 时自增并置为最新值;
1212
+ * summarize 只在待用回执的令牌与之相等时才注入。它是"归属证明":
1213
+ * 我们那次 compactRegion 里面派发的 summarize 一定看到自己的令牌,
1214
+ * 而 await 期间被别处抢先发起的压缩会把它改写成对方的令牌。
1215
+ */
1216
+ let activeFence = 0
1217
+ const pendingReceipt = new WeakMap()
1218
+
1219
+ function summaryService() {
1220
+ return ctx.get?.('compaction') ?? ctx.compaction ?? null
1221
+ }
1222
+
1223
+ function installSummaryHook() {
1224
+ if (summaryHook.installed) return () => {}
1225
+ summaryHook = { attempted: true, installed: false, reason: '' }
1226
+ const compaction = summaryService()
1227
+ if (compaction == null || typeof compaction.summarize !== 'function') {
1228
+ summaryHook.reason = compaction == null
1229
+ ? 'ctx.compaction 不存在 —— 需加载 @deepseek-ai/dsh-compaction-basic'
1230
+ : 'compaction.summarize 不是函数(该压缩后端不暴露这个接入点)'
1231
+ log('info', `${summaryHook.reason};第二层不介入`)
1232
+ writeHeartbeat()
1233
+ return () => {}
1234
+ }
1235
+ const original = compaction.summarize.bind(compaction)
1236
+ compaction.summarize = async (input, agent, signal) => {
1237
+ const entry = pendingReceipt.get(agent?.session)
1238
+ // 三道闸都要过才算"这是我们的那一次"(issue #29):
1239
+ // · entry 存在、未过期
1240
+ // · 未被领取(claimed)—— 同一次压缩里 summarize 若被多次调用,只注入一次
1241
+ // · 令牌仍是当前活跃令牌 —— 证明这次 summarize 发生在我们那次 compactRegion 之内,
1242
+ // 而不是被 await 期间别处的并发压缩抢先调用
1243
+ if (entry != null
1244
+ && !entry.claimed
1245
+ && entry.fence === activeFence
1246
+ && Date.now() - entry.at < 5 * 60 * 1000) {
1247
+ entry.claimed = true
1248
+ stats.receiptSummaries += 1
1249
+ return {
1250
+ summary: [{ type: 'text', text: entry.text }],
1251
+ provider: 'jev-receipt',
1252
+ model: 'deterministic',
1253
+ }
1254
+ }
1255
+ // 不是我们的:如实退回原实现,绝不吞掉别人的摘要。
1256
+ // 若 entry 存在但令牌不匹配,说明恰好撞上竞态 —— 记一笔,让"回执被抢"可观测。
1257
+ if (entry != null && !entry.claimed && entry.fence !== activeFence) {
1258
+ stats.receiptFenceMisses += 1
1259
+ }
1260
+ return original(input, agent, signal)
1261
+ }
1262
+ summaryHook = { attempted: true, installed: true, reason: 'ok' }
1263
+ log('info', `已接管 ctx.compaction.summarize(回执模式;compactOn=${cfg.compactOn}, quantile=${cfg.compactQuantile})`)
1264
+ writeHeartbeat()
1265
+ return () => {
1266
+ compaction.summarize = original
1267
+ }
1268
+ }
1269
+
1270
+ /** 用宿主自己的 token meter 量一段 surface 跨度的 token 数。 */
1271
+ function spanTokens(agent, seqs) {
1272
+ const meter = ctx.get?.('tokenMeter')
1273
+ if (meter == null || typeof meter.measure !== 'function') return null
1274
+ try {
1275
+ const wanted = new Set(seqs)
1276
+ const nodes = meter.measure(agent.session)?.nodes ?? []
1277
+ let total = 0
1278
+ let seen = 0
1279
+ for (const node of nodes) {
1280
+ if (!wanted.has(node.seq)) continue
1281
+ total += node.tokens ?? node.heuristicTokens ?? 0
1282
+ seen += 1
1283
+ }
1284
+ return seen > 0 ? total : null
1285
+ } catch {
1286
+ return null
1287
+ }
1288
+ }
1289
+
1290
+ /** 从事件里捞出 checkpoint 对应的原始文本(`jev_restore` 用)。 */
1291
+ function findCompactionRecord(session, { seq, start, end }) {
1292
+ const events = sessionEvents(session)
1293
+ if (seq != null) {
1294
+ const event = typeof session.eventAt === 'function' ? session.eventAt(seq) : events.find((e) => e.seq === seq)
1295
+ if (event?.type === 'compaction/summary') return event
1296
+ if (isCheckpointEvent(event)) {
1297
+ const id = event.data?.source?.compactionId
1298
+ return [...events].reverse().find((e) => e.type === 'compaction/summary' && e.data?.compactionId === id) ?? null
1299
+ }
1300
+ }
1301
+ return [...events].reverse().find((e) => e.type === 'compaction/summary'
1302
+ && (start == null || e.data?.shadowedRange?.start === start)
1303
+ && (end == null || e.data?.shadowedRange?.end === end)) ?? null
1304
+ }
1305
+
1306
+ /**
1307
+ * 做一次回执压缩。返回结构化报告(既给工具输出,也给心跳)。
1308
+ *
1309
+ * @param {object} agent
1310
+ * @param {{force?:boolean, dryRun?:boolean, signal?:AbortSignal}} [options]
1311
+ */
1312
+ async function compactPass(agent, options = {}) {
1313
+ const force = options.force === true
1314
+ const dryRun = options.dryRun ?? cfg.dryRun
1315
+ const report = { blocked: '', verdicts: 0, eligible: [], considered: 0, selection: null, actions: [] }
1316
+
1317
+ if (!cfg.compactReceipts) {
1318
+ report.blocked = 'compactReceipts=false'
1319
+ return report
1320
+ }
1321
+ if (cfg.compactOn === 'off' && !force) {
1322
+ report.blocked = 'compactOn=off'
1323
+ return report
1324
+ }
1325
+ const session = agent?.session
1326
+ if (session?.surface?.nodes == null) {
1327
+ report.blocked = '没有活动会话'
1328
+ return report
1329
+ }
1330
+ const compaction = summaryService()
1331
+ if (compaction == null || typeof compaction.compactRegion !== 'function') {
1332
+ report.blocked = 'ctx.compaction 不可用(compactRegion 缺失)'
1333
+ return report
1334
+ }
1335
+ if (!summaryHook.installed) {
1336
+ // 没有 summarize 接入点 = 我们的回执注不进去 = 会退化成模型摘要。宁可不做。
1337
+ report.blocked = `summarize 未接管(${summaryHook.reason || '未尝试'})—— 不做第二层,避免退化成模型摘要`
1338
+ return report
1339
+ }
1340
+ if (judge.ready === false) {
1341
+ report.blocked = '未配置 TYPESAFE_API_KEY'
1342
+ return report
1343
+ }
1344
+
1345
+ // 压力门:整对删除比截断风险大,所以默认阈值更高(70% vs 第一层的 55%)
1346
+ // 失败方向与第一层一致(issue #32):解析不出阈值、或拿不到用量,都**不做**。
1347
+ if (!force && cfg.compactOn !== 'always') {
1348
+ const meter = ctx.get?.('tokenMeter')
1349
+ let used = 0
1350
+ let measured = false
1351
+ try {
1352
+ if (typeof meter?.measure === 'function') {
1353
+ const measuredTokens = meter.measure(session)?.totalTokens
1354
+ if (typeof measuredTokens === 'number' && Number.isFinite(measuredTokens)) {
1355
+ used = measuredTokens
1356
+ measured = true
1357
+ }
1358
+ }
1359
+ } catch {
1360
+ measured = false
1361
+ }
1362
+ const limit = parseLimit(cfg.compactSoftLimit)
1363
+ const windowTokens = await resolveWindow(agent)
1364
+ const threshold = limit.kind === 'ratio'
1365
+ ? (windowTokens == null ? null : Math.floor(windowTokens * limit.value))
1366
+ : limit.value
1367
+ if (threshold == null) {
1368
+ report.blocked = '解析不出上下文窗口,保守跳过第二层'
1369
+ stats.compactSkipped += 1
1370
+ return report
1371
+ }
1372
+ if (!measured) {
1373
+ // 与第一层同口径(PR #28 review):阈值能定出来时不因 meter 缺失而放弃,
1374
+ // 只是压力门本次不设防。绝对阈值分支下 meter 本来就无关,
1375
+ // 早期实现无条件 return 会把"保守"变成"功能静默消失"。
1376
+ report.blocked = `拿不到 token 用量(meter 缺失或抛错),压力门本次不设防(阈值 ${threshold})`
1377
+ log('warn', report.blocked)
1378
+ } else if (used < threshold) {
1379
+ report.blocked = `压力不足(${used} < ${threshold})`
1380
+ stats.compactSkipped += 1
1381
+ return report
1382
+ }
1383
+ }
1384
+
1385
+ const cache = decisions.get(session)
1386
+ if (cache == null || cache.size === 0) {
1387
+ report.blocked = '还没有任何 Jev 判定(判定在 agent/pre-step 里跑)'
1388
+ stats.compactSkipped += 1
1389
+ return report
1390
+ }
1391
+ const surface = [...session.surface.nodes]
1392
+ // 分位总体必须只含**可整对移出**的节点:判定的缓存同时服务第一层(无白名单),
1393
+ // 若把白名单外/黑名单内的节点也算进总体,尾部名额会被它们占掉后被工具门白拒。
1394
+ const verdicts = surface.flatMap((seq) => {
1395
+ const value = cachedVerdictForEvent(cache, session.eventAt(seq))
1396
+ return value != null && isCompactableTool(value.tool, cfg) ? [{ seq, ...value }] : []
1397
+ })
1398
+ report.verdicts = verdicts.length
1399
+
1400
+ let eligibleSeqs
1401
+ if (cfg.compactMode === 'relative') {
1402
+ eligibleSeqs = computeEligibleSeqs(verdicts, {
1403
+ quantile: cfg.compactQuantile,
1404
+ minCandidates: cfg.minCandidatesForRelative,
1405
+ minCandidatesForAbsolute: cfg.minCandidatesForFloor,
1406
+ floorThreshold: cfg.floorThreshold,
1407
+ // 降级模式的说明必须能被看到:否则用户只会看到"交集为空",
1408
+ // 又回到"分不清是样本不够还是功能坏了"的老问题(issue #27)
1409
+ onNote: (note) => { report.quantileNote = note },
1410
+ })
1411
+ } else {
1412
+ eligibleSeqs = new Set(verdicts
1413
+ .filter((v) => typeof v.prob === 'number' && v.prob < cfg.compactThreshold
1414
+ && typeof v.effectProb === 'number' && v.effectProb < cfg.compactThreshold)
1415
+ .map((v) => v.seq))
1416
+ }
1417
+ report.eligible = [...eligibleSeqs].sort((a, b) => a - b)
1418
+ if (eligibleSeqs.size === 0) {
1419
+ // 降级模式的说明优先展示:它比"交集为空"更具体(issue #27)
1420
+ report.blocked = report.quantileNote
1421
+ ?? (cfg.compactMode === 'relative'
1422
+ ? `两轴尾部交集为空(候选 ${verdicts.length} 个,需要 ≥${cfg.minCandidatesForRelative} 个)`
1423
+ : '没有同时低于阈值的候选')
1424
+ stats.compactSkipped += 1
1425
+ return report
1426
+ }
1427
+
1428
+ const eventAt = (seq) => session.eventAt(seq)
1429
+ const { ranges, stats: selection } = selectReceiptRanges({
1430
+ surface,
1431
+ eventAt,
1432
+ cache,
1433
+ dropVerdict: (seq) => eligibleSeqs.has(seq),
1434
+ cfg: { ...cfg, preserveRecent: cfg.compactPreserveRecent },
1435
+ })
1436
+ report.selection = selection
1437
+ report.considered = ranges.length
1438
+ if (ranges.length === 0) {
1439
+ report.blocked = '没有合格的连续只读步骤段(见 selection 的各条排除计数)'
1440
+ stats.compactSkipped += 1
1441
+ return report
1442
+ }
1443
+
1444
+ let done = 0
1445
+ for (const range of ranges) {
1446
+ if (done >= cfg.maxCompactionsPerPass) break
1447
+ const isPartial = range.kind === 'partial'
1448
+ const pairs = isPartial ? range.steps.flatMap((step) => step.pairs ?? []) : []
1449
+ const spanSeqs = isPartial
1450
+ ? pairs.map((pair) => pair.seq)
1451
+ : surface.slice(range.startIdx, range.endIdx + 1)
1452
+ const partialReceipts = isPartial
1453
+ ? pairs.map((pair) => renderPartialResultReceipt(pair, { argChars: cfg.receiptArgChars }))
1454
+ : []
1455
+ const receipt = isPartial
1456
+ ? partialReceipts.join('\n')
1457
+ : renderReceipt(range, {
1458
+ eventAt,
1459
+ argChars: cfg.receiptArgChars,
1460
+ textChars: cfg.receiptTextChars,
1461
+ })
1462
+ const receiptTokens = estimateTokens(receipt)
1463
+ const shadowedTokens = spanTokens(agent, spanSeqs)
1464
+ const action = {
1465
+ start: range.start,
1466
+ end: range.end,
1467
+ nodes: spanSeqs.length,
1468
+ calls: range.steps.reduce((sum, step) => sum + step.calls.length, 0),
1469
+ resultChars: range.chars,
1470
+ receiptTokens,
1471
+ shadowedTokens,
1472
+ receipt,
1473
+ partial: isPartial,
1474
+ }
1475
+ if (shadowedTokens != null && receiptTokens > shadowedTokens * cfg.receiptMaxRatio) {
1476
+ action.skipped = `回执 ${receiptTokens} tokens 相对原内容 ${shadowedTokens} 太大(上限 ${(cfg.receiptMaxRatio * 100).toFixed(0)}%)`
1477
+ report.actions.push(action)
1478
+ continue
1479
+ }
1480
+ if (dryRun) {
1481
+ action.dryRun = true
1482
+ report.actions.push(action)
1483
+ done += 1
1484
+ continue
1485
+ }
1486
+
1487
+ if (isPartial) {
1488
+ // DSH 0.1.5 的 replace 一次只能插入一个 surface 节点,compactRegion 又要求
1489
+ // 区间两端配对平衡,因此不能从一个多调用 assistant 消息中直接抽走部分 pair。
1490
+ // 单独替换合格 result 的正文是宿主原生支持的安全协议:call/result 外壳不动,
1491
+ // 同批次不合格结果原样保留;逐项提交时即使中途失败,surface 仍始终合法。
1492
+ let applied = 0
1493
+ let appliedChars = 0
1494
+ try {
1495
+ const meter = ctx.get?.('tokenMeter')
1496
+ for (let offset = 0; offset < pairs.length; offset += 1) {
1497
+ const pair = pairs[offset]
1498
+ const event = session.eventAt(pair.seq)
1499
+ if (event?.type !== 'tool/result' || !session.surface.nodes.includes(pair.seq)) {
1500
+ throw new Error(`批量步骤的结果 s${pair.seq} 在回执替换前已离开 surface`)
1501
+ }
1502
+ const original = event.data?.message
1503
+ const blocks = original?.content
1504
+ if (!Array.isArray(blocks)) throw new Error(`tool/result s${pair.seq} 缺少 message.content`)
1505
+ const result = blocks.find((block) => block?.type === 'tool-result')
1506
+ if (result == null) throw new Error(`tool/result s${pair.seq} 缺少 tool-result block`)
1507
+ const text = partialReceipts[offset]
1508
+ const message = freezeMessage({
1509
+ ...original,
1510
+ content: blocks.map((block) => block === result
1511
+ ? { ...result, content: [{ type: 'text', text }] }
1512
+ : block),
1513
+ })
1514
+ const shadowedTokenCount = typeof meter?.estimateMessage === 'function'
1515
+ ? meter.estimateMessage(original)
1516
+ : estimateTokens(JSON.stringify(original))
1517
+ session.append('compaction/prune', {
1518
+ shadowedRange: { start: pair.seq, end: pair.seq },
1519
+ shadowedSeqs: [pair.seq],
1520
+ shadowedTokenCount,
1521
+ })
1522
+ session.append('tool/result', { ...event.data, message }, {
1523
+ surfaceOp: { op: 'replace', startSeq: pair.seq, endSeq: pair.seq },
1524
+ sourceEventSeqs: [pair.seq],
1525
+ })
1526
+ cache.delete(pair.seq)
1527
+ for (const sourceSeq of event.sourceEventSeqs ?? []) cache.delete(sourceSeq)
1528
+ applied += 1
1529
+ appliedChars += pair.chars
1530
+ }
1531
+ done += 1
1532
+ stats.compactions += 1
1533
+ stats.compactedSeqs += applied
1534
+ stats.compactedChars += appliedChars
1535
+ stats.receiptSummaries += applied
1536
+ action.ok = true
1537
+ action.shadowedSeqs = applied
1538
+ action.partialResults = applied
1539
+ report.actions.push(action)
1540
+ } catch (error) {
1541
+ if (applied > 0) {
1542
+ done += 1
1543
+ stats.compactions += 1
1544
+ stats.compactedSeqs += applied
1545
+ stats.compactedChars += appliedChars
1546
+ stats.receiptSummaries += applied
1547
+ action.nodes = applied
1548
+ action.calls = applied
1549
+ action.partialResults = applied
1550
+ action.resultChars = appliedChars
1551
+ action.shadowedSeqs = applied
1552
+ action.ok = true
1553
+ action.incomplete = true
1554
+ }
1555
+ stats.errors += 1
1556
+ action.error = error?.message ?? String(error)
1557
+ report.actions.push(action)
1558
+ }
1559
+ continue
1560
+ }
1561
+
1562
+ // 发一个一次性令牌并抢占 activeFence(issue #29)。
1563
+ // 之后若别处的并发压缩改写了 activeFence,我们这次的 summarize 就不会注入回执,
1564
+ // 也就不会把别人的区间替换成我们的回执——那是原实现最危险的失败模式。
1565
+ const fence = (fenceCounter += 1)
1566
+ activeFence = fence
1567
+ pendingReceipt.set(session, { text: receipt, at: Date.now(), fence, claimed: false })
1568
+ try {
1569
+ const result = await compaction.compactRegion(range.start, range.end, agent, options.signal)
1570
+ // 归属校验:compactRegion 返回时令牌若已被别人改写,说明这次压缩中途被打断
1571
+ // (或我们的回执被别人消费了)。此时不能谎报成功——如实记下来。
1572
+ if (activeFence !== fence) {
1573
+ stats.receiptFenceMisses += 1
1574
+ action.fenceLost = true
1575
+ }
1576
+ done += 1
1577
+ stats.compactions += 1
1578
+ stats.compactedSeqs += result?.shadowedSeqs?.length ?? spanSeqs.length
1579
+ stats.compactedChars += range.chars
1580
+ action.ok = true
1581
+ action.shadowedSeqs = result?.shadowedSeqs?.length ?? null
1582
+ action.compactionId = String(result?.compactionId ?? '')
1583
+ report.actions.push(action)
1584
+ // surface 已经变了,这些判定不再对应任何节点 → 连同 replacement 继承的旧 key 清掉
1585
+ for (const seq of spanSeqs) {
1586
+ const event = session.eventAt(seq)
1587
+ cache.delete(seq)
1588
+ for (const sourceSeq of event?.sourceEventSeqs ?? []) cache.delete(sourceSeq)
1589
+ }
1590
+ } catch (error) {
1591
+ stats.errors += 1
1592
+ action.error = error?.message ?? String(error)
1593
+ report.actions.push(action)
1594
+ } finally {
1595
+ // 只清理**自己**的令牌:若期间已被别人改写,那个令牌归对方管,不要去动它
1596
+ // (原实现无论谁覆盖都无条件 delete(session),会把别人的待用回执一起清掉)
1597
+ const current = pendingReceipt.get(session)
1598
+ if (current?.fence === fence) pendingReceipt.delete(session)
1599
+ if (activeFence === fence) activeFence = 0
1600
+ }
1601
+ }
1602
+
1603
+ const okCount = report.actions.filter((a) => a.ok).length
1604
+ const dry = report.actions.filter((a) => a.dryRun).length
1605
+ stats.lastCompactNote = okCount > 0
1606
+ ? `回执压缩 ${okCount} 段:处理 ${report.actions.reduce((sum, a) => sum + (a.nodes ?? 0), 0)} 个节点、省约 ${report.actions.reduce((sum, a) => sum + (a.resultChars ?? 0), 0)} 字符`
1607
+ : (dry > 0 ? `dry-run:${dry} 段可压(未执行)` : (report.blocked || '未执行'))
1608
+ log('debug', stats.lastCompactNote)
1609
+ writeHeartbeat({
1610
+ lastCompact: {
1611
+ blocked: report.blocked,
1612
+ eligible: report.eligible,
1613
+ // 分位总体太小而走降级时的说明;为空即正常走相对分位(issue #27 的可观测出口)
1614
+ quantileNote: report.quantileNote ?? null,
1615
+ selection: report.selection == null ? null : {
1616
+ skippedTail: report.selection.skippedTail,
1617
+ skippedTool: report.selection.skippedTool,
1618
+ skippedVerdict: report.selection.skippedVerdict,
1619
+ skippedGuard: report.selection.skippedGuard,
1620
+ skippedText: report.selection.skippedText,
1621
+ skippedReasoning: report.selection.skippedReasoning,
1622
+ skippedIncomplete: report.selection.skippedIncomplete,
1623
+ skippedShort: report.selection.skippedShort,
1624
+ skippedReceipt: report.selection.skippedReceipt,
1625
+ partialSteps: report.selection.partialSteps,
1626
+ partialResults: report.selection.partialResults,
1627
+ // 工具名如实落盘:这是"白名单没配上"唯一能自查的证据
1628
+ blockedToolNames: report.selection.blockedToolNames,
1629
+ allowedToolNames: report.selection.allowedToolNames,
1630
+ },
1631
+ actions: report.actions.map((a) => ({
1632
+ start: a.start, end: a.end, ok: a.ok ?? null, dryRun: a.dryRun ?? null,
1633
+ calls: a.calls, resultChars: a.resultChars, receiptTokens: a.receiptTokens,
1634
+ shadowedTokens: a.shadowedTokens, partial: a.partial ?? false,
1635
+ partialResults: a.partialResults ?? null, incomplete: a.incomplete ?? false,
1636
+ error: a.error ?? null, skipped: a.skipped ?? null,
1637
+ })),
1638
+ },
1639
+ })
1640
+ return report
1641
+ }
1642
+
1643
+ // ---------------------------------------------------------- 装配
1644
+ ctx.effect(() => installPrunerOverride())
1645
+ ctx.effect(() => installSummaryHook())
1646
+
1647
+ // 判定钩子:prepend 到最前,抢在 DSH 的 compaction-basic(其 pre-step 会调 pruneSession)之前。
1648
+ // 否则 pruneSession 读判定 cache 时 Jev 判定还没跑完 → 全部 fallback(第一层失效)。
1649
+ ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
1650
+ // P0-3:判定链条的最外层计数。没有它时,"一次性零判定"无法区分
1651
+ // 「事件没触发」/「提前 return」/「门控跳过」——三者的排查方向完全不同。
1652
+ stats.preStepEvents = (stats.preStepEvents ?? 0) + 1
1653
+ // 双保险:如果 apply() 时服务还没就绪,每次 pre-step 再试一次。
1654
+ // 依赖时序这种东西不该让插件"看起来加载成功、实际什么都没做"。
1655
+ if (!takeover.installed) installPrunerOverride()
1656
+ if (!summaryHook.installed) installSummaryHook()
1657
+ if (judge.ready === false) {
1658
+ stats.errors += 1
1659
+ stats.lastNote = '未配置 TYPESAFE_API_KEY,跳过判定'
1660
+ writeHeartbeat()
1661
+ return next()
1662
+ }
1663
+ try {
1664
+ // signal 透传(issue #9):中断后判定请求要能被取消,而不是继续占连接/计费
1665
+ await judgePass(agent, signal)
1666
+ } catch (error) {
1667
+ stats.errors += 1
1668
+ stats.lastNote = `判定失败:${error?.message ?? String(error)}`
1669
+ log('info', stats.lastNote)
1670
+ }
1671
+ return next()
1672
+ }, true)
1673
+
1674
+ ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
1675
+ try {
1676
+ const report = await compactPass(agent, { signal })
1677
+ // 被 skip 的 pass 此前**不写** lastCompactNote(只有成功路径 :1428 与 catch 会写),
1678
+ // 于是心跳/状态报告里留着上一轮的旧值 —— "第二层为什么没动"恰好看不见,
1679
+ // 而这正是最需要排查的那条路径(实测踩到:跑批端只能看到 compactSkipped=4,
1680
+ // 不知道原因)。把 report.blocked 与 selection 的逐条排除计数一并落盘。
1681
+ if (report?.blocked) {
1682
+ stats.lastCompactNote = report.blocked
1683
+ if (report.quantileNote) stats.lastCompactNote += `(${report.quantileNote})`
1684
+ if (report.selection) {
1685
+ const sel = report.selection
1686
+ const parts = []
1687
+ for (const [k, label] of [['skippedTail', 'tail'], ['skippedTool', 'tool'], ['skippedVerdict', 'verdict'], ['skippedIncomplete', 'incomplete'], ['skippedGuard', 'guard'], ['skippedText', 'text'], ['skippedReasoning', 'reasoning'], ['skippedShort', 'short']]) {
1688
+ if (sel[k]) parts.push(`${label}:${sel[k]}`)
1689
+ }
1690
+ if (sel.eligibleSteps) parts.push(`eligible:${sel.eligibleSteps}`)
1691
+ if (parts.length) stats.lastCompactNote += ` [${parts.join(' ')}]`
1692
+ }
1693
+ }
1694
+ } catch (error) {
1695
+ stats.errors += 1
1696
+ stats.lastCompactNote = `回执压缩失败:${error?.message ?? String(error)}`
1697
+ log('info', stats.lastCompactNote)
1698
+ }
1699
+ // P0-3:**每步必落盘**。此前只有「判定真的跑了 / 压缩真的做了」才写心跳——
1700
+ // 于是「门控跳过 / 没有候选 / session 形态不对」这类最需要排查的路径恰好不落盘,
1701
+ // 文件里永远是启动快照(实测踩坑:softLimit 未到时 bootedAt==now,
1702
+ // 外部无法区分「事件没触发」和「触发了但门没开」)。合并写下每步一次写成本可接受。
1703
+ writeHeartbeat()
1704
+ return next()
1705
+ })
1706
+
1707
+ function renderStatus(agent) {
1708
+ const session = agent?.session
1709
+ const cache = session != null ? (decisions.get(session) ?? new Map()) : new Map()
1710
+ const meter = ctx.get('tokenMeter')
1711
+ const used = session != null && typeof meter?.measure === 'function' ? (meter.measure(session)?.totalTokens ?? 0) : 0
1712
+ // 工具名索引诊断:白名单不命中时,这一行能立刻告诉你"是名字没配上"
1713
+ const nameProbe = session?.surface?.nodes != null
1714
+ ? probeToolNames({
1715
+ surface: [...session.surface.nodes],
1716
+ eventAt: (seq) => session.eventAt(seq),
1717
+ events: sessionEvents(session),
1718
+ limit: 6,
1719
+ })
1720
+ : { indexSize: 0, resolved: 0, unresolved: 0, names: [] }
1721
+ const lines = [
1722
+ `jev-prune usage: ${used} tokens model=${cfg.model} ready=${judge.ready !== false}`,
1723
+ `DSH 版本: ${dshVersion}(针对 ${TESTED_DSH_VERSION} 测试)`
1724
+ + (dshVersionMatches === false
1725
+ ? ' ⚠️ 版本系列不匹配——事件字段可能已变,请先跑一次 jev_probe_shapes 核对'
1726
+ : ''),
1727
+ `第一层 模式=${cfg.keepMode}${cfg.keepMode === 'budget'
1728
+ ? `(压力分位:裁掉池子总增益的「压力缺口比例」,由 judgePass 每轮自动计算;保护上限 prob≥${cfg.keepThreshold})`
1729
+ : `(绝对阈值 keep≥${cfg.keepThreshold})`} preserveRecent=${cfg.preserveRecent} minChars=${cfg.minCharsToPrune} 摘录=${cfg.resultExcerptChars}字符`,
1730
+ // 越界配置被钳制时必须显式列出:否则"我配了却没生效"会被误当成插件 bug(issue #28)
1731
+ ...(cfg[CONFIG_WARNINGS]?.length > 0
1732
+ ? [`⚠️ 配置修正 ${cfg[CONFIG_WARNINGS].length} 处(越界值已被钳制,实际生效值见上):`,
1733
+ ...cfg[CONFIG_WARNINGS].map((w) => ` · ${w}`)]
1734
+ : []),
1735
+ `第一层:判定 ${stats.judged} 次 / 请求 ${stats.requests} 次 `
1736
+ + `Jev 保留 ${stats.keptByJev} / Jev 裁掉 ${stats.prunedByJev} / 按体积兜底裁 ${stats.prunedByVolume}`
1737
+ + `(最近区保护 ${stats.keptByTail} / 黑名单保护 ${stats.keptByBlacklist} / 预算用尽保留 ${stats.keptByBudget} 不计入 Jev)`,
1738
+ // P0-3:概率分布 + 门控快照。这两行是"阈值是否失配 / 门为什么没开"的唯一现场证据,
1739
+ // 过去缺失导致 P0-1/P0-1b 只能在外部用探针挖出来。
1740
+ probSummary() == null
1741
+ ? '第一层:keep 概率分布(暂无样本)'
1742
+ : `第一层:keep 概率 P10/P50/P90 = ${probSummary().p10}/${probSummary().p50}/${probSummary().p90}`
1743
+ + `(样本 ${probSummary().n},高于阈值 ${probSummary().aboveKeepThreshold} / 低于 ${probSummary().belowKeepThreshold})`
1744
+ + (probSummary().n >= 10 && probSummary().aboveKeepThreshold === 0 && cfg.keepMode === 'absolute'
1745
+ ? ' ⚠️ 全部低于阈值——绝对阈值与 Jev 窄带失配,建议保持 keepMode=budget' : ''),
1746
+ stats.lastGate == null
1747
+ ? '第一层门控:尚未评估'
1748
+ : `第一层门控:used=${stats.lastGate.used}(measured=${stats.lastGate.measured})`
1749
+ + ` / 窗口=${stats.lastGate.windowTokens ?? '未知'} / 阈值=${stats.lastGate.threshold ?? '算不出'}`
1750
+ + ` ${stats.lastGate.skip ? `本次跳过:${stats.lastGate.reason}` : '已放行'}`,
1751
+ stats.lastBudget == null
1752
+ ? '第一层预算:尚未裁剪'
1753
+ : `第一层预算:${stats.lastBudget.note}`,
1754
+ // 口径:stats.skipped 累加的是**候选个数**(`+= fresh.length`),不是事件次数。
1755
+ // 这里的量词必须写"个",否则 7 个候选被同一道门挡下会显示成"跳过 7 次",
1756
+ // 与旁边同为计数的 `errors N 次`、以及事件计数的 `judgePassSkipped` 混淆。
1757
+ `第一层:累计省下 ${stats.savedChars} 字符 压力门控跳过候选 ${stats.skipped} 个 错误 ${stats.errors} 次`,
1758
+ // P0-3 记下的"为什么一次都没判定"此前只落进心跳 JSON,人类可读的这份报告里没有——
1759
+ // 而 judged=0 时它恰恰是唯一有价值的一行:不引用它,「门没开 / 没有候选 /
1760
+ // session 形态不对」三者完全不可区分,只能靠猜。仅在"零判定且确实跳过过"时出现。
1761
+ ...((stats.judged ?? 0) === 0 && (stats.judgePassSkipped ?? 0) > 0
1762
+ ? [`判定 pass:已跳过 ${stats.judgePassSkipped} 次;最近原因:${stats.lastJudgeSkipReason ?? '(未记录)'}`]
1763
+ : []),
1764
+ // 批次失败与重试计数只在异常时出现(issue #34):常态下不该占版面。
1765
+ // 口径修正(PR #28 review):这里要的是"最近一次 pass 重试了几次"(lastRetries),
1766
+ // 而不是 client 从建起来到现在的累计量——后者一旦抖动过就永久 >0,
1767
+ // 会让这一行在之后每一份报告里都出现,且数字只增不减。
1768
+ ...(stats.judgeBatchFailures > 0 || judge.lastRetries > 0
1769
+ ? [`判定请求:重试 ${judge.lastRetries ?? 0} 次 失败批次 ${stats.judgeBatchFailures} 个`
1770
+ + (judge.lastError ? ` 最近错误:${judge.lastError}` : '')]
1771
+ : []),
1772
+ // 累计重试只在真的发生过时出现,且与上面区分开,避免把历史当成现状
1773
+ ...(judge.retries > 0 && judge.lastRetries === 0
1774
+ ? [`判定请求:本 pass 无重试(本会话累计重试 ${judge.retries} 次、累计请求 ${judge.requests} 次)`]
1775
+ : []),
1776
+ `第二层:summarize=${summaryHook.installed ? '已接管' : `未接管(${summaryHook.reason || '未尝试'})`} `
1777
+ + `compactOn=${cfg.compactOn} preserveRecent=${cfg.compactPreserveRecent} `
1778
+ + `${cfg.compactMode}${cfg.compactMode === 'relative' ? `(quantile=${cfg.compactQuantile})` : `(<${cfg.compactThreshold})`}`,
1779
+ `第二层:回执压缩 ${stats.compactions} 段 / 处理 ${stats.compactedSeqs} 节点 / 省约 ${stats.compactedChars} 字符 `
1780
+ + `回执摘要被消费 ${stats.receiptSummaries} 次 压力跳过 ${stats.compactSkipped} 次`
1781
+ // 竞态计数只在非零时出现:它是异常路径,常态下不该占版面(issue #29)
1782
+ + (stats.receiptFenceMisses > 0
1783
+ ? ` ⚠️ 回执因并发压缩被抢 ${stats.receiptFenceMisses} 次(已退回模型摘要,未污染他人区间)`
1784
+ : ''),
1785
+ `工具名:索引 ${nameProbe.indexSize} 条,解析成功 ${nameProbe.resolved} / 失败 ${nameProbe.unresolved} `
1786
+ + `compactTools=${cfg.compactTools.length === 0 ? '[](只用黑名单)' : JSON.stringify(cfg.compactTools)}`,
1787
+ `本会话工具名:${nameProbe.names.length > 0 ? nameProbe.names.join(', ') : '(无)'}`,
1788
+ `session.events 形态:${JSON.stringify(describeEvents(session))}`,
1789
+ '',
1790
+ '已缓存判定(seq tool P(保留) P(副作用) 字符):',
1791
+ ]
1792
+ const rows = [...cache.entries()].sort((a, b) => a[0] - b[0])
1793
+ if (rows.length === 0) lines.push(' (空 —— 未到软阈值或还没有候选)')
1794
+ const fmt = (value) => (typeof value === 'number' ? value.toFixed(3) : ' n/a ')
1795
+ for (const [seq, item] of rows) {
1796
+ lines.push(` s${String(seq).padStart(5)} ${String(item.tool).padEnd(12)} `
1797
+ + `${item.keep ? '保留 ' : '裁掉 '} ${fmt(item.prob)} ${fmt(item.effectProb)} ${item.chars}`)
1798
+ }
1799
+ if (stats.lastNote) lines.push('', `最近(第一层): ${stats.lastNote}`)
1800
+ if (stats.lastCompactNote) lines.push(`最近(第二层): ${stats.lastCompactNote}`)
1801
+ return lines.join('\n')
1802
+ }
1803
+
1804
+ /** 把 compactPass 的报告渲染成人可读的多行文本。 */
1805
+ function renderCompactReport(report, { dryRun }) {
1806
+ const lines = [
1807
+ `${dryRun ? 'dry-run(未执行任何压缩)' : '回执压缩 pass 完成'}`,
1808
+ `候选判定 ${report.verdicts} 个;两轴尾部交集 ${report.eligible.length} 个`
1809
+ + `${report.eligible.length > 0 ? ` → [${report.eligible.map((s) => `s${s}`).join(',')}]` : ''}`,
1810
+ `合格范围 ${report.considered} 段`,
1811
+ ]
1812
+ if (report.blocked) lines.push(`未执行:${report.blocked}`)
1813
+ // 降级模式单独一行说明:与"未执行"分开,因为降级**可能仍然成功压缩了**(issue #27)
1814
+ if (report.quantileNote) lines.push(`分位说明:${report.quantileNote}`)
1815
+ if (report.selection) {
1816
+ const s = report.selection
1817
+ lines.push(`排除计数:最近区 ${s.skippedTail} / 工具不允许 ${s.skippedTool} / 判定不通过 ${s.skippedVerdict}`
1818
+ + ` / 证据守卫 ${s.skippedGuard} / 结论文本过长 ${s.skippedText} / 思考草稿过长 ${s.skippedReasoning}`
1819
+ + ` / 配对不完整 ${s.skippedIncomplete} / 已是回执 ${s.skippedReceipt ?? 0} / 省得太少 ${s.skippedShort}`)
1820
+ if ((s.partialSteps ?? 0) > 0) {
1821
+ lines.push(`并行批次部分回执:${s.partialSteps} 批 / ${s.partialResults} 个结果`)
1822
+ }
1823
+ // 工具名如实列出:白名单不命中时,这里能一眼看出"是名字没配上"而不是"模型判断不对"
1824
+ const allow = Object.entries(s.allowedToolNames ?? {})
1825
+ const block = Object.entries(s.blockedToolNames ?? {})
1826
+ if (allow.length > 0) lines.push(`通过工具门的调用名:${allow.map(([n, c]) => `${n}×${c}`).join(' ')}`)
1827
+ if (block.length > 0) {
1828
+ lines.push(`被工具门拦下的调用名:${block.map(([n, c]) => `${n}×${c}`).join(' ')}`
1829
+ + `(当前 compactTools=${cfg.compactTools.length === 0 ? '[] 已放宽' : '只读白名单'})`)
1830
+ }
1831
+ if (s.guardHits?.length > 0) {
1832
+ lines.push(`证据守卫命中:${s.guardHits.map((h) => `s${h.headSeq}(${h.matches.slice(0, 3).join('/')})`).join(' ')}`)
1833
+ }
1834
+ }
1835
+ for (const action of report.actions) {
1836
+ const head = `· s${action.start}–s${action.end}:${action.calls} 次调用,${action.nodes} 个节点,`
1837
+ + `原输出 ${action.resultChars} 字符 / ${action.shadowedTokens ?? '?'} tokens → 回执 ${action.receiptTokens} tokens`
1838
+ if (action.ok) lines.push(`${head} ✅ 已压缩(compactionId ${action.compactionId?.slice(0, 8) ?? '?'})`)
1839
+ else if (action.dryRun) lines.push(`${head} (dry-run,未执行)`)
1840
+ else if (action.error) lines.push(`${head} ❌ ${action.error}`)
1841
+ else if (action.skipped) lines.push(`${head} ⏭ ${action.skipped}`)
1842
+ }
1843
+ const first = report.actions.find((a) => a.receipt)
1844
+ if (first != null && (dryRun || first.dryRun)) {
1845
+ lines.push('', '回执全文:', first.receipt)
1846
+ }
1847
+ return lines.join('\n')
1848
+ }
1849
+
1850
+ const commands = ctx.get('commands')
1851
+ if (commands != null && typeof commands.register === 'function') {
1852
+ commands.register({
1853
+ name: 'jev',
1854
+ description: '查看 Jev 裁剪判定状态',
1855
+ async handler(invocation) {
1856
+ return { kind: 'success', text: renderStatus(invocation.agent) }
1857
+ },
1858
+ })
1859
+ }
1860
+
1861
+ // 工具注册要容错:`inject: ['tools']` 在真实 DSH 里保证 ctx.tools 存在,
1862
+ // 但如果注入没成(服务缺失/被禁用),不该让整个插件加载崩掉——
1863
+ // 判断与裁剪能力本身不依赖 tools 服务。
1864
+ const toolsService = ctx.tools ?? ctx.get?.('tools')
1865
+ if (toolsService != null && typeof toolsService.register === 'function') {
1866
+ toolsService.register(defineTool({
1867
+ name: 'jev_prune_status',
1868
+ description: 'Show Jev-driven tool-result pruning status: thresholds, cached per-node judgments, and savings.',
1869
+ parameters: {},
1870
+ output: {
1871
+ schema: { type: 'string' },
1872
+ render: (_args, value) => [{ type: 'text', text: value }],
1873
+ },
1874
+ async execute(_args, exec) {
1875
+ return renderStatus(exec?.agent)
1876
+ },
1877
+ }))
1878
+
1879
+ toolsService.register(defineTool({
1880
+ name: 'jev_prune_now',
1881
+ description: 'Force one Jev-scored tool-result pruning pass on the current session and report what happened. Normally pruning is driven by context pressure; this triggers it explicitly so the behaviour can be inspected.',
1882
+ parameters: {},
1883
+ output: {
1884
+ schema: { type: 'string' },
1885
+ render: (_args, value) => [{ type: 'text', text: value }],
1886
+ },
1887
+ async execute(_args, exec) {
1888
+ const session = exec?.agent?.session
1889
+ if (session?.surface?.nodes == null) return '没有活动会话'
1890
+ const pruner = ctx.get('toolResultPruner') ?? ctx.toolResultPruner
1891
+ if (pruner == null) return 'ctx.toolResultPruner 不可用'
1892
+ const cache = decisions.get(session) ?? new Map()
1893
+ // prob 可能是 null(Jev 只回了两轴之一,judgePass 容忍这种情况)——
1894
+ // 此前直接 .toFixed() 抛 TypeError,而裁剪其实已经执行完了(issue #3)
1895
+ const judgedSeqs = [...cache.entries()].map(([seq, v]) =>
1896
+ `s${seq}:${v.keep ? '保留' : '裁'}(${typeof v.prob === 'number' ? v.prob.toFixed(2) : 'n/a'})`)
1897
+ let out
1898
+ try {
1899
+ out = pruner.pruneSession(session)
1900
+ } catch (error) {
1901
+ return `裁剪失败:${error?.message ?? String(error)}`
1902
+ }
1903
+ const lines = [
1904
+ `裁剪完成:处理 ${out.pruned.length} 条,省下 ${out.charsRemoved} 字符`,
1905
+ `本会话已有判定:${judgedSeqs.length > 0 ? judgedSeqs.join(' ') : '(无)'}`,
1906
+ `累计:Jev 保留 ${stats.keptByJev} / Jev 裁掉 ${stats.prunedByJev} / 按体积兜底裁 ${stats.prunedByVolume}`,
1907
+ ]
1908
+ if (out.pruned.length > 0) {
1909
+ lines.push('逐条:')
1910
+ for (const p of out.pruned) lines.push(` s${p.originalSeq} (${p.callId ?? '?'}) ${p.charsBefore} → ${p.charsAfter} 字符`)
1911
+ } else if (judgedSeqs.length === 0) {
1912
+ lines.push('没有任何判定,所以全部退回按体积裁决。判定发生在 agent/pre-step;若刚才是首轮,先再做一次工具调用让判定跑起来。')
1913
+ // P0-3 记下的"为什么没判定"此前只落进心跳 JSON,人类可读的状态里看不到——
1914
+ // 而"judged=0 时无法区分门没开/没有候选/session 形态不对"正是它要解决的。
1915
+ // 这两个字段是唯一的事实来源,就地引用,不要再让使用者去猜。
1916
+ const skipCount = stats.judgePassSkipped ?? 0
1917
+ if (skipCount > 0) {
1918
+ lines.push(`判定 pass 已跳过 ${skipCount} 次;最近原因:${stats.lastJudgeSkipReason ?? '(未记录)'}`)
1919
+ }
1920
+ if (stats.lastGate?.skip === true) {
1921
+ lines.push(`压力门快照:used=${stats.lastGate.used} measured=${stats.lastGate.measured}`
1922
+ + ` window=${stats.lastGate.windowTokens} threshold=${stats.lastGate.threshold ?? '算不出'}`
1923
+ + `(${stats.lastGate.reason || '未知'})`)
1924
+ }
1925
+ }
1926
+ return lines.join('\n')
1927
+ },
1928
+ }))
1929
+
1930
+ toolsService.register(defineTool({
1931
+ name: 'jev_compact_now',
1932
+ description: 'Force one Jev-driven receipt compaction pass: pick spent read-only tool-call ranges and replace them with a deterministic receipt instead of a model-written summary. Normally driven by context pressure; this triggers it explicitly so the behaviour can be inspected. Pass dryRun to only report what would be compacted.',
1933
+ parameters: {
1934
+ dryRun: { type: 'boolean', description: 'Only report the chosen ranges and the receipt text; do not compact.' },
1935
+ },
1936
+ output: {
1937
+ schema: { type: 'string' },
1938
+ render: (_args, value) => [{ type: 'text', text: value }],
1939
+ },
1940
+ async execute(args, exec) {
1941
+ const agent = exec?.agent
1942
+ if (agent?.session == null) return '没有活动会话'
1943
+ const dryRun = args?.dryRun ?? cfg.dryRun
1944
+ let report
1945
+ try {
1946
+ report = await compactPass(agent, { force: true, dryRun, signal: exec?.signal })
1947
+ } catch (error) {
1948
+ return `回执压缩失败:${error?.message ?? String(error)}`
1949
+ }
1950
+ return renderCompactReport(report, { dryRun })
1951
+ },
1952
+ }))
1953
+
1954
+ toolsService.register(defineTool({
1955
+ name: 'jev_restore',
1956
+ description: 'Return the original text hidden by a compaction checkpoint (read-only safety valve). The originals always stay in the session log; this surfaces them again without restoring the surface.',
1957
+ parameters: {
1958
+ seq: { type: 'integer', description: 'Checkpoint surface seq, or the seq of a compaction/summary event' },
1959
+ start: { type: 'integer', description: 'Original shadowed range start seq' },
1960
+ end: { type: 'integer', description: 'Original shadowed range end seq' },
1961
+ },
1962
+ output: {
1963
+ schema: { type: 'string' },
1964
+ render: (_args, value) => [{ type: 'text', text: value }],
1965
+ },
1966
+ async execute(args, exec) {
1967
+ const session = exec?.agent?.session
1968
+ if (session == null) return '没有活动会话'
1969
+ let record
1970
+ try {
1971
+ record = findCompactionRecord(session, {
1972
+ seq: args?.seq ?? null,
1973
+ start: args?.start ?? null,
1974
+ end: args?.end ?? null,
1975
+ })
1976
+ } catch (error) {
1977
+ return `查找失败:${error?.message ?? String(error)}`
1978
+ }
1979
+ if (record == null) return '没有匹配的压缩检查点(给 seq,或给 start/end,或都不给则取最近一个)'
1980
+ const range = record.data?.shadowedRange ?? {}
1981
+ const seqs = record.data?.shadowedSeqs ?? []
1982
+ const parts = [`还原 s${range.start}–s${range.end}(共 ${seqs.length} 个节点,只读,不恢复 surface):`, '']
1983
+ for (const seq of seqs) {
1984
+ const event = session.eventAt(seq)
1985
+ if (event == null) continue
1986
+ const text = eventText(event)
1987
+ parts.push(`# s${seq} ${event.type}`, text.length > 4000 ? `${text.slice(0, 4000)}\n…(截断,原始事件仍可用 dsh 日志查看)` : text, '')
1988
+ }
1989
+ if (parts.length <= 2) parts.push('(原始事件已不在日志中 —— 会话可能被裁剪过)')
1990
+ return parts.join('\n')
1991
+ },
1992
+ }))
1993
+
1994
+ toolsService.register(defineTool({
1995
+ name: 'jev_probe_shapes',
1996
+ description: 'Dump the real DSH event shapes on the current surface (types, block types, key names). Use to verify field assumptions such as tool name and callId.',
1997
+ parameters: {
1998
+ limit: { type: 'integer', description: 'Max surface nodes to inspect (default 40)' },
1999
+ },
2000
+ output: {
2001
+ schema: { type: 'string' },
2002
+ render: (_args, value) => [{ type: 'text', text: value }],
2003
+ },
2004
+ async execute(args, exec) {
2005
+ const session = exec?.agent?.session
2006
+ if (session?.surface?.nodes == null) return '没有活动会话'
2007
+ const surface = [...session.surface.nodes]
2008
+ const eventAt = (seq) => session.eventAt(seq)
2009
+ const lines = []
2010
+ const { rows, summary } = probeShapes(surface, eventAt, args?.limit ?? 40)
2011
+ lines.push('事件形状汇总(type | blocks | data keys | source keys → 出现次数):')
2012
+ for (const item of summary) lines.push(` ${item.key} ×${item.count}`)
2013
+ lines.push('', '逐节点:')
2014
+ for (const row of rows) {
2015
+ lines.push(` s${row.seq} ${row.type} blocks=[${row.blocks.join(',')}] data=[${row.dataKeys.join(',')}] source=[${row.sourceKeys.join(',')}]`)
2016
+ }
2017
+
2018
+ // 工具名解析取证 —— 这一段才是"白名单为什么不命中"的答案所在
2019
+ const probe = probeToolNames({ surface, eventAt, events: sessionEvents(session), limit: 12 })
2020
+ lines.push('', `工具名解析:索引 ${probe.indexSize} 条,解析成功 ${probe.resolved} / 失败 ${probe.unresolved}`)
2021
+ lines.push(`本会话出现过的工具名(来自 tool/call 事件的 data.name):${probe.names.length > 0 ? probe.names.join(', ') : '(无)'}`)
2022
+ for (const row of probe.rows) {
2023
+ lines.push(` s${row.seq} callId=${row.callId ?? '?'} 解析工具名=${row.tool}`)
2024
+ }
2025
+ if (probe.unresolved > 0) {
2026
+ lines.push(`⚠️ 有 ${probe.unresolved} 条解析不出工具名 —— 这时白名单会一律拒绝,第二层将永不触发。`
2027
+ + '请把上面出现的真实工具名配进 compactTools,或把 compactTools 设为 [] 只用黑名单。')
2028
+ }
2029
+ lines.push('', `compactTools = ${JSON.stringify(cfg.compactTools)}`
2030
+ + `${cfg.compactTools.length === 0 ? '(空 = 不设白名单,只受 neverCompactTools 约束)' : ''}`)
2031
+ lines.push(`neverCompactTools = ${JSON.stringify(cfg.neverCompactTools)}(比较时归一化:小写 + 去掉 _ 与 -)`)
2032
+ lines.push('', `如想只用只读工具,可把 compactTools 配成:${JSON.stringify(DSH_READONLY_TOOLS)}`)
2033
+ return lines.join('\n')
2034
+ },
2035
+ }))
2036
+ } else {
2037
+ log('info', 'ctx.tools 不可用 —— 跳过状态/探针工具注册(判定与裁剪不受影响)')
2038
+ }
2039
+
2040
+ if (judge.ready === false) {
2041
+ ctx.logger?.info?.('[jev-prune] 未配置 TYPESAFE_API_KEY —— 插件已加载但不介入裁剪')
2042
+ }
2043
+ }