dsh-plugin-lrc 0.2.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.
@@ -0,0 +1,1106 @@
1
+ /**
2
+ * 路径 1 + 路径 2:确定性上下文裁剪(零 LLM)。
3
+ *
4
+ * ## 两条路径的分工
5
+ *
6
+ * - **路径 2(`pruneToolResults`)**:调用官方 `toolResultPruner` 服务,
7
+ * 把超长工具输出(默认 >8192 字符)截成「头 4096 + 标记 + 尾 1024」。
8
+ * 同步、幂等、不丢对话结构,每步都能跑,是廉价的持续过滤。
9
+ * - **路径 1(`trimContext`)**:把旧历史整段替换成一条规则式摘要。
10
+ * 更重、更激进,按 token 阈值 + 步数节流触发。
11
+ *
12
+ * 先剪枝再裁剪:剪枝后 token 往往已经降下来,路径 1 就不必触发。
13
+ *
14
+ * ## 为什么走这条路
15
+ *
16
+ * 宿主层的 `ctx.get('compaction')` 在桌面装配下拿不到 —— 压缩服务被关在
17
+ * 每个 agent preset 的 `isolate: { compaction: true }` 里。但
18
+ * `ctx.get('agentPresets').serviceFor(agent, 'compaction')` 能穿透隔离,
19
+ * 拿到该会话自己的 `BasicCompactionEngine`(探针已实测可用)。
20
+ *
21
+ * ## 为什么不用 compactIfNeeded
22
+ *
23
+ * `compactIfNeeded(agent, 'pressure')` 依赖 `engine.config.thresholdRatio`,
24
+ * 而 `resolveConfig()` 返回的是 `deepFreeze()` 过的对象 —— 临时改阈值会静默失败。
25
+ * `'context-overflow'` 分支虽然绕过阈值,但保留尾部为 0,会把整段历史压成一条,
26
+ * 过于激进。所以这里直接调 `compactRegion(start, end, agent, signal)`,
27
+ * 自己选范围、自己保留最近 N 个节点。
28
+ *
29
+ * ## 为什么能零 LLM
30
+ *
31
+ * `compactRegion` 内部经 `regionDependencies().summarize` **动态调用**
32
+ * `this.summarize(...)`,所以临时替换实例上的 `summarize` 即可接管摘要生成。
33
+ * 替换是同步的、不 yield,`assertWholeSurfaceUnchanged` 不会失败。
34
+ *
35
+ * ## 边界
36
+ *
37
+ * - 只裁「旧」历史,最近 `keepRecentNodes` 个 surface 节点原样保留
38
+ * - 裁剪边界必须工具配对平衡(不能把 tool-call 和 tool-result 切开)
39
+ * - 摘要必须比被藏内容短,否则引擎抛错 —— 这里按被藏字符数的 1/4 硬性封顶
40
+ * - 任何异常都吞掉并返回原因,绝不影响对话
41
+ */
42
+ import { randomUUID } from 'node:crypto';
43
+ import { isConversationNode } from './event-classify.ts';
44
+
45
+ /** 裁剪参数。 */
46
+ export interface TrimOptions {
47
+ /** 上下文低于这个 token 数就不裁(避免无意义抖动) */
48
+ minTokens: number;
49
+ /** 保留最近多少个 surface 节点原文 */
50
+ keepRecentNodes: number;
51
+ /** 摘要字符上限 */
52
+ maxSummaryChars: number;
53
+ /**
54
+ * 强制裁剪:跳过 token 阈值判断。
55
+ *
56
+ * 用于「节点数超过硬上限」的兜底 —— token 计量一旦失真(宿主换版本、
57
+ * meter 返回 0、阈值配错),纯 token 触发会让上下文无限增长。
58
+ * 节点数是**不依赖任何服务**的硬指标,用它兜底最可靠。
59
+ */
60
+ force?: boolean;
61
+ }
62
+
63
+ /** 一次裁剪的结果。 */
64
+ export interface TrimOutcome {
65
+ ok: boolean;
66
+ /** 机器可读原因:ok / below-threshold / no-range / engine-unavailable / error:… */
67
+ reason: string;
68
+ /** 诊断细节:失败时带上节点数 / token 数 / 事件源,避免静默 */
69
+ detail?: string;
70
+ /** 被藏起来的 token 数(成功时) */
71
+ shadowedTokens?: number;
72
+ /** 摘要字符数(成功时) */
73
+ summaryChars?: number;
74
+ /** 摘要各区块条目数(成功时,诊断用) */
75
+ summaryStats?: string;
76
+ }
77
+
78
+ /** 裁剪范围(surface 位置上的闭区间)。 */
79
+ export interface TrimRange {
80
+ start: number;
81
+ end: number;
82
+ startIdx: number;
83
+ endIdx: number;
84
+ }
85
+
86
+ function safeGet(ctx: unknown, name: string): unknown {
87
+ try {
88
+ return (ctx as { get?: (n: string) => unknown })?.get?.(name);
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 取会话的事件数组。
96
+ *
97
+ * **不能只读 `session.events`**:不同 DSH 版本的公开成员不一样 ——
98
+ * 有的版本是 `events` getter,有的只有 `log`,有的提供 `snapshotEvents()`。
99
+ * 只认一个字段会让 `trimContext` 恒返回 `no-range` 且被静默吞掉
100
+ * (实测生产里 120 步超过阈值、一次都没裁)。
101
+ *
102
+ * @param session - 宿主会话对象
103
+ * @returns 事件数组;三种形状都不匹配时返回 undefined
104
+ */
105
+ export function resolveSessionEvents(session: unknown): unknown[] | undefined {
106
+ const s = session as {
107
+ snapshotEvents?: () => unknown;
108
+ events?: unknown;
109
+ log?: unknown;
110
+ };
111
+ try {
112
+ const snapshot = s?.snapshotEvents?.();
113
+ if (Array.isArray(snapshot)) return snapshot;
114
+ } catch {
115
+ // 继续尝试下一种形状
116
+ }
117
+ if (Array.isArray(s?.events)) return s.events;
118
+ if (Array.isArray(s?.log)) return s.log;
119
+ return undefined;
120
+ }
121
+
122
+ /**
123
+ * 是否是裁剪摘要节点(宿主 `compactRegion` 产出的 checkpoint)。
124
+ *
125
+ * ## 为什么必须单独识别
126
+ *
127
+ * 宿主把摘要写成 `user/message` + `source.kind = 'compact-checkpoint'`。
128
+ * `classifyEvent` 对 `user/message` 的判定是「kind 不是 user 就归 injected」,
129
+ * 于是摘要被归为 `injected`,`isTrimBoundaryEligible` 直接跳过它。
130
+ *
131
+ * 后果(实测 3.5 小时):每轮 trim 把旧区间换成一条约 2,310 字符的摘要,
132
+ * 摘要落在被替换区间的起点;下一轮 trim 从摘要**之后**的第一个可裁剪节点开始,
133
+ * 于是**任何一条摘要都不会被后续裁剪覆盖**。真实内容被压到 36%,
134
+ * 摘要层线性累积到 64%,成为上下文最大单项(实测 97 节点里 85 条是摘要)。
135
+ *
136
+ * 摘要必须可再次被裁剪:它是「旧历史的压缩表示」,不是运行时事实。
137
+ *
138
+ * ## 为什么还要按内容前缀兜底
139
+ *
140
+ * 只认 `source.kind` 的话,宿主一旦改字段名,85 条堆叠会立刻复现。
141
+ * 实测宿主 checkpoint 正文以 `This is an automatically generated checkpoint` 开头,
142
+ * LRC 自己的摘要以 `## 已裁剪的历史` 开头,两者都可作为稳定判据。
143
+ */
144
+ const SUMMARY_TEXT_PREFIXES = [
145
+ '## 已裁剪的历史',
146
+ 'This is an automatically generated checkpoint',
147
+ '<compacted-summary>',
148
+ ];
149
+
150
+ export function isSummaryNode(event: unknown): boolean {
151
+ const e = event as {
152
+ type?: string;
153
+ data?: { source?: { kind?: string }; content?: unknown };
154
+ };
155
+ if (e?.type !== 'user/message') return false;
156
+ const kind = e.data?.source?.kind;
157
+ if (
158
+ kind === 'compact-checkpoint' ||
159
+ kind === 'compaction' ||
160
+ kind === 'summary' ||
161
+ // LRC 自己产出的「已剪枝」节点(prune-reasoning 的替换体):
162
+ // 它是旧对话的改写版,必须可再次被 trim 覆盖,否则会像摘要一样累积。
163
+ kind === 'lrc-prune'
164
+ ) {
165
+ return true;
166
+ }
167
+ // 兜底:按内容前缀识别,避免宿主改 source.kind 后堆叠复现
168
+ const content = e.data?.content;
169
+ if (!Array.isArray(content)) return false;
170
+ for (const block of content) {
171
+ const text = (block as { text?: unknown })?.text;
172
+ if (typeof text !== 'string') continue;
173
+ const head = text.trimStart();
174
+ if (SUMMARY_TEXT_PREFIXES.some((prefix) => head.startsWith(prefix))) return true;
175
+ }
176
+ return false;
177
+ }
178
+
179
+ /**
180
+ * 是否可以作为裁剪区间的边界节点。
181
+ *
182
+ * 判定统一走 `event-classify.ts`:真实对话节点(user / assistant / tool-result)
183
+ * 可以替换;`system/message`、注入上下文、未知类型一律跳过。
184
+ *
185
+ * **摘要节点是例外**:它虽然被 `classifyEvent` 归为 `injected`,但必须可再次裁剪,
186
+ * 否则摘要层只增不减(见 `isSummaryNode`)。
187
+ *
188
+ * 之前这里是黑名单,漏掉 `system/message` 导致起点恒为 0,
189
+ * `compactRegion` 必报 `node 0 holds the system prompt`。
190
+ */
191
+ function isTrimBoundaryEligible(event: unknown): boolean {
192
+ return isConversationNode(event) || isSummaryNode(event);
193
+ }
194
+
195
+ /** 一个 surface 事件对「未闭合工具调用数」的增量,与 DSH 的 tool-pairing 口径一致。 */
196
+ function toolDelta(event: unknown): number {
197
+ const e = event as {
198
+ type?: string;
199
+ data?: { message?: { content?: unknown } };
200
+ };
201
+ if (e?.type === 'assistant/message') {
202
+ const content = e.data?.message?.content;
203
+ if (!Array.isArray(content)) return 0;
204
+ let calls = 0;
205
+ for (const block of content) {
206
+ if ((block as { type?: string })?.type === 'tool-call') calls += 1;
207
+ }
208
+ return calls;
209
+ }
210
+ if (e?.type === 'tool/result') return -1;
211
+ return 0;
212
+ }
213
+
214
+ /**
215
+ * 选一段可安全裁剪的旧历史。
216
+ *
217
+ * `cut[i]` 表示「第 i 个节点之前的切点是否平衡」,`cut[n]` 是尾部切点。
218
+ * 起点取第一个平衡切点(`cut[0]` 恒为 true),终点从「保留尾部」往前退到平衡切点。
219
+ *
220
+ * @param nodes - `session.surface.nodes`(当前 surface 的事件 seq 列表)
221
+ * @param events - `session.events`(按 seq 索引的事件数组)
222
+ * @param keepRecentNodes - 尾部原样保留的节点数
223
+ * @returns 可裁剪范围;没有安全范围时返回 null
224
+ */
225
+ export function selectTrimRange(
226
+ nodes: readonly number[],
227
+ events: readonly unknown[],
228
+ keepRecentNodes: number,
229
+ ): TrimRange | null {
230
+ const n = nodes.length;
231
+ if (n <= keepRecentNodes + 1) return null;
232
+
233
+ const cut: boolean[] = new Array(n + 1).fill(false);
234
+ let running = 0;
235
+ cut[0] = true;
236
+ for (let i = 0; i < n; i += 1) {
237
+ running += toolDelta(events[nodes[i] as number]);
238
+ cut[i + 1] = running === 0;
239
+ }
240
+
241
+ // 终点必须同时满足:切点平衡 + 是可替换的对话节点
242
+ let endIdx = n - keepRecentNodes - 1;
243
+ while (
244
+ endIdx >= 0 &&
245
+ (cut[endIdx + 1] !== true || !isTrimBoundaryEligible(events[nodes[endIdx] as number]))
246
+ ) {
247
+ endIdx -= 1;
248
+ }
249
+ if (endIdx < 0) return null;
250
+
251
+ // 起点同理:跳过 system/message、技能目录、运行时上下文等不可替换节点
252
+ let startIdx = 0;
253
+ while (
254
+ startIdx <= endIdx &&
255
+ (cut[startIdx] !== true || !isTrimBoundaryEligible(events[nodes[startIdx] as number]))
256
+ ) {
257
+ startIdx += 1;
258
+ }
259
+ if (startIdx > endIdx) return null;
260
+
261
+ return {
262
+ start: nodes[startIdx] as number,
263
+ end: nodes[endIdx] as number,
264
+ startIdx,
265
+ endIdx,
266
+ };
267
+ }
268
+
269
+ /**
270
+ * 起点回退:从 `startIdx + 1` 起找第一个「切点平衡 + 可替换」的节点。
271
+ *
272
+ * ## 为什么需要
273
+ *
274
+ * 某些 DSH 版本把系统提示写成**没有 source 的 `user/message`**,
275
+ * `classifyEvent` 按兼容旧格式的规则把它判成 `user`,于是
276
+ * `selectTrimRange` 认为 node 0 可替换,`compactRegion` 必报
277
+ * `node 0 holds the system prompt`。实测该区间没有兜底时会一路涨到 81k tokens。
278
+ *
279
+ * 回退不能简单 `startIdx + 1`:如果被跳过的节点是 `tool/result`,
280
+ * 直接后移会破坏 tool-pairing 平衡,宿主仍会拒绝。所以这里同时校验
281
+ * 「被跳过的前缀增量之和为 0」。
282
+ *
283
+ * @param nodes - `session.surface.nodes`
284
+ * @param events - 按 seq 索引的事件数组
285
+ * @param startIdx - 原起点
286
+ * @param endIdx - 原终点
287
+ * @returns 可用的新起点下标;找不到时返回 null
288
+ */
289
+ export function findRetryStart(
290
+ nodes: readonly number[],
291
+ events: readonly unknown[],
292
+ startIdx: number,
293
+ endIdx: number,
294
+ ): number | null {
295
+ let running = 0;
296
+ for (let i = startIdx; i < endIdx; i += 1) {
297
+ running += toolDelta(events[nodes[i] as number]);
298
+ const candidate = i + 1;
299
+ if (running === 0 && isTrimBoundaryEligible(events[nodes[candidate] as number])) {
300
+ return candidate;
301
+ }
302
+ }
303
+ return null;
304
+ }
305
+
306
+ /** 从内容块里取纯文本(只认 text,推理不进摘要)。 */
307
+ function textOf(content: unknown): string {
308
+ if (typeof content === 'string') return content;
309
+ if (!Array.isArray(content)) return '';
310
+ const out: string[] = [];
311
+ for (const block of content) {
312
+ const b = block as { type?: string; text?: unknown };
313
+ if (b?.type === 'text' && typeof b.text === 'string' && b.text.length > 0) out.push(b.text);
314
+ }
315
+ return out.join('\n');
316
+ }
317
+
318
+ /** 压成一行并截断。 */
319
+ function oneLine(text: string, max: number): string {
320
+ const flat = text.replace(/\s+/g, ' ').trim();
321
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
322
+ }
323
+
324
+ /** 去重后按上限收集。 */
325
+ function collect(target: string[], seen: Set<string>, line: string, limit: number): void {
326
+ if (target.length >= limit) return;
327
+ if (line.length === 0 || seen.has(line)) return;
328
+ seen.add(line);
329
+ target.push(line);
330
+ }
331
+
332
+ /**
333
+ * 宿主每轮注入的系统噪音前缀。
334
+ *
335
+ * ## 为什么必须过滤
336
+ *
337
+ * 推理侧回放里,这些内容全部是 `role:'user'` 的纯文本块,和用户真正说的话
338
+ * 在结构上**无法区分**。实测 145 条「用户请求」里 137 条是噪音(94%):
339
+ * 运行时上下文快照、技能目录更新通知、审批策略变更、checkpoint 外壳。
340
+ *
341
+ * 后果是摘要的「用户请求」区被噪音占满,真正的任务描述被挤掉 ——
342
+ * 摘要看起来 2000 字符写满,实际信息量为零。
343
+ */
344
+ const NOISE_PREFIXES = [
345
+ 'Current runtime context.',
346
+ 'Current working directory:',
347
+ 'Current DSH file policy:',
348
+ 'Current DeepSeek Harness',
349
+ 'This is an automatically generated checkpoint',
350
+ '<compacted-summary>',
351
+ '</compacted-summary>',
352
+ '<system-reminder>',
353
+ 'The available skill catalog changed',
354
+ 'The user changed the approval policy',
355
+ 'Approval prompts are disabled',
356
+ // 每轮 prepare 时宿主采样时间并注入,同样伪装成 role:'user'
357
+ 'Time sampled while preparing',
358
+ 'Browser time zone for this request:',
359
+ '## 已裁剪的历史',
360
+ '### 用户请求',
361
+ '### 工具调用与结果',
362
+ '### 工具错误',
363
+ '### 助手结论',
364
+ '### 早期结论',
365
+ '[LRC 状态]',
366
+ ] as const;
367
+
368
+ /** 是否是宿主注入的系统噪音(非用户真实发言)。 */
369
+ function isNoiseText(text: string): boolean {
370
+ const t = text.trimStart();
371
+ if (t.length === 0) return true;
372
+ // 只比前缀:噪音标记总在开头,避免正文里偶然提到这些词被误杀
373
+ const head = t.length > 160 ? t.slice(0, 160) : t;
374
+ return NOISE_PREFIXES.some((p) => head.startsWith(p));
375
+ }
376
+
377
+ /**
378
+ * 是否是「过程叙述」:描述「接下来要做什么」,不是已完成的结论。
379
+ *
380
+ * 实测 AI 会把「接下来我要确认工具参数存放在哪里」当成结论写进摘要,
381
+ * 导致 `### 助手结论` 约 10% 是待办意图。含完成时态词时不判为过程叙述。
382
+ */
383
+ function isProcessNarration(text: string): boolean {
384
+ if (!/接下来|下一步|我要|待确认|待验证|还需要|准备|打算|将要/.test(text)) return false;
385
+ return !/已完成|已确认|已验证|已修复|结论是|因此|所以|说明|表明/.test(text);
386
+ }
387
+
388
+ /**
389
+ * 把一条旧摘要的内容吸收进当前桶,而不是原样再嵌一层。
390
+ *
391
+ * 摘要自身会被再次裁剪(`isSummaryNode`)。若把旧摘要当成「用户请求」整段塞回,
392
+ * 每轮就多套一层「已裁剪的历史」,深度线性增长且全是重复文本(实测 67 层嵌套)。
393
+ * 这里按区块把旧摘要**摊平**,只取其中的真实条目。
394
+ */
395
+ function absorbPriorSummary(
396
+ text: string,
397
+ buckets: { requests: string[]; calls: string[]; errors: string[]; conclusions: string[] },
398
+ seen: Set<string>,
399
+ ): void {
400
+ const limits = { requests: 8, calls: 20, errors: 10, conclusions: 10 };
401
+ let section: keyof typeof buckets | undefined;
402
+
403
+ for (const rawLine of text.split('\n')) {
404
+ const line = rawLine.trim();
405
+ if (line.length === 0) continue;
406
+ if (line.startsWith('## ')) continue;
407
+
408
+ if (line.startsWith('### ')) {
409
+ const title = line.slice(4).trim();
410
+ if (title.startsWith('用户请求')) section = 'requests';
411
+ else if (title.startsWith('工具调用')) section = 'calls';
412
+ else if (title.startsWith('工具错误')) section = 'errors';
413
+ else if (title.startsWith('助手结论') || title.startsWith('早期结论')) section = 'conclusions';
414
+ else section = undefined;
415
+ continue;
416
+ }
417
+
418
+ if (section === undefined) continue;
419
+ if (!line.startsWith('- ')) continue;
420
+ const body = line.slice(2);
421
+ if (isNoiseText(body)) continue;
422
+ collect(buckets[section], seen, `- ${body}`, limits[section]);
423
+ }
424
+ }
425
+
426
+ /**
427
+ * 生成确定性摘要(零 LLM)。
428
+ *
429
+ * 只保留四类信息:用户请求、工具调用、工具错误、助手结论。
430
+ * 工具成功输出只留首行 —— 那才是上下文膨胀的主因。
431
+ *
432
+ * @param messages - 被裁剪区间的重放消息(`SummarizationInput.messages`)
433
+ * @param maxChars - 摘要字符上限
434
+ * @returns 摘要文本
435
+ */
436
+ export function buildDeterministicSummary(
437
+ messages: readonly unknown[],
438
+ maxChars: number,
439
+ /**
440
+ * 可选出参:回填**第一遍(真实对话)**收集到的条数。
441
+ *
442
+ * 为什么需要:第二遍会把旧摘要补位进各桶,导致 `summaryStats` 里的
443
+ * 「请求 8」恒等于 absorb 上限(8),而不是真实请求数(真实上限是 12)。
444
+ * 把真实条数单独回填,诊断时才能区分「真实负载」与「补位填充」。
445
+ */
446
+ realCounts?: { requests: number; conclusions: number; errors: number; calls: number },
447
+ ): string {
448
+ const requests: string[] = [];
449
+ const calls: string[] = [];
450
+ const errors: string[] = [];
451
+ const conclusions: string[] = [];
452
+ const seen = new Set<string>();
453
+ /** 旧摘要文本,留到第二遍再吸收(见下方「两遍扫描」说明) */
454
+ const priorSummaries: string[] = [];
455
+
456
+ for (const raw of messages) {
457
+ const message = raw as { role?: string; content?: unknown; isError?: unknown };
458
+ const content = message?.content;
459
+ if (!Array.isArray(content)) continue;
460
+
461
+ // 真实重放里工具结果是独立消息 `role:'tool'`,正文直接是 `[{type:'text'}]`,
462
+ // **没有** `type:'tool-result'` 块。以前只认块类型,于是几百 KB 的工具输出
463
+ // 全被当成「助手结论」塞进摘要(10 条 × 200 字符上限,还挤掉了真正的结论)。
464
+ if (message.role === 'tool') {
465
+ const text = textOf(content);
466
+ if (text.length > 0) {
467
+ const isError = message.isError === true ||
468
+ (Array.isArray(content) && (content as { isError?: unknown }[]).some((b) => b?.isError === true));
469
+ if (isError) collect(errors, seen, `- ${oneLine(text, 300)}`, 15);
470
+ else collect(calls, seen, `- 结果:${oneLine(text.split('\n')[0] ?? '', 150)}`, 30);
471
+ }
472
+ continue;
473
+ }
474
+
475
+ for (const block of content) {
476
+ const b = block as {
477
+ type?: string;
478
+ text?: unknown;
479
+ name?: unknown;
480
+ arguments?: unknown;
481
+ isError?: unknown;
482
+ content?: unknown;
483
+ };
484
+ if (b?.type === 'text' && typeof b.text === 'string') {
485
+ // 旧摘要:先记下来,第二遍再吸收(不整段嵌套,否则每轮多一层「已裁剪的历史」)
486
+ if (isNoiseText(b.text) && b.text.trimStart().startsWith('## 已裁剪的历史')) {
487
+ priorSummaries.push(b.text);
488
+ continue;
489
+ }
490
+ // 宿主每轮注入的运行时事实 / 技能目录 / 审批策略:不是用户说的话
491
+ if (isNoiseText(b.text)) continue;
492
+ const line = oneLine(b.text, 300);
493
+ if (message.role === 'user') collect(requests, seen, `- ${line}`, 12);
494
+ // 过程叙述(「接下来我要…」)不是结论,不进 `### 助手结论`
495
+ else if (!isProcessNarration(b.text)) collect(conclusions, seen, `- ${line}`, 15);
496
+ } else if (b?.type === 'tool-call') {
497
+ const name = typeof b.name === 'string' ? b.name : 'tool';
498
+ const args = typeof b.arguments === 'string' ? oneLine(b.arguments, 150) : '';
499
+ collect(calls, seen, `- ${name}(${args})`, 30);
500
+ } else if (b?.type === 'tool-result') {
501
+ const text = textOf(b.content);
502
+ if (text.length === 0) continue;
503
+ if (b.isError === true) collect(errors, seen, `- ${oneLine(text, 300)}`, 15);
504
+ else collect(calls, seen, `- 结果:${oneLine(text.split('\n')[0] ?? '', 150)}`, 30);
505
+ }
506
+ }
507
+ }
508
+
509
+ // ── 两遍扫描:真实内容优先,旧摘要只补空位 ──
510
+ //
511
+ // 实测一次 trim 会同时覆盖 85 条堆叠的 checkpoint(摘要自增殖的遗留)。
512
+ // 若按时间顺序边扫边吸收,**最老的摘要会先占满 12 个请求槽**,
513
+ // 之后所有真实用户请求和较新的摘要条目全被 `collect` 丢弃 ——
514
+ // 摘要看起来写满了,实际留下的是最不相关的旧内容。
515
+ //
516
+ // 所以:第一遍只收真实对话;第二遍按**从新到旧**吸收旧摘要,只填空位。
517
+ //
518
+ // 在吸收之前快照真实条数:吸收会把旧摘要补进各桶,之后桶大小就不再等于真实负载。
519
+ if (realCounts !== undefined) {
520
+ realCounts.requests = requests.length;
521
+ realCounts.conclusions = conclusions.length;
522
+ realCounts.errors = errors.length;
523
+ realCounts.calls = calls.length;
524
+ }
525
+ for (let i = priorSummaries.length - 1; i >= 0; i -= 1) {
526
+ absorbPriorSummary(priorSummaries[i] as string, { requests, calls, errors, conclusions }, seen);
527
+ }
528
+
529
+ // 区块顺序 = 信息价值顺序,截断从最低价值端砍。
530
+ //
531
+ // 旧顺序是 请求→工具调用→错误→结论,2000 字符一到就直接 `slice(0, maxChars)`
532
+ // 砍尾,于是**助手结论整块消失**,只留下最廉价的工具调用列表。
533
+ // 实测 68 条摘要全部以「-…」结尾,没有一条含「### 助手结论」。
534
+ interface Section {
535
+ title: string;
536
+ lines: string[];
537
+ /** 越低越先被牺牲 */
538
+ weight: number;
539
+ }
540
+ const all: Section[] = [
541
+ { title: '### 用户请求', lines: requests, weight: 3 },
542
+ { title: '### 助手结论', lines: conclusions, weight: 2 },
543
+ { title: '### 工具错误', lines: errors, weight: 1 },
544
+ { title: '### 工具调用与结果', lines: calls, weight: 0 },
545
+ ].filter((s) => s.lines.length > 0);
546
+
547
+ const render = (sections: Section[]): string => {
548
+ const out: string[] = ['## 已裁剪的历史(确定性摘要,无 LLM)'];
549
+ for (const s of sections) out.push(s.title, ...s.lines);
550
+ return out.join('\n');
551
+ };
552
+
553
+ let text = render(all);
554
+ if (text.length <= maxChars) return text;
555
+
556
+ // 超限:按 weight 升序逐条丢弃最低价值段落,直到装得下
557
+ const working = all.map((s) => ({ ...s, lines: [...s.lines] }));
558
+ while (text.length > maxChars) {
559
+ // 先删权重最低的段落的最后一条;该段空了就整段移除
560
+ const target = working
561
+ .filter((s) => s.lines.length > 0)
562
+ .sort((a, b) => a.weight - b.weight)[0];
563
+ if (!target) break;
564
+ target.lines.pop();
565
+ text = render(working.filter((s) => s.lines.length > 0));
566
+ }
567
+ if (text.length <= maxChars) return text;
568
+ return `${text.slice(0, maxChars - 1)}…`;
569
+ }
570
+
571
+ /**
572
+ * 统计摘要各区块的条目数(诊断用)。
573
+ *
574
+ * 摘要质量不能只看字符数:4000 字符全被「工具调用」占满、结论 0 条,
575
+ * 和「请求 4 / 结论 3 / 调用 12」是完全不同的信息密度。
576
+ * 把条目数打进 trim 日志,才能判断某次裁剪是不是把关键信息挤掉了。
577
+ */
578
+ export function summaryStats(
579
+ text: string,
580
+ /**
581
+ * 可选:第一遍真实条数(由 `buildDeterministicSummary` 回填)。
582
+ *
583
+ * 传入后,若某桶的最终条数 ≠ 真实条数(差额来自旧摘要补位),
584
+ * 会标注「(真实 N)」,避免把补位上限误读成真实负载。
585
+ */
586
+ realCounts?: { requests: number; conclusions: number; errors: number; calls: number },
587
+ ): string {
588
+ const counts = { requests: 0, conclusions: 0, errors: 0, calls: 0 };
589
+ let section: keyof typeof counts | undefined;
590
+ for (const rawLine of text.split('\n')) {
591
+ const line = rawLine.trim();
592
+ if (line.startsWith('### ')) {
593
+ const title = line.slice(4).trim();
594
+ if (title.startsWith('用户请求')) section = 'requests';
595
+ else if (title.startsWith('助手结论') || title.startsWith('早期结论')) section = 'conclusions';
596
+ else if (title.startsWith('工具错误')) section = 'errors';
597
+ else if (title.startsWith('工具调用')) section = 'calls';
598
+ else section = undefined;
599
+ continue;
600
+ }
601
+ if (section !== undefined && line.startsWith('- ')) counts[section] += 1;
602
+ }
603
+ const part = (label: string, total: number, real: number | undefined): string =>
604
+ real !== undefined && real !== total ? `${label} ${total}(真实 ${real})` : `${label} ${total}`;
605
+ return (
606
+ `${part('请求', counts.requests, realCounts?.requests)} / ` +
607
+ `${part('结论', counts.conclusions, realCounts?.conclusions)} / ` +
608
+ `${part('错误', counts.errors, realCounts?.errors)} / ` +
609
+ `${part('调用', counts.calls, realCounts?.calls)}`
610
+ );
611
+ }
612
+
613
+ /**
614
+ * 从引擎上取 token 计量服务(引擎自己的 ctx 一定在 preset 隔离组内)。
615
+ *
616
+ * **必须用 `ctx.get('tokenMeter')`,不能直接读 `ctx.tokenMeter`**:
617
+ * cordis 的服务属性访问要求当前 fiber 声明了 `inject`,否则抛
618
+ * `cannot get property "tokenMeter" without inject`(实测连续两次崩溃)。
619
+ * `get()` 是无需 inject 的安全访问器。
620
+ */
621
+ function meterOf(engine: unknown, ctx: unknown): { measure?: (s: unknown) => unknown } | undefined {
622
+ const engineCtx = (engine as { ctx?: unknown })?.ctx;
623
+ const own = safeGet(engineCtx, 'tokenMeter');
624
+ if (own !== undefined) return own as { measure?: (s: unknown) => unknown };
625
+ return safeGet(ctx, 'tokenMeter') as { measure?: (s: unknown) => unknown } | undefined;
626
+ }
627
+
628
+ /** 一次工具输出剪枝的结果。 */
629
+ export interface PruneOutcome {
630
+ ok: boolean;
631
+ /** ok / no-session / pruner-unavailable / error:… */
632
+ reason: string;
633
+ /** 本次被剪枝的工具结果条数 */
634
+ pruned?: number;
635
+ /** 省下的字符数 */
636
+ charsRemoved?: number;
637
+ }
638
+
639
+ /**
640
+ * 找到 `toolResultPruner` 服务。
641
+ *
642
+ * 它和 `compaction` 一样挂在 preset 的隔离组里,宿主层 `ctx.get()` 可能拿不到。
643
+ * 依次尝试三条路:宿主层 → `agentPresets.serviceFor` → 压缩引擎自己的 ctx。
644
+ */
645
+ function resolvePruner(ctx: unknown, agent: unknown): unknown {
646
+ const direct = safeGet(ctx, 'toolResultPruner');
647
+ if (typeof (direct as { pruneSession?: unknown } | undefined)?.pruneSession === 'function') {
648
+ return direct;
649
+ }
650
+
651
+ const presets = safeGet(ctx, 'agentPresets') as
652
+ | { serviceFor?: (a: unknown, n: string) => unknown }
653
+ | undefined;
654
+ try {
655
+ const viaPreset = presets?.serviceFor?.(agent, 'toolResultPruner');
656
+ if (typeof (viaPreset as { pruneSession?: unknown } | undefined)?.pruneSession === 'function') {
657
+ return viaPreset;
658
+ }
659
+ } catch {
660
+ // 继续尝试下一条路
661
+ }
662
+
663
+ try {
664
+ const engine = presets?.serviceFor?.(agent, 'compaction');
665
+ const viaEngine = (engine as { ctx?: { get?: (n: string) => unknown } } | undefined)?.ctx?.get?.(
666
+ 'toolResultPruner',
667
+ );
668
+ if (typeof (viaEngine as { pruneSession?: unknown } | undefined)?.pruneSession === 'function') {
669
+ return viaEngine;
670
+ }
671
+ } catch {
672
+ // 三条路都不通就放弃
673
+ }
674
+ return undefined;
675
+ }
676
+
677
+ /**
678
+ * 路径 2:确定性工具输出剪枝(零 LLM,同步)。
679
+ *
680
+ * 只截断超长工具结果的中间段,保留头尾与完整事件结构,因此可以每步都跑。
681
+ * 幂等:剪过的结果已低于阈值,下次调用是空操作。
682
+ *
683
+ * 永不抛错:任何失败都转成 `{ ok: false, reason }`。
684
+ *
685
+ * @param deps - 宿主 ctx、agent 与日志函数
686
+ * @returns 剪枝结果
687
+ */
688
+ export function pruneToolResults(deps: {
689
+ ctx: unknown;
690
+ agent: unknown;
691
+ log: (message: string) => void;
692
+ /**
693
+ * 临时覆盖官方 pruner 的阈值(字符)。
694
+ *
695
+ * 官方默认 8192,实测对本类会话只命中 3/128 —— 大量 4K~8K 的工具输出
696
+ * 卡在阈值边缘。这里临时把 `pruner.config` 换成更低的阈值,
697
+ * 复用官方那套经过验证的头/中/尾截断算法,剪完立刻恢复。
698
+ */
699
+ thresholdChars?: number;
700
+ }): PruneOutcome {
701
+ const { ctx, agent, log } = deps;
702
+ const session = (agent as { session?: unknown })?.session;
703
+ if (session === undefined) return { ok: false, reason: 'no-session' };
704
+
705
+ const pruner = resolvePruner(ctx, agent) as
706
+ | { pruneSession?: (s: unknown) => unknown; config?: unknown }
707
+ | undefined;
708
+ if (typeof pruner?.pruneSession !== 'function') {
709
+ return { ok: false, reason: 'pruner-unavailable' };
710
+ }
711
+
712
+ // 临时降阈值:config 是普通属性(值被 deepFreeze,属性本身可写)。
713
+ // 不可写就跳过覆盖,退回官方默认阈值。
714
+ const originalConfig = pruner.config;
715
+ let patched = false;
716
+ if (deps.thresholdChars !== undefined && originalConfig !== undefined) {
717
+ const threshold = deps.thresholdChars;
718
+ const head = Math.min(2048, Math.max(0, threshold - 600));
719
+ const tail = Math.min(512, Math.max(0, threshold - head - 100));
720
+ try {
721
+ pruner.config = { ...(originalConfig as object), thresholdChars: threshold, headChars: head, tailChars: tail };
722
+ patched = true;
723
+ } catch {
724
+ // 属性不可写:保持官方配置
725
+ }
726
+ }
727
+
728
+ try {
729
+ const result = pruner.pruneSession(session) as
730
+ | { pruned?: unknown[]; charsRemoved?: number }
731
+ | undefined;
732
+ const count = Array.isArray(result?.pruned) ? result.pruned.length : 0;
733
+ const charsRemoved = result?.charsRemoved ?? 0;
734
+ if (count > 0) {
735
+ log(`[lrc][prune] 裁剪 ${count} 条工具输出(省 ${charsRemoved} 字符)`);
736
+ }
737
+ return { ok: true, reason: 'ok', pruned: count, charsRemoved };
738
+ } catch (error) {
739
+ return {
740
+ ok: false,
741
+ reason: `error:${error instanceof Error ? error.message : String(error)}`,
742
+ };
743
+ } finally {
744
+ if (patched) pruner.config = originalConfig;
745
+ }
746
+ }
747
+
748
+ /** 工具参数被截断后插入的标记。 */
749
+ export const ARGS_TRUNCATED_MARKER = '\n[... tool arguments truncated ...]';
750
+
751
+ /**
752
+ * 追加一条「单节点替换」事件。
753
+ *
754
+ * ## 为什么替换体一律用 `user/message`
755
+ *
756
+ * 宿主的两条硬规则把 `assistant/message` 当替换体的路封死了:
757
+ * 1. `assistant/message` 不能携带 `sourceEventSeqs`("embeds its source stream");
758
+ * 2. 任何 replace 都要求 `sourceEventSeqs` 覆盖全部 shadowed 节点。
759
+ * 二者合起来 ⇒ 用 assistant 替换 assistant 必然被拒。
760
+ *
761
+ * 宿主自己的 `compactRegion` 也是用 `user/message` 当替换体
762
+ * (见 dsh-compaction-basic 的 `commitCompactionBody`)。这里沿用同一做法。
763
+ *
764
+ * ## 为什么含 tool-call 的节点不能替换
765
+ *
766
+ * 宿主的 tool-pairing 平衡按 `eventDelta` 计算:`assistant/message` 里的
767
+ * `tool-call` 计 +1,`tool/result` 计 -1,`user/message` 计 0。
768
+ * 把含 tool-call 的 assistant 换成 user 会让平衡永久偏移,
769
+ * 下一次 `compactRegion` 会报 "tool/result has no matching tool-call"。
770
+ * 所以含 tool-call 的节点一律跳过(返回 false),只替换纯文本/推理节点。
771
+ *
772
+ * @returns 是否真的替换了
773
+ */
774
+ function appendReplace(session: unknown, content: unknown[], seq: number): boolean {
775
+ const append = (session as { append?: unknown })?.append;
776
+ if (typeof append !== 'function') throw new Error('session.append unavailable');
777
+ // 含 tool-call 的节点不能替换(会破坏 tool-pairing 平衡)
778
+ if (content.some((block) => (block as { type?: string })?.type === 'tool-call')) {
779
+ return false;
780
+ }
781
+ (append as (t: string, d: unknown, o: unknown) => unknown).call(
782
+ session,
783
+ 'user/message',
784
+ // 必须带 `role: 'user'` 与 `id`:宿主 `createUserMessage` 会补这两个字段,
785
+ // 而 `deriveEventMessage` 对 user/message 是**原样返回 data**,
786
+ // 缺 role 会让 LLM 适配器拿不到消息角色。
787
+ { role: 'user', id: randomUUID(), content, source: { kind: 'lrc-prune' } },
788
+ {
789
+ surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq },
790
+ sourceEventSeqs: [seq],
791
+ },
792
+ );
793
+ return true;
794
+ }
795
+
796
+ /** 工具参数截断参数。 */
797
+ export interface PruneArgsOptions {
798
+ /** 超过多少字符才截断 */
799
+ thresholdChars: number;
800
+ /** 保留开头多少字符 */
801
+ headChars: number;
802
+ /**
803
+ * 保留最近多少个节点不丢弃参数。
804
+ *
805
+ * 比这更旧的工具调用,参数直接替换成 `{}` —— 工具早已执行完毕,
806
+ * 参数只用于展示;只截断到 2000 字符的话,40 步下来仍有 8 万字符。
807
+ */
808
+ keepRecentNodes: number;
809
+ }
810
+
811
+ /**
812
+ * 路径 2b:截断 `tool-call.arguments`(官方 pruner 不覆盖的部分)。
813
+ *
814
+ * 官方 `toolResultPruner` 只处理 `tool/result`,而 `tool-call.arguments`
815
+ * 是只增不减的 —— 实测一个会话里 toolArgs 占上下文 16%(12.3 万字符)。
816
+ *
817
+ * 替换的是 assistant 消息本身,但保留全部 tool-call id,所以工具配对不受影响;
818
+ * 参数只用于展示与重放,工具早已执行完毕。
819
+ *
820
+ * 永不抛错:任何失败都转成 `{ ok: false, reason }`。
821
+ */
822
+ export function pruneToolCallArguments(deps: {
823
+ ctx: unknown;
824
+ agent: unknown;
825
+ log: (message: string) => void;
826
+ options?: Partial<PruneArgsOptions>;
827
+ }): PruneOutcome {
828
+ const { agent, log } = deps;
829
+ const thresholdChars = deps.options?.thresholdChars ?? 4000;
830
+ const headChars = deps.options?.headChars ?? 2000;
831
+ const keepRecentNodes = deps.options?.keepRecentNodes ?? 8;
832
+
833
+ const session = (agent as { session?: unknown })?.session;
834
+ if (session === undefined) return { ok: false, reason: 'no-session' };
835
+
836
+ const nodes = (session as { surface?: { nodes?: unknown } })?.surface?.nodes;
837
+ const events = resolveSessionEvents(session);
838
+ const append = (session as { append?: unknown })?.append;
839
+ if (!Array.isArray(nodes) || events === undefined || typeof append !== 'function') {
840
+ return { ok: false, reason: 'no-range' };
841
+ }
842
+
843
+ // ★2026-11-07:宿主 surface 校验不允许替换含 tool-call 的 assistant 节点
844
+ // (会破坏 tool-pairing 平衡,下一次 compactRegion 必报
845
+ // "tool/result has no matching tool-call"),故本路径在当前宿主版本下不可用。
846
+ // 保留函数与配置项,等宿主提供「原地改参数」能力后再启用。
847
+ return { ok: false, reason: 'incompatible-with-host-surface' };
848
+ }
849
+
850
+ /** 推理块裁剪参数。 */
851
+ export interface PruneReasoningOptions {
852
+ /** 保留最近多少个 surface 节点不处理 */
853
+ keepRecentNodes: number;
854
+ }
855
+
856
+ /**
857
+ * 路径 2c:丢弃旧消息里的 reasoning 块。
858
+ *
859
+ * 实测 reasoning 占可见上下文 29.5%,是增速最快的一项,而 prune 只裁工具输出、
860
+ * trim 的摘要也明确排除推理 —— 这块完全没人管。
861
+ *
862
+ * 只处理**同时含其他块**的 assistant 消息:纯推理消息丢掉推理会变成空消息。
863
+ * 保留最近 N 个节点,避免影响当前正在进行的推理链。
864
+ *
865
+ * 永不抛错。
866
+ */
867
+ export function pruneReasoning(deps: {
868
+ ctx: unknown;
869
+ agent: unknown;
870
+ log: (message: string) => void;
871
+ options?: Partial<PruneReasoningOptions>;
872
+ }): PruneOutcome {
873
+ const { agent, log } = deps;
874
+ const keepRecentNodes = deps.options?.keepRecentNodes ?? 8;
875
+
876
+ const session = (agent as { session?: unknown })?.session;
877
+ if (session === undefined) return { ok: false, reason: 'no-session' };
878
+
879
+ const nodes = (session as { surface?: { nodes?: unknown } })?.surface?.nodes;
880
+ const events = resolveSessionEvents(session);
881
+ if (!Array.isArray(nodes) || events === undefined) {
882
+ return { ok: false, reason: 'no-range' };
883
+ }
884
+
885
+ const cutoff = nodes.length - keepRecentNodes;
886
+ let pruned = 0;
887
+ let charsRemoved = 0;
888
+ try {
889
+ for (let index = 0; index < cutoff; index += 1) {
890
+ const seq = nodes[index] as number;
891
+ const event = events[seq] as
892
+ | { type?: string; data?: { message?: { content?: unknown } } }
893
+ | undefined;
894
+ if (event?.type !== 'assistant/message') continue;
895
+ const message = event.data?.message;
896
+ const content = message?.content;
897
+ if (!Array.isArray(content)) continue;
898
+
899
+ const reasoningBlocks = content.filter(
900
+ (block) => (block as { type?: string })?.type === 'reasoning',
901
+ );
902
+ if (reasoningBlocks.length === 0) continue;
903
+ // 纯推理消息不能丢:丢了就变成空消息
904
+ if (reasoningBlocks.length === content.length) continue;
905
+
906
+ const nextContent = content.filter(
907
+ (block) => (block as { type?: string })?.type !== 'reasoning',
908
+ );
909
+ // 含 tool-call 的消息会被 appendReplace 跳过(保护 tool-pairing 平衡);
910
+ // 只有真正替换成功才计入 charsRemoved,避免日志虚报。
911
+ if (appendReplace(session, nextContent, seq)) {
912
+ for (const block of reasoningBlocks) {
913
+ const text = (block as { text?: unknown }).text;
914
+ if (typeof text === 'string') charsRemoved += text.length;
915
+ }
916
+ pruned += 1;
917
+ }
918
+ }
919
+ } catch (error) {
920
+ return {
921
+ ok: false,
922
+ reason: `error:${error instanceof Error ? error.message : String(error)}`,
923
+ pruned,
924
+ charsRemoved,
925
+ };
926
+ }
927
+
928
+ if (pruned > 0) {
929
+ log(`[lrc][prune-reasoning] 丢弃 ${pruned} 条旧推理(省 ${charsRemoved} 字符)`);
930
+ }
931
+ return { ok: true, reason: 'ok', pruned, charsRemoved };
932
+ }
933
+
934
+ /**
935
+ * 执行一次确定性裁剪。
936
+ *
937
+ * 永不抛错:任何失败都转成 `{ ok: false, reason }`,调用方只记日志。
938
+ *
939
+ * @param deps - 宿主 ctx、agent、取消信号、参数与日志函数
940
+ * @returns 裁剪结果
941
+ */
942
+ export async function trimContext(deps: {
943
+ ctx: unknown;
944
+ agent: unknown;
945
+ signal?: AbortSignal;
946
+ options: TrimOptions;
947
+ log: (message: string) => void;
948
+ }): Promise<TrimOutcome> {
949
+ const { ctx, agent, signal, options, log } = deps;
950
+
951
+ const presets = safeGet(ctx, 'agentPresets') as
952
+ | { serviceFor?: (a: unknown, n: string) => unknown }
953
+ | undefined;
954
+ let engine: unknown;
955
+ try {
956
+ engine = presets?.serviceFor?.(agent, 'compaction');
957
+ } catch (error) {
958
+ return { ok: false, reason: `error:serviceFor ${String(error)}` };
959
+ }
960
+ const compactRegion = (engine as { compactRegion?: unknown } | undefined)?.compactRegion;
961
+ if (typeof compactRegion !== 'function') {
962
+ return {
963
+ ok: false,
964
+ reason: 'engine-unavailable',
965
+ detail: `serviceFor=${typeof presets?.serviceFor} engine=${engine === undefined ? 'undefined' : 'present'}`,
966
+ };
967
+ }
968
+
969
+ const session = (agent as { session?: unknown })?.session;
970
+ const nodes = (session as { surface?: { nodes?: unknown } })?.surface?.nodes;
971
+ const events = resolveSessionEvents(session);
972
+ if (!Array.isArray(nodes) || events === undefined) {
973
+ return {
974
+ ok: false,
975
+ reason: 'no-range',
976
+ detail: `nodes=${Array.isArray(nodes) ? nodes.length : 'missing'} events=${events === undefined ? 'missing' : events.length}`,
977
+ };
978
+ }
979
+
980
+ const range = selectTrimRange(nodes, events, options.keepRecentNodes);
981
+ if (range === null) {
982
+ return {
983
+ ok: false,
984
+ reason: 'no-range',
985
+ detail: `nodes=${nodes.length} keepRecent=${options.keepRecentNodes}(节点太少或找不到平衡切点)`,
986
+ };
987
+ }
988
+
989
+ const meter = meterOf(engine, ctx);
990
+ let totalTokens = 0;
991
+ let shadowedTokens = 0;
992
+ try {
993
+ const measurement = meter?.measure?.(session) as
994
+ | { totalTokens?: number; nodes?: Array<{ tokens?: number }> }
995
+ | undefined;
996
+ totalTokens = measurement?.totalTokens ?? 0;
997
+ const priced = measurement?.nodes;
998
+ if (Array.isArray(priced)) {
999
+ for (let i = range.startIdx; i <= range.endIdx; i += 1) {
1000
+ shadowedTokens += priced[i]?.tokens ?? 0;
1001
+ }
1002
+ }
1003
+ } catch {
1004
+ // 计量失败就按 0 处理:宁可少裁,不可误裁
1005
+ }
1006
+ if (!options.force && totalTokens < options.minTokens) {
1007
+ return {
1008
+ ok: false,
1009
+ reason: 'below-threshold',
1010
+ detail: `totalTokens=${totalTokens} min=${options.minTokens} meter=${meter === undefined ? 'missing' : 'present'}`,
1011
+ };
1012
+ }
1013
+
1014
+ const target = engine as {
1015
+ summarize?: (...args: unknown[]) => Promise<unknown>;
1016
+ compactRegion: (s: number, e: number, a: unknown, sig?: AbortSignal) => Promise<unknown>;
1017
+ };
1018
+ if (typeof target.summarize !== 'function') {
1019
+ return {
1020
+ ok: false,
1021
+ reason: 'engine-unavailable',
1022
+ detail: `summarize=${typeof target.summarize}`,
1023
+ };
1024
+ }
1025
+
1026
+ const hadOwn = Object.prototype.hasOwnProperty.call(target, 'summarize');
1027
+ const original = target.summarize;
1028
+ let summaryChars = 0;
1029
+ let summaryText = '';
1030
+ // 第一遍真实条数(不含旧摘要补位),供 summaryStats 区分真实负载与补位填充。
1031
+ let summaryRealCounts: { requests: number; conclusions: number; errors: number; calls: number } = {
1032
+ requests: 0,
1033
+ conclusions: 0,
1034
+ errors: 0,
1035
+ calls: 0,
1036
+ };
1037
+
1038
+ target.summarize = async (input: unknown) => {
1039
+ const messages = (input as { messages?: unknown })?.messages;
1040
+ const list = Array.isArray(messages) ? messages : [];
1041
+ // 被藏内容的字符总量:摘要必须明显更短,否则引擎会抛「summary is not smaller」
1042
+ let sourceChars = 0;
1043
+ for (const message of list) {
1044
+ const content = (message as { content?: unknown })?.content;
1045
+ if (Array.isArray(content)) {
1046
+ for (const block of content) {
1047
+ const b = block as { text?: unknown; arguments?: unknown };
1048
+ if (typeof b?.text === 'string') sourceChars += b.text.length;
1049
+ if (typeof b?.arguments === 'string') sourceChars += b.arguments.length;
1050
+ }
1051
+ }
1052
+ }
1053
+ const cap = Math.max(200, Math.min(options.maxSummaryChars, Math.floor(sourceChars / 4)));
1054
+ const text = buildDeterministicSummary(list, cap, summaryRealCounts);
1055
+ summaryChars = text.length;
1056
+ summaryText = text;
1057
+ return {
1058
+ summary: [{ type: 'text', text }],
1059
+ provider: 'lrc',
1060
+ model: 'deterministic',
1061
+ };
1062
+ };
1063
+
1064
+ try {
1065
+ await target.compactRegion(range.start, range.end, agent, signal);
1066
+ log(
1067
+ `[lrc][trim] 已裁剪 ${range.endIdx - range.startIdx + 1} 个节点` +
1068
+ `(总 ${totalTokens} tokens,${options.force ? '节点数强制' : '超阈值'};` +
1069
+ `约 ${shadowedTokens} tokens → 摘要 ${summaryChars} 字符:${summaryStats(summaryText, summaryRealCounts)})`,
1070
+ );
1071
+ return { ok: true, reason: 'ok', shadowedTokens, summaryChars, summaryStats: summaryStats(summaryText, summaryRealCounts) };
1072
+ } catch (error) {
1073
+ const message = error instanceof Error ? error.message : String(error);
1074
+ // ★2026-11-07:兜底——起点落在 system prompt 上时,向后找第一个平衡切点重试一次。
1075
+ // 宿主对 node 0 有硬保护(只允许 system/message 重写它),而某些版本把系统提示
1076
+ // 写成无 source 的 user/message,会被判成可替换节点;没有兜底时该区间会一路涨到模型收尾。
1077
+ if (message.includes('holds the system prompt')) {
1078
+ const retryIdx = findRetryStart(nodes, events, range.startIdx, range.endIdx);
1079
+ if (retryIdx !== null) {
1080
+ try {
1081
+ await target.compactRegion(nodes[retryIdx] as number, range.end, agent, signal);
1082
+ log(
1083
+ `[lrc][trim] 起点回退后成功(${retryIdx}..${range.endIdx})` +
1084
+ `(总 ${totalTokens} tokens,${options.force ? '节点数强制' : '超阈值'};` +
1085
+ `约 ${shadowedTokens} tokens → 摘要 ${summaryChars} 字符:${summaryStats(summaryText, summaryRealCounts)})`,
1086
+ );
1087
+ return { ok: true, reason: 'ok', shadowedTokens, summaryChars, summaryStats: summaryStats(summaryText, summaryRealCounts) };
1088
+ } catch (retryError) {
1089
+ return {
1090
+ ok: false,
1091
+ reason: `error:${retryError instanceof Error ? retryError.message : String(retryError)}`,
1092
+ detail: `retry range=${retryIdx}..${range.endIdx}`,
1093
+ };
1094
+ }
1095
+ }
1096
+ }
1097
+ return {
1098
+ ok: false,
1099
+ reason: `error:${message}`,
1100
+ detail: `range=${range.startIdx}..${range.endIdx} shadowedTokens=${shadowedTokens}`,
1101
+ };
1102
+ } finally {
1103
+ if (hadOwn) target.summarize = original;
1104
+ else delete (target as { summarize?: unknown }).summarize;
1105
+ }
1106
+ }