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/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/README_zh.md +175 -0
- package/assets/banner.png +0 -0
- package/assets/banner.svg +73 -0
- package/assets/demo-poster.png +0 -0
- package/assets/demo.cast +6 -0
- package/assets/demo.gif +0 -0
- package/assets/demo.json +51 -0
- package/assets/two-layers.png +0 -0
- package/assets/two-layers.svg +118 -0
- package/cordis.patch.yml +3 -0
- package/demo/README.md +23 -0
- package/demo/fixtures.mjs +301 -0
- package/demo/render.py +31 -0
- package/demo/run.mjs +85 -0
- package/docs/ARCHITECTURE.md +63 -0
- package/docs/CONTRIBUTING.md +48 -0
- package/docs/PORTING.md +60 -0
- package/docs/RELEASING.md +27 -0
- package/docs/implementation.md +310 -0
- package/docs/measurements.md +16 -0
- package/docs/review-2026-10-05.md +44 -0
- package/examples/README.md +7 -0
- package/examples/minimal.yml +10 -0
- package/index.js +2043 -0
- package/package.json +77 -0
- package/scripts/inspect_session.mjs +235 -0
- package/scripts/verify_layout.mjs +24 -0
- package/scripts/verify_real_shapes.mjs +99 -0
- package/scripts/wire_profile.mjs +173 -0
- package/src/jev.js +320 -0
- package/src/prune.js +383 -0
- package/src/receipt.js +800 -0
- package/src/state.js +532 -0
- package/test/check.js +1971 -0
- package/test/smoke_apply.mjs +1359 -0
package/src/prune.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prune.js —— 裁剪机制(纯函数)。从 index.js 抽出来是为了能单测:
|
|
3
|
+
* index.js 要 import DSH 的包,导致里面的逻辑在 DSH 环境外根本跑不起来,
|
|
4
|
+
* 而"按码点切、标记只插一次、非文本块保序"这些恰恰最容易写错。
|
|
5
|
+
*
|
|
6
|
+
* 与 DSH 自带裁剪器(@deepseek-ai/dsh-compaction-tool-result-pruner)同口径:
|
|
7
|
+
* · 按 **Unicode 码点** 切,不按 UTF-16 code unit —— 否则会把代理对劈成半个字符
|
|
8
|
+
* · 标记插在被移除区间的**第一个相交文本块**里,且只插一次
|
|
9
|
+
* · 非文本块原样保留、保持顺序
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 我们插入的裁剪标记。用来识别"已经裁过",避免重复判定。 */
|
|
13
|
+
export const JEV_PRUNE_MARKER =
|
|
14
|
+
'\n[… Jev 判定该工具结果已过期,中间内容已裁剪;原始事件仍在会话日志中,可重跑工具 …]\n'
|
|
15
|
+
|
|
16
|
+
/** 文本块的总码点数。非文本块计 0(与 DSH 的 measureContent 一致)。 */
|
|
17
|
+
export function countChars(blocks) {
|
|
18
|
+
if (!Array.isArray(blocks)) return 0
|
|
19
|
+
let chars = 0
|
|
20
|
+
for (const block of blocks) {
|
|
21
|
+
if (block?.type === 'text' && typeof block.text === 'string') chars += Array.from(block.text).length
|
|
22
|
+
}
|
|
23
|
+
return chars
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 工具名归一化:小写 + 去掉下划线与连字符。
|
|
28
|
+
*
|
|
29
|
+
* 为什么放在 prune.js(依赖链的最底层):**两层的安全黑名单都必须用它**。
|
|
30
|
+
* 外部审查抓到的真 bug:第一层 `neverPruneTools.includes(tool)` 是字面比较,
|
|
31
|
+
* 默认值是 PascalCase('Edit'),而真实 DSH 的工具名是全小写(edit/write)——
|
|
32
|
+
* 结果"改写类永不裁剪"这条承诺在第一层静默失效(第二层修了,第一层漏了)。
|
|
33
|
+
* 归一化后 'Edit'/'edit'、'MultiEdit'/'multi_edit'、'ApplyPatch'/'apply_patch' 都等价。
|
|
34
|
+
*/
|
|
35
|
+
export function normalizeToolName(name) {
|
|
36
|
+
return String(name ?? '').trim().toLowerCase().replace(/[_-]/g, '')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 名字是否在列表里(归一化比较;空名单/空名字一律 false)。 */
|
|
40
|
+
export function isToolIn(list, name) {
|
|
41
|
+
const needle = normalizeToolName(name)
|
|
42
|
+
if (needle.length === 0) return false
|
|
43
|
+
return (list ?? []).some((item) => normalizeToolName(item) === needle)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve a cached judgment through a replacement event's source seqs. */
|
|
47
|
+
export function cachedVerdictForEvent(cache, event) {
|
|
48
|
+
if (cache == null || event == null) return null
|
|
49
|
+
const direct = cache.get(event.seq)
|
|
50
|
+
if (direct != null) return direct
|
|
51
|
+
for (const seq of event.sourceEventSeqs ?? []) {
|
|
52
|
+
const inherited = cache.get(seq)
|
|
53
|
+
if (inherited != null) return inherited
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 把 blocks 的中间挖掉,保留头 headChars 与尾 tailChars,中间插入 marker。
|
|
60
|
+
*
|
|
61
|
+
* @param {Array} blocks 内容块数组
|
|
62
|
+
* @param {number} headChars 保留的头部码点数
|
|
63
|
+
* @param {number} tailChars 保留的尾部码点数
|
|
64
|
+
* @param {string} marker 插入的标记文本
|
|
65
|
+
* @param {number} minGain 至少省下这么多码点才值得动手(不足则返回 null)
|
|
66
|
+
* @returns {Array|null} 裁剪后的块数组;不值得裁时返回 null
|
|
67
|
+
*/
|
|
68
|
+
export function sliceWithBudget(blocks, headChars, tailChars, marker, minGain = 40) {
|
|
69
|
+
if (!Array.isArray(blocks) || blocks.length === 0) return null
|
|
70
|
+
const totalChars = countChars(blocks)
|
|
71
|
+
const overhead = Array.from(marker).length
|
|
72
|
+
// 省下来的还不够标记本身占的地方 → 不动手
|
|
73
|
+
if (totalChars - (headChars + tailChars) < Math.max(minGain, overhead)) return null
|
|
74
|
+
|
|
75
|
+
const removedStart = headChars
|
|
76
|
+
const removedEnd = totalChars - tailChars
|
|
77
|
+
const out = []
|
|
78
|
+
let consumed = 0
|
|
79
|
+
let markerInserted = false
|
|
80
|
+
|
|
81
|
+
for (const block of blocks) {
|
|
82
|
+
if (block?.type !== 'text' || typeof block.text !== 'string') {
|
|
83
|
+
out.push(block) // 非文本块保序原样保留
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
const points = Array.from(block.text) // 码点数组,不是 UTF-16 单元
|
|
87
|
+
const blockStart = consumed
|
|
88
|
+
const blockEnd = blockStart + points.length
|
|
89
|
+
const headEnd = Math.min(points.length, Math.max(0, removedStart - blockStart))
|
|
90
|
+
const tailStart = Math.min(points.length, Math.max(0, removedEnd - blockStart))
|
|
91
|
+
const intersects = blockStart < removedEnd && blockEnd > removedStart
|
|
92
|
+
const useMarker = intersects && !markerInserted ? marker : ''
|
|
93
|
+
if (useMarker.length > 0) markerInserted = true
|
|
94
|
+
const text = points.slice(0, headEnd).join('') + useMarker + points.slice(tailStart).join('')
|
|
95
|
+
if (text.length > 0) out.push({ ...block, text })
|
|
96
|
+
consumed = blockEnd
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!markerInserted) return null // 没有真正挖掉任何东西
|
|
100
|
+
const after = countChars(out)
|
|
101
|
+
if (after >= totalChars) return null // 显式不变量:裁剪后必须更短
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 触发条件解析:"55%" → 窗口比例;"154000" → 绝对 token 数。 */
|
|
106
|
+
export function parseLimit(raw) {
|
|
107
|
+
const text = String(raw).trim()
|
|
108
|
+
if (text.endsWith('%')) {
|
|
109
|
+
const value = Number(text.slice(0, -1))
|
|
110
|
+
if (!Number.isFinite(value) || value <= 0 || value > 100) throw new Error(`invalid limit ${raw}`)
|
|
111
|
+
return { kind: 'ratio', value: value / 100 }
|
|
112
|
+
}
|
|
113
|
+
const value = Number(text)
|
|
114
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error(`invalid limit ${raw}`)
|
|
115
|
+
return { kind: 'tokens', value }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 单个节点的裁决。抽成纯函数是为了能穷举测试——这是全插件最容易写错的地方,
|
|
120
|
+
* 四个分支的优先级错了就会误删承重内容。
|
|
121
|
+
*
|
|
122
|
+
* 优先级(从上到下,先命中先返回):
|
|
123
|
+
* 1. 落在最近区(含正在进行的调用) → keep(永不触碰)
|
|
124
|
+
* 2. 工具在 neverPruneTools 里 → keep
|
|
125
|
+
* 3. Jev 说还要 → keep(哪怕它很大 —— DSH 会误删这类)
|
|
126
|
+
* 4. Jev 说过期 → prune(哪怕它不大 —— DSH 完全不碰这类)
|
|
127
|
+
* · 但短于 minCharsToPrune 时不裁(省不到东西、还丢信息)
|
|
128
|
+
* 5. 没有判定 → fallback(退回 DSH 原来的按体积裁决)
|
|
129
|
+
*
|
|
130
|
+
* 注意:`keepMode: 'budget'` 时不走这个函数逐节点判——见 `planTrims`。
|
|
131
|
+
* 保留它是为了 `absolute` 模式(旧行为)与既有测试的兼容。
|
|
132
|
+
*/
|
|
133
|
+
export function decideAction({ inTail, tool, neverPruneTools, verdict, charsBefore, minCharsToPrune }) {
|
|
134
|
+
if (inTail) return 'keep'
|
|
135
|
+
// 归一化比较(外部审查回归):真实 DSH 的工具名是小写 edit/write,
|
|
136
|
+
// 字面 includes 对 PascalCase 黑名单永远不命中 → "改写类永不裁剪"静默失效
|
|
137
|
+
if (isToolIn(neverPruneTools, tool)) return 'keep'
|
|
138
|
+
if (verdict == null) return 'fallback'
|
|
139
|
+
if (verdict.keep) return 'keep'
|
|
140
|
+
if (charsBefore < minCharsToPrune) return 'keep'
|
|
141
|
+
return 'prune'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* **预算匹配的裁剪选择**(P0-1)。为什么不是纯相对分位:
|
|
146
|
+
*
|
|
147
|
+
* 纯分位("每轮裁掉概率最低的 34%")把两件事耦合成了一个拍脑袋的比例——
|
|
148
|
+
* · 会话里**所有结果都还有用**时它照样裁(不需要压缩时的动作是纯损失);
|
|
149
|
+
* · 比例与"宿主需要腾多少空间"无关(需要省 5% 时裁 34% 是过度,需要 60% 时是不够)。
|
|
150
|
+
*
|
|
151
|
+
* 而 Jev 概率是**窄带**的(实测真实会话 42/42 低于 0.5,中位数 0.13),
|
|
152
|
+
* 绝对阈值同样不可用("0.5 一刀切"会把所有判定过的结果都判成可裁)。
|
|
153
|
+
*
|
|
154
|
+
* 所以这里换一个正交的分解:
|
|
155
|
+
* · **裁多少** ← 由既有体积规则决定:`budget = 对 chars > volumeBudgetThresholdChars
|
|
156
|
+
* 的结果,若按我们的 head/tail 规则裁剪本可省下的字符总量`(默认阈值 8192 = DSH 自带
|
|
157
|
+
* tool-result-pruner 的真实默认值)。体积规则是"该腾多少"的既成事实,不改它;
|
|
158
|
+
* · **裁哪些** ← 由 Jev 的概率**排序**决定:从最低开始裁,裁到省出的量 ≥ budget 就停;
|
|
159
|
+
* · 绝对阈值退居**保护上限**:prob ≥ keepThreshold 的一律不进候选池(明显该留的不参与排序);
|
|
160
|
+
* · 候选太少(< minCandidatesForBudget)时降级为绝对下限模式(只裁 prob < floorThreshold),
|
|
161
|
+
* 口径与第二层的 floor 分支一致。
|
|
162
|
+
*
|
|
163
|
+
* 于是 `budget = 0`(没有结果超过体积阈值)→ 一条都不裁,这正是"不做无谓动作"的保证。
|
|
164
|
+
*
|
|
165
|
+
* @param {Array<{seq:number,index:number,tool:string,chars:number,gain:number,prob:number|null,
|
|
166
|
+
* effectProb:number|null,verdict:object|null,inTail:boolean,blacklisted:boolean,alreadyPruned?:boolean}>} nodes
|
|
167
|
+
* @param {object} cfg
|
|
168
|
+
* @returns {{mode:string,selected:Set<number>,budget:number,spent:number,poolSize:number,
|
|
169
|
+
* keptByCeiling:number,note:string}|null} null = 不用预算模式(调用方退回逐节点裁决)
|
|
170
|
+
*/
|
|
171
|
+
export function planTrims(nodes, cfg = {}) {
|
|
172
|
+
if ((cfg.keepMode ?? 'absolute') !== 'budget') return null
|
|
173
|
+
const keepCeiling = cfg.keepThreshold ?? 0.5
|
|
174
|
+
const floor = cfg.keepFloorThreshold ?? 0.2
|
|
175
|
+
const minCandidates = cfg.minCandidatesForBudget ?? 4
|
|
176
|
+
const minGain = cfg.minGainChars ?? 40
|
|
177
|
+
// 压力缺口比例(judgePass 传入,0~1):0 = 无缺口不裁,1 = 裁掉池子全部增益。
|
|
178
|
+
// 缺失/非法一律钳到 0(失败方向:解析不出压力就不动手,与两层的门控口径一致)。
|
|
179
|
+
const ratio = Math.min(1, Math.max(0, Number(cfg.pressureRatio) || 0))
|
|
180
|
+
|
|
181
|
+
const eligible = nodes.filter((n) => !n.inTail && !n.blacklisted && !n.alreadyPruned && n.verdict != null
|
|
182
|
+
&& n.gain >= minGain && n.chars >= (cfg.minCharsToPrune ?? 400))
|
|
183
|
+
const ceilingProtected = eligible.filter((n) => typeof n.prob === 'number' && n.prob >= keepCeiling)
|
|
184
|
+
// 修 null 排序 bug:prob 缺失(result 轴批次失败)时**不进池子**,更不能被当成 0 优先裁。
|
|
185
|
+
const pool = eligible.filter((n) => typeof n.prob === 'number' && n.prob < keepCeiling)
|
|
186
|
+
|
|
187
|
+
if (pool.length === 0) {
|
|
188
|
+
return { mode: 'budget', selected: [], budget: 0, spent: 0, poolSize: 0, keptByCeiling: ceilingProtected.length, ratio, note: '无可裁候选(全部落在保护上限之上或不可裁)' }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 小样本:排序没有意义 → 降级为绝对下限(与第二层 floor 分支同口径)
|
|
192
|
+
if (pool.length < minCandidates) {
|
|
193
|
+
const selected = pool.filter((n) => n.prob < floor).map((n) => n.seq)
|
|
194
|
+
const spent = pool.filter((n) => n.prob < floor).reduce((s, n) => s + n.gain, 0)
|
|
195
|
+
return {
|
|
196
|
+
mode: 'floor', selected, budget: 0, spent, poolSize: pool.length, keptByCeiling: ceilingProtected.length, ratio,
|
|
197
|
+
note: `候选仅 ${pool.length} 条(< ${minCandidates})→ 降级绝对下限:只裁 prob < ${floor} 的 ${selected.length} 条`,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 预算 = 压力缺口比例 × 池子总增益。「裁多少」与「能裁的是谁」落在同一个总体(pool),
|
|
202
|
+
// 不再锚定体积规则(那是 #30 自查里"方向反了"的根源)。
|
|
203
|
+
const totalGain = pool.reduce((s, n) => s + n.gain, 0)
|
|
204
|
+
const budget = ratio * totalGain
|
|
205
|
+
if (!(budget > 0)) {
|
|
206
|
+
return { mode: 'budget', selected: [], budget: 0, spent: 0, poolSize: pool.length, keptByCeiling: ceilingProtected.length, ratio, note: `压力缺口为 0(ratio=${ratio})→ 不裁` }
|
|
207
|
+
}
|
|
208
|
+
const sorted = [...pool].sort((a, b) => (a.prob - b.prob) || (a.seq - b.seq))
|
|
209
|
+
const selected = []
|
|
210
|
+
let spent = 0
|
|
211
|
+
for (const node of sorted) {
|
|
212
|
+
if (spent >= budget) break
|
|
213
|
+
selected.push(node.seq)
|
|
214
|
+
spent += node.gain
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
mode: 'budget', selected, budget, spent, poolSize: pool.length, keptByCeiling: ceilingProtected.length, ratio,
|
|
218
|
+
note: `压力分位 ${(ratio * 100).toFixed(1)}%(预算 ${Math.round(budget)} 字符)→ 从 ${pool.length} 条候选中按概率升序裁 ${selected.length} 条,省 ${spent}`,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 逐节点扫描 surface 并落地裁剪。
|
|
224
|
+
*
|
|
225
|
+
* 依赖全部注入(pruner / freeze / toolNameOf / callIdOf),所以可以脱离 DSH 单测。
|
|
226
|
+
*
|
|
227
|
+
* ⚠️ shadow-price 协议与 DSH 自带实现**逐字一致**:先把 `compaction/prune` 事件
|
|
228
|
+
* 与替换事件**紧邻 append**,纯消费者才能按同一套账本扣减 token。
|
|
229
|
+
*
|
|
230
|
+
* 两个阶段(P0-1):先**收集**全部工具结果节点的元数据 → `planTrims` 在全局视野下
|
|
231
|
+
* 决定"裁哪些"(预算模式)→ 再**应用**。绝对模式(keepMode !== 'budget')保持原有的
|
|
232
|
+
* 逐节点 `decideAction` 行为不变。
|
|
233
|
+
*
|
|
234
|
+
* @returns {{pruned: Array, charsRemoved: number, plan: object|null, decisions: Array}}
|
|
235
|
+
*/
|
|
236
|
+
export function pruneSessionWithJev({ pruner, session, cache, cfg, stats, freeze, toolNameOf, callIdOf }) {
|
|
237
|
+
const surface = [...session.surface.nodes]
|
|
238
|
+
const lastAllowed = surface.length - 1 - cfg.preserveRecent
|
|
239
|
+
const pruned = []
|
|
240
|
+
let charsRemoved = 0
|
|
241
|
+
|
|
242
|
+
// ---- 阶段一:收集(不做任何裁决) ----
|
|
243
|
+
const nodes = []
|
|
244
|
+
for (let index = 0; index < surface.length; index += 1) {
|
|
245
|
+
const seq = surface[index]
|
|
246
|
+
const event = session.eventAt(seq)
|
|
247
|
+
if (event?.type !== 'tool/result') continue
|
|
248
|
+
const original = session.deriveEventMessage(event)
|
|
249
|
+
const result = original?.content?.[0]
|
|
250
|
+
if (result == null || result.type !== 'tool-result') continue
|
|
251
|
+
const blocks = result.content
|
|
252
|
+
const chars = countChars(blocks)
|
|
253
|
+
const marker = Array.from(cfg.marker ?? JEV_PRUNE_MARKER).length
|
|
254
|
+
const gain = chars - (cfg.headChars + cfg.tailChars) - marker
|
|
255
|
+
const tool = toolNameOf(event)
|
|
256
|
+
const verdict = cachedVerdictForEvent(cache, event)
|
|
257
|
+
const alreadyPruned = Array.isArray(blocks) && blocks.some(
|
|
258
|
+
(block) => typeof block?.text === 'string' && block.text.includes(cfg.marker ?? JEV_PRUNE_MARKER),
|
|
259
|
+
)
|
|
260
|
+
nodes.push({
|
|
261
|
+
seq, index, event, original, result, blocks, chars, gain, tool, verdict, alreadyPruned,
|
|
262
|
+
inTail: index > lastAllowed,
|
|
263
|
+
blacklisted: isToolIn(cfg.neverPruneTools, tool),
|
|
264
|
+
prob: typeof verdict?.prob === 'number' ? verdict.prob : null,
|
|
265
|
+
effectProb: typeof verdict?.effectProb === 'number' ? verdict.effectProb : null,
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---- 阶段二:全局计划(仅 budget 模式返回非 null) ----
|
|
270
|
+
const plan = planTrims(nodes, cfg)
|
|
271
|
+
// selected 现在是数组(可 JSON 序列化,修落盘丢失),这里转 Set 供 has() 用。
|
|
272
|
+
// 注意:plan 非 null 时即使 selected 为空数组也必须得到非 null 的 planned(空 Set = 不裁任何节点),
|
|
273
|
+
// 不能退回 absolute 分支——那会窄带下 verdict.keep 全 false 导致全裁。
|
|
274
|
+
const planned = plan != null ? new Set(plan.selected) : null
|
|
275
|
+
const decisions = []
|
|
276
|
+
|
|
277
|
+
for (const node of nodes) {
|
|
278
|
+
const { seq, index, event, original, result, blocks, chars, tool, verdict } = node
|
|
279
|
+
const charsBefore = chars
|
|
280
|
+
|
|
281
|
+
let action
|
|
282
|
+
let reason
|
|
283
|
+
if (node.inTail) { action = 'keep'; reason = 'tail' }
|
|
284
|
+
else if (node.blacklisted) { action = 'keep'; reason = 'blacklist' }
|
|
285
|
+
else if (node.alreadyPruned) { action = 'keep'; reason = 'already-pruned' }
|
|
286
|
+
else if (planned != null) {
|
|
287
|
+
// 预算模式:由全局计划裁决。
|
|
288
|
+
// ⚠️ 无判定的节点**仍走 fallback**(退回 DSH 原生按体积裁决)——预算模式只改
|
|
289
|
+
// "Jev 驱动的决策",不改兜底契约。这条由 smoke 的"三条候选都走到 pruneContent"钉住。
|
|
290
|
+
if (verdict == null) { action = 'fallback'; reason = 'no-verdict-fallback' }
|
|
291
|
+
else if (charsBefore < (cfg.minCharsToPrune ?? 400)) { action = 'keep'; reason = 'too-short' }
|
|
292
|
+
else if (node.gain < (cfg.minGainChars ?? 40)) { action = 'keep'; reason = 'no-gain' }
|
|
293
|
+
else if (planned.has(seq)) { action = 'prune'; reason = `selected(${plan.mode})` }
|
|
294
|
+
else if (typeof node.prob === 'number' && node.prob >= (cfg.keepThreshold ?? 0.5)) { action = 'keep'; reason = 'keep-ceiling' }
|
|
295
|
+
else { action = 'keep'; reason = 'budget-exhausted' }
|
|
296
|
+
} else {
|
|
297
|
+
action = decideAction({
|
|
298
|
+
inTail: index > lastAllowed,
|
|
299
|
+
tool,
|
|
300
|
+
neverPruneTools: cfg.neverPruneTools,
|
|
301
|
+
verdict,
|
|
302
|
+
charsBefore,
|
|
303
|
+
minCharsToPrune: cfg.minCharsToPrune,
|
|
304
|
+
})
|
|
305
|
+
reason = action === 'keep'
|
|
306
|
+
? (index > lastAllowed ? 'tail' : isToolIn(cfg.neverPruneTools, tool) ? 'blacklist' : verdict?.keep ? 'verdict-keep' : 'short-or-unknown')
|
|
307
|
+
: action === 'fallback' ? 'no-verdict-fallback' : 'verdict-prune'
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// keep 的三个来源分开计数(issue #8):此前落在最近区/黑名单保护的节点
|
|
311
|
+
// 都被记进 keptByJev——"Jev 保留"虚高,而真正的硬规则保护在统计里完全不可见,
|
|
312
|
+
// 用户拿这行数字判断"Jev 的判断在起作用吗"会得出错误结论。
|
|
313
|
+
// P0-1 追加第四类:被**预算**(而非判定)留下来的。
|
|
314
|
+
if (action === 'keep') {
|
|
315
|
+
if (index > lastAllowed) stats.keptByTail += 1
|
|
316
|
+
else if (isToolIn(cfg.neverPruneTools, tool)) stats.keptByBlacklist += 1
|
|
317
|
+
else if (node.alreadyPruned) { /* 已裁节点只跳过,不重复计入保留统计 */ }
|
|
318
|
+
else if (verdict?.keep) stats.keptByJev += 1
|
|
319
|
+
else if (planned != null) stats.keptByBudget = (stats.keptByBudget ?? 0) + 1
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let content = null
|
|
323
|
+
if (action === 'prune') {
|
|
324
|
+
content = sliceWithBudget(blocks, cfg.headChars, cfg.tailChars, cfg.marker ?? JEV_PRUNE_MARKER)
|
|
325
|
+
if (content != null) stats.prunedByJev += 1
|
|
326
|
+
} else if (action === 'fallback') {
|
|
327
|
+
content = pruner.pruneContent(blocks)
|
|
328
|
+
if (content != null) stats.prunedByVolume += 1
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (content == null) {
|
|
332
|
+
// 观察记录:即使不动手也留下"为什么"
|
|
333
|
+
decisions.push({ seq, tool, chars: charsBefore, gain: node.gain, prob: node.prob, effectProb: node.effectProb, action, reason, applied: false })
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
const charsAfter = countChars(content)
|
|
337
|
+
if (charsAfter >= charsBefore) {
|
|
338
|
+
decisions.push({ seq, tool, chars: charsBefore, gain: node.gain, prob: node.prob, effectProb: node.effectProb, action, reason: `${reason}/no-shrink`, applied: false })
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
decisions.push({ seq, tool, chars: charsBefore, charsAfter, gain: charsBefore - charsAfter, prob: node.prob, effectProb: node.effectProb, action, reason, applied: true })
|
|
343
|
+
|
|
344
|
+
if (cfg.dryRun) {
|
|
345
|
+
charsRemoved += charsBefore - charsAfter
|
|
346
|
+
continue
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const message = freeze({ ...original, content: [{ ...result, content }] })
|
|
350
|
+
appendShadowPrice({ pruner, session, seq, original })
|
|
351
|
+
const replacement = session.append(
|
|
352
|
+
'tool/result',
|
|
353
|
+
{ ...event.data, message },
|
|
354
|
+
{ surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] },
|
|
355
|
+
)
|
|
356
|
+
pruned.push({
|
|
357
|
+
originalSeq: seq,
|
|
358
|
+
replacementSeq: replacement?.seq ?? null,
|
|
359
|
+
callId: callIdOf(event),
|
|
360
|
+
charsBefore,
|
|
361
|
+
charsAfter,
|
|
362
|
+
})
|
|
363
|
+
charsRemoved += charsBefore - charsAfter
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
stats.savedChars += charsRemoved
|
|
367
|
+
if (plan != null) stats.lastBudget = plan
|
|
368
|
+
return { pruned, charsRemoved, plan, decisions }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function appendShadowPrice({ pruner, session, seq, original }) {
|
|
372
|
+
let tokens = 0
|
|
373
|
+
try {
|
|
374
|
+
tokens = pruner.ctx?.tokenMeter?.estimateMessage?.(original) ?? 0
|
|
375
|
+
} catch {
|
|
376
|
+
tokens = 0
|
|
377
|
+
}
|
|
378
|
+
session.append('compaction/prune', {
|
|
379
|
+
shadowedRange: { start: seq, end: seq },
|
|
380
|
+
shadowedSeqs: [seq],
|
|
381
|
+
shadowedTokenCount: tokens,
|
|
382
|
+
})
|
|
383
|
+
}
|