dsh-plugin-om 0.0.28 → 0.0.30

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/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { scopeOf } from "@deepseek-ai/dsh-scope";
2
2
  import { renderPrompt } from "@deepseek-ai/dsh-system-prompt";
3
+ import { BlockAssembler, createToolResultMessage } from "@deepseek-ai/dsh-llm";
3
4
  import { SessionId } from "@deepseek-ai/dsh-session";
4
5
  import { SUBAGENT_DESCRIPTOR_VERSION } from "@deepseek-ai/dsh-subagent";
5
6
  import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -28,6 +29,8 @@ const HISTORY_TIP = "当前块是历史消息的压缩产物,不要复述";
28
29
  * 完整消息是摘要日志与 recall 共用的定位单位;首条 index 为 0,按会话顺序递增、全局稳定。
29
30
  */
30
31
  const COMPLETE_MESSAGE_DEFINITION = "`完整消息`指一条`用户消息`、`系统消息`、`模型输出文本`或`具有result的toolcall`;首条 index 为 0,按会话顺序递增。";
32
+ /** 最终 <history> 块内文块首的格式说明注释(XML 注释,完整消息定义 + 条目标签语义)。 */
33
+ const HISTORY_FORMAT_NOTE = `<!-- 完整消息:${COMPLETE_MESSAGE_DEFINITION} <TAG index="N">表示单条完整消息,<TAG start="A" end="B"> 表示连续模块,start/end 是首尾完整消息的 index;<sys type="KIND" index="N"> 表示被压缩的系统消息,块中为空 -->`;
31
34
  /**
32
35
  * 压缩因 signal 中止而放弃时 compaction/end 的 error 标识(服务端写入、客户端过滤):
33
36
  * 中止不是失败,客户端据此隐藏失败行(宿主不变量要求无 summary 的 end 必须带 error)。
@@ -39,109 +42,25 @@ function isPluginOwnedSource(source) {
39
42
  return source.plugin === "dsh-plugin-om" || source.plugin === "compact";
40
43
  }
41
44
  //#endregion
42
- //#region src/om-event.ts
43
- /** om 信封 text 前缀:标识该 feedback/record 记录由本插件写入。 */
44
- const OM_EVENT_PREFIX = "om:1:";
45
- /** 校验载荷字段与类别匹配(运行时守卫,保证返回类型的诚实性)。 */
46
- function isValidPayload(kind, data) {
47
- switch (kind) {
48
- case "om/warning": return typeof data.problem === "string" && typeof data.message === "string";
49
- case "om/observe-pending": return typeof data.triggerMessageIndex === "number" && Number.isSafeInteger(data.triggerMessageIndex);
50
- case "om/observe-invalidate": return typeof data.pendingSeq === "number" && Number.isSafeInteger(data.pendingSeq);
51
- }
52
- }
45
+ //#region src/logger.ts
53
46
  /**
54
- * 解码一条会话事件为 om 私有事件:仅识别 feedback/record 中带 om 信封前缀的
55
- * text;前缀缺失、JSON 非法、kind 未知或载荷字段缺失时返回 undefined。
47
+ * 插件日志门面:step 为步骤级(debug)日志,按配置开关过滤;info/warn 始终输出。
48
+ * 导出 PluginLogger / makeLogger。
56
49
  */
57
- function readOmEvent(event) {
58
- if (event === void 0 || event === null || event.type !== "feedback/record") return void 0;
59
- const text = event.data?.text;
60
- if (typeof text !== "string" || !text.startsWith("om:1:")) return void 0;
61
- let envelope;
62
- try {
63
- envelope = JSON.parse(text.slice(5));
64
- } catch {
65
- return;
66
- }
67
- if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) return void 0;
68
- const { kind, ...rest } = envelope;
69
- if (typeof kind !== "string") return void 0;
70
- if (![
71
- "om/warning",
72
- "om/observe-pending",
73
- "om/observe-invalidate"
74
- ].includes(kind)) return void 0;
75
- const omKind = kind;
76
- if (!isValidPayload(omKind, rest)) return void 0;
50
+ /** 构建插件日志门面:统一加 PLUGIN_LABEL 前缀,step 按 debug 开关过滤。 */
51
+ function makeLogger(ctx, debug) {
77
52
  return {
78
- kind: omKind,
79
- data: rest,
80
- seq: event.seq
53
+ step(message) {
54
+ if (debug) ctx.logger.debug(`${PLUGIN_LABEL}: ${message}`);
55
+ },
56
+ info(message) {
57
+ ctx.logger.info(`${PLUGIN_LABEL}: ${message}`);
58
+ },
59
+ warn(message) {
60
+ ctx.logger.warn(`${PLUGIN_LABEL}: ${message}`);
61
+ }
81
62
  };
82
63
  }
83
- /** 编码一条 om 私有事件为 feedback/record 的 text 信封。 */
84
- function encodeOmEvent(kind, data) {
85
- return `${OM_EVENT_PREFIX}${JSON.stringify({
86
- kind,
87
- ...data
88
- })}`;
89
- }
90
- /** 追加一条 om 私有事件(借用 feedback/record,log-only,不进 surface),返回事件 seq。 */
91
- function appendOmEvent(session, kind, data) {
92
- return session.append("feedback/record", { text: encodeOmEvent(kind, data) }).seq;
93
- }
94
- function findOmEvents(session, kind) {
95
- const result = [];
96
- for (const event of session.events) {
97
- const om = readOmEvent(event);
98
- if (om !== void 0 && (kind === void 0 || om.kind === kind)) result.push(om);
99
- }
100
- return result;
101
- }
102
- //#endregion
103
- //#region src/degrade.ts
104
- /**
105
- * 降级报告:压缩流程中的挂载失败统一出口。
106
- * 导出 reportDegrade / DEGRADE_PROBLEMS / DegradedProblem。
107
- *
108
- * - 挂载失败类问题(服务缺失、服务调用异常)始终 console.warn 到宿主进程外部输出,
109
- * 并向会话日志追加 log-only 的 om 警告事件(借用 feedback/record 的 om 信封,
110
- * 见 om-event.ts;客户端渲染为「功能降级」警告行);
111
- * 同一会话同一问题只报告一次(按日志扫描去重,重启不重复)
112
- * - 报告动作自身绝不抛错:追加失败只记日志,不阻塞压缩
113
- * - 辅助函数的普通运行时报错(组装失败、请求头读取失败)不走本模块,仅记日志
114
- */
115
- /** 各降级问题面向用户的简短说明(om/warning 信封载荷 message;客户端警告行直接展示)。 */
116
- const DEGRADE_PROBLEMS = {
117
- "systemPrompt-missing": "系统提示词服务未挂载,上下文压力估算不扣除系统提示词 tokens(压缩触发会偏早)",
118
- "tokenMeter-unavailable": "token 计量服务异常,上下文压力估算降级(可能跳过压缩或按 0 计)"
119
- };
120
- /**
121
- * 报告一次挂载失败类降级:console.warn 始终输出到宿主进程外部;同会话同问题首次
122
- * 出现时,向会话日志追加 log-only om 警告事件(客户端渲染警告行,每会话最多
123
- * 一次)并输出 logger.warn(避免每个 pre-step 重复刷日志)。
124
- * 日志扫描/事件追加失败只记日志,绝不抛错、不阻塞压缩。
125
- */
126
- function reportDegrade(session, logger, problem) {
127
- const message = DEGRADE_PROBLEMS[problem];
128
- let first = true;
129
- try {
130
- first = !findOmEvents(session, "om/warning").some((om) => om.data.problem === problem);
131
- } catch {}
132
- if (!first) return;
133
- console.warn(`${PLUGIN_LABEL}: ${message}`);
134
- logger.warn(message);
135
- try {
136
- appendOmEvent(session, "om/warning", {
137
- problem,
138
- message
139
- });
140
- } catch (error) {
141
- const text = error instanceof Error ? error.message : String(error);
142
- logger.warn(`om 警告事件追加失败: ${text}`);
143
- }
144
- }
145
64
  //#endregion
146
65
  //#region src/utils.ts
147
66
  /** 判断值是否为普通对象(非 null、非数组)。 */
@@ -202,204 +121,66 @@ function routedTarget(session) {
202
121
  } catch {}
203
122
  }
204
123
  //#endregion
205
- //#region src/log-index.ts
206
- /**
207
- * 会话日志索引:完整消息索引与渲染。
208
- * 导出 indexCompleteMessages(完整消息四类折叠索引,recall 与摘要共用同一套编号)、
209
- * indexMessages / surfaceIndexOf / messageIdOfEvent(消息级定位辅助)、
210
- * collectImageRefs / renderCompleteMessageParts / renderCompleteMessage(完整消息渲染
211
- * 与图片附件收集)。事件日志仅追加(被遮蔽的事件仍可读,recall 依赖此性质)。
212
- *
213
- * 完整消息分四类:user(用户消息)、sys(系统消息,压缩日志中以 <sys> 空块表示)、
214
- * assistant(模型输出文本)、toolcall(单个工具调用及其结果,result 按 callId 匹配并入)。
215
- * index 从 0 起、按日志顺序递增、只追加不重排(压缩后旧摘要条目引用的 index 仍然有效)。
216
- */
217
- /** 查找 seq 在表层节点序列中的下标(不在则返回 -1)。 */
218
- function surfaceIndexOf(nodes, seq) {
219
- for (let i = 0; i < nodes.length; i += 1) if (nodes[i] === seq) return i;
220
- return -1;
124
+ //#region src/compaction-log.ts
125
+ /** 诊断子会话的 descriptor provider(宿主子代理列表识别用)。 */
126
+ const COMPACTION_LOG_PROVIDER = "om-compaction-log";
127
+ /** 压缩 pass 的中文标签(会话记录 label 用;未知阶段回落「压缩」)。 */
128
+ function phaseLabel(phase) {
129
+ if (phase === "observe") return "观察";
130
+ if (phase === "reflect") return "反思";
131
+ return "压缩";
221
132
  }
222
- /**
223
- * 完整消息索引:按日志顺序把消息事件折叠为完整消息序列(四类,见文件头)。
224
- * 工具调用结果按 source.callId 匹配其 tool-call 并入该条;未匹配的 result 独立成条(防御)。
225
- * 本插件自产消息不占位;压缩在 agent/pre-step 触发(call-result 完备),不存在未闭合调用。
226
- */
227
- function indexCompleteMessages(session) {
228
- const cms = [];
229
- const pending = /* @__PURE__ */ new Map();
230
- const events = session.events;
231
- for (let seq = 0; seq < events.length; seq += 1) {
232
- const event = events[seq];
233
- if (!event) continue;
234
- if (event.type === "user/message") {
235
- const source = event.data.source;
236
- if (isPluginOwnedSource(source)) continue;
237
- if (source?.kind === "user") cms.push({
238
- index: cms.length,
239
- type: "user",
240
- seqs: [seq]
241
- });
242
- else cms.push({
243
- index: cms.length,
244
- type: "sys",
245
- seqs: [seq],
246
- ...source?.kind === void 0 ? {} : { kind: source.kind }
247
- });
248
- } else if (event.type === "assistant/message") {
249
- const message = event.data.message;
250
- if (!message || !Array.isArray(message.content)) continue;
251
- let hasText = false;
252
- for (const block of message.content) if (block.type === "text") {
253
- hasText = true;
254
- break;
255
- }
256
- if (hasText) cms.push({
257
- index: cms.length,
258
- type: "assistant",
259
- seqs: [seq]
260
- });
261
- for (const block of message.content) {
262
- if (block.type !== "tool-call") continue;
263
- const callId = String(block.id ?? "");
264
- const cm = {
265
- index: cms.length,
266
- type: "toolcall",
267
- seqs: [seq],
268
- ...callId === "" ? {} : { callId }
269
- };
270
- cms.push(cm);
271
- if (callId !== "") pending.set(callId, cm);
272
- }
273
- } else if (event.type === "tool/result") {
274
- const source = event.data.message?.source;
275
- const callId = String(source?.callId ?? "");
276
- const cm = callId === "" ? void 0 : pending.get(callId);
277
- if (cm) {
278
- cm.seqs.push(seq);
279
- pending.delete(callId);
280
- } else cms.push({
281
- index: cms.length,
282
- type: "toolcall",
283
- seqs: [seq],
284
- ...callId === "" ? {} : { callId }
285
- });
286
- }
287
- }
288
- return cms;
133
+ /** 压缩会话记录 label:成功为会话记录、失败为失败日志,均含压缩阶段与轮数。 */
134
+ function compressionRecordLabel(phase, rounds, success) {
135
+ return `OM 压缩${success ? "会话记录" : "失败日志"}(${phaseLabel(phase)} · ${rounds} 轮)`;
289
136
  }
290
137
  /**
291
- * 递归收集内容块中的图片附件元数据(recall 输出保留图片用):
292
- * image 块按附件元数据收集(字段不全则忽略);tool-result 块递归收集其 content。
138
+ * 把一次压缩工具循环的完整会话消息组落盘为子会话:ctx.sessions.create 创建子会话
139
+ * (header origin 'subagent'、parentSession 指向主会话、delegationDepth = 父 + 1、
140
+ * cwd 继承主会话),追加 one-shot descriptor(provider om-compaction-log,label 含
141
+ * 压缩阶段与轮数),逐消息原样追加(user 指令/提醒与 tool-result 为 user/message,
142
+ * assistant 含 tool-call 块为 assistant/message),flush 持久化,返回子会话 id。
143
+ * 成功与失败均调用;落盘自身绝不抛错,任何失败仅 logger.warn 并返回 undefined。
293
144
  */
294
- function collectImageRefs(content, out) {
295
- if (!Array.isArray(content)) return;
296
- for (const block of content) {
297
- if (!isRecord(block)) continue;
298
- if (block.type === "image") {
299
- const a = block.attachment;
300
- if (isRecord(a) && typeof a.attachmentId === "string" && a.attachmentId !== "" && typeof a.mediaType === "string" && typeof a.bytes === "number" && Number.isFinite(a.bytes) && typeof a.width === "number" && Number.isFinite(a.width) && typeof a.height === "number" && Number.isFinite(a.height)) out.push({
301
- attachmentId: a.attachmentId,
302
- mediaType: a.mediaType,
303
- bytes: a.bytes,
304
- width: a.width,
305
- height: a.height,
306
- ...typeof a.name === "string" && a.name !== "" ? { name: a.name } : {}
307
- });
308
- } else if (block.type === "tool-result") collectImageRefs(block.content, out);
145
+ async function recordCompressionSession(ctx, parentSession, options) {
146
+ const logger = makeLogger(ctx, options.debug);
147
+ try {
148
+ const header = parentSession.header;
149
+ const child = ctx.sessions.create(SessionId(`om-compaction-log-${uuid()}`), { meta: {
150
+ ...header.cwd === void 0 ? {} : { cwd: header.cwd },
151
+ parentSession: parentSession.id,
152
+ origin: "subagent",
153
+ delegationDepth: (header.delegationDepth ?? 0) + 1
154
+ } });
155
+ child.append("subagent/descriptor", {
156
+ version: SUBAGENT_DESCRIPTOR_VERSION,
157
+ mode: "one-shot",
158
+ provider: COMPACTION_LOG_PROVIDER,
159
+ label: compressionRecordLabel(options.phase, options.rounds, options.success)
160
+ });
161
+ let step = 0;
162
+ for (const message of options.messages) {
163
+ step += 1;
164
+ if (message.role === "assistant") child.append("assistant/message", {
165
+ turn: 0,
166
+ step,
167
+ message
168
+ }, { surfaceOp: "append" });
169
+ else child.append("user/message", message, { surfaceOp: "append" });
170
+ }
171
+ try {
172
+ await ctx.sessions.flush(child);
173
+ } catch (error) {
174
+ const message = error instanceof Error ? error.message : String(error);
175
+ logger.warn(`压缩会话记录子会话 flush 失败(子会话 ${child.id} 已创建): ${message}`);
176
+ }
177
+ return child.id;
178
+ } catch (error) {
179
+ const message = error instanceof Error ? error.message : String(error);
180
+ logger.warn(`压缩会话记录子会话落盘失败: ${message}`);
181
+ return;
309
182
  }
310
183
  }
311
- /**
312
- * 渲染一条完整消息为「文本 + 图片」(recall / recall-semantic 输出用):
313
- * user/sys 取消息原文,assistant 取文本块,toolcall 为调用块 + 结果文本
314
- * (pruner 裁剪超大结果);同时收集该条完整消息携带的图片附件(含 tool-result 嵌套,
315
- * pruner 裁剪掉的图片不收集)。
316
- */
317
- function renderCompleteMessageParts(session, cm, pruner) {
318
- const images = [];
319
- if (cm.type === "user" || cm.type === "sys") {
320
- const seq = cm.seqs[0];
321
- const event = seq === void 0 ? void 0 : session.events[seq];
322
- const message = event ? session.deriveEventMessage(event) : null;
323
- if (message && Array.isArray(message.content)) collectImageRefs(message.content, images);
324
- return {
325
- text: message ? renderMessageText(message) : "",
326
- images
327
- };
328
- }
329
- if (cm.type === "assistant") {
330
- const seq = cm.seqs[0];
331
- const event = seq === void 0 ? void 0 : session.events[seq];
332
- const message = event ? session.deriveEventMessage(event) : null;
333
- if (!message || !Array.isArray(message.content)) return {
334
- text: "",
335
- images
336
- };
337
- collectImageRefs(message.content, images);
338
- const texts = [];
339
- for (const block of message.content) if (block.type === "text") texts.push(String(block.text));
340
- return {
341
- text: texts.join("\n"),
342
- images
343
- };
344
- }
345
- const parts = [];
346
- const callSeq = cm.seqs[0];
347
- const callEvent = callSeq === void 0 ? void 0 : session.events[callSeq];
348
- if (callEvent?.type === "assistant/message") {
349
- const message = session.deriveEventMessage(callEvent);
350
- if (message && Array.isArray(message.content)) {
351
- let call;
352
- for (const block of message.content) if (block.type === "tool-call" && String(block.id ?? "") === (cm.callId ?? "")) {
353
- call = block;
354
- break;
355
- }
356
- if (call) parts.push(`[tool-call ${String(call.name ?? "")} id=${String(call.id ?? "")}]\n${safeJson(call.arguments)}`);
357
- }
358
- }
359
- const resultSeq = cm.seqs[1];
360
- const resultEvent = resultSeq === void 0 ? void 0 : session.events[resultSeq];
361
- if (resultEvent?.type === "tool/result") {
362
- let message = session.deriveEventMessage(resultEvent);
363
- if (message && pruner?.pruneContent) {
364
- const pruned = pruner.pruneContent(message.content);
365
- if (pruned) message = {
366
- ...message,
367
- content: pruned
368
- };
369
- }
370
- if (message && Array.isArray(message.content)) collectImageRefs(message.content, images);
371
- const text = message ? renderMessageText(message) : "";
372
- if (text.trim() !== "") parts.push(`[result]\n${text}`);
373
- }
374
- return {
375
- text: parts.join("\n"),
376
- images
377
- };
378
- }
379
- /** 渲染一条完整消息的文本(压缩输入与语义嵌入共用):renderCompleteMessageParts 的纯文本投影。 */
380
- function renderCompleteMessage(session, cm, pruner) {
381
- return renderCompleteMessageParts(session, cm, pruner).text;
382
- }
383
- //#endregion
384
- //#region src/logger.ts
385
- /**
386
- * 插件日志门面:step 为步骤级(debug)日志,按配置开关过滤;info/warn 始终输出。
387
- * 导出 PluginLogger / makeLogger。
388
- */
389
- /** 构建插件日志门面:统一加 PLUGIN_LABEL 前缀,step 按 debug 开关过滤。 */
390
- function makeLogger(ctx, debug) {
391
- return {
392
- step(message) {
393
- if (debug) ctx.logger.debug(`${PLUGIN_LABEL}: ${message}`);
394
- },
395
- info(message) {
396
- ctx.logger.info(`${PLUGIN_LABEL}: ${message}`);
397
- },
398
- warn(message) {
399
- ctx.logger.warn(`${PLUGIN_LABEL}: ${message}`);
400
- }
401
- };
402
- }
403
184
  //#endregion
404
185
  //#region node_modules/.pnpm/@xmldom+xmldom@0.9.11/node_modules/@xmldom/xmldom/lib/conventions.js
405
186
  var require_conventions = /* @__PURE__ */ __commonJSMin(((exports) => {
@@ -7491,7 +7272,7 @@ var require_dom_parser = /* @__PURE__ */ __commonJSMin(((exports) => {
7491
7272
  exports.onWarningStopParsing = onWarningStopParsing;
7492
7273
  }));
7493
7274
  //#endregion
7494
- //#region src/compaction-log.ts
7275
+ //#region src/log-index.ts
7495
7276
  var import_lib = (/* @__PURE__ */ __commonJSMin(((exports) => {
7496
7277
  var conventions = require_conventions();
7497
7278
  exports.assign = conventions.assign;
@@ -7531,236 +7312,237 @@ var import_lib = (/* @__PURE__ */ __commonJSMin(((exports) => {
7531
7312
  exports.onErrorStopParsing = domParser.onErrorStopParsing;
7532
7313
  exports.onWarningStopParsing = domParser.onWarningStopParsing;
7533
7314
  })))();
7534
- /** 诊断子会话的 descriptor provider(宿主子代理列表识别用)。 */
7535
- const COMPACTION_LOG_PROVIDER = "om-compaction-log";
7536
- /** 压缩 pass 的中文标签(诊断子会话 label 用;未知阶段回落「压缩」)。 */
7537
- function phaseLabel(phase) {
7538
- if (phase === "observe") return "观察";
7539
- if (phase === "reflect") return "反思";
7540
- return "压缩";
7541
- }
7542
- /** 诊断子会话 label:含压缩阶段与尝试序号。 */
7543
- function compactionLogLabel(phase, attemptNo) {
7544
- return `OM 压缩日志(${phaseLabel(phase)} · 第 ${attemptNo} 次尝试)`;
7545
- }
7546
- /** 追加一次尝试的「提示词 → 原始输出」消息组(surfaceOp append;id 为品牌类型,session.append 运行时校验)。 */
7547
- function appendAttemptMessages(child, attempt, step, target) {
7548
- const userMessage = {
7549
- id: uuid(),
7550
- role: "user",
7551
- content: [{
7552
- type: "text",
7553
- text: attempt.prompt
7554
- }],
7555
- source: {
7556
- kind: "plugin",
7557
- plugin: PLUGIN_LABEL
7558
- }
7559
- };
7560
- child.append("user/message", userMessage, { surfaceOp: "append" });
7561
- const assistantMessage = {
7562
- id: uuid(),
7563
- role: "assistant",
7564
- content: [{
7565
- type: "text",
7566
- text: attempt.rawOutput
7567
- }],
7568
- source: {
7569
- kind: "model",
7570
- provider: target.provider,
7571
- model: target.model
7572
- }
7573
- };
7574
- child.append("assistant/message", {
7575
- turn: 0,
7576
- step,
7577
- message: assistantMessage
7578
- }, { surfaceOp: "append" });
7579
- }
7580
7315
  /**
7581
- * 把一次摘要尝试落盘为诊断子会话:ctx.sessions.create 创建子会话(header origin
7582
- * 'subagent'、parentSession 指向主会话、delegationDepth = 父 + 1、cwd 继承主会话),
7583
- * 追加 one-shot descriptor(provider om-compaction-log,label 含压缩阶段与尝试序号),
7584
- * 原样追加「提示词 + 原始输出」消息组,flush 持久化检查点,返回子会话 id。落盘自身
7585
- * 绝不抛错:任何失败仅 logger.warn 并返回 undefined(不影响压缩流程)。
7316
+ * 会话日志索引:完整消息索引与渲染。
7317
+ * 导出 indexCompleteMessages(完整消息四类折叠索引,recall 与摘要共用同一套编号)、
7318
+ * indexMessages / surfaceIndexOf / messageIdOfEvent(消息级定位辅助)、
7319
+ * collectImageRefs / renderCompleteMessageParts / renderCompleteMessage(完整消息渲染
7320
+ * 与图片附件收集)。事件日志仅追加(被遮蔽的事件仍可读,recall 依赖此性质)。
7321
+ *
7322
+ * 完整消息分四类:user(用户消息)、sys(系统消息,压缩日志中以 <sys> 空块表示)、
7323
+ * assistant(模型输出文本)、toolcall(单个工具调用及其结果,result 按 callId 匹配并入)。
7324
+ * index 从 0 起、按日志顺序递增、只追加不重排(压缩后旧摘要条目引用的 index 仍然有效)。
7586
7325
  */
7587
- async function recordCompactionAttempt(ctx, parentSession, options) {
7588
- const logger = makeLogger(ctx, options.debug);
7589
- try {
7590
- const header = parentSession.header;
7591
- const child = ctx.sessions.create(SessionId(`om-compaction-log-${uuid()}`), { meta: {
7592
- ...header.cwd === void 0 ? {} : { cwd: header.cwd },
7593
- parentSession: parentSession.id,
7594
- origin: "subagent",
7595
- delegationDepth: (header.delegationDepth ?? 0) + 1
7596
- } });
7597
- child.append("subagent/descriptor", {
7598
- version: SUBAGENT_DESCRIPTOR_VERSION,
7599
- mode: "one-shot",
7600
- provider: COMPACTION_LOG_PROVIDER,
7601
- label: compactionLogLabel(options.phase, options.attemptNo)
7602
- });
7603
- appendAttemptMessages(child, options.attempt, 1, options.target);
7604
- try {
7605
- await ctx.sessions.flush(child);
7606
- } catch (error) {
7607
- const message = error instanceof Error ? error.message : String(error);
7608
- logger.warn(`压缩日志子会话 flush 失败(子会话 ${child.id} 已创建): ${message}`);
7609
- }
7610
- return child.id;
7611
- } catch (error) {
7612
- const message = error instanceof Error ? error.message : String(error);
7613
- logger.warn(`压缩日志子会话落盘失败(第 ${options.attemptNo} 次尝试): ${message}`);
7614
- return;
7615
- }
7326
+ /** 查找 seq 在表层节点序列中的下标(不在则返回 -1)。 */
7327
+ function surfaceIndexOf(nodes, seq) {
7328
+ for (let i = 0; i < nodes.length; i += 1) if (nodes[i] === seq) return i;
7329
+ return -1;
7616
7330
  }
7617
- //#endregion
7618
- //#region src/rate-limit.ts
7619
7331
  /**
7620
- * 全局限流门(插件进程级共享状态):任一摘要请求遇 429 后进入冷却期,
7621
- * 此后所有摘要请求发出前先等待到「最近一次 429 + rateLimitWaitMs」之后。
7622
- * 等待期间 signal 中止则立即放弃;等待期间的新 429 顺延冷却期。
7623
- * 导出 isRateLimitError / noteRateLimit / gateRateLimit / RATE_LIMIT_WAIT_MS_DEFAULT /
7624
- * resetRateLimitGate。
7332
+ * 完整消息索引:按日志顺序把消息事件折叠为完整消息序列(四类,见文件头)。
7333
+ * 工具调用结果按 source.callId 匹配其 tool-call 并入该条;未匹配的 result 独立成条(防御)。
7334
+ * 本插件自产消息不占位;压缩在 agent/pre-step 触发(call-result 完备),不存在未闭合调用。
7625
7335
  */
7626
- /** 最近一次 429 限流的时间戳(ms;null = 未遇过限流,门直接放行)。 */
7627
- let lastRateLimitAt = null;
7628
- /** 判定错误信息是否为 429 限流(匹配 429 或 rate limit,大小写不敏感)。 */
7629
- function isRateLimitError(message) {
7630
- return /\b429\b|rate[\s_-]?limit/i.test(message);
7631
- }
7632
- /** 记录一次 429 限流:把冷却期起点更新为当前时间。 */
7633
- function noteRateLimit() {
7634
- lastRateLimitAt = Date.now();
7635
- }
7636
- /** 可中止延时:等待满 ms 返回 true;等待期间 signal 中止返回 false。 */
7637
- function delay(ms, signal) {
7638
- return new Promise((resolve) => {
7639
- if (signal?.aborted) {
7640
- resolve(false);
7641
- return;
7336
+ function indexCompleteMessages(session) {
7337
+ const cms = [];
7338
+ const pending = /* @__PURE__ */ new Map();
7339
+ const events = session.events;
7340
+ for (let seq = 0; seq < events.length; seq += 1) {
7341
+ const event = events[seq];
7342
+ if (!event) continue;
7343
+ if (event.type === "user/message") {
7344
+ const source = event.data.source;
7345
+ if (isPluginOwnedSource(source)) continue;
7346
+ if (source?.kind === "user") cms.push({
7347
+ index: cms.length,
7348
+ type: "user",
7349
+ seqs: [seq]
7350
+ });
7351
+ else cms.push({
7352
+ index: cms.length,
7353
+ type: "sys",
7354
+ seqs: [seq],
7355
+ ...source?.kind === void 0 ? {} : { kind: source.kind }
7356
+ });
7357
+ } else if (event.type === "assistant/message") {
7358
+ const message = event.data.message;
7359
+ if (!message || !Array.isArray(message.content)) continue;
7360
+ let hasText = false;
7361
+ for (const block of message.content) if (block.type === "text") {
7362
+ hasText = true;
7363
+ break;
7364
+ }
7365
+ if (hasText) cms.push({
7366
+ index: cms.length,
7367
+ type: "assistant",
7368
+ seqs: [seq]
7369
+ });
7370
+ for (const block of message.content) {
7371
+ if (block.type !== "tool-call") continue;
7372
+ const callId = String(block.id ?? "");
7373
+ const cm = {
7374
+ index: cms.length,
7375
+ type: "toolcall",
7376
+ seqs: [seq],
7377
+ ...callId === "" ? {} : { callId }
7378
+ };
7379
+ cms.push(cm);
7380
+ if (callId !== "") pending.set(callId, cm);
7381
+ }
7382
+ } else if (event.type === "tool/result") {
7383
+ const source = event.data.message?.source;
7384
+ const callId = String(source?.callId ?? "");
7385
+ const cm = callId === "" ? void 0 : pending.get(callId);
7386
+ if (cm) {
7387
+ cm.seqs.push(seq);
7388
+ pending.delete(callId);
7389
+ } else cms.push({
7390
+ index: cms.length,
7391
+ type: "toolcall",
7392
+ seqs: [seq],
7393
+ ...callId === "" ? {} : { callId }
7394
+ });
7642
7395
  }
7643
- const timer = setTimeout(() => {
7644
- cleanup();
7645
- resolve(true);
7646
- }, ms);
7647
- const onAbort = () => {
7648
- cleanup();
7649
- resolve(false);
7650
- };
7651
- const cleanup = () => {
7652
- clearTimeout(timer);
7653
- signal?.removeEventListener("abort", onAbort);
7654
- };
7655
- signal?.addEventListener("abort", onAbort, { once: true });
7656
- });
7396
+ }
7397
+ return cms;
7657
7398
  }
7658
7399
  /**
7659
- * 限流等待门:处于 429 冷却期时等待到期限再放行(返回 true);
7660
- * 等待期间 signal 中止返回 false。未遇过限流或冷却期已过立即放行。waitMs ≤ 0 视为不限流。
7400
+ * 递归收集内容块中的图片附件元数据(recall 输出保留图片用):
7401
+ * image 块按附件元数据收集(字段不全则忽略);tool-result 块递归收集其 content。
7661
7402
  */
7662
- async function gateRateLimit(waitMs, signal) {
7663
- if (waitMs <= 0) return true;
7664
- while (lastRateLimitAt !== null) {
7665
- const remaining = lastRateLimitAt + waitMs - Date.now();
7666
- if (remaining <= 0) return true;
7667
- if (!await delay(remaining, signal)) return false;
7403
+ function collectImageRefs(content, out) {
7404
+ if (!Array.isArray(content)) return;
7405
+ for (const block of content) {
7406
+ if (!isRecord(block)) continue;
7407
+ if (block.type === "image") {
7408
+ const a = block.attachment;
7409
+ if (isRecord(a) && typeof a.attachmentId === "string" && a.attachmentId !== "" && typeof a.mediaType === "string" && typeof a.bytes === "number" && Number.isFinite(a.bytes) && typeof a.width === "number" && Number.isFinite(a.width) && typeof a.height === "number" && Number.isFinite(a.height)) out.push({
7410
+ attachmentId: a.attachmentId,
7411
+ mediaType: a.mediaType,
7412
+ bytes: a.bytes,
7413
+ width: a.width,
7414
+ height: a.height,
7415
+ ...typeof a.name === "string" && a.name !== "" ? { name: a.name } : {}
7416
+ });
7417
+ } else if (block.type === "tool-result") collectImageRefs(block.content, out);
7668
7418
  }
7669
- return true;
7670
7419
  }
7671
- //#endregion
7672
- //#region src/summarize.ts
7673
7420
  /**
7674
- * 共享压缩提示词(观察/反思同一套):定义 history 块(模型消息 + index 的表达形式)、
7675
- * 完整消息定义、压缩要求、输出格式与数据源说明。
7676
- * skipReasoning=true(默认,与 compressSkipReasoning 默认一致)时压缩输入不含
7677
- * <reasoning> 参考条目,提示词相应省略 <reasoning> 的说明两行。
7421
+ * 渲染一条完整消息为「文本 + 图片」(recall / recall-semantic 输出用):
7422
+ * user/sys 取消息原文,assistant 取文本块,toolcall 为调用块 + 结果文本
7423
+ * (pruner 裁剪超大结果);同时收集该条完整消息携带的图片附件(含 tool-result 嵌套,
7424
+ * pruner 裁剪掉的图片不收集)。
7678
7425
  */
7679
- function buildHistoryPrompt(skipReasoning = true) {
7680
- return [
7681
- "压缩 <history> 消息记录。你应当输出**单个**合法的 <history> 块。",
7682
- "",
7683
- "【history 块定义】",
7684
- "- <history> 是历史消息的记录块。",
7685
- "- <user_message index=\"N\">:用户消息条目。",
7686
- "- <sys type=\"(kind)\" index=\"N\">:系统消息条目。",
7687
- ...skipReasoning ? [] : ["- <reasoning>:模型的思考过程,仅作压缩参考,产物中不要出现。"],
7688
- "- <assistant index=\"N\">:单条完整消息(模型输出文本,或 toolcall 及其 result)。",
7689
- "- <assistant start=\"A\" end=\"B\">:多条连续完整消息聚合的模块(A/B 为模块首尾完整消息的 index)。",
7690
- "",
7691
- "【压缩要求】",
7692
- "- <user_message> <sys> 条目从输入中逐条保留,不做任何处理。",
7693
- ...skipReasoning ? [] : ["- <reasoning> 只作参考,输出产物中不包含 <reasoning> 块。"],
7694
- "- 将具有关联性的 <assistant> 消息按内在逻辑连贯性划分为连续模块,聚合为 <assistant start=\"\" end=\"\"> 块",
7695
- "- 单条重要的完整消息以 <assistant index=\"\"> 单独呈现",
7696
- "- 压缩后的 <assistant> 块内,应当描述**行为逻辑**,强调关键的**结论、产出和任务**;涉及到的具体文件保留完整路径",
7697
- "- 加载的 skill 属于**关键信息**:应当产出独立块且不过多省略。",
7698
- "- 压缩后的消息,区间边界与输入的消息必须完全相同,内部 index/start/end 必须连续,相邻区间的左右界必须相邻,",
7699
- "",
7700
- "【摘要粒度】",
7701
- "- 越往后越细:靠近末尾(最近)的完整消息保留更多细节(关键文件、改动与结论),开头(较早)的完整消息可适当从简。",
7702
- "- 用户消息不受此约束:始终逐条保留原文,不做概括与省略。",
7703
- "",
7704
- "【输出格式】输出单个合法的 <history> 块,**不包含其他任何内容**:",
7705
- `<${HISTORY_TAG}>`,
7706
- "<user_message index=\"(index)\">",
7707
- "(user 消息原文)",
7708
- "</user_message>",
7709
- "<sys type=\"(kind)\" index=\"(index)\"></sys>",
7710
- "<assistant start=\"(起始 index)\" end=\"(结束 index)\">",
7711
- "(模块的目的、行为与结果摘要)",
7712
- "</assistant>",
7713
- "<assistant index=\"(index)\">",
7714
- "(单条完整消息的模块摘要)",
7715
- "</assistant>",
7716
- `</${HISTORY_TAG}>`,
7717
- "",
7718
- "【数据源】下方的 <history> 消息记录是本次要压缩的全部消息;压缩结果作为一个新的 <history> 块输出。"
7719
- ].join("\n");
7426
+ function renderCompleteMessageParts(session, cm, pruner) {
7427
+ const images = [];
7428
+ if (cm.type === "user" || cm.type === "sys") {
7429
+ const seq = cm.seqs[0];
7430
+ const event = seq === void 0 ? void 0 : session.events[seq];
7431
+ const message = event ? session.deriveEventMessage(event) : null;
7432
+ if (message && Array.isArray(message.content)) collectImageRefs(message.content, images);
7433
+ return {
7434
+ text: message ? renderMessageText(message) : "",
7435
+ images
7436
+ };
7437
+ }
7438
+ if (cm.type === "assistant") {
7439
+ const seq = cm.seqs[0];
7440
+ const event = seq === void 0 ? void 0 : session.events[seq];
7441
+ const message = event ? session.deriveEventMessage(event) : null;
7442
+ if (!message || !Array.isArray(message.content)) return {
7443
+ text: "",
7444
+ images
7445
+ };
7446
+ collectImageRefs(message.content, images);
7447
+ const texts = [];
7448
+ for (const block of message.content) if (block.type === "text") texts.push(String(block.text));
7449
+ return {
7450
+ text: texts.join("\n"),
7451
+ images
7452
+ };
7453
+ }
7454
+ const parts = [];
7455
+ const callSeq = cm.seqs[0];
7456
+ const callEvent = callSeq === void 0 ? void 0 : session.events[callSeq];
7457
+ if (callEvent?.type === "assistant/message") {
7458
+ const message = session.deriveEventMessage(callEvent);
7459
+ if (message && Array.isArray(message.content)) {
7460
+ let call;
7461
+ for (const block of message.content) if (block.type === "tool-call" && String(block.id ?? "") === (cm.callId ?? "")) {
7462
+ call = block;
7463
+ break;
7464
+ }
7465
+ if (call) parts.push(`[tool-call ${String(call.name ?? "")} id=${String(call.id ?? "")}]\n${safeJson(call.arguments)}`);
7466
+ }
7467
+ }
7468
+ const resultSeq = cm.seqs[1];
7469
+ const resultEvent = resultSeq === void 0 ? void 0 : session.events[resultSeq];
7470
+ if (resultEvent?.type === "tool/result") {
7471
+ let message = session.deriveEventMessage(resultEvent);
7472
+ if (message && pruner?.pruneContent) {
7473
+ const pruned = pruner.pruneContent(message.content);
7474
+ if (pruned) message = {
7475
+ ...message,
7476
+ content: pruned
7477
+ };
7478
+ }
7479
+ if (message && Array.isArray(message.content)) collectImageRefs(message.content, images);
7480
+ const text = message ? renderMessageText(message) : "";
7481
+ if (text.trim() !== "") parts.push(`[result]\n${text}`);
7482
+ }
7483
+ return {
7484
+ text: parts.join("\n"),
7485
+ images
7486
+ };
7720
7487
  }
7721
- /**
7722
- * 静默 DOMParser:非致命解析问题不再走 xmldom 默认的 console.error 输出
7723
- * (模型输出非法 XML 时避免刷 console),fatalError 仍抛 ParseError、解析语义不变。
7724
- */
7488
+ /** 渲染一条完整消息的文本(压缩输入与语义嵌入共用):renderCompleteMessageParts 的纯文本投影。 */
7489
+ function renderCompleteMessage(session, cm, pruner) {
7490
+ return renderCompleteMessageParts(session, cm, pruner).text;
7491
+ }
7492
+ //#endregion
7493
+ //#region src/compress-view.ts
7494
+ /** 静默 DOMParser:非致命解析问题不刷 console,fatalError 仍抛 ParseError、解析语义不变。 */
7725
7495
  function newQuietParser() {
7726
7496
  return new import_lib.DOMParser({ onError: () => {} });
7727
7497
  }
7728
- /** 渲染用户消息条目(DOM 元素):文本块原样;图片/文件等非文本块以注释补充。 */
7729
- function renderUserEntry(doc, session, cm) {
7498
+ /**
7499
+ * 提取用户消息条目的文本与注释(文本块拼接为原文;图片/其他块降级为注释文本,
7500
+ * 渲染时输出为 XML 注释)。无任何内容返回 null(该 index 在视图中不占条目)。
7501
+ */
7502
+ function userEntryParts(session, cm) {
7730
7503
  const seq = cm.seqs[0];
7731
7504
  const event = seq === void 0 ? void 0 : session.events[seq];
7732
7505
  const message = event ? session.deriveEventMessage(event) : null;
7733
7506
  if (!message || !Array.isArray(message.content)) return null;
7734
- const el = doc.createElement("user_message");
7735
- el.setAttribute("index", String(cm.index));
7736
- let hasContent = false;
7737
- for (const block of message.content) if (block.type === "text") {
7738
- el.appendChild(doc.createTextNode(String(block.text)));
7739
- hasContent = true;
7740
- } else if (block.type === "image") {
7507
+ const texts = [];
7508
+ const notes = [];
7509
+ for (const block of message.content) if (block.type === "text") texts.push(String(block.text));
7510
+ else if (block.type === "image") {
7741
7511
  const ref = block.attachment;
7742
7512
  const name = ref?.name ? `:${ref.name}` : "";
7743
7513
  const meta = ref ? `(${String(ref.mediaType ?? "")} ${String(ref.width ?? "")}×${String(ref.height ?? "")},${String(ref.bytes ?? "")} bytes)` : "";
7744
- el.appendChild(doc.createComment(` 图片附件${name}${meta} `));
7745
- hasContent = true;
7746
- } else {
7747
- el.appendChild(doc.createComment(` ${String(block.type)} 块 `));
7748
- hasContent = true;
7749
- }
7750
- if (!hasContent) return null;
7751
- return el;
7514
+ notes.push(` 图片附件${name}${meta} `);
7515
+ } else notes.push(` ${String(block.type)} 块 `);
7516
+ if (texts.length === 0 && notes.length === 0) return null;
7517
+ return {
7518
+ text: texts.join("\n"),
7519
+ notes
7520
+ };
7752
7521
  }
7753
7522
  /**
7754
- * 渲染完整消息记录(观察输入):输出一个合法的 <history> 块——
7755
- * user → <user_message>(文本原样、图片注释)、sys → <sys> 空块、
7756
- * assistant/toolcall → <assistant>(原样文本);skipReasoning=false 时另把
7757
- * assistant 的 reasoning → <reasoning>(参考条目)。
7758
- * 文本经 XML 序列化自动转义;仅渲染 seqs 全部落在给定集合内的完整消息。
7523
+ * 提取 toolcall 完整消息的工具名(按 callId 在所属 assistant 消息中定位 tool-call 块)。
7524
+ * 找不到返回 undefined。
7759
7525
  */
7760
- function renderMessages(session, seqs, skipReasoning = true) {
7526
+ function toolCallNameOf(session, cm) {
7527
+ if (cm.type !== "toolcall") return void 0;
7528
+ const seq = cm.seqs[0];
7529
+ const event = seq === void 0 ? void 0 : session.events[seq];
7530
+ if (event?.type !== "assistant/message") return void 0;
7531
+ const message = event.data.message;
7532
+ if (!message || !Array.isArray(message.content)) return void 0;
7533
+ for (const block of message.content) if (block.type === "tool-call" && String(block.id ?? "") === (cm.callId ?? "")) return String(block.name ?? "");
7534
+ }
7535
+ /**
7536
+ * 观察视图:被压缩区间(表层 seq 集合)内的完整消息投影为条目——
7537
+ * user → 原文(图片等非文本块为注释)、sys → 空条目、assistant/toolcall → 原文渲染,
7538
+ * reasoning 作为参考条目置于其所属 assistant 条目之前(每条 assistant 消息输出一次;
7539
+ * skipReasoning 时省略)。
7540
+ * 要求区间为区间内完整消息的首尾 index。
7541
+ */
7542
+ function buildObserveView(session, seqs, options = {}) {
7761
7543
  const shadowed = new Set(seqs);
7762
7544
  const reasoningBySeq = /* @__PURE__ */ new Map();
7763
- if (!skipReasoning) for (const seq of seqs) {
7545
+ for (const seq of seqs) {
7764
7546
  const event = session.events[seq];
7765
7547
  if (event?.type !== "assistant/message") continue;
7766
7548
  const message = event.data.message;
@@ -7769,76 +7551,661 @@ function renderMessages(session, seqs, skipReasoning = true) {
7769
7551
  for (const block of message.content) if (block.type === "reasoning" && typeof block.text === "string") reasonings.push(block.text);
7770
7552
  if (reasonings.length > 0) reasoningBySeq.set(seq, reasonings);
7771
7553
  }
7772
- const emittedReasoning = /* @__PURE__ */ new Set();
7773
- const doc = newQuietParser().parseFromString(`<${HISTORY_TAG} />`, "text/xml");
7774
7554
  const entries = [];
7555
+ const emittedReasoning = /* @__PURE__ */ new Set();
7775
7556
  for (const cm of indexCompleteMessages(session)) {
7776
7557
  if (!cm.seqs.every((seq) => shadowed.has(seq))) continue;
7777
7558
  if (cm.type === "sys") {
7778
- const sysEl = doc.createElement("sys");
7779
- sysEl.setAttribute("type", cm.kind ?? "");
7780
- sysEl.setAttribute("index", String(cm.index));
7781
- sysEl.appendChild(doc.createTextNode(""));
7782
- entries.push(sysEl);
7559
+ entries.push({
7560
+ kind: "sys",
7561
+ lo: cm.index,
7562
+ hi: cm.index,
7563
+ text: "",
7564
+ ...cm.kind === void 0 ? {} : { sysKind: cm.kind }
7565
+ });
7783
7566
  continue;
7784
7567
  }
7785
7568
  if (cm.type === "user") {
7786
- const rendered = renderUserEntry(doc, session, cm);
7787
- if (rendered === null) continue;
7788
- entries.push(rendered);
7789
- } else {
7569
+ const parts = userEntryParts(session, cm);
7570
+ if (parts === null) continue;
7571
+ entries.push({
7572
+ kind: "user",
7573
+ lo: cm.index,
7574
+ hi: cm.index,
7575
+ text: parts.text,
7576
+ ...parts.notes.length === 0 ? {} : { notes: parts.notes }
7577
+ });
7578
+ continue;
7579
+ }
7580
+ if (options.skipReasoning !== true) {
7790
7581
  const callSeq = cm.seqs[0];
7791
7582
  const reasonings = callSeq === void 0 ? void 0 : reasoningBySeq.get(callSeq);
7792
- if (!skipReasoning && callSeq !== void 0 && reasonings !== void 0 && !emittedReasoning.has(callSeq)) {
7583
+ if (callSeq !== void 0 && reasonings !== void 0 && !emittedReasoning.has(callSeq)) {
7793
7584
  emittedReasoning.add(callSeq);
7794
- for (const text of reasonings) {
7795
- const re = doc.createElement("reasoning");
7796
- re.appendChild(doc.createTextNode(text));
7797
- entries.push(re);
7798
- }
7585
+ for (const text of reasonings) entries.push({
7586
+ kind: "reasoning",
7587
+ lo: cm.index,
7588
+ hi: cm.index,
7589
+ text
7590
+ });
7799
7591
  }
7800
- const text = renderCompleteMessage(session, cm);
7801
- if (text.trim() === "") continue;
7802
- const el = doc.createElement("assistant");
7803
- el.setAttribute("index", String(cm.index));
7804
- el.appendChild(doc.createTextNode(text));
7805
- entries.push(el);
7806
7592
  }
7593
+ const text = renderCompleteMessage(session, cm);
7594
+ if (text.trim() === "") continue;
7595
+ const toolName = cm.type === "toolcall" ? toolCallNameOf(session, cm) : void 0;
7596
+ entries.push({
7597
+ kind: "assistant",
7598
+ lo: cm.index,
7599
+ hi: cm.index,
7600
+ text,
7601
+ ...toolName === void 0 ? {} : { toolName }
7602
+ });
7603
+ }
7604
+ return {
7605
+ entries,
7606
+ ...viewBounds(entries)
7607
+ };
7608
+ }
7609
+ /** 提取视图要求区间:全部可定位条目的最小 lo 与最大 hi(无可定位条目时均 undefined)。 */
7610
+ function viewBounds(entries) {
7611
+ let minIndex;
7612
+ let maxIndex;
7613
+ for (const entry of entries) {
7614
+ if (entry.lo === void 0 || entry.hi === void 0) continue;
7615
+ if (minIndex === void 0 || entry.lo < minIndex) minIndex = entry.lo;
7616
+ if (maxIndex === void 0 || entry.hi > maxIndex) maxIndex = entry.hi;
7807
7617
  }
7618
+ return {
7619
+ ...minIndex === void 0 ? {} : { minIndex },
7620
+ ...maxIndex === void 0 ? {} : { maxIndex }
7621
+ };
7622
+ }
7623
+ /** 提取 <history> 块的内文(去开/闭标签;非块文本原样返回)。 */
7624
+ function historyInner(text) {
7625
+ const closeTag = `</${HISTORY_TAG}>`;
7626
+ const close = text.lastIndexOf(closeTag);
7627
+ if (close === -1) return text;
7628
+ const open = text.indexOf(`<${HISTORY_TAG}`);
7629
+ if (open === -1) return text;
7630
+ const gt = text.indexOf(">", open);
7631
+ if (gt === -1 || gt >= close) return text;
7632
+ return text.slice(gt + 1, close);
7633
+ }
7634
+ /** 读取元素整数属性(非负整数;缺失 / 非数字返回 undefined)。 */
7635
+ function intAttr(el, name) {
7636
+ const raw = el.getAttribute(name);
7637
+ if (raw === null || raw === "") return void 0;
7638
+ if (!/^\d+$/.test(raw)) return void 0;
7639
+ return Number(raw);
7640
+ }
7641
+ /**
7642
+ * 解析一个已有 <history> 块的内条目(反思视图):user_message / sys / assistant
7643
+ * (index 单条或 start/end 区间)/ reasoning。整块无法解析或根非 <history> 时降级为
7644
+ * 单条不可定位的历史遗留条目(text 为块内文原文,构建最终块时原样保留)。
7645
+ */
7646
+ function parseBlockEntries(blockText, blockSeq) {
7647
+ const opaque = () => [{
7648
+ kind: "assistant",
7649
+ text: historyInner(blockText),
7650
+ blockSeq
7651
+ }];
7652
+ let doc;
7653
+ try {
7654
+ doc = newQuietParser().parseFromString(blockText, "text/xml");
7655
+ } catch {
7656
+ return opaque();
7657
+ }
7658
+ const root = doc.documentElement;
7659
+ if (!root || root.nodeName !== "history") return opaque();
7660
+ const entries = [];
7661
+ const children = root.childNodes;
7662
+ for (let i = 0; i < children.length; i += 1) {
7663
+ const node = children[i];
7664
+ if (node?.nodeType !== 1) continue;
7665
+ const el = node;
7666
+ const text = el.textContent ?? "";
7667
+ if (el.nodeName === "user_message") {
7668
+ const index = intAttr(el, "index");
7669
+ if (index === void 0) continue;
7670
+ entries.push({
7671
+ kind: "user",
7672
+ lo: index,
7673
+ hi: index,
7674
+ text,
7675
+ blockSeq
7676
+ });
7677
+ } else if (el.nodeName === "sys") {
7678
+ const index = intAttr(el, "index");
7679
+ if (index === void 0) continue;
7680
+ const type = el.getAttribute("type");
7681
+ entries.push({
7682
+ kind: "sys",
7683
+ lo: index,
7684
+ hi: index,
7685
+ text: "",
7686
+ ...type === null ? {} : { sysKind: type },
7687
+ blockSeq
7688
+ });
7689
+ } else if (el.nodeName === "assistant") {
7690
+ const index = intAttr(el, "index");
7691
+ if (index !== void 0) {
7692
+ entries.push({
7693
+ kind: "assistant",
7694
+ lo: index,
7695
+ hi: index,
7696
+ text,
7697
+ blockSeq
7698
+ });
7699
+ continue;
7700
+ }
7701
+ const start = intAttr(el, "start");
7702
+ const end = intAttr(el, "end");
7703
+ if (start !== void 0 && end !== void 0) entries.push({
7704
+ kind: "assistant",
7705
+ lo: start,
7706
+ hi: end,
7707
+ text,
7708
+ blockSeq
7709
+ });
7710
+ } else if (el.nodeName === "reasoning") entries.push({
7711
+ kind: "reasoning",
7712
+ text,
7713
+ blockSeq
7714
+ });
7715
+ }
7716
+ if (entries.length === 0) return opaque();
7717
+ return entries;
7718
+ }
7719
+ /**
7720
+ * 反思视图:全部 <history> 块(historySection 收集,按表层顺序)的内条目投影为条目。
7721
+ * 要求区间为全部块内条目引用的最小 / 最大 index;块解析失败降级为不可定位遗留条目;
7722
+ * skipReasoning 时不含 <reasoning> 参考条目。
7723
+ */
7724
+ function buildReflectView(blocks, options = {}) {
7725
+ const entries = [];
7726
+ for (const block of blocks) entries.push(...parseBlockEntries(block.text, block.seq));
7727
+ const filtered = options.skipReasoning === true ? entries.filter((entry) => entry.kind !== "reasoning") : entries;
7728
+ return {
7729
+ entries: filtered,
7730
+ ...viewBounds(filtered)
7731
+ };
7732
+ }
7733
+ /**
7734
+ * 把一个视图条目构建为 XML 元素(文本经 DOM 文本节点自动转义;user 条目的注释
7735
+ * 输出为 XML 注释节点)。getHistory 输出与最终 <history> 块共用。
7736
+ */
7737
+ function entryToElement(doc, entry) {
7738
+ if (entry.kind === "user") {
7739
+ const el = doc.createElement("user_message");
7740
+ if (entry.lo !== void 0) el.setAttribute("index", String(entry.lo));
7741
+ el.appendChild(doc.createTextNode(entry.text));
7742
+ for (const note of entry.notes ?? []) el.appendChild(doc.createComment(note));
7743
+ return el;
7744
+ }
7745
+ if (entry.kind === "sys") {
7746
+ const el = doc.createElement("sys");
7747
+ el.setAttribute("type", entry.sysKind ?? "");
7748
+ if (entry.lo !== void 0) el.setAttribute("index", String(entry.lo));
7749
+ el.appendChild(doc.createTextNode(""));
7750
+ return el;
7751
+ }
7752
+ if (entry.kind === "reasoning") {
7753
+ const el = doc.createElement("reasoning");
7754
+ el.appendChild(doc.createTextNode(entry.text));
7755
+ return el;
7756
+ }
7757
+ const el = doc.createElement("assistant");
7758
+ if (entry.lo !== void 0 && entry.hi !== void 0 && entry.lo === entry.hi) el.setAttribute("index", String(entry.lo));
7759
+ else if (entry.lo !== void 0 && entry.hi !== void 0) {
7760
+ el.setAttribute("start", String(entry.lo));
7761
+ el.setAttribute("end", String(entry.hi));
7762
+ }
7763
+ el.appendChild(doc.createTextNode(entry.text));
7764
+ return el;
7765
+ }
7766
+ /**
7767
+ * 渲染条目序列为 XML 文本(无 <history> 包裹,条目逐行拼接)——getHistory 的输出形式。
7768
+ */
7769
+ function renderEntriesXml(entries) {
7770
+ if (entries.length === 0) return "";
7771
+ const doc = newQuietParser().parseFromString("<root />", "text/xml");
7808
7772
  const serializer = new import_lib.XMLSerializer();
7809
- return `<${HISTORY_TAG}>\n${entries.map((el) => serializer.serializeToString(el)).join("\n")}\n</${HISTORY_TAG}>`;
7773
+ return entries.map((entry) => serializer.serializeToString(entryToElement(doc, entry))).join("\n");
7774
+ }
7775
+ /** 压缩会话的工具状态:视图 + 替换记录 + skill 二次确认标记 + 完成标记。 */
7776
+ var CompressionState = class CompressionState {
7777
+ view;
7778
+ replacements = [];
7779
+ challengedSkills = /* @__PURE__ */ new Set();
7780
+ _completed = false;
7781
+ /**
7782
+ * @param view 压缩视图(观察或反思),工具校验与最终块构建的数据源。
7783
+ */
7784
+ constructor(view) {
7785
+ this.view = view;
7786
+ }
7787
+ /** 已成功应用的替换次数(0 表示空提交)。 */
7788
+ get replacementCount() {
7789
+ return this.replacements.length;
7790
+ }
7791
+ /** 是否已调用 completeCompression。 */
7792
+ get completed() {
7793
+ return this._completed;
7794
+ }
7795
+ /**
7796
+ * 执行一次压缩工具调用:解析 JSON 参数并按工具名分发。
7797
+ * 未知工具名或参数非法 JSON 返回错误结果,不抛出。
7798
+ */
7799
+ executeCall(name, rawArgs) {
7800
+ if (name === "getHistory" || name === "compressHistory" || name === "completeCompression") {
7801
+ let args;
7802
+ if (typeof rawArgs === "string" && rawArgs.trim() === "") args = {};
7803
+ else try {
7804
+ args = JSON.parse(rawArgs);
7805
+ } catch {
7806
+ return {
7807
+ text: `参数不是合法的 JSON:${rawArgs.slice(0, 200)}`,
7808
+ isError: true
7809
+ };
7810
+ }
7811
+ const record = args ?? {};
7812
+ if (name === "getHistory") return this.getHistory(record);
7813
+ if (name === "compressHistory") return this.compressHistory(record);
7814
+ return this.complete();
7815
+ }
7816
+ return {
7817
+ text: `未知工具 ${name}(可用:getHistory / compressHistory / completeCompression)`,
7818
+ isError: true
7819
+ };
7820
+ }
7821
+ /** 解析可选数值参数(缺省 undefined;非有限数返回 NaN)。 */
7822
+ optionalNumber(value) {
7823
+ if (value === void 0 || value === null) return void 0;
7824
+ if (typeof value !== "number" || !Number.isFinite(value)) return NaN;
7825
+ return Math.floor(value);
7826
+ }
7827
+ /**
7828
+ * getHistory:返回 [start..end] 区间内的条目(压缩视图,无 <history> 包裹)。
7829
+ * start/end 缺省为要求区间首尾;越界或 start > end 返回错误。区间切入已有压缩
7830
+ * 块(反思视图)时返回该块全部条目。
7831
+ */
7832
+ getHistory(args) {
7833
+ const { minIndex, maxIndex } = this.view;
7834
+ if (minIndex === void 0 || maxIndex === void 0) return {
7835
+ text: "当前压缩区间没有可定位的条目",
7836
+ isError: true
7837
+ };
7838
+ const startRaw = this.optionalNumber(args.start);
7839
+ const endRaw = this.optionalNumber(args.end);
7840
+ if (startRaw !== void 0 && Number.isNaN(startRaw)) return {
7841
+ text: "start 必须是数字",
7842
+ isError: true
7843
+ };
7844
+ if (endRaw !== void 0 && Number.isNaN(endRaw)) return {
7845
+ text: "end 必须是数字",
7846
+ isError: true
7847
+ };
7848
+ const start = startRaw ?? minIndex;
7849
+ const end = endRaw ?? maxIndex;
7850
+ if (start < minIndex || start > maxIndex) return {
7851
+ text: `start ${start} 越界(要求区间 [${minIndex}..${maxIndex}])`,
7852
+ isError: true
7853
+ };
7854
+ if (end < minIndex || end > maxIndex) return {
7855
+ text: `end ${end} 越界(要求区间 [${minIndex}..${maxIndex}])`,
7856
+ isError: true
7857
+ };
7858
+ if (start > end) return {
7859
+ text: `start ${start} 不能大于 end ${end}`,
7860
+ isError: true
7861
+ };
7862
+ const intersects = (entry) => entry.lo !== void 0 && entry.hi !== void 0 && entry.lo <= end && entry.hi >= start;
7863
+ const expandedBlocks = /* @__PURE__ */ new Set();
7864
+ for (const entry of this.view.entries) if (entry.blockSeq !== void 0 && intersects(entry)) expandedBlocks.add(entry.blockSeq);
7865
+ const selected = this.view.entries.filter((entry) => entry.blockSeq !== void 0 ? expandedBlocks.has(entry.blockSeq) : intersects(entry));
7866
+ if (selected.length === 0) return {
7867
+ text: `区间 [${start}..${end}] 没有条目`,
7868
+ isError: false
7869
+ };
7870
+ return {
7871
+ text: `<!-- 完整消息区间 [${start}..${end}],共 ${selected.length} 条 -->\n${renderEntriesXml(selected)}`,
7872
+ isError: false
7873
+ };
7874
+ }
7875
+ /** 区间的简短描述(单条 index / 区间 start..end)。 */
7876
+ static spanLabel(lo, hi) {
7877
+ return lo === hi ? `完整消息 index ${lo}` : `完整消息区间 [${lo}..${hi}]`;
7878
+ }
7879
+ /**
7880
+ * compressHistory:把 index 单条或 start..end 连续区间的 assistant 条目替换为
7881
+ * content 摘要。校验失败返回错误结果(不应用);skill 块首次被覆盖返回要求
7882
+ * 重新思考的错误结果(不应用,标记已挑战);通过后记录替换并覆盖被完全包含
7883
+ * 的旧替换。
7884
+ */
7885
+ compressHistory(args) {
7886
+ const { minIndex, maxIndex } = this.view;
7887
+ if (minIndex === void 0 || maxIndex === void 0) return {
7888
+ text: "当前压缩区间没有可定位的条目",
7889
+ isError: true
7890
+ };
7891
+ const content = args.content;
7892
+ if (typeof content !== "string" || content.trim() === "") return {
7893
+ text: "content 必须是非空摘要文本",
7894
+ isError: true
7895
+ };
7896
+ const hasIndex = args.index !== void 0 && args.index !== null;
7897
+ const hasStart = args.start !== void 0 && args.start !== null;
7898
+ const hasEnd = args.end !== void 0 && args.end !== null;
7899
+ if (hasIndex && (hasStart || hasEnd)) return {
7900
+ text: "index 与 start/end 不能同时提供",
7901
+ isError: true
7902
+ };
7903
+ if (hasStart !== hasEnd) return {
7904
+ text: "start 与 end 必须成对提供",
7905
+ isError: true
7906
+ };
7907
+ let lo;
7908
+ let hi;
7909
+ if (hasIndex) {
7910
+ const index = args.index;
7911
+ if (typeof index !== "number" || !Number.isInteger(index) || index < 0) return {
7912
+ text: "index 必须是非负整数",
7913
+ isError: true
7914
+ };
7915
+ lo = index;
7916
+ hi = index;
7917
+ } else if (hasStart) {
7918
+ const start = args.start;
7919
+ const end = args.end;
7920
+ if (typeof start !== "number" || typeof end !== "number" || !Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < 0) return {
7921
+ text: "start 与 end 必须是非负整数",
7922
+ isError: true
7923
+ };
7924
+ if (start > end) return {
7925
+ text: `start ${start} 不能大于 end ${end}`,
7926
+ isError: true
7927
+ };
7928
+ lo = start;
7929
+ hi = end;
7930
+ } else return {
7931
+ text: "index 与 start/end 至少提供一个",
7932
+ isError: true
7933
+ };
7934
+ if (lo < minIndex || hi > maxIndex) return {
7935
+ text: `${CompressionState.spanLabel(lo, hi)} 超出要求的压缩区间 [${minIndex}..${maxIndex}]`,
7936
+ isError: true
7937
+ };
7938
+ let insideCount = 0;
7939
+ for (const entry of this.view.entries) {
7940
+ if (entry.lo === void 0 || entry.hi === void 0) continue;
7941
+ if (entry.lo > hi || entry.hi < lo) continue;
7942
+ if (entry.kind === "user") return {
7943
+ text: `${CompressionState.spanLabel(lo, hi)} 覆盖用户消息(index ${entry.lo}),用户消息不可压缩`,
7944
+ isError: true
7945
+ };
7946
+ if (entry.kind === "sys") return {
7947
+ text: `${CompressionState.spanLabel(lo, hi)} 覆盖系统消息(index ${entry.lo}),系统消息不可压缩`,
7948
+ isError: true
7949
+ };
7950
+ if (entry.kind === "assistant" && !(entry.lo >= lo && entry.hi <= hi)) return {
7951
+ text: `${CompressionState.spanLabel(lo, hi)} 与 assistant 条目 [${entry.lo}..${entry.hi}] 部分重叠,需完整包含或不相交`,
7952
+ isError: true
7953
+ };
7954
+ if (entry.kind === "assistant") insideCount += 1;
7955
+ }
7956
+ if (insideCount === 0) return {
7957
+ text: `${CompressionState.spanLabel(lo, hi)} 内没有可压缩的 assistant 条目`,
7958
+ isError: true
7959
+ };
7960
+ for (const rep of this.replacements) if (rep.lo <= hi && rep.hi >= lo && !(rep.lo >= lo && rep.hi <= hi)) return {
7961
+ text: `${CompressionState.spanLabel(lo, hi)} 与已有替换区间 [${rep.lo}..${rep.hi}] 部分重叠,只能完全包含或不相交`,
7962
+ isError: true
7963
+ };
7964
+ const unchallenged = [];
7965
+ for (const entry of this.view.entries) {
7966
+ if (entry.kind !== "assistant" || entry.toolName !== "skill") continue;
7967
+ if (entry.lo === void 0 || entry.lo < lo || entry.lo > hi) continue;
7968
+ if (!this.challengedSkills.has(entry.lo)) unchallenged.push(entry.lo);
7969
+ }
7970
+ if (unchallenged.length > 0) {
7971
+ for (const index of unchallenged) this.challengedSkills.add(index);
7972
+ return {
7973
+ text: `${CompressionState.spanLabel(lo, hi)} 包含 skill 加载(完整消息 index ${unchallenged.join("、")})。请重新思考该 skill 是否确定与后续任务无关:确定不相关时再次调用 compressHistory 压缩该区间;不确定或相关时不要压缩该区间,保持原样即可`,
7974
+ isError: true
7975
+ };
7976
+ }
7977
+ for (let i = this.replacements.length - 1; i >= 0; i -= 1) {
7978
+ const rep = this.replacements[i];
7979
+ if (rep === void 0) continue;
7980
+ if (rep.lo >= lo && rep.hi <= hi) this.replacements.splice(i, 1);
7981
+ }
7982
+ this.replacements.push({
7983
+ lo,
7984
+ hi,
7985
+ content
7986
+ });
7987
+ return {
7988
+ text: `已压缩${CompressionState.spanLabel(lo, hi)}(${insideCount} 条 assistant 条目)`,
7989
+ isError: false
7990
+ };
7991
+ }
7992
+ /** completeCompression:标记压缩完成(调用后压缩会话立即停止)。 */
7993
+ complete() {
7994
+ this._completed = true;
7995
+ return {
7996
+ text: "压缩完成",
7997
+ isError: false
7998
+ };
7999
+ }
8000
+ /**
8001
+ * 构建最终 <history> 块:按 index 顺序合并视图条目与替换记录——user / sys 条目
8002
+ * 原样、被替换区间生成 <assistant index|start end> 摘要条目(content 转义嵌入)、
8003
+ * 未替换 assistant 条目原样保留、reasoning 不进产物;块首为格式说明注释,开标签
8004
+ * 携带 tip 属性。产物为合法 XML,无需校验。
8005
+ */
8006
+ buildFinalBlock() {
8007
+ const doc = new import_lib.DOMParser({ onError: () => {} }).parseFromString(`<${HISTORY_TAG} />`, "text/xml");
8008
+ const root = doc.documentElement;
8009
+ if (!root) return "";
8010
+ const coveredBy = (entry) => entry.lo !== void 0 && entry.hi !== void 0 && this.replacements.some((rep) => entry.lo !== void 0 && entry.hi !== void 0 && entry.lo >= rep.lo && entry.hi <= rep.hi);
8011
+ const reps = [...this.replacements].sort((a, b) => a.lo - b.lo);
8012
+ let nextRep = 0;
8013
+ const emitReplacement = (rep) => {
8014
+ const el = doc.createElement("assistant");
8015
+ if (rep.lo === rep.hi) el.setAttribute("index", String(rep.lo));
8016
+ else {
8017
+ el.setAttribute("start", String(rep.lo));
8018
+ el.setAttribute("end", String(rep.hi));
8019
+ }
8020
+ el.appendChild(doc.createTextNode(rep.content));
8021
+ root.appendChild(el);
8022
+ };
8023
+ const flushReplacementsBefore = (lo) => {
8024
+ while (nextRep < reps.length) {
8025
+ const rep = reps[nextRep];
8026
+ if (rep === void 0) break;
8027
+ if (lo !== void 0 && rep.lo >= lo) break;
8028
+ emitReplacement(rep);
8029
+ nextRep += 1;
8030
+ }
8031
+ };
8032
+ for (const entry of this.view.entries) {
8033
+ if (entry.kind === "reasoning") continue;
8034
+ if (coveredBy(entry)) continue;
8035
+ flushReplacementsBefore(entry.lo);
8036
+ root.appendChild(entryToElement(doc, entry));
8037
+ }
8038
+ flushReplacementsBefore(void 0);
8039
+ const serializer = new import_lib.XMLSerializer();
8040
+ const inner = Array.from(root.childNodes).map((node) => serializer.serializeToString(node)).join("\n");
8041
+ return `<${HISTORY_TAG} tip="${HISTORY_TIP}">\n${HISTORY_FORMAT_NOTE}\n${inner}\n</${HISTORY_TAG}>`;
8042
+ }
8043
+ };
8044
+ /** 压缩会话工具的 wire 定义(GenerateOptions.tools)。 */
8045
+ const COMPRESSION_TOOL_SCHEMAS = [
8046
+ {
8047
+ name: "getHistory",
8048
+ description: "查看压缩区间内的历史条目。返回压缩视图:已被压缩的内容以摘要条目呈现,区间切入已压缩块时返回该块全部条目。start/end 缺省为要求区间的第一个/最后一个完整消息 index,必须在要求区间内。",
8049
+ parameters: {
8050
+ type: "object",
8051
+ properties: {
8052
+ start: {
8053
+ type: "number",
8054
+ description: "区间起始完整消息 index(缺省为要求区间第一个)"
8055
+ },
8056
+ end: {
8057
+ type: "number",
8058
+ description: "区间结束完整消息 index(缺省为要求区间最后一个)"
8059
+ }
8060
+ }
8061
+ }
8062
+ },
8063
+ {
8064
+ name: "compressHistory",
8065
+ description: "把 index 单条或 start..end 连续区间的 assistant 类条目(模型输出文本、工具调用)替换为 content 摘要。index 与 start/end 二选一,start 与 end 成对提供且 start==end 等同 index。区间不得覆盖用户消息或系统消息,不得与已有替换区间部分重叠(完全包含则覆盖)。用户消息与系统消息保持原样,无需处理。",
8066
+ parameters: {
8067
+ type: "object",
8068
+ properties: {
8069
+ index: {
8070
+ type: "number",
8071
+ description: "单条完整消息 index"
8072
+ },
8073
+ start: {
8074
+ type: "number",
8075
+ description: "区间起始完整消息 index(与 end 成对提供)"
8076
+ },
8077
+ end: {
8078
+ type: "number",
8079
+ description: "区间结束完整消息 index(与 start 成对提供)"
8080
+ },
8081
+ content: {
8082
+ type: "string",
8083
+ description: "替换后的摘要文本(纯文本)"
8084
+ }
8085
+ },
8086
+ required: ["content"]
8087
+ }
8088
+ },
8089
+ {
8090
+ name: "completeCompression",
8091
+ description: "全部压缩完成后调用,立即结束压缩会话。未压缩的条目将原样保留,允许不压缩任何内容直接完成。",
8092
+ parameters: {
8093
+ type: "object",
8094
+ properties: {}
8095
+ }
8096
+ }
8097
+ ];
8098
+ //#endregion
8099
+ //#region src/rate-limit.ts
8100
+ /**
8101
+ * 全局限流门(插件进程级共享状态):任一摘要请求遇 429 后进入冷却期,
8102
+ * 此后所有摘要请求发出前先等待到「最近一次 429 + rateLimitWaitMs」之后。
8103
+ * 等待期间 signal 中止则立即放弃;等待期间的新 429 顺延冷却期。
8104
+ * 导出 isRateLimitError / noteRateLimit / gateRateLimit / RATE_LIMIT_WAIT_MS_DEFAULT /
8105
+ * resetRateLimitGate。
8106
+ */
8107
+ /** 最近一次 429 限流的时间戳(ms;null = 未遇过限流,门直接放行)。 */
8108
+ let lastRateLimitAt = null;
8109
+ /** 判定错误信息是否为 429 限流(匹配 429 或 rate limit,大小写不敏感)。 */
8110
+ function isRateLimitError(message) {
8111
+ return /\b429\b|rate[\s_-]?limit/i.test(message);
8112
+ }
8113
+ /** 记录一次 429 限流:把冷却期起点更新为当前时间。 */
8114
+ function noteRateLimit() {
8115
+ lastRateLimitAt = Date.now();
8116
+ }
8117
+ /** 可中止延时:等待满 ms 返回 true;等待期间 signal 中止返回 false。 */
8118
+ function delay(ms, signal) {
8119
+ return new Promise((resolve) => {
8120
+ if (signal?.aborted) {
8121
+ resolve(false);
8122
+ return;
8123
+ }
8124
+ const timer = setTimeout(() => {
8125
+ cleanup();
8126
+ resolve(true);
8127
+ }, ms);
8128
+ const onAbort = () => {
8129
+ cleanup();
8130
+ resolve(false);
8131
+ };
8132
+ const cleanup = () => {
8133
+ clearTimeout(timer);
8134
+ signal?.removeEventListener("abort", onAbort);
8135
+ };
8136
+ signal?.addEventListener("abort", onAbort, { once: true });
8137
+ });
8138
+ }
8139
+ /**
8140
+ * 限流等待门:处于 429 冷却期时等待到期限再放行(返回 true);
8141
+ * 等待期间 signal 中止返回 false。未遇过限流或冷却期已过立即放行。waitMs ≤ 0 视为不限流。
8142
+ */
8143
+ async function gateRateLimit(waitMs, signal) {
8144
+ if (waitMs <= 0) return true;
8145
+ while (lastRateLimitAt !== null) {
8146
+ const remaining = lastRateLimitAt + waitMs - Date.now();
8147
+ if (remaining <= 0) return true;
8148
+ if (!await delay(remaining, signal)) return false;
8149
+ }
8150
+ return true;
8151
+ }
8152
+ //#endregion
8153
+ //#region src/compress-loop.ts
8154
+ /**
8155
+ * 工具驱动的压缩循环:以新会话方式直连 ctx.llm.stream(),模型通过 getHistory /
8156
+ * compressHistory / completeCompression 三个工具完成压缩,替代直出 <history> 块。
8157
+ * 导出 runCompressionLoop / buildCompressionPrompt / buildCompressionTaskText /
8158
+ * CompressionLoopOptions / CompressionOutcome / COMPRESSION_NUDGE_TEXT。
8159
+ *
8160
+ * - 首条 user 消息仅含压缩指令与 start/end 区间(buildCompressionTaskText),不含
8161
+ * 历史消息内容;共享压缩提示词作为 system
8162
+ * - 每轮请求携带压缩工具 schemas(purpose='compaction',maxTokens 沿用配置);流经
8163
+ * BlockAssembler 组装为 assistant 消息,工具执行结果以 tool-result 消息回填
8164
+ * - completeCompression 调用后立即停止(同轮后续工具调用不再执行),最终 <history>
8165
+ * 块由 CompressionState 构建,全程无需整块校验
8166
+ * - 模型输出纯文本(无工具调用)时追加提醒消息继续,连续 2 轮仍无工具调用判失败
8167
+ * - 429 限流走全局限流等待门(gateRateLimit / noteRateLimit);其余请求级错误依赖
8168
+ * dsh 运行时重试,插件不做整体重试,错误直接判失败
8169
+ * - signal 中止标记 aborted;token usage 汇总全部轮次
8170
+ * - 成功与失败均把循环消息组原样落盘为子会话(recordCompressionSession),成功记
8171
+ * 录 sessionId 于日志,失败记录作为诊断子会话 id 向上传播
8172
+ */
8173
+ /**
8174
+ * 共享压缩提示词(观察/反思同一套):完整消息定义、工具语义、压缩要求、skill 规则
8175
+ * 与提交方式。作为压缩会话的 system;skipReasoning=true 时省略 <reasoning> 说明行。
8176
+ */
8177
+ function buildCompressionPrompt(skipReasoning) {
8178
+ return [
8179
+ "压缩历史消息为摘要。你应当通过工具查看、压缩并完成提交。",
8180
+ "",
8181
+ `【完整消息定义】${COMPLETE_MESSAGE_DEFINITION}`,
8182
+ "",
8183
+ "【工具】",
8184
+ "- getHistory(option?: {start?, end?}):查看压缩区间内的历史条目。start/end 缺省为要求区间的第一个/最后一个完整消息 index,必须在要求区间内。返回压缩视图:已压缩内容以摘要条目呈现,区间切入已压缩块时返回整块,不带 <history> 包裹。",
8185
+ "- compressHistory(option?: {index?, start?, end?, content}):把 index 单条或 start..end 连续区间的 assistant 类条目替换为 content 摘要(纯文本)。index 与 start/end 二选一,start==end 等同 index。区间不得覆盖用户消息或系统消息;与已有替换区间部分重叠会被拒绝,完全包含则覆盖。",
8186
+ "- completeCompression():全部压缩完成后调用,立即结束。",
8187
+ "",
8188
+ "【压缩要求】",
8189
+ "- 先用 getHistory 查看区间内容,再划分模块分批压缩;用户消息与系统消息不可压缩、保持原样,无需处理。",
8190
+ ...skipReasoning ? [] : ["- <reasoning> 仅作压缩参考,不进产物。"],
8191
+ "- 将具有关联性的 assistant 消息按内在逻辑连贯性划分为连续模块,聚合为区间压缩(start..end):content 描述模块的目的、行为与结果;涉及的具体文件保留在内容中,多个前缀相同的路径合并简写。",
8192
+ "- 单条重要的完整消息以 index 单独压缩,内容不受限制。",
8193
+ "- 摘要粒度越往后越细:靠近末尾(最近)的完整消息保留更多细节(关键文件、改动与结论),开头(较早)的完整消息可适当从简。",
8194
+ "- 加载的 skill 属于关键信息(通过工具名为 skill 的调用识别)。不能判断该 skill 是否与后续任务相关时,不要压缩它:首次尝试压缩 skill 块会被要求再次思考,确定不相关才继续,其余情况保持原样。",
8195
+ "- 未压缩的条目将原样保留;宁可保留也不要强行压缩不确定的内容。",
8196
+ "- 全部完成后调用 completeCompression 结束;不要输出与工具调用无关的文本。"
8197
+ ].join("\n");
7810
8198
  }
7811
- /** 流收集器:提取文本输出 + usage + finish(不依赖宿主 BlockAssembler)。 */
7812
- var StreamCollector = class {
7813
- textBuf = "";
7814
- _usage;
7815
- _finish;
7816
- /** 喂入一个流 chunk(仅消费文本/usage/finish,其余忽略)。 */
7817
- push(chunk) {
7818
- switch (chunk.type) {
7819
- case "text-delta":
7820
- this.textBuf += chunk.text;
7821
- break;
7822
- case "usage":
7823
- this._usage = chunk.usage;
7824
- break;
7825
- case "finish": this._finish = chunk.reason;
7826
- }
7827
- }
7828
- /** 拼接后的文本输出。 */
7829
- get text() {
7830
- return this.textBuf;
7831
- }
7832
- /** 摘要 token usage(无则 undefined)。 */
7833
- get usage() {
7834
- return this._usage;
7835
- }
7836
- /** 终止原因(流未给出 finish 时视为 stop)。 */
7837
- get finish() {
7838
- return this._finish ?? { kind: "stop" };
7839
- }
7840
- };
7841
- /** 构造插件自产 user 消息(摘要调用的输入消息;id 为品牌类型 MessageId)。 */
8199
+ /**
8200
+ * 构建压缩会话首条 user 消息文本(压缩指令 + start/end 区间,不含历史消息内容)。
8201
+ */
8202
+ function buildCompressionTaskText(phase, start, end) {
8203
+ if (phase === "reflect") return `合并压缩全部 <history> 块,完整消息区间 [${start}..${end}]:用 getHistory 查看已有块条目,用 compressHistory 重新压缩合并 assistant 条目,完成后调用 completeCompression。`;
8204
+ return `压缩完整消息区间 [${start}..${end}]:用 getHistory 查看条目,用 compressHistory 分批压缩 assistant 条目,完成后调用 completeCompression。`;
8205
+ }
8206
+ /** 模型输出纯文本(无工具调用)时追加的提醒消息文本。 */
8207
+ const COMPRESSION_NUDGE_TEXT = "请通过工具执行压缩:用 getHistory 查看区间条目,用 compressHistory 压缩 assistant 条目,完成后调用 completeCompression。不要输出与工具调用无关的文本。";
8208
+ /** 构造插件自产 user 消息(压缩指令/提醒;id 为品牌类型 MessageId)。 */
7842
8209
  function makePluginUserMessage(text) {
7843
8210
  return {
7844
8211
  id: uuid(),
@@ -7853,351 +8220,277 @@ function makePluginUserMessage(text) {
7853
8220
  }
7854
8221
  };
7855
8222
  }
8223
+ /** 把工具执行结果封装为 tool-result 消息(回填到压缩会话)。 */
8224
+ function toolResultOf(call, result) {
8225
+ return createToolResultMessage({
8226
+ callId: call.id,
8227
+ content: [{
8228
+ type: "text",
8229
+ text: result.text
8230
+ }],
8231
+ isError: result.isError
8232
+ });
8233
+ }
7856
8234
  /**
7857
- * 构建摘要请求选项:指令作为 system,渲染输入作为唯一的 user 消息,
7858
- * 不沿用主会话请求前缀(前缀复用需模型自行计数,导致索引异常)。
8235
+ * 把本轮已组装的部分 assistant 输出计入会话记录(error 终态时的诊断价值)。
8236
+ * 无任何文本/工具调用块(空流)时不追加。
7859
8237
  */
7860
- function buildSummaryOptions(session, instruction, contextText, maxTokens, target, signal) {
7861
- return {
8238
+ function pushPartialAssistant(assembler, messages, target) {
8239
+ const blocks = assembler.blocks();
8240
+ if (blocks.length === 0) return;
8241
+ if (blocks.every((block) => block.type === "text" && block.text.trim() === "")) return;
8242
+ messages.push(assembler.message({
8243
+ kind: "model",
7862
8244
  provider: target.provider,
7863
- model: target.model,
7864
- ...maxTokens === void 0 ? {} : { maxTokens },
7865
- sessionId: session.id,
7866
- purpose: "compaction",
7867
- ...signal === void 0 ? {} : { signal },
7868
- system: instruction,
7869
- messages: [makePluginUserMessage(contextText ?? "")]
7870
- };
8245
+ model: target.model
8246
+ }));
7871
8247
  }
7872
- /** 产出日志后插入首个 <history> 后的格式说明(XML 注释,完整消息定义 + 条目标签语义)。 */
7873
- const HISTORY_FORMAT_NOTE = `<!-- 完整消息:${COMPLETE_MESSAGE_DEFINITION} <TAG index="N">表示单条完整消息,<TAG start="A" end="B"> 表示连续模块,start/end 是首尾完整消息的 index;<sys type="KIND" index="N"> 表示被压缩的系统消息,块中为空 -->`;
7874
- /**
7875
- * 剥离 <history> 块内文块首的格式说明注释(HISTORY_FORMAT_NOTE 整体精确匹配,仅块首
7876
- * 一处);正文条目内出现的同名注释串不动。非块首或不匹配时原样返回。
7877
- */
7878
- function stripLeadingFormatNote(inner) {
7879
- if (!inner.startsWith(HISTORY_FORMAT_NOTE)) return inner;
7880
- return inner.slice(HISTORY_FORMAT_NOTE.length).replace(/^\s+/, "");
8248
+ /** 累加两份 token usage(可选字段任一存在即求和保留)。 */
8249
+ function addUsage(total, add) {
8250
+ if (total === void 0) return { ...add };
8251
+ const sum = (a, b) => a === void 0 && b === void 0 ? void 0 : (a ?? 0) + (b ?? 0);
8252
+ const usage = {
8253
+ inputTokens: total.inputTokens + add.inputTokens,
8254
+ outputTokens: total.outputTokens + add.outputTokens
8255
+ };
8256
+ const cacheRead = sum(total.cacheReadTokens, add.cacheReadTokens);
8257
+ const cacheWrite = sum(total.cacheWriteTokens, add.cacheWriteTokens);
8258
+ const reasoning = sum(total.reasoningTokens, add.reasoningTokens);
8259
+ if (cacheRead !== void 0) usage.cacheReadTokens = cacheRead;
8260
+ if (cacheWrite !== void 0) usage.cacheWriteTokens = cacheWrite;
8261
+ if (reasoning !== void 0) usage.reasoningTokens = reasoning;
8262
+ return usage;
7881
8263
  }
7882
- /** 读取元素整数属性(非负整数;缺失 / 非数字返回 undefined)。 */
7883
- function intAttr(el, name) {
7884
- const raw = el.getAttribute(name);
7885
- if (raw === null || raw === "") return void 0;
7886
- if (!/^\d+$/.test(raw)) return void 0;
7887
- return Number(raw);
8264
+ /** 是否为 429 限流失败(异常消息或终态 failure 载荷)。 */
8265
+ function isRateLimitFailure(message, status) {
8266
+ return status === 429 || isRateLimitError(message);
7888
8267
  }
7889
8268
  /**
7890
- * 用 XML 解析器解析一个 <history> 块(结构合法性校验):
7891
- * 非法 XML / 根非 <history> / 顶层出现未定义元素 / 条目属性缺失或非法 → null;
7892
- * 出现 <reasoning> 元素时标记 hasReasoning(产物不允许)。
8269
+ * 运行工具驱动的压缩循环:直到模型调用 completeCompression(成功)、连续 2 轮无
8270
+ * 工具调用、请求级错误或 signal 中止(失败)。成功与失败均落盘循环会话记录。
7893
8271
  */
7894
- function parseHistoryBlock(xml) {
7895
- let doc;
7896
- try {
7897
- doc = newQuietParser().parseFromString(xml, "text/xml");
7898
- } catch {
7899
- return null;
7900
- }
7901
- const root = doc.documentElement;
7902
- if (!root || root.nodeName !== "history") return null;
7903
- const entries = [];
7904
- let hasReasoning = false;
7905
- const children = root.childNodes;
7906
- for (let i = 0; i < children.length; i += 1) {
7907
- const node = children[i];
7908
- if (node?.nodeType !== 1) continue;
7909
- const el = node;
7910
- const tag = el.nodeName;
7911
- if (tag === "reasoning") {
7912
- hasReasoning = true;
8272
+ async function runCompressionLoop(ctx, session, options) {
8273
+ const logger = makeLogger(ctx, options.debug);
8274
+ const state = new CompressionState(options.view);
8275
+ const messages = [makePluginUserMessage(options.taskText)];
8276
+ let rounds = 0;
8277
+ let nudges = 0;
8278
+ let usage;
8279
+ const recordSessionId = async (success) => recordCompressionSession(ctx, session, {
8280
+ phase: options.phase,
8281
+ target: options.target,
8282
+ messages,
8283
+ rounds,
8284
+ success,
8285
+ debug: options.debug
8286
+ });
8287
+ const failWith = async (error, aborted) => {
8288
+ const id = await recordSessionId(false);
8289
+ return {
8290
+ ok: false,
8291
+ error,
8292
+ aborted,
8293
+ ...id === void 0 ? {} : { recordSessionId: id }
8294
+ };
8295
+ };
8296
+ logger.step(`压缩循环开始(${options.phase === "reflect" ? "反思" : "观察"},provider ${options.target.provider},model ${options.target.model},maxTokens ${options.maxTokens === void 0 ? "未设置" : String(options.maxTokens)})`);
8297
+ for (;;) {
8298
+ if (options.signal?.aborted) {
8299
+ logger.warn("压缩循环中止(signal 已中止),放弃本次压缩");
8300
+ return await failWith(COMPACTION_ABORTED_ERROR, true);
8301
+ }
8302
+ if (!await gateRateLimit(options.rateLimitWaitMs, options.signal)) {
8303
+ logger.warn("压缩循环中止(限流等待被 signal 中止),放弃本次压缩");
8304
+ return await failWith(COMPACTION_ABORTED_ERROR, true);
8305
+ }
8306
+ const requestOptions = {
8307
+ provider: options.target.provider,
8308
+ model: options.target.model,
8309
+ ...options.maxTokens === void 0 ? {} : { maxTokens: options.maxTokens },
8310
+ sessionId: session.id,
8311
+ purpose: "compaction",
8312
+ ...options.signal === void 0 ? {} : { signal: options.signal },
8313
+ system: buildCompressionPrompt(options.skipReasoning),
8314
+ messages: [...messages],
8315
+ tools: COMPRESSION_TOOL_SCHEMAS
8316
+ };
8317
+ const assembler = new BlockAssembler();
8318
+ try {
8319
+ for await (const chunk of ctx.llm.stream(requestOptions)) assembler.push(chunk);
8320
+ } catch (error) {
8321
+ const message = error instanceof Error ? error.message : String(error);
8322
+ if (isRateLimitFailure(message)) {
8323
+ noteRateLimit();
8324
+ logger.warn(`压缩循环触发限流(429),等待 ${options.rateLimitWaitMs}ms 后重试`);
8325
+ continue;
8326
+ }
8327
+ logger.warn(`压缩循环请求失败: ${message}`);
8328
+ return await failWith(message, false);
8329
+ }
8330
+ const finish = assembler.finish;
8331
+ if (finish.kind === "aborted") {
8332
+ logger.warn("压缩循环中止(流以 aborted 结束),放弃本次压缩");
8333
+ return await failWith(COMPACTION_ABORTED_ERROR, true);
8334
+ }
8335
+ if (finish.kind === "error") {
8336
+ const failureMessage = typeof finish.failure?.message === "string" && finish.failure.message !== "" ? finish.failure.message : "流以 error 终态结束(无失败详情)";
8337
+ if (isRateLimitFailure(failureMessage, typeof finish.failure?.status === "number" ? finish.failure.status : void 0)) {
8338
+ noteRateLimit();
8339
+ logger.warn(`压缩循环触发限流(429),等待 ${options.rateLimitWaitMs}ms 后重试`);
8340
+ continue;
8341
+ }
8342
+ logger.warn(`压缩循环请求失败: ${failureMessage}`);
8343
+ pushPartialAssistant(assembler, messages, options.target);
8344
+ return await failWith(failureMessage, false);
8345
+ }
8346
+ rounds += 1;
8347
+ if (assembler.usage !== void 0) usage = addUsage(usage, assembler.usage);
8348
+ const assistantMessage = assembler.message({
8349
+ kind: "model",
8350
+ provider: options.target.provider,
8351
+ model: options.target.model
8352
+ });
8353
+ messages.push(assistantMessage);
8354
+ const calls = assembler.blocks().filter((block) => block.type === "tool-call");
8355
+ if (calls.length === 0) {
8356
+ nudges += 1;
8357
+ if (nudges >= 2) {
8358
+ const error = "模型连续 2 轮未调用压缩工具,放弃本次压缩";
8359
+ logger.warn(error);
8360
+ return await failWith(error, false);
8361
+ }
8362
+ logger.warn("模型未调用压缩工具(输出纯文本),追加提醒后继续");
8363
+ messages.push(makePluginUserMessage(COMPRESSION_NUDGE_TEXT));
7913
8364
  continue;
7914
8365
  }
7915
- if (tag === "user_message") {
7916
- const index = intAttr(el, "index");
7917
- if (index === void 0) return null;
7918
- entries.push({
7919
- kind: "user",
7920
- index
7921
- });
7922
- } else if (tag === "sys") {
7923
- const index = intAttr(el, "index");
7924
- if (index === void 0) return null;
7925
- entries.push({
7926
- kind: "sys",
7927
- index
7928
- });
7929
- } else if (tag === "assistant") {
7930
- const index = intAttr(el, "index");
7931
- if (index !== void 0) entries.push({
7932
- kind: "assistant",
7933
- index
7934
- });
7935
- else {
7936
- const start = intAttr(el, "start");
7937
- const end = intAttr(el, "end");
7938
- if (start === void 0 || end === void 0) return null;
7939
- entries.push({
7940
- kind: "assistant",
7941
- start,
7942
- end
7943
- });
8366
+ nudges = 0;
8367
+ let completed = false;
8368
+ for (const call of calls) {
8369
+ if (call.name === "completeCompression") {
8370
+ messages.push(toolResultOf(call, state.complete()));
8371
+ completed = true;
8372
+ break;
7944
8373
  }
7945
- } else return null;
8374
+ messages.push(toolResultOf(call, state.executeCall(call.name, call.arguments)));
8375
+ }
8376
+ if (completed) {
8377
+ const text = state.buildFinalBlock();
8378
+ if (state.replacementCount === 0) logger.warn("压缩完成但未执行任何压缩替换(空提交)");
8379
+ logger.info(`压缩循环完成(${rounds} 轮,${state.replacementCount} 次压缩替换,输出 ${text.length} 字符)`);
8380
+ const id = await recordSessionId(true);
8381
+ return {
8382
+ ok: true,
8383
+ text,
8384
+ rounds,
8385
+ ...usage === void 0 ? {} : { usage },
8386
+ ...id === void 0 ? {} : { recordSessionId: id }
8387
+ };
8388
+ }
7946
8389
  }
7947
- return {
7948
- entries,
7949
- hasReasoning
7950
- };
7951
8390
  }
7952
- /**
7953
- * 解析文本中全部 <history> 块内的条目(逐块解析提取,兼容多块拼接文本)。
7954
- * 非法块跳过;仅提取不校验顺序(连续性由 historyContinuity 校验)。
7955
- */
7956
- function parseHistoryEntries(text) {
7957
- const out = [];
7958
- for (const m of text.matchAll(/<history[\s\S]*?<\/history>/g)) {
7959
- const parsed = parseHistoryBlock(m[0]);
7960
- if (parsed === null) continue;
7961
- out.push(...parsed.entries);
8391
+ //#endregion
8392
+ //#region src/om-event.ts
8393
+ /** om 信封 text 前缀:标识该 feedback/record 记录由本插件写入。 */
8394
+ const OM_EVENT_PREFIX = "om:1:";
8395
+ /** 校验载荷字段与类别匹配(运行时守卫,保证返回类型的诚实性)。 */
8396
+ function isValidPayload(kind, data) {
8397
+ switch (kind) {
8398
+ case "om/warning": return typeof data.problem === "string" && typeof data.message === "string";
8399
+ case "om/observe-pending": return typeof data.triggerMessageIndex === "number" && Number.isSafeInteger(data.triggerMessageIndex);
8400
+ case "om/observe-invalidate": return typeof data.pendingSeq === "number" && Number.isSafeInteger(data.pendingSeq);
7962
8401
  }
7963
- return out;
7964
8402
  }
7965
8403
  /**
7966
- * 校验条目 index/start/end 连续性:每条给出覆盖区间,按出现顺序相邻条目必须首尾相接,
7967
- * 返回整体覆盖区间;空条目 / 非法范围 / 跳号、重叠或乱序 → 返回 null。
8404
+ * 解码一条会话事件为 om 私有事件:仅识别 feedback/record 中带 om 信封前缀的
8405
+ * text;前缀缺失、JSON 非法、kind 未知或载荷字段缺失时返回 undefined。
7968
8406
  */
7969
- function historyContinuity(entries) {
7970
- if (entries.length === 0) return null;
7971
- const ranges = [];
7972
- for (const e of entries) {
7973
- const lo = e.kind === "assistant" && e.start !== void 0 ? e.start : e.index ?? 0;
7974
- const hi = e.kind === "assistant" && e.end !== void 0 ? e.end : e.index ?? 0;
7975
- if (lo > hi) return null;
7976
- ranges.push({
7977
- lo,
7978
- hi
7979
- });
7980
- }
7981
- for (let i = 1; i < ranges.length; i += 1) {
7982
- const prev = ranges[i - 1];
7983
- const curr = ranges[i];
7984
- if (prev === void 0 || curr === void 0) return null;
7985
- if (curr.lo !== prev.hi + 1) return null;
8407
+ function readOmEvent(event) {
8408
+ if (event === void 0 || event === null || event.type !== "feedback/record") return void 0;
8409
+ const text = event.data?.text;
8410
+ if (typeof text !== "string" || !text.startsWith("om:1:")) return void 0;
8411
+ let envelope;
8412
+ try {
8413
+ envelope = JSON.parse(text.slice(5));
8414
+ } catch {
8415
+ return;
7986
8416
  }
7987
- const first = ranges[0];
7988
- const last = ranges[ranges.length - 1];
7989
- if (first === void 0 || last === void 0) return null;
8417
+ if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) return void 0;
8418
+ const { kind, ...rest } = envelope;
8419
+ if (typeof kind !== "string") return void 0;
8420
+ if (![
8421
+ "om/warning",
8422
+ "om/observe-pending",
8423
+ "om/observe-invalidate"
8424
+ ].includes(kind)) return void 0;
8425
+ const omKind = kind;
8426
+ if (!isValidPayload(omKind, rest)) return void 0;
7990
8427
  return {
7991
- start: first.lo,
7992
- end: last.hi
8428
+ kind: omKind,
8429
+ data: rest,
8430
+ seq: event.seq
7993
8431
  };
7994
8432
  }
7995
- /** <history> 开标签(允许携带属性):模糊定位输出中日志块起点。 */
7996
- const HISTORY_OPEN_TAG_RE = /<history(\s[^>]*)?>/;
7997
- /** history 块内条目标签的 token 正则(开 / 闭 / 自闭合):模糊提取按标签逐个扫描配对。 */
7998
- const HISTORY_ENTRY_TOKEN_RE = /<(\/)?(user_message|sys|assistant|reasoning)\b([^>]*?)(\/)?>/g;
7999
- /** 解析标签属性串为键值对(支持双引号 / 单引号 / 无引号取值;非法片段忽略)。 */
8000
- function parseTagAttrs(raw) {
8001
- const attrs = {};
8002
- for (const m of raw.matchAll(/([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) {
8003
- const key = m[1] ?? "";
8004
- const value = m[3] ?? m[4] ?? m[5] ?? "";
8005
- if (key !== "") attrs[key] = value;
8006
- }
8007
- return attrs;
8433
+ /** 编码一条 om 私有事件为 feedback/record 的 text 信封。 */
8434
+ function encodeOmEvent(kind, data) {
8435
+ return `${OM_EVENT_PREFIX}${JSON.stringify({
8436
+ kind,
8437
+ ...data
8438
+ })}`;
8008
8439
  }
8009
- /**
8010
- * 解码 XML 预定义实体(&amp; 最后解码,避免 &amp;lt; 之类被二次解码)。
8011
- * 模糊提取的条目文本解码后经 XML 序列化重新转义,已有转义形式不发生二次转义。
8012
- */
8013
- function decodeXmlEntities(text) {
8014
- return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&amp;/g, "&");
8440
+ /** 追加一条 om 私有事件(借用 feedback/record,log-only,不进 surface),返回事件 seq。 */
8441
+ function appendOmEvent(session, kind, data) {
8442
+ return session.append("feedback/record", { text: encodeOmEvent(kind, data) }).seq;
8015
8443
  }
8016
- /**
8017
- * 模糊重建 <history> 块(xmldom 原生无模糊匹配,按条目标签做字符串级扫描配对):
8018
- * 依次提取 user_message / sys / assistant / reasoning 条目——开闭标签就近配对、
8019
- * 自闭合直接成条、未闭合条目以文本末尾收口;未知元素与其间杂文忽略;条目文本
8020
- * 解码后重新序列化为合法 XML。扫描不到任何条目返回 null。
8021
- */
8022
- function rebuildHistoryBlock(inner) {
8023
- const entries = [];
8024
- const stack = [];
8025
- for (const m of inner.matchAll(HISTORY_ENTRY_TOKEN_RE)) {
8026
- const tag = m[2] ?? "";
8027
- if (m[1] === "/") for (let i = stack.length - 1; i >= 0; i -= 1) {
8028
- const open = stack[i];
8029
- if (open?.tag === tag) {
8030
- entries.push({
8031
- tag,
8032
- attrs: open.attrs,
8033
- content: inner.slice(open.contentFrom, m.index)
8034
- });
8035
- stack.splice(i, 1);
8036
- break;
8037
- }
8038
- }
8039
- else if (m[4] === "/") entries.push({
8040
- tag,
8041
- attrs: parseTagAttrs(m[3] ?? ""),
8042
- content: ""
8043
- });
8044
- else stack.push({
8045
- tag,
8046
- attrs: parseTagAttrs(m[3] ?? ""),
8047
- contentFrom: m.index + m[0].length
8048
- });
8444
+ function findOmEvents(session, kind) {
8445
+ const result = [];
8446
+ for (const event of session.events) {
8447
+ const om = readOmEvent(event);
8448
+ if (om !== void 0 && (kind === void 0 || om.kind === kind)) result.push(om);
8049
8449
  }
8050
- for (const open of stack) entries.push({
8051
- tag: open.tag,
8052
- attrs: open.attrs,
8053
- content: inner.slice(open.contentFrom)
8054
- });
8055
- if (entries.length === 0) return null;
8056
- const doc = newQuietParser().parseFromString(`<${HISTORY_TAG} />`, "text/xml");
8057
- const root = doc.documentElement;
8058
- if (!root) return null;
8059
- for (const e of entries) {
8060
- const el = doc.createElement(e.tag);
8061
- for (const [key, value] of Object.entries(e.attrs)) el.setAttribute(key, decodeXmlEntities(value));
8062
- el.appendChild(doc.createTextNode(decodeXmlEntities(e.content)));
8063
- root.appendChild(el);
8064
- }
8065
- return new import_lib.XMLSerializer().serializeToString(root);
8450
+ return result;
8066
8451
  }
8452
+ //#endregion
8453
+ //#region src/degrade.ts
8067
8454
  /**
8068
- * 从 AI 摘要输出中提取合法日志(不信任 AI 的总结结果):
8069
- * 取首个 <history> 开标签(允许带属性)到最后一个 </history> 切为候选块,
8070
- * 找不到或内容过短返回具体原因;候选块经 XML 结构校验,整块非法时按条目模糊
8071
- * 提取重建为合法块(不要求模型输出整体合法 XML);随后统一校验:无 reasoning、
8072
- * index 连续且与 expected 覆盖区间一致;通过后统一改写为带 tip 属性的开标签
8073
- * 并在块顶插入格式说明注释。所有失败原因均为说明性描述,不携带解析器原始报错。
8455
+ * 降级报告:压缩流程中的挂载失败统一出口。
8456
+ * 导出 reportDegrade / DEGRADE_PROBLEMS / DegradedProblem。
8457
+ *
8458
+ * - 挂载失败类问题(服务缺失、服务调用异常)始终 console.warn 到宿主进程外部输出,
8459
+ * 并向会话日志追加 log-only 的 om 警告事件(借用 feedback/record 的 om 信封,
8460
+ * 见 om-event.ts;客户端渲染为「功能降级」警告行);
8461
+ * 同一会话同一问题只报告一次(按日志扫描去重,重启不重复)
8462
+ * - 报告动作自身绝不抛错:追加失败只记日志,不阻塞压缩
8463
+ * - 辅助函数的普通运行时报错(组装失败、请求头读取失败)不走本模块,仅记日志
8074
8464
  */
8075
- function extractSummaryDetailed(raw, expected) {
8076
- const closeTag = `</${HISTORY_TAG}>`;
8077
- const openMatch = HISTORY_OPEN_TAG_RE.exec(raw);
8078
- const open = openMatch?.index ?? -1;
8079
- const close = raw.lastIndexOf(closeTag);
8080
- if (openMatch === null || close === -1 || close < open) return { error: "输出中找不到完整的 <history> 块(缺少 <history> 开标签或 </history> 闭标签)" };
8081
- const inner = raw.slice(open + openMatch[0].length, close);
8082
- if (inner.trim().length < 10) return { error: "输出中 <history> 块内容过短(少于 10 字符)" };
8083
- const block = raw.slice(open, close + closeTag.length);
8084
- const candidate = parseHistoryBlock(block) === null ? rebuildHistoryBlock(inner) : block;
8085
- if (candidate === null) return { error: "输出不是合法的 <history> 块(XML 结构非法,按条目模糊提取也未找到有效条目)" };
8086
- const parsed = parseHistoryBlock(candidate);
8087
- if (parsed === null) return { error: "输出不是合法的 <history> 块(条目结构非法:属性缺失或包含未定义元素)" };
8088
- if (parsed.hasReasoning) return { error: "输出包含 <reasoning> 条目(产物不允许携带思考过程)" };
8089
- const span = historyContinuity(parsed.entries);
8090
- if (span === null) return { error: "输出条目 index/start/end 不连续(跳号、重叠或乱序)" };
8091
- if (expected?.start !== void 0 && span.start !== expected.start) return { error: `输出覆盖区间从 ${span.start} 开始,与期望起始 ${expected.start} 不一致` };
8092
- if (expected?.end !== void 0 && span.end !== expected.end) return { error: `输出覆盖区间止于 ${span.end},与期望结束 ${expected.end} 不一致` };
8093
- const candidateInner = candidate.slice(candidate.indexOf(">") + 1, candidate.length - closeTag.length).trim();
8094
- return { log: `<${HISTORY_TAG} tip="${HISTORY_TIP}">\n${HISTORY_FORMAT_NOTE}\n${candidateInner}\n</${HISTORY_TAG}>` };
8095
- }
8465
+ /** 各降级问题面向用户的简短说明(om/warning 信封载荷 message;客户端警告行直接展示)。 */
8466
+ const DEGRADE_PROBLEMS = {
8467
+ "systemPrompt-missing": "系统提示词服务未挂载,上下文压力估算不扣除系统提示词 tokens(压缩触发会偏早)",
8468
+ "tokenMeter-unavailable": "token 计量服务异常,上下文压力估算降级(可能跳过压缩或按 0 计)"
8469
+ };
8096
8470
  /**
8097
- * 直连 LLM 执行一次摘要(观察或反思),返回文本与可选 token usage。
8098
- * 每次实际发出的 LLM 调用(无论成功、校验不通过、非 stop 结束还是异常)完成后,
8099
- * 立即把该次尝试的完整提示词与模型原始输出原样落盘为诊断子会话(phase 标注观察
8100
- * 或反思),子会话 id 写入该次尝试的主会话日志;请求发出前被中止的尝试(无实际
8101
- * 调用)不落盘。失败(抛异常 / 空输出 / 非 stop 结束 / 校验不通过)记录日志并重试;
8102
- * 全部尝试耗尽返回失败结果(携带最后一次尝试的实际报错/具体问题与其诊断子会话 id)。
8103
- * signal 中止(含限流等待被中止)立即放弃并标记 aborted。每次请求发出前先过全局
8104
- * 限流等待门。
8471
+ * 报告一次挂载失败类降级:console.warn 始终输出到宿主进程外部;同会话同问题首次
8472
+ * 出现时,向会话日志追加 log-only om 警告事件(客户端渲染警告行,每会话最多
8473
+ * 一次)并输出 logger.warn(避免每个 pre-step 重复刷日志)。
8474
+ * 日志扫描/事件追加失败只记日志,绝不抛错、不阻塞压缩。
8105
8475
  */
8106
- async function runSummarySubagent(ctx, agent, instruction, contextText, maxTokens, target, debug, signal, options) {
8107
- const session = agent.session;
8108
- const logger = makeLogger(ctx, debug);
8109
- const maxAttempts = options?.maxAttempts ?? 11;
8110
- let lastFailure = {};
8111
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
8112
- if (signal?.aborted) {
8113
- logger.warn(`摘要调用中止(第 ${attempt}/${maxAttempts} 次尝试前 signal 已中止),放弃本次压缩`);
8114
- return {
8115
- ok: false,
8116
- error: COMPACTION_ABORTED_ERROR,
8117
- aborted: true,
8118
- ...lastFailure.diagnosticSessionId === void 0 ? {} : { diagnosticSessionId: lastFailure.diagnosticSessionId }
8119
- };
8120
- }
8121
- const rateLimitWaitMs = options?.rateLimitWaitMs ?? 6e4;
8122
- if (!await gateRateLimit(rateLimitWaitMs, signal)) {
8123
- logger.warn(`摘要调用中止(第 ${attempt}/${maxAttempts} 次尝试前限流等待被 signal 中止),放弃本次压缩`);
8124
- return {
8125
- ok: false,
8126
- error: COMPACTION_ABORTED_ERROR,
8127
- aborted: true,
8128
- ...lastFailure.diagnosticSessionId === void 0 ? {} : { diagnosticSessionId: lastFailure.diagnosticSessionId }
8129
- };
8130
- }
8131
- logger.step(`摘要调用开始(第 ${attempt}/${maxAttempts} 次,provider ${target.provider},model ${target.model},maxTokens ${maxTokens === void 0 ? "未设置" : String(maxTokens)})`);
8132
- const prompt = `${instruction}\n\n${contextText ?? ""}`;
8133
- const collector = new StreamCollector();
8134
- let streamCompleted = false;
8135
- /** 本次尝试的诊断子会话 id(调用完成后立即落盘;落盘失败时缺失)。 */
8136
- const logAttempt = async () => recordCompactionAttempt(ctx, session, {
8137
- phase: options?.phase,
8138
- target,
8139
- attempt: {
8140
- prompt,
8141
- rawOutput: collector.text
8142
- },
8143
- attemptNo: attempt,
8144
- debug
8476
+ function reportDegrade(session, logger, problem) {
8477
+ const message = DEGRADE_PROBLEMS[problem];
8478
+ let first = true;
8479
+ try {
8480
+ first = !findOmEvents(session, "om/warning").some((om) => om.data.problem === problem);
8481
+ } catch {}
8482
+ if (!first) return;
8483
+ console.warn(`${PLUGIN_LABEL}: ${message}`);
8484
+ logger.warn(message);
8485
+ try {
8486
+ appendOmEvent(session, "om/warning", {
8487
+ problem,
8488
+ message
8145
8489
  });
8146
- try {
8147
- const requestOptions = buildSummaryOptions(session, instruction, contextText, maxTokens, target, signal);
8148
- for await (const chunk of ctx.llm.stream(requestOptions)) collector.push(chunk);
8149
- streamCompleted = true;
8150
- const diagnosticSessionId = await logAttempt();
8151
- const extracted = extractSummaryDetailed(collector.text, options?.expected);
8152
- const finish = collector.finish;
8153
- if (finish.kind !== "stop") {
8154
- lastFailure = {
8155
- reason: `摘要流以 ${String(finish.kind)} 结束(非正常完成)`,
8156
- ...diagnosticSessionId === void 0 ? {} : { diagnosticSessionId }
8157
- };
8158
- logger.warn(`摘要未完成(第 ${attempt}/${maxAttempts} 次,${lastFailure.reason},子会话 ${diagnosticSessionId ?? "未落盘"})` + (attempt < maxAttempts ? ",将重试" : ",重试耗尽,放弃本次压缩"));
8159
- continue;
8160
- }
8161
- if ("error" in extracted) {
8162
- lastFailure = {
8163
- reason: extracted.error,
8164
- ...diagnosticSessionId === void 0 ? {} : { diagnosticSessionId }
8165
- };
8166
- logger.warn(`摘要输出未通过校验(第 ${attempt}/${maxAttempts} 次,${extracted.error},子会话 ${diagnosticSessionId ?? "未落盘"})` + (attempt < maxAttempts ? ",将重试" : ",重试耗尽,放弃本次压缩"));
8167
- continue;
8168
- }
8169
- const text = extracted.log;
8170
- const usage = collector.usage;
8171
- logger.info(`摘要调用成功(第 ${attempt}/${maxAttempts} 次,输出 ${text.length} 字符` + (usage === void 0 ? "" : `,input ${String(usage.inputTokens ?? "?")} / output ${String(usage.outputTokens ?? "?")} tokens`) + `,子会话 ${diagnosticSessionId ?? "未落盘"})`);
8172
- return {
8173
- ok: true,
8174
- text,
8175
- attemptCount: attempt,
8176
- ...usage === void 0 ? {} : { usage },
8177
- ...diagnosticSessionId === void 0 ? {} : { diagnosticSessionId }
8178
- };
8179
- } catch (error) {
8180
- const diagnosticSessionId = streamCompleted ? void 0 : await logAttempt();
8181
- const message = error instanceof Error ? error.message : String(error);
8182
- if (isRateLimitError(message)) {
8183
- noteRateLimit();
8184
- logger.warn(`摘要调用触发限流(429,第 ${attempt}/${maxAttempts} 次),下一次请求前至少等待 ${rateLimitWaitMs}ms`);
8185
- }
8186
- lastFailure = {
8187
- error: message,
8188
- ...diagnosticSessionId === void 0 ? {} : { diagnosticSessionId }
8189
- };
8190
- logger.warn(`摘要调用失败(第 ${attempt}/${maxAttempts} 次,${message},子会话 ${diagnosticSessionId ?? "未落盘"})` + (attempt < maxAttempts ? ",将重试" : ",重试耗尽,放弃本次压缩"));
8191
- }
8490
+ } catch (error) {
8491
+ const text = error instanceof Error ? error.message : String(error);
8492
+ logger.warn(`om 警告事件追加失败: ${text}`);
8192
8493
  }
8193
- const lastError = lastFailure.error ?? lastFailure.reason ?? "未知原因";
8194
- logger.warn(`摘要调用最终失败(已尝试 ${maxAttempts} 次,最后错误:${lastError}),拒绝放行本轮 step`);
8195
- return {
8196
- ok: false,
8197
- error: lastError,
8198
- aborted: false,
8199
- ...lastFailure.diagnosticSessionId === void 0 ? {} : { diagnosticSessionId: lastFailure.diagnosticSessionId }
8200
- };
8201
8494
  }
8202
8495
  //#endregion
8203
8496
  //#region src/compress.ts
@@ -8206,21 +8499,27 @@ async function runSummarySubagent(ctx, agent, instruction, contextText, maxToken
8206
8499
  * 导出 estimateTextTokens / isPairBalancedAfter / computeCompressRange / historySection /
8207
8500
  * findObservePending / reflectPass / observePass / maybeCompress。
8208
8501
  *
8209
- * - 反思:全部 <history> 块 token 合计 ≥ reflectThresholdTokens 时,块内文拼合为单个
8210
- * <history> 块输入摘要,合并为一条
8502
+ * - 摘要生成走工具驱动的压缩循环(compress-loop.ts runCompressionLoop):首条 user
8503
+ * 消息仅含压缩指令与 start/end 区间,模型经 getHistory / compressHistory /
8504
+ * completeCompression 工具完成压缩;最终 <history> 块由插件从视图与替换记录构建,
8505
+ * 全程无需整块校验
8506
+ * - 反思:全部 <history> 块 token 合计 ≥ reflectThresholdTokens 时,把全部块条目作为
8507
+ * 压缩视图重新压缩合并(视图无条目时跳过,如全部为不可解析的历史遗留块)
8211
8508
  * - 观察(触发 → 待定 → 延迟执行):净压力(上下文压力 − 已压缩块 token 合计 − 系统提示词
8212
8509
  * token 估算 − 工具定义 token 估算)首次 ≥ observeThresholdTokens 时记录待定标记
8213
8510
  * (触发点 = 当时的最后一条完整消息 index),本次不压缩;待定后新增完整消息数 ≥
8214
- * tailMessageCount 时,把压缩边界至触发点的全部消息摘要为新 <history> 块并精确替换
8215
- * 被压缩区间(旧块保留),等待期间的新消息成为下一轮未压缩尾部(延迟窗口内压力允许
8511
+ * tailMessageCount 时,把压缩边界至触发点的全部消息作为压缩视图替换为新 <history>
8512
+ * 块(旧块保留),等待期间的新消息成为下一轮未压缩尾部(延迟窗口内压力允许
8216
8513
  * 短暂超阈值);tailMessageCount=0 时触发当轮直接执行(不落待定标记)
8217
8514
  * - 待定标记以 log-only om 信封事件(借用 feedback/record,kind 为 om/observe-pending /
8218
8515
  * om/observe-invalidate,见 om-event.ts)持久化在会话日志中(重启后从日志恢复);
8219
- * 摘要失败保留待定,下个 pre-step 直接重试执行
8516
+ * 压缩失败保留待定,下个 pre-step 直接重试执行
8220
8517
  * - 两级在 pre-step 阻塞串行执行(先反思后观察);仅主会话生效;omEnabled=false 关闭
8221
8518
  * - 压缩边界:最后一个合法 <history> 块之后的消息视为未压缩,其前不重复压缩
8222
- * - 摘要尝试全部耗尽时 pass 返回失败结果(携带最后一次尝试的实际报错),压缩流程
8223
- * 向上传播,pre-step 据此拒绝本 step 中断当前 turn;signal 中止标记 aborted(不中断)
8519
+ * - 压缩循环最终失败(连续无工具调用 / 请求级错误)时 pass 返回失败结果(携带最后一次
8520
+ * 错误),压缩流程向上传播,pre-step 据此拒绝本 step 中断当前 turn;signal 中止标记
8521
+ * aborted(不中断);成功与失败均落盘压缩会话记录子会话(失败时其 id 随结果传播为
8522
+ * 诊断子会话 id)
8224
8523
  * - 提交走宿主 compaction/* 生命周期事件(start 带 phase → summary → 替换消息 → end),
8225
8524
  * 失败补 end(error,实际报错 + 诊断子会话 sessionId);替换消息 source 标记插件标识供 UI 认领
8226
8525
  * - 挂载失败类问题(systemPrompt/tokenMeter 服务异常)始终 console 到外部进程,并追加
@@ -8234,16 +8533,20 @@ function estimateTextTokens(text) {
8234
8533
  return Math.ceil(text.length / 4);
8235
8534
  }
8236
8535
  /**
8237
- * 反思输入块引用的最大完整消息 index(解析全部条目取最大 end;无条目返回 -1)。
8238
- * 反思输出必须覆盖输入引用的完整 index 区间(0..max),连续性校验据此约束。
8536
+ * 运行压缩循环并归一化结果:失败统一追加 compaction/end(error)(携带诊断子会话 id),
8537
+ * 成功记会话记录日志;返回循环结果(成功时由调用方提交)。
8239
8538
  */
8240
- function reflectExpectedEnd(contextText) {
8241
- let max = -1;
8242
- for (const e of parseHistoryEntries(contextText)) {
8243
- const hi = e.kind === "assistant" && e.end !== void 0 ? e.end : e.index ?? 0;
8244
- if (hi > max) max = hi;
8539
+ async function runPassLoop(ctx, session, lifecycle, options, logger, phase) {
8540
+ const outcome = await runCompressionLoop(ctx, session, options);
8541
+ if (!outcome.ok) {
8542
+ logger.warn(`${phase === "reflect" ? "反思" : "观察"}:压缩循环失败(${outcome.error}),诊断子会话 ${outcome.recordSessionId ?? "未落盘"},追加 compaction/end(error)`);
8543
+ try {
8544
+ appendCompactionEnd(session, lifecycle, outcome.error, outcome.recordSessionId);
8545
+ } catch {}
8546
+ return outcome;
8245
8547
  }
8246
- return max;
8548
+ if (outcome.recordSessionId !== void 0) logger.step(`压缩会话记录子会话 ${outcome.recordSessionId}`);
8549
+ return outcome;
8247
8550
  }
8248
8551
  /**
8249
8552
  * 判定消息是否为本插件的压缩日志消息并提取日志文本(按 source 标记判断,兼容旧宿主
@@ -8273,14 +8576,6 @@ function historyInnerText(text) {
8273
8576
  return text.slice(gt + 1, close).trim();
8274
8577
  }
8275
8578
  /**
8276
- * 反思输入拼合:全部块内文(historyInnerText 去掉开标签属性,块首格式说明注释剥离)
8277
- * 按序合并进单个 <history> 块。正文条目内出现的属性/注释同名串原样保留。
8278
- */
8279
- function mergeHistoryBlocks(blocks) {
8280
- const inner = blocks.map((block) => stripLeadingFormatNote(historyInnerText(block.text))).join("\n");
8281
- return `<${HISTORY_TAG}>\n${inner}\n</${HISTORY_TAG}>`;
8282
- }
8283
- /**
8284
8579
  * 判定表层节点 seq 之后的切点是否 tool-call/result 配对平衡:按表层顺序折叠未闭合的
8285
8580
  * 工具调用数,处理到 seq 后计数为 0 即平衡(防止把 tool-call 与其结果切到两侧)。
8286
8581
  */
@@ -8462,9 +8757,10 @@ function appendHistoryMessage(session, content, sourceEventSeqs, surfaceOp, comp
8462
8757
  });
8463
8758
  }
8464
8759
  /**
8465
- * 反思:全部 <history> 块 token 合计 ≥ reflectThresholdTokens 时,全部块内文拼合为
8466
- * 单个 <history> 块送入摘要调用,整个块区段合并替换为一条更紧凑的摘要。失败不产生
8467
- * 部分替换;摘要尝试全部耗尽返回失败结果(error = 最后一次尝试的实际报错/具体问题)。
8760
+ * 反思:全部 <history> 块 token 合计 ≥ reflectThresholdTokens 时,把全部块内条目作为
8761
+ * 压缩视图送入工具压缩循环,整个块区段合并替换为一条更紧凑的摘要。视图无条目(全部
8762
+ * 为不可解析的历史遗留块)时跳过。失败不产生部分替换;最终失败返回失败结果
8763
+ * (error = 最后一次错误)。
8468
8764
  */
8469
8765
  async function reflectPass(ctx, agent, config, target, signal) {
8470
8766
  const session = agent.session;
@@ -8489,43 +8785,41 @@ async function reflectPass(ctx, agent, config, target, signal) {
8489
8785
  logger.step("反思:块区段缺失,跳过");
8490
8786
  return { failed: false };
8491
8787
  }
8788
+ const view = buildReflectView(blocks, { skipReasoning: config.compressSkipReasoning });
8789
+ if (view.minIndex === void 0 || view.maxIndex === void 0) {
8790
+ logger.step("反思:块内无可定位条目(历史遗留块),跳过");
8791
+ return { failed: false };
8792
+ }
8492
8793
  const blockSeqs = blocks.map((block) => block.seq);
8493
- const instruction = buildHistoryPrompt(config.compressSkipReasoning);
8494
- const contextText = mergeHistoryBlocks(blocks);
8495
- const expectedEnd = reflectExpectedEnd(contextText);
8496
8794
  const lifecycle = {
8497
8795
  compactionId: newCompactionId(),
8498
8796
  turn: openTurnOf(session)
8499
8797
  };
8500
8798
  try {
8501
- logger.step("反思:追加 compaction/start(摘要调用前开启压缩中提示)");
8799
+ logger.step("反思:追加 compaction/start(压缩循环前开启压缩中提示)");
8502
8800
  appendCompactionStart(session, lifecycle, "reflect");
8503
8801
  } catch (error) {
8504
8802
  const message = error instanceof Error ? error.message : String(error);
8505
8803
  logger.warn(`反思压缩启动失败: ${message}`);
8506
8804
  return { failed: false };
8507
8805
  }
8508
- const summaryResult = await runSummarySubagent(ctx, agent, instruction, contextText, config.compressMaxTokens, target, config.debug, signal, {
8509
- maxAttempts: config.compressRetryCount + 1,
8510
- expected: expectedEnd < 0 ? { start: 0 } : {
8511
- start: 0,
8512
- end: expectedEnd
8513
- },
8806
+ const summaryResult = await runPassLoop(ctx, session, lifecycle, {
8807
+ view,
8808
+ phase: "reflect",
8809
+ taskText: buildCompressionTaskText("reflect", view.minIndex, view.maxIndex),
8810
+ target,
8811
+ maxTokens: config.compressMaxTokens,
8514
8812
  rateLimitWaitMs: config.rateLimitWaitMs,
8515
- phase: "reflect"
8516
- });
8517
- if (!summaryResult.ok) {
8518
- logger.warn(`反思:摘要调用失败(${summaryResult.error}),诊断子会话 ${summaryResult.diagnosticSessionId ?? "未落盘"},追加 compaction/end(error)`);
8519
- try {
8520
- appendCompactionEnd(session, lifecycle, summaryResult.error, summaryResult.diagnosticSessionId);
8521
- } catch {}
8522
- return {
8523
- failed: true,
8524
- error: summaryResult.error,
8525
- aborted: summaryResult.aborted,
8526
- ...summaryResult.diagnosticSessionId === void 0 ? {} : { diagnosticSessionId: summaryResult.diagnosticSessionId }
8527
- };
8528
- }
8813
+ skipReasoning: config.compressSkipReasoning,
8814
+ debug: config.debug,
8815
+ ...signal === void 0 ? {} : { signal }
8816
+ }, logger, "reflect");
8817
+ if (!summaryResult.ok) return {
8818
+ failed: true,
8819
+ error: summaryResult.error,
8820
+ aborted: summaryResult.aborted,
8821
+ ...summaryResult.recordSessionId === void 0 ? {} : { diagnosticSessionId: summaryResult.recordSessionId }
8822
+ };
8529
8823
  const report = summaryResult.text;
8530
8824
  try {
8531
8825
  logger.step("反思提交:追加 compaction/summary(影子价格认领)");
@@ -8542,7 +8836,7 @@ async function reflectPass(ctx, agent, config, target, signal) {
8542
8836
  provider: target.provider,
8543
8837
  model: target.model,
8544
8838
  maxTokens: config.compressMaxTokens,
8545
- attemptCount: summaryResult.attemptCount - 1,
8839
+ attemptCount: summaryResult.rounds,
8546
8840
  ...summaryResult.usage === void 0 ? {} : { usage: summaryResult.usage }
8547
8841
  });
8548
8842
  logger.step("反思提交:替换整个 <history> 块区段为合并摘要");
@@ -8552,7 +8846,7 @@ async function reflectPass(ctx, agent, config, target, signal) {
8552
8846
  end: last.seq
8553
8847
  }, lifecycle.compactionId);
8554
8848
  logger.step("反思提交:追加 compaction/end");
8555
- appendCompactionEnd(session, lifecycle, void 0, summaryResult.diagnosticSessionId);
8849
+ appendCompactionEnd(session, lifecycle, void 0, summaryResult.recordSessionId);
8556
8850
  logger.info(`反思完成(摘要 ${tokens} tokens ≥ 阈值 ${threshold},合并 ${blocks.length} 个块为一条)`);
8557
8851
  return { failed: false };
8558
8852
  } catch (error) {
@@ -8627,9 +8921,9 @@ function measurePressureTokens(ctx, session, logger) {
8627
8921
  * observeThresholdTokens 时记录待定标记(触发点 = 当时的最后一条完整消息 index),本次
8628
8922
  * 不压缩(tailMessageCount=0 当轮直接执行,不落待定标记);已有待定标记时按新增完整
8629
8923
  * 消息数 ≥ tailMessageCount 决定执行,压缩区间截至触发点(新增消息成为新未压缩尾部,
8630
- * 延迟窗口内压力允许短暂超阈值)。执行成功(或无可行区间)后写待定失效标记;摘要
8631
- * 失败保留待定,下个 pre-step 直接重试执行。摘要尝试全部耗尽返回失败结果
8632
- * (error = 最后一次尝试的实际报错/具体问题)。
8924
+ * 延迟窗口内压力允许短暂超阈值)。执行成功(或无可行区间)后写待定失效标记;压缩
8925
+ * 失败保留待定,下个 pre-step 直接重试执行。最终失败返回失败结果
8926
+ * (error = 最后一次错误)。
8633
8927
  */
8634
8928
  async function observePass(ctx, agent, config, waitCount, target, signal) {
8635
8929
  const session = agent.session;
@@ -8706,52 +9000,48 @@ async function observePass(ctx, agent, config, waitCount, target, signal) {
8706
9000
  return { failed: false };
8707
9001
  }
8708
9002
  const shadowedSet = new Set(replaceSeqs);
8709
- const inRangeCms = indexCompleteMessages(session).filter((cm) => cm.seqs.every((seq) => shadowedSet.has(seq)));
8710
- if (inRangeCms.length === 0) {
9003
+ if (indexCompleteMessages(session).filter((cm) => cm.seqs.every((seq) => shadowedSet.has(seq))).length === 0) {
8711
9004
  logger.step("观察:区间内无完整消息,清除待定标记视为完成");
8712
9005
  clearPending();
8713
9006
  return { failed: false };
8714
9007
  }
8715
- const startIndex = inRangeCms[0]?.index ?? 0;
8716
- const endIndex = inRangeCms[inRangeCms.length - 1]?.index ?? startIndex;
8717
- logger.step(`观察:保留旧块 ${blocks.length} 条,替换 [${replaceStart}..${range.end}](${replaceSeqs.length} 个表层节点,压缩至${triggerNote}),新消息 index ${startIndex}..${endIndex}`);
8718
- const instruction = buildHistoryPrompt(config.compressSkipReasoning);
8719
- const contextText = renderMessages(session, replaceSeqs, config.compressSkipReasoning);
9008
+ const view = buildObserveView(session, replaceSeqs, { skipReasoning: config.compressSkipReasoning });
9009
+ if (view.minIndex === void 0 || view.maxIndex === void 0) {
9010
+ logger.step("观察:区间内无可定位条目,清除待定标记视为完成");
9011
+ clearPending();
9012
+ return { failed: false };
9013
+ }
9014
+ logger.step(`观察:保留旧块 ${blocks.length} 条,替换 [${replaceStart}..${range.end}](${replaceSeqs.length} 个表层节点,压缩至${triggerNote}),视图区间 ${view.minIndex}..${view.maxIndex}`);
8720
9015
  const lifecycle = {
8721
9016
  compactionId: newCompactionId(),
8722
9017
  turn: openTurnOf(session)
8723
9018
  };
8724
9019
  try {
8725
- logger.step("观察:追加 compaction/start(摘要调用前开启压缩中提示)");
9020
+ logger.step("观察:追加 compaction/start(压缩循环前开启压缩中提示)");
8726
9021
  appendCompactionStart(session, lifecycle, "observe");
8727
9022
  } catch (error) {
8728
9023
  const message = error instanceof Error ? error.message : String(error);
8729
9024
  logger.warn(`观察压缩启动失败: ${message}`);
8730
9025
  return { failed: false };
8731
9026
  }
8732
- const summaryResult = await runSummarySubagent(ctx, agent, instruction, contextText, config.compressMaxTokens, target, config.debug, signal, {
8733
- maxAttempts: config.compressRetryCount + 1,
8734
- expected: {
8735
- start: startIndex,
8736
- end: endIndex
8737
- },
9027
+ const summaryResult = await runPassLoop(ctx, session, lifecycle, {
9028
+ view,
9029
+ phase: "observe",
9030
+ taskText: buildCompressionTaskText("observe", view.minIndex, view.maxIndex),
9031
+ target,
9032
+ maxTokens: config.compressMaxTokens,
8738
9033
  rateLimitWaitMs: config.rateLimitWaitMs,
8739
- phase: "observe"
8740
- });
8741
- if (!summaryResult.ok) {
8742
- logger.warn(`观察:摘要调用失败(${summaryResult.error}),诊断子会话 ${summaryResult.diagnosticSessionId ?? "未落盘"},追加 compaction/end(error)`);
8743
- try {
8744
- appendCompactionEnd(session, lifecycle, summaryResult.error, summaryResult.diagnosticSessionId);
8745
- } catch {}
8746
- return {
8747
- failed: true,
8748
- error: summaryResult.error,
8749
- aborted: summaryResult.aborted,
8750
- ...summaryResult.diagnosticSessionId === void 0 ? {} : { diagnosticSessionId: summaryResult.diagnosticSessionId }
8751
- };
8752
- }
9034
+ skipReasoning: config.compressSkipReasoning,
9035
+ debug: config.debug,
9036
+ ...signal === void 0 ? {} : { signal }
9037
+ }, logger, "observe");
9038
+ if (!summaryResult.ok) return {
9039
+ failed: true,
9040
+ error: summaryResult.error,
9041
+ aborted: summaryResult.aborted,
9042
+ ...summaryResult.recordSessionId === void 0 ? {} : { diagnosticSessionId: summaryResult.recordSessionId }
9043
+ };
8753
9044
  const report = summaryResult.text;
8754
- const attemptCount = summaryResult.attemptCount - 1;
8755
9045
  const usage = summaryResult.usage;
8756
9046
  const shadowedTokenCount = replaceSeqs.reduce((total, seq) => {
8757
9047
  const event = session.events[seq];
@@ -8784,7 +9074,7 @@ async function observePass(ctx, agent, config, waitCount, target, signal) {
8784
9074
  provider: target.provider,
8785
9075
  model: target.model,
8786
9076
  maxTokens: config.compressMaxTokens,
8787
- attemptCount,
9077
+ attemptCount: summaryResult.rounds,
8788
9078
  ...usage === void 0 ? {} : { usage }
8789
9079
  });
8790
9080
  logger.step("观察提交:替换被压缩新消息区间为 <history>(旧块保留)");
@@ -8794,7 +9084,7 @@ async function observePass(ctx, agent, config, waitCount, target, signal) {
8794
9084
  end: range.end
8795
9085
  }, lifecycle.compactionId);
8796
9086
  logger.step("观察提交:追加 compaction/end");
8797
- appendCompactionEnd(session, lifecycle, void 0, summaryResult.diagnosticSessionId);
9087
+ appendCompactionEnd(session, lifecycle, void 0, summaryResult.recordSessionId);
8798
9088
  clearPending();
8799
9089
  logger.info(`观察压缩完成(替换 ${replaceSeqs.length} 个表层节点,约 ${shadowedTokenCount} tokens,压缩至${triggerNote})`);
8800
9090
  return { failed: false };
@@ -9051,7 +9341,6 @@ const DEFAULT_CONFIG = Object.freeze({
9051
9341
  compressMaxTokens: void 0,
9052
9342
  rateLimitWaitMs: 6e4,
9053
9343
  tailMessageCount: 5,
9054
- compressRetryCount: 5,
9055
9344
  compressSkipReasoning: true,
9056
9345
  omEnabled: true,
9057
9346
  debug: false,
@@ -9065,8 +9354,7 @@ const NUMBER_KEYS = [
9065
9354
  ["reflectThresholdTokens", true],
9066
9355
  ["compressMaxTokens", true],
9067
9356
  ["rateLimitWaitMs", true],
9068
- ["tailMessageCount", true],
9069
- ["compressRetryCount", true]
9357
+ ["tailMessageCount", true]
9070
9358
  ];
9071
9359
  /** 归一化原始配置输入:缺省 / null / 空串 / 非对象视为空对象。 */
9072
9360
  function normalizeConfigInput(raw) {
@@ -9455,10 +9743,12 @@ function buildSemanticRecallTool(options) {
9455
9743
  /**
9456
9744
  * dsh-plugin-om 入口(tsdown 打包入口):导出 name / inject / apply。
9457
9745
  * apply 注册 recall / recall-semantic 工具,并接线 agent/pre-step 自动压缩
9458
- * (先反思后观察,仅主会话生效)。压缩摘要尝试全部耗尽时拒绝本 step 中断当前
9459
- * turn(signal 中止除外),主会话日志记录失败原因与诊断子会话 sessionId(每次
9460
- * 尝试的完整提示词与模型原始输出由 compaction-log.ts 落盘为诊断子会话)。压缩与
9461
- * 检索的实现见 compress.ts / compaction-log.ts / recall.ts / semantic-recall.ts。
9746
+ * (先反思后观察,仅主会话生效)。压缩走工具驱动的压缩循环(模型经 getHistory /
9747
+ * compressHistory / completeCompression 工具完成,见 compress-loop.ts);最终失败
9748
+ * (连续无工具调用 / 请求级错误)时拒绝本 step 中断当前 turn(signal 中止除外),
9749
+ * 主会话日志记录失败原因与诊断子会话 sessionId(完整循环会话由 compaction-log.ts
9750
+ * 落盘为子会话)。压缩与检索的实现见 compress.ts / compress-loop.ts /
9751
+ * compaction-log.ts / recall.ts / semantic-recall.ts。
9462
9752
  */
9463
9753
  /** 插件名(Loader 识别入口的稳定标识)。 */
9464
9754
  const name = "dsh-plugin-om";