dsh-plugin-om 0.0.21 → 0.0.23

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.
Files changed (47) hide show
  1. package/README.md +70 -159
  2. package/dist/client/OmCompactionCard.d.ts +6 -11
  3. package/dist/client/OmCompactionCard.d.ts.map +1 -1
  4. package/dist/client/definition.d.ts +21 -20
  5. package/dist/client/definition.d.ts.map +1 -1
  6. package/dist/client/format.d.ts +2 -2
  7. package/dist/client/format.d.ts.map +1 -1
  8. package/dist/client/index.d.ts +5 -11
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/locales.d.ts +5 -4
  11. package/dist/client/locales.d.ts.map +1 -1
  12. package/dist/client.js +87 -28
  13. package/dist/client.js.map +1 -1
  14. package/dist/compress.d.ts +30 -48
  15. package/dist/compress.d.ts.map +1 -1
  16. package/dist/config.d.ts +13 -22
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/constants.d.ts +15 -10
  19. package/dist/constants.d.ts.map +1 -1
  20. package/dist/embedding.d.ts +19 -36
  21. package/dist/embedding.d.ts.map +1 -1
  22. package/dist/index.d.ts +1 -22
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.mjs +584 -772
  25. package/dist/json-schema.d.ts +4 -7
  26. package/dist/json-schema.d.ts.map +1 -1
  27. package/dist/log-index.d.ts +19 -12
  28. package/dist/log-index.d.ts.map +1 -1
  29. package/dist/logger.d.ts +3 -3
  30. package/dist/logger.d.ts.map +1 -1
  31. package/dist/model-download.d.ts +7 -11
  32. package/dist/model-download.d.ts.map +1 -1
  33. package/dist/rate-limit.d.ts +9 -14
  34. package/dist/rate-limit.d.ts.map +1 -1
  35. package/dist/recall-output.d.ts +37 -0
  36. package/dist/recall-output.d.ts.map +1 -0
  37. package/dist/recall.d.ts +7 -17
  38. package/dist/recall.d.ts.map +1 -1
  39. package/dist/semantic-recall.d.ts +10 -19
  40. package/dist/semantic-recall.d.ts.map +1 -1
  41. package/dist/summarize.d.ts +56 -49
  42. package/dist/summarize.d.ts.map +1 -1
  43. package/dist/types.d.ts +22 -35
  44. package/dist/types.d.ts.map +1 -1
  45. package/dist/utils.d.ts +9 -8
  46. package/dist/utils.d.ts.map +1 -1
  47. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { scopeOf } from "@deepseek-ai/dsh-scope";
2
+ import { renderPrompt } from "@deepseek-ai/dsh-system-prompt";
1
3
  import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
4
  import { homedir } from "node:os";
3
5
  import path from "node:path";
@@ -9,35 +11,39 @@ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).expor
9
11
  //#endregion
10
12
  //#region src/constants.ts
11
13
  /**
12
- * 共享常量:集中定义插件级魔法字符串与事件名,避免散落各模块。
14
+ * 共享常量:插件级魔法字符串,集中定义避免散落各模块。
15
+ * 导出 PLUGIN_LABEL / HISTORY_TAG / HISTORY_TIP / COMPLETE_MESSAGE_DEFINITION /
16
+ * COMPACT_CHECKPOINT_PLUGIN / COMPACTION_ABORTED_ERROR / isPluginOwnedSource。
13
17
  */
14
- /** 插件标识:压缩消息 source.plugin 取值(注入压缩日志的来源标记)与日志前缀。 */
18
+ /** 插件标识:压缩消息 source.plugin 取值与日志前缀。 */
15
19
  const PLUGIN_LABEL = "dsh-plugin-om";
16
- /** 压缩日志标签名:<history>...</history> 包裹观察/反思日志块(输入与输出均为合法 history 块)。 */
20
+ /** 压缩日志标签名:<history>...</history> 包裹观察/反思日志块。 */
17
21
  const HISTORY_TAG = "history";
18
- /** 压缩日志块开标签上的 tip 属性(对 AI 的提醒:本块是历史压缩产物,不要复述)。 */
22
+ /** 压缩日志块开标签的 tip 属性(对 AI 的提醒:本块是历史压缩产物,不要复述)。 */
19
23
  const HISTORY_TIP = "当前块是历史消息的压缩产物,不要复述";
20
24
  /**
21
- * 「完整消息」定义(提示词 / recall 工具描述 / 块顶注释共用同一字符串)。
25
+ * 「完整消息」定义(提示词 / 工具描述 / 块顶注释共用)。
22
26
  * 完整消息是摘要日志与 recall 共用的定位单位;首条 index 为 0,按会话顺序递增、全局稳定。
23
- * 用户消息指 user_message 中 source.kind 为 user 的部分;其余部分(宿主注入的上下文等)
24
- * 为系统消息,同样是一条完整消息。
25
27
  */
26
28
  const COMPLETE_MESSAGE_DEFINITION = "`完整消息`被定义为`用户消息`,`系统消息`,`模型输出文本`和`具有result的toolcall`的集合。首条`完整消息`的index是0,后续的index递增。";
27
- /** 判定 user/message 的 source 是否为本插件自产(压缩日志机制消息;含兼容旧宿主 checkpoint 标记 'compact')。这类消息不占完整消息 index。 */
29
+ /**
30
+ * 压缩因 signal 中止而放弃时 compaction/end 的 error 标识(服务端写入、客户端过滤):
31
+ * 中止不是失败,客户端据此隐藏失败行(宿主不变量要求无 summary 的 end 必须带 error)。
32
+ */
33
+ const COMPACTION_ABORTED_ERROR = "压缩已中止(signal aborted)";
34
+ /** 判定 user/message 的 source 是否为本插件自产(压缩日志消息;含旧宿主 checkpoint 标记兼容)。这类消息不占完整消息 index。 */
28
35
  function isPluginOwnedSource(source) {
29
36
  if (source?.kind !== "plugin") return false;
30
37
  return source.plugin === "dsh-plugin-om" || source.plugin === "compact";
31
38
  }
32
39
  //#endregion
33
40
  //#region src/utils.ts
34
- /** 判断值是否为普通对象:typeof object 且非 null 且非数组(类型收窄用)。 */
41
+ /** 判断值是否为普通对象(非 null、非数组)。 */
35
42
  function isRecord(value) {
36
43
  return typeof value === "object" && value !== null && !Array.isArray(value);
37
44
  }
38
- /** 生成 uuid:优先 crypto.randomUUID,回退为时间戳+随机串拼接(保证唯一性)。 */
45
+ /** 生成 uuid:优先 crypto.randomUUID,回退为时间戳+随机串拼接。 */
39
46
  function uuid() {
40
- /** 全局 crypto 对象(提供 randomUUID 的现代环境才存在)。 */
41
47
  const cryptoObj = globalThis.crypto;
42
48
  if (cryptoObj && typeof cryptoObj.randomUUID === "function") return cryptoObj.randomUUID();
43
49
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
@@ -45,15 +51,13 @@ function uuid() {
45
51
  /** 提取 content 数组中的纯文本块(type === 'text' 的 block.text 拼接)。 */
46
52
  function blocksToText(blocks) {
47
53
  if (!Array.isArray(blocks)) return "";
48
- /** 拼接结果缓冲区。 */
49
54
  const out = [];
50
55
  for (const block of blocks) if (isRecord(block) && block.type === "text" && typeof block.text === "string") out.push(block.text);
51
56
  return out.join("");
52
57
  }
53
- /** 消息内容的字符数:递归计入 text 块与 tool-result 内嵌文本(与宿主 token 估算口径一致)。 */
58
+ /** 消息内容的字符数:递归计入 text 块与 tool-result 内嵌文本。 */
54
59
  function textCharCount(message) {
55
60
  if (!message || !Array.isArray(message.content)) return 0;
56
- /** 累计字符数。 */
57
61
  let total = 0;
58
62
  for (const block of message.content) if (block.type === "text") total += block.text.length;
59
63
  else if (block.type === "tool-result") total += blocksToText(block.content).length;
@@ -62,7 +66,6 @@ function textCharCount(message) {
62
66
  /** 面向 recall 的完整消息呈现:text 原样;tool-call 展开(参数=代码);tool-result 取文本。 */
63
67
  function renderMessageText(message) {
64
68
  if (!message || !Array.isArray(message.content)) return "";
65
- /** 各内容块呈现后的拼接缓冲区。 */
66
69
  const parts = [];
67
70
  for (const block of message.content) if (block.type === "text") parts.push(block.text);
68
71
  else if (block.type === "tool-call") parts.push(`[tool-call ${block.name} id=${String(block.id)}]
@@ -70,7 +73,7 @@ ${safeJson(block.arguments)}`);
70
73
  else if (block.type === "tool-result") parts.push(blocksToText(block.content));
71
74
  return parts.join("\n");
72
75
  }
73
- /** 安全 JSON 序列化:序列化失败时退回 String 呈现(避免渲染异常)。 */
76
+ /** 安全 JSON 序列化:序列化失败时退回 String 呈现。 */
74
77
  function safeJson(value) {
75
78
  try {
76
79
  return JSON.stringify(value, null, 2);
@@ -78,14 +81,13 @@ function safeJson(value) {
78
81
  return String(value);
79
82
  }
80
83
  }
81
- /** 主会话判定:subagent 会话 header.origin === 'subagent'(压缩/recall 仅主会话生效)。 */
84
+ /** 主会话判定:subagent 会话 header.origin === 'subagent' */
82
85
  function isMainSession(session) {
83
86
  return session.header?.origin !== "subagent";
84
87
  }
85
88
  /** 解析会话路由目标(provider/model),未路由时返回 undefined。 */
86
89
  function routedTarget(session) {
87
90
  try {
88
- /** 会话请求头中的路由配置。 */
89
91
  const config = session.requestHeader()?.config;
90
92
  if (config?.provider && config.model) return {
91
93
  provider: config.provider,
@@ -96,39 +98,29 @@ function routedTarget(session) {
96
98
  //#endregion
97
99
  //#region src/log-index.ts
98
100
  /**
99
- * 会话日志索引:完整消息索引(index 定位完整消息,recall 与摘要共用同一套编号),
100
- * 以及表层节点定位辅助。事件日志仅追加(被遮蔽的事件仍可读,recall 依赖此性质)。
101
+ * 会话日志索引:完整消息索引与渲染。
102
+ * 导出 indexCompleteMessages(完整消息四类折叠索引,recall 与摘要共用同一套编号)、
103
+ * indexMessages / surfaceIndexOf / messageIdOfEvent(消息级定位辅助)、
104
+ * collectImageRefs / renderCompleteMessageParts / renderCompleteMessage(完整消息渲染
105
+ * 与图片附件收集)。事件日志仅追加(被遮蔽的事件仍可读,recall 依赖此性质)。
101
106
  *
102
- * 完整消息是摘要日志与 recall 共用的定位单位,分四类:
103
- * - user:用户消息(user/message source.kind === 'user')占一条;
104
- * - sys:系统消息(user/message 中其余 source.kind 的部分,如宿主注入的上下文)
105
- * 占一条;压缩日志中以 <sys type="KIND" index="N"> 空块表示(内容不进入输入);
106
- * - assistant:模型输出文本(assistant/message 中的文本块)占一条;
107
- * - toolcall:单个工具调用及其结果占一条(同一条 AI 消息里的文本与工具调用拆开,
108
- * tool/result 按 source.callId 匹配其 tool-call;未匹配的 result 独立成条,防御)。
109
- * 本插件自产的压缩日志消息(source.kind === 'plugin' 且 plugin 为本插件标识)不占 index。
110
- * index 从 0 起、按日志顺序递增、只追加不重排 → 会话内全局稳定,
111
- * 压缩后旧摘要条目引用的 index 仍然有效。
107
+ * 完整消息分四类:user(用户消息)、sys(系统消息,压缩日志中以 <sys> 空块表示)、
108
+ * assistant(模型输出文本)、toolcall(单个工具调用及其结果,result callId 匹配并入)。
109
+ * index 0 起、按日志顺序递增、只追加不重排(压缩后旧摘要条目引用的 index 仍然有效)。
112
110
  */
113
111
  /**
114
112
  * 完整消息索引:按日志顺序把消息事件折叠为完整消息序列(四类,见文件头)。
115
113
  * 工具调用结果按 source.callId 匹配其 tool-call 并入该条;未匹配的 result 独立成条(防御)。
116
- * 插件在 agent/pre-step 触发压缩(日志 call-result 完备),因此压缩时不存在未闭合的
117
- * tool-call;索引对未闭合调用不设特殊处理。
114
+ * 本插件自产消息不占位;压缩在 agent/pre-step 触发(call-result 完备),不存在未闭合调用。
118
115
  */
119
116
  function indexCompleteMessages(session) {
120
- /** 完整消息序列(index = 数组下标,0 起)。 */
121
117
  const cms = [];
122
- /** 等待结果的 toolcall 完整消息(callId → cm)。 */
123
118
  const pending = /* @__PURE__ */ new Map();
124
- /** 会话全部事件(仅追加)。 */
125
119
  const events = session.events;
126
120
  for (let seq = 0; seq < events.length; seq += 1) {
127
- /** 当前待检查事件。 */
128
121
  const event = events[seq];
129
122
  if (!event) continue;
130
123
  if (event.type === "user/message") {
131
- /** 事件 source(区分用户消息与宿主注入/插件消息)。 */
132
124
  const source = event.data.source;
133
125
  if (isPluginOwnedSource(source)) continue;
134
126
  if (source?.kind === "user") cms.push({
@@ -143,10 +135,8 @@ function indexCompleteMessages(session) {
143
135
  ...source?.kind === void 0 ? {} : { kind: source.kind }
144
136
  });
145
137
  } else if (event.type === "assistant/message") {
146
- /** 助手消息(含文本与 tool-call 块)。 */
147
138
  const message = event.data.message;
148
139
  if (!message || !Array.isArray(message.content)) continue;
149
- /** 是否存在文本块(有则先产出 assistant 条)。 */
150
140
  let hasText = false;
151
141
  for (const block of message.content) if (block.type === "text") {
152
142
  hasText = true;
@@ -159,9 +149,7 @@ function indexCompleteMessages(session) {
159
149
  });
160
150
  for (const block of message.content) {
161
151
  if (block.type !== "tool-call") continue;
162
- /** 工具调用 id(匹配 result 用)。 */
163
152
  const callId = String(block.id ?? "");
164
- /** 本调用对应的完整消息(结果随后并入)。 */
165
153
  const cm = {
166
154
  index: cms.length,
167
155
  type: "toolcall",
@@ -172,11 +160,8 @@ function indexCompleteMessages(session) {
172
160
  if (callId !== "") pending.set(callId, cm);
173
161
  }
174
162
  } else if (event.type === "tool/result") {
175
- /** 结果消息的 source(callId 关联调用)。 */
176
163
  const source = event.data.message?.source;
177
- /** 关联的调用 id。 */
178
164
  const callId = String(source?.callId ?? "");
179
- /** 匹配到的 toolcall 完整消息(无则独立成条)。 */
180
165
  const cm = callId === "" ? void 0 : pending.get(callId);
181
166
  if (cm) {
182
167
  cm.seqs.push(seq);
@@ -192,42 +177,66 @@ function indexCompleteMessages(session) {
192
177
  return cms;
193
178
  }
194
179
  /**
195
- * 渲染一条完整消息的文本(recall 输出 / new 模式输入共用):
196
- * - user / sys:消息原文;
197
- * - assistant:仅文本块;
198
- * - toolcall:调用块(工具名 + 参数)+ 结果文本(pruner 裁剪超大结果)。
180
+ * 递归收集内容块中的图片附件元数据(recall 输出保留图片用):
181
+ * image 块按附件元数据收集(字段不全则忽略);tool-result 块递归收集其 content。
199
182
  */
200
- function renderCompleteMessage(session, cm, pruner) {
183
+ function collectImageRefs(content, out) {
184
+ if (!Array.isArray(content)) return;
185
+ for (const block of content) {
186
+ if (!isRecord(block)) continue;
187
+ if (block.type === "image") {
188
+ const a = block.attachment;
189
+ 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({
190
+ attachmentId: a.attachmentId,
191
+ mediaType: a.mediaType,
192
+ bytes: a.bytes,
193
+ width: a.width,
194
+ height: a.height,
195
+ ...typeof a.name === "string" && a.name !== "" ? { name: a.name } : {}
196
+ });
197
+ } else if (block.type === "tool-result") collectImageRefs(block.content, out);
198
+ }
199
+ }
200
+ /**
201
+ * 渲染一条完整消息为「文本 + 图片」(recall / recall-semantic 输出用):
202
+ * user/sys 取消息原文,assistant 取文本块,toolcall 为调用块 + 结果文本
203
+ * (pruner 裁剪超大结果);同时收集该条完整消息携带的图片附件(含 tool-result 嵌套,
204
+ * pruner 裁剪掉的图片不收集)。
205
+ */
206
+ function renderCompleteMessageParts(session, cm, pruner) {
207
+ const images = [];
201
208
  if (cm.type === "user" || cm.type === "sys") {
202
- /** 用户/系统消息事件(seq 缺失则无法渲染)。 */
203
209
  const seq = cm.seqs[0];
204
210
  const event = seq === void 0 ? void 0 : session.events[seq];
205
- /** 派生的消息对象。 */
206
211
  const message = event ? session.deriveEventMessage(event) : null;
207
- return message ? renderMessageText(message) : "";
212
+ if (message && Array.isArray(message.content)) collectImageRefs(message.content, images);
213
+ return {
214
+ text: message ? renderMessageText(message) : "",
215
+ images
216
+ };
208
217
  }
209
218
  if (cm.type === "assistant") {
210
- /** 助手消息事件(seq 缺失则无法渲染)。 */
211
219
  const seq = cm.seqs[0];
212
220
  const event = seq === void 0 ? void 0 : session.events[seq];
213
- /** 派生的消息对象。 */
214
221
  const message = event ? session.deriveEventMessage(event) : null;
215
- if (!message || !Array.isArray(message.content)) return "";
216
- /** 文本块拼接缓冲。 */
222
+ if (!message || !Array.isArray(message.content)) return {
223
+ text: "",
224
+ images
225
+ };
226
+ collectImageRefs(message.content, images);
217
227
  const texts = [];
218
228
  for (const block of message.content) if (block.type === "text") texts.push(String(block.text));
219
- return texts.join("\n");
229
+ return {
230
+ text: texts.join("\n"),
231
+ images
232
+ };
220
233
  }
221
- /** 渲染缓冲。 */
222
234
  const parts = [];
223
- /** 承载调用块的 assistant 事件(seq 缺失则跳过调用块)。 */
224
235
  const callSeq = cm.seqs[0];
225
236
  const callEvent = callSeq === void 0 ? void 0 : session.events[callSeq];
226
237
  if (callEvent?.type === "assistant/message") {
227
- /** 助手消息对象(取对应调用块)。 */
228
238
  const message = session.deriveEventMessage(callEvent);
229
239
  if (message && Array.isArray(message.content)) {
230
- /** 匹配的 tool-call 块。 */
231
240
  let call;
232
241
  for (const block of message.content) if (block.type === "tool-call" && String(block.id ?? "") === (cm.callId ?? "")) {
233
242
  call = block;
@@ -236,33 +245,35 @@ function renderCompleteMessage(session, cm, pruner) {
236
245
  if (call) parts.push(`[tool-call ${String(call.name ?? "")} id=${String(call.id ?? "")}]\n${safeJson(call.arguments)}`);
237
246
  }
238
247
  }
239
- /** 结果事件 seq(无则调用未闭合)。 */
240
248
  const resultSeq = cm.seqs[1];
241
249
  const resultEvent = resultSeq === void 0 ? void 0 : session.events[resultSeq];
242
250
  if (resultEvent?.type === "tool/result") {
243
- /** 结果消息对象(pruner 裁剪超大内容后渲染)。 */
244
251
  let message = session.deriveEventMessage(resultEvent);
245
252
  if (message && pruner?.pruneContent) {
246
- /** 裁剪后的内容块。 */
247
253
  const pruned = pruner.pruneContent(message.content);
248
254
  if (pruned) message = {
249
255
  ...message,
250
256
  content: pruned
251
257
  };
252
258
  }
253
- /** 结果文本。 */
259
+ if (message && Array.isArray(message.content)) collectImageRefs(message.content, images);
254
260
  const text = message ? renderMessageText(message) : "";
255
261
  if (text.trim() !== "") parts.push(`[result]\n${text}`);
256
262
  }
257
- return parts.join("\n");
263
+ return {
264
+ text: parts.join("\n"),
265
+ images
266
+ };
267
+ }
268
+ /** 渲染一条完整消息的文本(压缩输入与语义嵌入共用):renderCompleteMessageParts 的纯文本投影。 */
269
+ function renderCompleteMessage(session, cm, pruner) {
270
+ return renderCompleteMessageParts(session, cm, pruner).text;
258
271
  }
259
272
  //#endregion
260
273
  //#region src/logger.ts
261
274
  /**
262
- * 日志辅助:步骤级(debug)日志默认仅 dev(非 production)输出,便于开发调试;
263
- * 失败等关键日志始终输出(不受 debug 开关影响)。
264
- * 开关配置键 debug:true 强制开启、false 强制关闭,缺省按 NODE_ENV !== 'production' 判定
265
- * (默认值由 resolveConfig 解析,见 config.ts)。
275
+ * 插件日志门面:step 为步骤级(debug)日志,按配置开关过滤;info/warn 始终输出。
276
+ * 导出 PluginLogger / makeLogger。
266
277
  */
267
278
  /** 构建插件日志门面:统一加 PLUGIN_LABEL 前缀,step 按 debug 开关过滤。 */
268
279
  function makeLogger(ctx, debug) {
@@ -7410,19 +7421,15 @@ var import_lib = (/* @__PURE__ */ __commonJSMin(((exports) => {
7410
7421
  exports.onWarningStopParsing = domParser.onWarningStopParsing;
7411
7422
  })))();
7412
7423
  /**
7413
- * 全局限流门(插件进程级共享状态):任一摘要请求遇 429 限流后记录时间戳,
7414
- * 此后所有摘要请求在发出前先等待到「最近一次 429 + rateLimitWaitMs」之后——
7415
- * 并行压缩时其他块的下一次请求同样受限。等待期间 signal 中止则立即放弃。
7416
- *
7417
- * 门在进入时与等待结束时都会重读最近限流时间戳:等待期间发生新的 429 会顺延
7418
- * 冷却期(循环等待直到通过或中止)。
7424
+ * 全局限流门(插件进程级共享状态):任一摘要请求遇 429 后进入冷却期,
7425
+ * 此后所有摘要请求发出前先等待到「最近一次 429 + rateLimitWaitMs」之后。
7426
+ * 等待期间 signal 中止则立即放弃;等待期间的新 429 顺延冷却期。
7427
+ * 导出 isRateLimitError / noteRateLimit / gateRateLimit / RATE_LIMIT_WAIT_MS_DEFAULT /
7428
+ * resetRateLimitGate。
7419
7429
  */
7420
7430
  /** 最近一次 429 限流的时间戳(ms;null = 未遇过限流,门直接放行)。 */
7421
7431
  let lastRateLimitAt = null;
7422
- /**
7423
- * 判定错误信息是否为 429 限流:匹配 429(独立数字)或 rate limit(空格/连字符/
7424
- * 下划线分隔与 camelCase 均可),大小写不敏感。
7425
- */
7432
+ /** 判定错误信息是否为 429 限流(匹配 429 或 rate limit,大小写不敏感)。 */
7426
7433
  function isRateLimitError(message) {
7427
7434
  return /\b429\b|rate[\s_-]?limit/i.test(message);
7428
7435
  }
@@ -7437,17 +7444,14 @@ function delay(ms, signal) {
7437
7444
  resolve(false);
7438
7445
  return;
7439
7446
  }
7440
- /** 定时器句柄(正常到点或中止清理用)。 */
7441
7447
  const timer = setTimeout(() => {
7442
7448
  cleanup();
7443
7449
  resolve(true);
7444
7450
  }, ms);
7445
- /** 中止监听(到点立即返回 false)。 */
7446
7451
  const onAbort = () => {
7447
7452
  cleanup();
7448
7453
  resolve(false);
7449
7454
  };
7450
- /** 清理定时器与中止监听。 */
7451
7455
  const cleanup = () => {
7452
7456
  clearTimeout(timer);
7453
7457
  signal?.removeEventListener("abort", onAbort);
@@ -7456,14 +7460,12 @@ function delay(ms, signal) {
7456
7460
  });
7457
7461
  }
7458
7462
  /**
7459
- * 限流等待门:处于 429 冷却期(最近一次 429 + waitMs 未到)时等待到期限,
7460
- * 通过后返回 true;等待期间 signal 中止返回 false。未遇过限流或冷却期已过
7461
- * 立即放行。waitMs ≤ 0 视为不限流。
7463
+ * 限流等待门:处于 429 冷却期时等待到期限再放行(返回 true);
7464
+ * 等待期间 signal 中止返回 false。未遇过限流或冷却期已过立即放行。waitMs ≤ 0 视为不限流。
7462
7465
  */
7463
7466
  async function gateRateLimit(waitMs, signal) {
7464
7467
  if (waitMs <= 0) return true;
7465
7468
  while (lastRateLimitAt !== null) {
7466
- /** 距冷却期结束的剩余等待(≤ 0 表示已过冷却期)。 */
7467
7469
  const remaining = lastRateLimitAt + waitMs - Date.now();
7468
7470
  if (remaining <= 0) return true;
7469
7471
  if (!await delay(remaining, signal)) return false;
@@ -7474,13 +7476,11 @@ async function gateRateLimit(waitMs, signal) {
7474
7476
  //#region src/summarize.ts
7475
7477
  /**
7476
7478
  * 共享压缩提示词(观察/反思同一套):定义 history 块(模型消息 + index 的表达形式)、
7477
- * 完整消息定义、要求压缩(完整保留用户消息 / reasoning 仅参考 / 关联 assistant 合并 /
7478
- * index/start/end 连续)、输出格式(一个合法 <history> 块,无 reasoning)、数据源说明。
7479
- * mode 控制【摘要粒度】:无参为通用提示词(反思调用);'summary' 要求简单摘要(较早分块);
7480
- * 'detailed' 要求越往后越细(最后一块保留细节)。观察分块时除最后一块外均用 'summary'。
7479
+ * 完整消息定义、压缩要求(完整保留用户/系统消息、reasoning 仅参考、关联 assistant
7480
+ * 合并为模块、index 连续)、输出格式与数据源说明。
7481
7481
  */
7482
- function buildHistoryPrompt(mode) {
7483
- const lines = [
7482
+ function buildHistoryPrompt() {
7483
+ return [
7484
7484
  "把下方的 <history> 消息记录压缩为一份更紧凑的 <history> 压缩日志。不用工具、不展示思考、不输出多余文字。",
7485
7485
  "",
7486
7486
  "【history 块定义】",
@@ -7516,37 +7516,30 @@ function buildHistoryPrompt(mode) {
7516
7516
  `</${HISTORY_TAG}>`,
7517
7517
  "",
7518
7518
  "【数据源】下方的 <history> 消息记录是本次要压缩的全部消息;压缩结果作为一个新的 <history> 块输出。"
7519
- ];
7520
- if (mode !== void 0) {
7521
- const granularity = mode === "summary" ? "【摘要粒度】本条为较早的消息,做简单摘要即可:概括要点,不必展开细节;但用户消息仍逐条保留原文,不概括、不省略。" : "【摘要粒度】本条为最近的消息,越往后越细:靠近末尾的完整消息保留更多细节(关键文件、改动与结论),前面的可适当从简;但用户消息始终逐条保留原文,不概括、不省略。";
7522
- const marker = "【输出格式】只输出一个 <history> 包裹的合法 XML 日志块";
7523
- const markerIndex = lines.findIndex((line) => line.includes(marker));
7524
- if (markerIndex !== -1) lines.splice(markerIndex, 0, granularity);
7525
- }
7526
- return lines.join("\n");
7519
+ ].join("\n");
7527
7520
  }
7528
- /** 渲染用户消息条目(DOM 元素):文本块原样;图片/文件等非文本块以注释补充(说明传入了什么)。 */
7521
+ /**
7522
+ * 静默 DOMParser:非致命解析问题不再走 xmldom 默认的 console.error 输出
7523
+ * (模型输出非法 XML 时避免刷 console),fatalError 仍抛 ParseError、解析语义不变。
7524
+ */
7525
+ function newQuietParser() {
7526
+ return new import_lib.DOMParser({ onError: () => {} });
7527
+ }
7528
+ /** 渲染用户消息条目(DOM 元素):文本块原样;图片/文件等非文本块以注释补充。 */
7529
7529
  function renderUserEntry(doc, session, cm) {
7530
- /** 用户消息事件(seq 缺失则无法渲染)。 */
7531
7530
  const seq = cm.seqs[0];
7532
7531
  const event = seq === void 0 ? void 0 : session.events[seq];
7533
- /** 派生的消息对象。 */
7534
7532
  const message = event ? session.deriveEventMessage(event) : null;
7535
7533
  if (!message || !Array.isArray(message.content)) return null;
7536
- /** user_message 元素(文本 + 注释)。 */
7537
7534
  const el = doc.createElement("user_message");
7538
7535
  el.setAttribute("index", String(cm.index));
7539
- /** 是否存在可输出内容(文本或注释)。 */
7540
7536
  let hasContent = false;
7541
7537
  for (const block of message.content) if (block.type === "text") {
7542
7538
  el.appendChild(doc.createTextNode(String(block.text)));
7543
7539
  hasContent = true;
7544
7540
  } else if (block.type === "image") {
7545
- /** 图片附件元数据(名称 / 媒体类型 / 尺寸 / 字节数)。 */
7546
7541
  const ref = block.attachment;
7547
- /** 显示名(有则附上)。 */
7548
7542
  const name = ref?.name ? `:${ref.name}` : "";
7549
- /** 元数据串。 */
7550
7543
  const meta = ref ? `(${String(ref.mediaType ?? "")} ${String(ref.width ?? "")}×${String(ref.height ?? "")},${String(ref.bytes ?? "")} bytes)` : "";
7551
7544
  el.appendChild(doc.createComment(` 图片附件${name}${meta} `));
7552
7545
  hasContent = true;
@@ -7558,40 +7551,29 @@ function renderUserEntry(doc, session, cm) {
7558
7551
  return el;
7559
7552
  }
7560
7553
  /**
7561
- * 渲染完整消息记录(观察输入,new 模式):输出一个合法的 <history> 块——
7562
- * - user → <user_message index="N">(文本原样;图片/文件注释);
7563
- * - sys → <sys type="KIND" index="N"></sys>(系统消息空块,内容不进入压缩输入);
7564
- * - 每条 assistant 消息的 reasoning → <reasoning>(参考条目,产物中没有);
7565
- * - assistant / toolcall → <assistant index="N">(模型输出文本 / toolcall&result 原样)。
7566
- * 文本经 XML 序列化自动转义(用户输入 / 文本 / reasoning 中的特殊字符不破坏 XML 合法性)。
7567
- * 仅渲染 seqs 全部落在给定集合内的完整消息(本插件自产消息天然不占位)。
7554
+ * 渲染完整消息记录(观察输入):输出一个合法的 <history> 块——
7555
+ * user → <user_message>(文本原样、图片注释)、sys → <sys> 空块、assistant 的
7556
+ * reasoning → <reasoning>(参考条目)、assistant/toolcall <assistant>(原样文本)。
7557
+ * 文本经 XML 序列化自动转义;仅渲染 seqs 全部落在给定集合内的完整消息。
7568
7558
  */
7569
7559
  function renderMessages(session, seqs) {
7570
- /** 遮蔽 seq 集合。 */
7571
7560
  const shadowed = new Set(seqs);
7572
- /** 各 assistant 消息的 reasoning 文本(seq → 文本列表,按日志顺序)。 */
7573
7561
  const reasoningBySeq = /* @__PURE__ */ new Map();
7574
7562
  for (const seq of seqs) {
7575
- /** 当前 assistant 消息(非消息事件跳过)。 */
7576
7563
  const event = session.events[seq];
7577
7564
  if (event?.type !== "assistant/message") continue;
7578
7565
  const message = event.data.message;
7579
7566
  if (!message || !Array.isArray(message.content)) continue;
7580
- /** 该消息的 reasoning 块文本。 */
7581
7567
  const reasonings = [];
7582
7568
  for (const block of message.content) if (block.type === "reasoning" && typeof block.text === "string") reasonings.push(block.text);
7583
7569
  if (reasonings.length > 0) reasoningBySeq.set(seq, reasonings);
7584
7570
  }
7585
- /** 已输出 reasoning 的 assistant 消息 seq(每条消息的 reasoning 只输出一次)。 */
7586
7571
  const emittedReasoning = /* @__PURE__ */ new Set();
7587
- /** DOM 文档(构建条目元素)。 */
7588
- const doc = new import_lib.DOMParser().parseFromString(`<${HISTORY_TAG} />`, "text/xml");
7589
- /** 顶层条目元素(按日志顺序)。 */
7572
+ const doc = newQuietParser().parseFromString(`<${HISTORY_TAG} />`, "text/xml");
7590
7573
  const entries = [];
7591
7574
  for (const cm of indexCompleteMessages(session)) {
7592
7575
  if (!cm.seqs.every((seq) => shadowed.has(seq))) continue;
7593
7576
  if (cm.type === "sys") {
7594
- /** sys 元素(type=source.kind,index=完整消息序号)。 */
7595
7577
  const sysEl = doc.createElement("sys");
7596
7578
  sysEl.setAttribute("type", cm.kind ?? "");
7597
7579
  sysEl.setAttribute("index", String(cm.index));
@@ -7600,45 +7582,35 @@ function renderMessages(session, seqs) {
7600
7582
  continue;
7601
7583
  }
7602
7584
  if (cm.type === "user") {
7603
- /** 用户消息条目(无文本且无注释则跳过)。 */
7604
7585
  const rendered = renderUserEntry(doc, session, cm);
7605
7586
  if (rendered === null) continue;
7606
7587
  entries.push(rendered);
7607
7588
  } else {
7608
- /** 承载该条完整消息的 assistant seq(缺失则无 reasoning 可输出)。 */
7609
7589
  const callSeq = cm.seqs[0];
7610
- /** 该消息的 reasoning(无则跳过)。 */
7611
7590
  const reasonings = callSeq === void 0 ? void 0 : reasoningBySeq.get(callSeq);
7612
7591
  if (callSeq !== void 0 && reasonings !== void 0 && !emittedReasoning.has(callSeq)) {
7613
7592
  emittedReasoning.add(callSeq);
7614
7593
  for (const text of reasonings) {
7615
- /** reasoning 元素(文本自动转义)。 */
7616
7594
  const re = doc.createElement("reasoning");
7617
7595
  re.appendChild(doc.createTextNode(text));
7618
7596
  entries.push(re);
7619
7597
  }
7620
7598
  }
7621
- /** 该条完整消息的文本(assistant=文本;toolcall=调用参数+结果,原样)。 */
7622
7599
  const text = renderCompleteMessage(session, cm);
7623
7600
  if (text.trim() === "") continue;
7624
- /** assistant 元素(文本自动转义)。 */
7625
7601
  const el = doc.createElement("assistant");
7626
7602
  el.setAttribute("index", String(cm.index));
7627
7603
  el.appendChild(doc.createTextNode(text));
7628
7604
  entries.push(el);
7629
7605
  }
7630
7606
  }
7631
- /** XML 序列化器(文本/注释自动转义)。 */
7632
7607
  const serializer = new import_lib.XMLSerializer();
7633
7608
  return `<${HISTORY_TAG}>\n${entries.map((el) => serializer.serializeToString(el)).join("\n")}\n</${HISTORY_TAG}>`;
7634
7609
  }
7635
- /** 流收集器:提取文本输出 + usage + finish(不依赖宿主 BlockAssembler,保持零运行时依赖)。 */
7610
+ /** 流收集器:提取文本输出 + usage + finish(不依赖宿主 BlockAssembler)。 */
7636
7611
  var StreamCollector = class {
7637
- /** 文本输出缓冲(text-delta 拼接;reasoning 不计入)。 */
7638
7612
  textBuf = "";
7639
- /** usage chunk(无则 undefined)。 */
7640
7613
  _usage;
7641
- /** finish chunk(流结束仍无则视为 stop)。 */
7642
7614
  _finish;
7643
7615
  /** 喂入一个流 chunk(仅消费文本/usage/finish,其余忽略)。 */
7644
7616
  push(chunk) {
@@ -7665,7 +7637,7 @@ var StreamCollector = class {
7665
7637
  return this._finish ?? { kind: "stop" };
7666
7638
  }
7667
7639
  };
7668
- /** 构造插件自产 user 消息(指令或 new 模式的输入消息;id 为品牌类型 MessageId)。 */
7640
+ /** 构造插件自产 user 消息(摘要调用的输入消息;id 为品牌类型 MessageId)。 */
7669
7641
  function makePluginUserMessage(text) {
7670
7642
  return {
7671
7643
  id: uuid(),
@@ -7681,14 +7653,14 @@ function makePluginUserMessage(text) {
7681
7653
  };
7682
7654
  }
7683
7655
  /**
7684
- * 构建摘要请求选项(new 方式):指令作为 system,渲染输入(被压缩消息)作为
7685
- * 唯一的 user 消息——不沿用主会话请求前缀(前缀复用需模型自行计数,导致索引异常)。
7656
+ * 构建摘要请求选项:指令作为 system,渲染输入作为唯一的 user 消息,
7657
+ * 不沿用主会话请求前缀(前缀复用需模型自行计数,导致索引异常)。
7686
7658
  */
7687
7659
  function buildSummaryOptions(session, instruction, contextText, maxTokens, target, signal) {
7688
7660
  return {
7689
7661
  provider: target.provider,
7690
7662
  model: target.model,
7691
- maxTokens,
7663
+ ...maxTokens === void 0 ? {} : { maxTokens },
7692
7664
  sessionId: session.id,
7693
7665
  purpose: "compaction",
7694
7666
  ...signal === void 0 ? {} : { signal },
@@ -7700,7 +7672,6 @@ function buildSummaryOptions(session, instruction, contextText, maxTokens, targe
7700
7672
  const HISTORY_FORMAT_NOTE = `<!-- 完整消息:${COMPLETE_MESSAGE_DEFINITION} <TAG index="N">表示单条完整消息,<TAG start="A" end="B"> 表示连续模块,start/end 是首尾完整消息的 index;<sys type="KIND" index="N"> 表示被压缩的系统消息,块中为空 -->`;
7701
7673
  /** 读取元素整数属性(非负整数;缺失 / 非数字返回 undefined)。 */
7702
7674
  function intAttr(el, name) {
7703
- /** 属性原始值(缺失为 null)。 */
7704
7675
  const raw = el.getAttribute(name);
7705
7676
  if (raw === null || raw === "") return void 0;
7706
7677
  if (!/^\d+$/.test(raw)) return void 0;
@@ -7708,43 +7679,31 @@ function intAttr(el, name) {
7708
7679
  }
7709
7680
  /**
7710
7681
  * 用 XML 解析器解析一个 <history> 块(结构合法性校验):
7711
- * - 非法 XML(标签不匹配 / 未闭合 / 多根等)→ null;
7712
- * - 根节点必须是 <history>;
7713
- * - 顶层只允许 user_message / assistant / sys 条目元素(其余元素类型视为不合法);
7714
- * - 出现 <reasoning> 元素时标记 hasReasoning(产物不允许)。
7715
- * 条目按文档顺序提取(index / start / end 属性缺失或不合法 → 不合法)。
7682
+ * 非法 XML / 根非 <history> / 顶层出现未定义元素 / 条目属性缺失或非法 → null;
7683
+ * 出现 <reasoning> 元素时标记 hasReasoning(产物不允许)。
7716
7684
  */
7717
7685
  function parseHistoryBlock(xml) {
7718
- /** DOM 文档(解析失败抛错)。 */
7719
7686
  let doc;
7720
7687
  try {
7721
- doc = new import_lib.DOMParser().parseFromString(xml, "text/xml");
7688
+ doc = newQuietParser().parseFromString(xml, "text/xml");
7722
7689
  } catch {
7723
7690
  return null;
7724
7691
  }
7725
- /** 根节点(必须为 <history>)。 */
7726
7692
  const root = doc.documentElement;
7727
7693
  if (!root || root.nodeName !== "history") return null;
7728
- /** 条目缓冲区。 */
7729
7694
  const entries = [];
7730
- /** 是否含 reasoning 元素。 */
7731
7695
  let hasReasoning = false;
7732
- /** 根的子节点。 */
7733
7696
  const children = root.childNodes;
7734
7697
  for (let i = 0; i < children.length; i += 1) {
7735
- /** 当前子节点(跳过文本与注释)。 */
7736
7698
  const node = children[i];
7737
7699
  if (node?.nodeType !== 1) continue;
7738
- /** 子元素(节点类型收窄)。 */
7739
7700
  const el = node;
7740
- /** 元素标签名。 */
7741
7701
  const tag = el.nodeName;
7742
7702
  if (tag === "reasoning") {
7743
7703
  hasReasoning = true;
7744
7704
  continue;
7745
7705
  }
7746
7706
  if (tag === "user_message") {
7747
- /** index 属性(缺失/非法 → 不合法)。 */
7748
7707
  const index = intAttr(el, "index");
7749
7708
  if (index === void 0) return null;
7750
7709
  entries.push({
@@ -7752,7 +7711,6 @@ function parseHistoryBlock(xml) {
7752
7711
  index
7753
7712
  });
7754
7713
  } else if (tag === "sys") {
7755
- /** index 属性(缺失/非法 → 不合法)。 */
7756
7714
  const index = intAttr(el, "index");
7757
7715
  if (index === void 0) return null;
7758
7716
  entries.push({
@@ -7760,7 +7718,6 @@ function parseHistoryBlock(xml) {
7760
7718
  index
7761
7719
  });
7762
7720
  } else if (tag === "assistant") {
7763
- /** 单条 index 或模块 start..end(二选一)。 */
7764
7721
  const index = intAttr(el, "index");
7765
7722
  if (index !== void 0) entries.push({
7766
7723
  kind: "assistant",
@@ -7785,13 +7742,11 @@ function parseHistoryBlock(xml) {
7785
7742
  }
7786
7743
  /**
7787
7744
  * 解析文本中全部 <history> 块内的条目(反思输入为多个块拼接:逐块解析提取)。
7788
- * 非法块(结构错误 / 根非 history)跳过;仅提取不校验顺序(连续性由 historyContinuity 校验)。
7745
+ * 非法块跳过;仅提取不校验顺序(连续性由 historyContinuity 校验)。
7789
7746
  */
7790
7747
  function parseHistoryEntries(text) {
7791
- /** 条目缓冲区。 */
7792
7748
  const out = [];
7793
7749
  for (const m of text.matchAll(/<history[\s\S]*?<\/history>/g)) {
7794
- /** 单块解析结果(非法块跳过)。 */
7795
7750
  const parsed = parseHistoryBlock(m[0]);
7796
7751
  if (parsed === null) continue;
7797
7752
  out.push(...parsed.entries);
@@ -7799,16 +7754,13 @@ function parseHistoryEntries(text) {
7799
7754
  return out;
7800
7755
  }
7801
7756
  /**
7802
- * 校验条目 index/start/end 连续性:每条给出覆盖区间 [lo, hi](单条 index 为自身),
7803
- * 按块内出现顺序,相邻条目必须首尾相接(后一条 lo = 前一条 hi + 1),返回整体覆盖区间;
7804
- * 空条目 / 非法范围(start > end) / 跳号、重叠或乱序 → 返回 null。
7757
+ * 校验条目 index/start/end 连续性:每条给出覆盖区间,按出现顺序相邻条目必须首尾相接,
7758
+ * 返回整体覆盖区间;空条目 / 非法范围 / 跳号、重叠或乱序 返回 null。
7805
7759
  */
7806
7760
  function historyContinuity(entries) {
7807
7761
  if (entries.length === 0) return null;
7808
- /** 各条目覆盖区间(保持块内出现顺序)。 */
7809
7762
  const ranges = [];
7810
7763
  for (const e of entries) {
7811
- /** 模块(start/end)或单条(index 为自身)。 */
7812
7764
  const lo = e.kind === "assistant" && e.start !== void 0 ? e.start : e.index ?? 0;
7813
7765
  const hi = e.kind === "assistant" && e.end !== void 0 ? e.end : e.index ?? 0;
7814
7766
  if (lo > hi) return null;
@@ -7818,13 +7770,11 @@ function historyContinuity(entries) {
7818
7770
  });
7819
7771
  }
7820
7772
  for (let i = 1; i < ranges.length; i += 1) {
7821
- /** 相邻两条(防御性判空)。 */
7822
7773
  const prev = ranges[i - 1];
7823
7774
  const curr = ranges[i];
7824
7775
  if (prev === void 0 || curr === void 0) return null;
7825
7776
  if (curr.lo !== prev.hi + 1) return null;
7826
7777
  }
7827
- /** 首尾条目(防御性判空)。 */
7828
7778
  const first = ranges[0];
7829
7779
  const last = ranges[ranges.length - 1];
7830
7780
  if (first === void 0 || last === void 0) return null;
@@ -7833,284 +7783,234 @@ function historyContinuity(entries) {
7833
7783
  end: last.hi
7834
7784
  };
7835
7785
  }
7786
+ /** <history> 开标签(允许携带属性):模糊定位输出中日志块起点。 */
7787
+ const HISTORY_OPEN_TAG_RE = /<history(\s[^>]*)?>/;
7788
+ /** history 块内条目标签的 token 正则(开 / 闭 / 自闭合):模糊提取按标签逐个扫描配对。 */
7789
+ const HISTORY_ENTRY_TOKEN_RE = /<(\/)?(user_message|sys|assistant|reasoning)\b([^>]*?)(\/)?>/g;
7790
+ /** 解析标签属性串为键值对(支持双引号 / 单引号 / 无引号取值;非法片段忽略)。 */
7791
+ function parseTagAttrs(raw) {
7792
+ const attrs = {};
7793
+ for (const m of raw.matchAll(/([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) {
7794
+ const key = m[1] ?? "";
7795
+ const value = m[3] ?? m[4] ?? m[5] ?? "";
7796
+ if (key !== "") attrs[key] = value;
7797
+ }
7798
+ return attrs;
7799
+ }
7800
+ /**
7801
+ * 解码 XML 预定义实体(&amp; 最后解码,避免 &amp;lt; 之类被二次解码)。
7802
+ * 模糊提取的条目文本解码后经 XML 序列化重新转义,已有转义形式不发生二次转义。
7803
+ */
7804
+ function decodeXmlEntities(text) {
7805
+ return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&amp;/g, "&");
7806
+ }
7807
+ /**
7808
+ * 模糊重建 <history> 块(xmldom 原生无模糊匹配,按条目标签做字符串级扫描配对):
7809
+ * 依次提取 user_message / sys / assistant / reasoning 条目——开闭标签就近配对、
7810
+ * 自闭合直接成条、未闭合条目以文本末尾收口;未知元素与其间杂文忽略;条目文本
7811
+ * 解码后重新序列化为合法 XML。扫描不到任何条目返回 null。
7812
+ */
7813
+ function rebuildHistoryBlock(inner) {
7814
+ const entries = [];
7815
+ const stack = [];
7816
+ for (const m of inner.matchAll(HISTORY_ENTRY_TOKEN_RE)) {
7817
+ const tag = m[2] ?? "";
7818
+ if (m[1] === "/") for (let i = stack.length - 1; i >= 0; i -= 1) {
7819
+ const open = stack[i];
7820
+ if (open?.tag === tag) {
7821
+ entries.push({
7822
+ tag,
7823
+ attrs: open.attrs,
7824
+ content: inner.slice(open.contentFrom, m.index)
7825
+ });
7826
+ stack.splice(i, 1);
7827
+ break;
7828
+ }
7829
+ }
7830
+ else if (m[4] === "/") entries.push({
7831
+ tag,
7832
+ attrs: parseTagAttrs(m[3] ?? ""),
7833
+ content: ""
7834
+ });
7835
+ else stack.push({
7836
+ tag,
7837
+ attrs: parseTagAttrs(m[3] ?? ""),
7838
+ contentFrom: m.index + m[0].length
7839
+ });
7840
+ }
7841
+ for (const open of stack) entries.push({
7842
+ tag: open.tag,
7843
+ attrs: open.attrs,
7844
+ content: inner.slice(open.contentFrom)
7845
+ });
7846
+ if (entries.length === 0) return null;
7847
+ const doc = newQuietParser().parseFromString(`<${HISTORY_TAG} />`, "text/xml");
7848
+ const root = doc.documentElement;
7849
+ if (!root) return null;
7850
+ for (const e of entries) {
7851
+ const el = doc.createElement(e.tag);
7852
+ for (const [key, value] of Object.entries(e.attrs)) el.setAttribute(key, decodeXmlEntities(value));
7853
+ el.appendChild(doc.createTextNode(decodeXmlEntities(e.content)));
7854
+ root.appendChild(el);
7855
+ }
7856
+ return new import_lib.XMLSerializer().serializeToString(root);
7857
+ }
7836
7858
  /**
7837
7859
  * 从 AI 摘要输出中提取合法日志(不信任 AI 的总结结果):
7838
- * - 取首个 <history> 到最后一个 </history>(含两个首尾)切为日志;
7839
- * - 找不到、顺序颠倒(首个开标签在最后一个闭标签之后)或中间内容长度 < MIN_HISTORY_LENGTH
7840
- * 视为不合法(返回 null,调用方按失败重试);
7841
- * - XML 解析器校验结构合法性(非法 XML / 多块输出 / 根非 <history> → 不合法);
7842
- * - 产物不允许包含 <reasoning> 块(仅作参考);
7843
- * - index/start/end 连续性校验(historyContinuity + 与 expected 覆盖区间一致);
7844
- * - 产出后把首个开标签改写为带 tip 属性的版本(对 AI 的提醒),并在其后插入
7845
- * 格式说明注释(HISTORY_FORMAT_NOTE,块顶)。
7860
+ * 取首个 <history> 开标签(允许带属性)到最后一个 </history> 切为候选块,
7861
+ * 找不到或内容过短返回具体原因;候选块经 XML 结构校验,整块非法时按条目模糊
7862
+ * 提取重建为合法块(不要求模型输出整体合法 XML);随后统一校验:无 reasoning、
7863
+ * index 连续且与 expected 覆盖区间一致;通过后统一改写为带 tip 属性的开标签
7864
+ * 并在块顶插入格式说明注释。所有失败原因均为说明性描述,不携带解析器原始报错。
7846
7865
  */
7847
- function extractSummaryLog(raw, expected) {
7848
- /** 开标签(模型输出格式)。 */
7849
- const openTag = `<${HISTORY_TAG}>`;
7850
- /** 闭标签。 */
7866
+ function extractSummaryDetailed(raw, expected) {
7851
7867
  const closeTag = `</${HISTORY_TAG}>`;
7852
- /** tip 属性的开标签(插件产出格式)。 */
7853
- const openTagWithTip = `<${HISTORY_TAG} tip="${HISTORY_TIP}">`;
7854
- /** 首个开标签位置(无则 -1)。 */
7855
- const open = raw.indexOf(openTag);
7856
- /** 最后一个闭标签位置(无则 -1)。 */
7868
+ const openMatch = HISTORY_OPEN_TAG_RE.exec(raw);
7869
+ const open = openMatch?.index ?? -1;
7857
7870
  const close = raw.lastIndexOf(closeTag);
7858
- if (open === -1 || close === -1 || close < open) return null;
7859
- if (raw.slice(open + openTag.length, close).trim().length < 10) return null;
7860
- /** 完整日志块(含两个首尾)。 */
7871
+ if (openMatch === null || close === -1 || close < open) return { error: "输出中找不到完整的 <history> 块(缺少 <history> 开标签或 </history> 闭标签)" };
7872
+ const inner = raw.slice(open + openMatch[0].length, close);
7873
+ if (inner.trim().length < 10) return { error: "输出中 <history> 块内容过短(少于 10 字符)" };
7861
7874
  const block = raw.slice(open, close + closeTag.length);
7862
- /** XML 解析结果。 */
7863
- const parsed = parseHistoryBlock(block);
7864
- if (parsed === null) return null;
7865
- if (parsed.hasReasoning) return null;
7866
- /** 条目覆盖区间。 */
7875
+ const candidate = parseHistoryBlock(block) === null ? rebuildHistoryBlock(inner) : block;
7876
+ if (candidate === null) return { error: "输出不是合法的 <history> 块(XML 结构非法,按条目模糊提取也未找到有效条目)" };
7877
+ const parsed = parseHistoryBlock(candidate);
7878
+ if (parsed === null) return { error: "输出不是合法的 <history> 块(条目结构非法:属性缺失或包含未定义元素)" };
7879
+ if (parsed.hasReasoning) return { error: "输出包含 <reasoning> 条目(产物不允许携带思考过程)" };
7867
7880
  const span = historyContinuity(parsed.entries);
7868
- if (span === null) return null;
7869
- if (expected?.start !== void 0 && span.start !== expected.start) return null;
7870
- if (expected?.end !== void 0 && span.end !== expected.end) return null;
7871
- /** 首个开标签改写为带 tip 版本,块顶紧跟格式说明注释。 */
7872
- return block.replace(openTag, openTagWithTip).replace(openTagWithTip, `${openTagWithTip}\n${HISTORY_FORMAT_NOTE}`);
7881
+ if (span === null) return { error: "输出条目 index/start/end 不连续(跳号、重叠或乱序)" };
7882
+ if (expected?.start !== void 0 && span.start !== expected.start) return { error: `输出覆盖区间从 ${span.start} 开始,与期望起始 ${expected.start} 不一致` };
7883
+ if (expected?.end !== void 0 && span.end !== expected.end) return { error: `输出覆盖区间止于 ${span.end},与期望结束 ${expected.end} 不一致` };
7884
+ const candidateInner = candidate.slice(candidate.indexOf(">") + 1, candidate.length - closeTag.length).trim();
7885
+ return { log: `<${HISTORY_TAG} tip="${HISTORY_TIP}">\n${HISTORY_FORMAT_NOTE}\n${candidateInner}\n</${HISTORY_TAG}>` };
7873
7886
  }
7874
7887
  /**
7875
7888
  * 直连 LLM 执行一次摘要(观察或反思),返回文本与可选 token usage。
7876
- * 失败(抛异常 / 空输出 / 非 stop 结束 / 校验不通过)均记录日志并重试,总共最多尝试
7877
- * options.maxAttempts 次(默认 SUMMARY_DEFAULT_MAX_ATTEMPTS);全部尝试失败返回 null
7878
- * (不产生任何日志变更)。输出长度受 maxTokens 限制。
7879
- * 每次请求发出前先过全局限流等待门(gateRateLimit):任一请求遇 429 后,后续所有
7880
- * 摘要请求(含并行中的其他调用方)在「最近一次 429 + options.rateLimitWaitMs」之前
7881
- * 不会发出(缺省 RATE_LIMIT_WAIT_MS_DEFAULT)。
7889
+ * 失败(抛异常 / 空输出 / 非 stop 结束 / 校验不通过)均记录日志并重试,每次尝试的
7890
+ * 结果或报错始终写入日志(成功 info / 失败 warn,不受 debug 影响);全部尝试耗尽
7891
+ * 返回失败结果(携带最后一次尝试的实际报错/具体问题,不产生任何日志变更)。
7892
+ * signal 中止(含限流等待被中止)立即放弃并标记 aborted。每次请求发出前先过全局
7893
+ * 限流等待门。
7882
7894
  */
7883
7895
  async function runSummarySubagent(ctx, agent, instruction, contextText, maxTokens, target, debug, signal, options) {
7884
- /** 当前会话。 */
7885
7896
  const session = agent.session;
7886
- /** 插件日志门面(失败日志始终输出)。 */
7887
7897
  const logger = makeLogger(ctx, debug);
7888
- /** 总尝试次数(首次 + 重试;缺省兜底)。 */
7889
7898
  const maxAttempts = options?.maxAttempts ?? 11;
7890
- /** 最后一次失败的原因(error=调用异常 / finish=未完成原因;最终失败日志使用)。 */
7891
7899
  let lastFailure = {};
7892
7900
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
7893
7901
  if (signal?.aborted) {
7894
- logger.warn(`摘要调用中止(第 ${attempt}/${maxAttempts} 次尝试前 signal 已中止),放弃本次摘要`);
7895
- return null;
7902
+ logger.warn(`摘要调用中止(第 ${attempt}/${maxAttempts} 次尝试前 signal 已中止),放弃本次压缩`);
7903
+ return {
7904
+ ok: false,
7905
+ error: COMPACTION_ABORTED_ERROR,
7906
+ aborted: true
7907
+ };
7896
7908
  }
7897
- /** 限流冷却时长(ms;调用方未传时用默认值)。 */
7898
7909
  const rateLimitWaitMs = options?.rateLimitWaitMs ?? 6e4;
7899
7910
  if (!await gateRateLimit(rateLimitWaitMs, signal)) {
7900
- logger.warn(`摘要调用中止(第 ${attempt}/${maxAttempts} 次尝试前限流等待被 signal 中止),放弃本次摘要`);
7901
- return null;
7911
+ logger.warn(`摘要调用中止(第 ${attempt}/${maxAttempts} 次尝试前限流等待被 signal 中止),放弃本次压缩`);
7912
+ return {
7913
+ ok: false,
7914
+ error: COMPACTION_ABORTED_ERROR,
7915
+ aborted: true
7916
+ };
7902
7917
  }
7903
- logger.step(`摘要调用开始(第 ${attempt}/${maxAttempts} 次,provider ${target.provider},model ${target.model},maxTokens ${maxTokens})`);
7918
+ logger.step(`摘要调用开始(第 ${attempt}/${maxAttempts} 次,provider ${target.provider},model ${target.model},maxTokens ${maxTokens === void 0 ? "未设置" : String(maxTokens)})`);
7904
7919
  try {
7905
- /** 摘要请求选项(new 方式组装)。 */
7906
7920
  const requestOptions = buildSummaryOptions(session, instruction, contextText, maxTokens, target, signal);
7907
- /** 流收集器(文本/usage/finish)。 */
7908
7921
  const collector = new StreamCollector();
7909
7922
  for await (const chunk of ctx.llm.stream(requestOptions)) collector.push(chunk);
7910
- /** 提取合法日志(首个 <history> 到最后一个 </history>,无 reasoning、index 连续;不信任 AI 输出)。 */
7911
- const text = extractSummaryLog(collector.text, options?.expected);
7912
- /** 终止原因(仅 stop 视为完成)。 */
7923
+ const extracted = extractSummaryDetailed(collector.text, options?.expected);
7913
7924
  const finish = collector.finish;
7914
- if (finish.kind !== "stop" || text === null) {
7915
- /** 未完成原因(空输出 / 非法日志 / 校验不通过 / 非 stop 终止原因)。 */
7916
- const reason = finish.kind === "stop" ? collector.text.trim() === "" ? "无输出" : "缺少合法 <history> 块(含 reasoning 或 index 不连续)" : String(finish.kind);
7917
- lastFailure = { finish: reason };
7918
- logger.warn(`摘要未完成(第 ${attempt}/${maxAttempts} 次,${reason})` + (attempt < maxAttempts ? ",将重试" : ",重试耗尽,忽略本次摘要"));
7925
+ if (finish.kind !== "stop") {
7926
+ lastFailure = { reason: `摘要流以 ${String(finish.kind)} 结束(非正常完成)` };
7927
+ logger.warn(`摘要未完成(第 ${attempt}/${maxAttempts} 次,${lastFailure.reason})` + (attempt < maxAttempts ? ",将重试" : ",重试耗尽,放弃本次压缩"));
7919
7928
  continue;
7920
7929
  }
7921
- /** 摘要请求的 token usage(归入主会话记录;无则省略)。 */
7930
+ if ("error" in extracted) {
7931
+ lastFailure = { reason: extracted.error };
7932
+ logger.warn(`摘要输出未通过校验(第 ${attempt}/${maxAttempts} 次,${extracted.error})` + (attempt < maxAttempts ? ",将重试" : ",重试耗尽,放弃本次压缩"));
7933
+ continue;
7934
+ }
7935
+ const text = extracted.log;
7922
7936
  const usage = collector.usage;
7923
- logger.step(`摘要调用成功(第 ${attempt}/${maxAttempts} 次,输出 ${text.length} 字符` + (usage === void 0 ? "" : `,input ${String(usage.inputTokens ?? "?")} / output ${String(usage.outputTokens ?? "?")} tokens`) + ")");
7937
+ logger.info(`摘要调用成功(第 ${attempt}/${maxAttempts} 次,输出 ${text.length} 字符` + (usage === void 0 ? "" : `,input ${String(usage.inputTokens ?? "?")} / output ${String(usage.outputTokens ?? "?")} tokens`) + ")");
7924
7938
  return {
7939
+ ok: true,
7925
7940
  text,
7926
7941
  attemptCount: attempt,
7927
7942
  ...usage === void 0 ? {} : { usage }
7928
7943
  };
7929
7944
  } catch (error) {
7930
- /** 错误信息(统一为字符串)。 */
7931
7945
  const message = error instanceof Error ? error.message : String(error);
7932
7946
  if (isRateLimitError(message)) {
7933
7947
  noteRateLimit();
7934
7948
  logger.warn(`摘要调用触发限流(429,第 ${attempt}/${maxAttempts} 次),下一次请求前至少等待 ${rateLimitWaitMs}ms`);
7935
7949
  }
7936
7950
  lastFailure = { error: message };
7937
- logger.warn(`摘要调用失败(第 ${attempt}/${maxAttempts} 次,${message})` + (attempt < maxAttempts ? ",将重试" : ",重试耗尽,忽略本次摘要"));
7951
+ logger.warn(`摘要调用失败(第 ${attempt}/${maxAttempts} 次,${message})` + (attempt < maxAttempts ? ",将重试" : ",重试耗尽,放弃本次压缩"));
7938
7952
  }
7939
7953
  }
7940
- /** 全部尝试失败:记录最终失败日志(含最后原因,便于诊断)。 */
7941
- logger.warn(`摘要调用最终失败(已尝试 ${maxAttempts} 次` + (lastFailure.error !== void 0 ? `,最后错误:${lastFailure.error}` : "") + (lastFailure.finish !== void 0 ? `,最后结果:${lastFailure.finish}` : "") + "),忽略本次摘要");
7942
- return null;
7954
+ const lastError = lastFailure.error ?? lastFailure.reason ?? "未知原因";
7955
+ logger.warn(`摘要调用最终失败(已尝试 ${maxAttempts} 次,最后错误:${lastError}),拒绝放行本轮 step`);
7956
+ return {
7957
+ ok: false,
7958
+ error: lastError,
7959
+ aborted: false
7960
+ };
7943
7961
  }
7944
7962
  //#endregion
7945
7963
  //#region src/compress.ts
7946
7964
  /**
7947
- * 自动压缩(OM 观察/反思两级阈值,思路参考 Mastra Observational Memory):
7948
- * - 观察:未压缩消息 tokens observeThresholdTokens(默认 100000)→ 直连 ctx.llm.stream()
7949
- * 摘要(new 方式:共享提示词作为 system、被压缩消息渲染为 <history> 块输入)把未压缩消息
7950
- * 压缩为观察日志,作为独立的新 <history> 块,只精确替换被压缩的新消息区间(旧块原地
7951
- * 保留,多块并存按序排列;不再把旧+新合并进一条消息);
7952
- * - 反思:全部 <history> 块 tokens 合计 ≥ reflectThresholdTokens(默认 30000)→
7953
- * 同上摘要调用精简合并(输入为多个块拼接,共用同一套提示词),把整个块区段合并为一条。
7954
- * 两级检查在 pre-step 阻塞串行执行(先反思后观察),避免压缩失败或重复压缩。
7955
- * 自动压缩由配置键 omEnabled 开关(false 时关闭;recall 工具不受影响)。
7956
- *
7957
- * 压缩边界(historySection):消息列表中最后一个合法的 <history> 块(source 为插件)
7958
- * 之后的消息视为未压缩;其前(含自身)视为已压缩,不重复压缩、不计入观察阈值。
7965
+ * 两级自动压缩(观察/反思)与 compaction 生命周期提交。
7966
+ * 导出 estimateTextTokens / isPairBalancedAfter / computeCompressRange / historySection /
7967
+ * reflectPass / observePass / maybeCompress。
7959
7968
  *
7960
- * 压缩结果写入宿主 compaction/* 生命周期事件(compaction/start compaction/summary
7961
- * 替换 <history> 消息 compaction/end),使消息记录(聊天视图压缩卡片)与轨迹视图
7962
- * 可见;compaction/summary 同时承担影子价格认领(shadowedTokenCount),不再单独发
7963
- * compaction/prune。替换消息的 source 使用插件标识(plugin: 'dsh-plugin-om' +
7964
- * compactionId),UI 据此关联 summary 与替换消息。compaction/start 在摘要调用前追加
7965
- * (UI 压缩中提示行的开启标记,载荷带 phase 区分观察/反思);摘要失败/无输出或提交
7966
- * 失败时追加 compaction/end(error) 结束生命周期(失败不产生替换消息)。
7967
- *
7968
- * 观察区间:pre-step 触发时日志 call-result 完备,区间不再受 turn/end 封顶——压缩边界
7969
- * 表层长度-1-tailCount(尾部保留 config.tailMessageCount 条不压缩),当前 turn 中已
7970
- * 完备的消息同样可压缩;区间终点回退到 tool-call/result 配对平衡点(不切段)。
7971
- * 摘要输出的 <history> 块经连续性校验(无 reasoning、index/start/end 连续且覆盖预期
7972
- * 区间),失败按 config.compressRetryCount 重试。仅主会话生效。
7969
+ * - 反思:全部 <history> token 合计 ≥ reflectThresholdTokens 时,摘要合并为一条
7970
+ * - 观察:净压力(上下文压力 已压缩块 token 合计 − 系统提示词 token 估算 − 工具定义
7971
+ * token 估算)≥ observeThresholdTokens 时,
7972
+ * 摘要未压缩消息为新 <history> 块并精确替换被压缩区间(旧块保留)
7973
+ * - 两级在 pre-step 阻塞串行执行(先反思后观察);仅主会话生效;omEnabled=false 关闭
7974
+ * - 压缩边界:最后一个合法 <history> 块之后的消息视为未压缩,其前不重复压缩
7975
+ * - 摘要尝试全部耗尽时 pass 返回失败结果(携带最后一次尝试的实际报错),压缩流程
7976
+ * 向上传播,pre-step 据此拒绝本 step 中断当前 turn;signal 中止标记 aborted(不中断)
7977
+ * - 提交走宿主 compaction/* 生命周期事件(start phase summary → 替换消息 → end),
7978
+ * 失败补 end(error,实际报错);替换消息 source 标记插件标识供 UI 认领
7973
7979
  */
7974
7980
  /** 历史文本 token 估算:4 字符 ≈ 1 token(与宿主 dsh-token-meter 启发式一致)。 */
7975
7981
  function estimateTextTokens(text) {
7976
7982
  return Math.ceil(text.length / 4);
7977
7983
  }
7978
7984
  /**
7979
- * 有界并发池:最多 limit 个任务同时运行(limit 非法时按 1 处理),任务按 index 顺序
7980
- * 取用,结果数组与 items 按 index 对齐(任务自身以返回值表达失败,不在此抛出)。
7981
- * items 为空返回空数组。
7982
- */
7983
- async function runWithConcurrency(items, limit, task) {
7984
- /** 结果数组(按 index 对齐)。 */
7985
- const results = new Array(items.length);
7986
- /** 下一个待处理任务的下标(worker 间共享取号)。 */
7987
- let next = 0;
7988
- /** worker 数量(clamp 到 [1, items.length])。 */
7989
- const workerCount = Math.max(1, Math.min(Math.floor(limit), items.length));
7990
- /** 各 worker:循环取号执行任务直到取尽。 */
7991
- const workers = Array.from({ length: workerCount }, async () => {
7992
- while (next < items.length) {
7993
- /** 本 worker 领取的任务下标。 */
7994
- const index = next;
7995
- next += 1;
7996
- results[index] = await task(items[index], index);
7997
- }
7998
- });
7999
- await Promise.all(workers);
8000
- return results;
8001
- }
8002
- /**
8003
- * 按 token 边界把完整消息序列分块(观察并行压缩用):完整消息不跨块——
8004
- * 一条完整消息(含其 thinking/text/toolcall&result 全部 seqs)必然整体落在同一块,
8005
- * 单条超界的消息独立成块。空输入返回空数组。
8006
- */
8007
- function chunkCompleteMessages(session, cms, tokenBoundary, estimateMessage) {
8008
- if (cms.length === 0) return [];
8009
- /** 分块结果。 */
8010
- const chunks = [];
8011
- /** 当前块。 */
8012
- let current = [];
8013
- /** 当前块累计 token(按各完整消息 seqs 的派生消息估算合计)。 */
8014
- let currentTokens = 0;
8015
- for (const cm of cms) {
8016
- /** 当前完整消息的 token 估算(其全部表层 seqs 的派生消息估算之和)。 */
8017
- let cmTokens = 0;
8018
- for (const seq of cm.seqs) {
8019
- /** 表层事件(seq 缺失跳过)。 */
8020
- const event = session.events[seq];
8021
- /** 派生消息(用于 token 估算)。 */
8022
- const message = event ? session.deriveEventMessage(event) : null;
8023
- if (message) cmTokens += estimateMessage(message);
8024
- }
8025
- if (current.length > 0 && currentTokens + cmTokens > tokenBoundary) {
8026
- chunks.push(current);
8027
- current = [];
8028
- currentTokens = 0;
8029
- }
8030
- current.push(cm);
8031
- currentTokens += cmTokens;
8032
- }
8033
- if (current.length > 0) chunks.push(current);
8034
- return chunks;
8035
- }
8036
- /**
8037
- * 合并分块摘要为单个 <history> 块:剥离各块的 <history> 开/闭标签(保留块内全部内容),
8038
- * 以统一的开标签(带 tip 属性)与闭标签包裹拼接。块内内容原样保留(含格式说明注释)。
8039
- */
8040
- function mergeChunkReports(parts) {
8041
- /** 各块内层内容(剥离外壳失败时原样保留该块文本)。 */
8042
- const inners = [];
8043
- for (const part of parts) {
8044
- /** 首个 <history 开标签位置。 */
8045
- const open = part.indexOf(`<${HISTORY_TAG}`);
8046
- /** 开标签右括号位置。 */
8047
- const gt = open === -1 ? -1 : part.indexOf(">", open);
8048
- /** 最后一个 </history> 闭标签位置。 */
8049
- const close = part.lastIndexOf(`</${HISTORY_TAG}>`);
8050
- if (open === -1 || gt === -1 || close === -1 || close <= gt) {
8051
- inners.push(part);
8052
- continue;
8053
- }
8054
- inners.push(part.slice(gt + 1, close));
8055
- }
8056
- return `<${HISTORY_TAG} tip="${HISTORY_TIP}">\n${inners.join("\n").trim()}\n</${HISTORY_TAG}>`;
8057
- }
8058
- /** 合并多块摘要的 token usage(同名数字字段求和;全部为空返回 undefined)。 */
8059
- function mergeUsage(usages) {
8060
- if (usages.length === 0) return void 0;
8061
- /** 合并结果(必填字段从 0 起累加)。 */
8062
- const out = {
8063
- inputTokens: 0,
8064
- outputTokens: 0
8065
- };
8066
- for (const u of usages) {
8067
- out.inputTokens += u.inputTokens;
8068
- out.outputTokens += u.outputTokens;
8069
- if (u.cacheReadTokens !== void 0) out.cacheReadTokens = (out.cacheReadTokens ?? 0) + u.cacheReadTokens;
8070
- if (u.cacheWriteTokens !== void 0) out.cacheWriteTokens = (out.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
8071
- if (u.reasoningTokens !== void 0) out.reasoningTokens = (out.reasoningTokens ?? 0) + u.reasoningTokens;
8072
- }
8073
- return out;
8074
- }
8075
- /**
8076
7985
  * 反思输入块引用的最大完整消息 index(解析全部条目取最大 end;无条目返回 -1)。
8077
7986
  * 反思输出必须覆盖输入引用的完整 index 区间(0..max),连续性校验据此约束。
8078
7987
  */
8079
7988
  function reflectExpectedEnd(contextText) {
8080
- /** 最大 end(无条目保持 -1)。 */
8081
7989
  let max = -1;
8082
7990
  for (const e of parseHistoryEntries(contextText)) {
8083
- /** 当前条目上界(模块 end 或单条 index)。 */
8084
7991
  const hi = e.kind === "assistant" && e.end !== void 0 ? e.end : e.index ?? 0;
8085
7992
  if (hi > max) max = hi;
8086
7993
  }
8087
7994
  return max;
8088
7995
  }
8089
7996
  /**
8090
- * 判定消息是否为本插件的压缩日志消息并提取日志文本(不通过文本含标签判断,
8091
- * 改用 source 标记——plugin 为插件标识 'dsh-plugin-om'(兼容旧日志的宿主 checkpoint
8092
- * 标记 'compact')。
8093
- * 仅当消息文本含 <history> 开标签(含带 tip 属性版本)时视为压缩日志,返回整段文本;
8094
- * 其余消息返回 undefined(旧格式 <om-history> 块按「不兼容,干净切换」视为普通消息)。
7997
+ * 判定消息是否为本插件的压缩日志消息并提取日志文本(按 source 标记判断,兼容旧宿主
7998
+ * checkpoint 标记 'compact')。仅当消息文本含 <history> 开标签时视为压缩日志,返回整段
7999
+ * 文本;其余返回 undefined。
8095
8000
  */
8096
8001
  function historyTextOf(event) {
8097
8002
  if (event?.type !== "user/message") return void 0;
8098
- /** 消息 source(本插件自产消息的标记;含兼容旧宿主 checkpoint 标记)。 */
8099
8003
  const source = event.data.source;
8100
8004
  if (!isPluginOwnedSource(source)) return void 0;
8101
- /** 消息纯文本。 */
8102
8005
  const text = blocksToText(event.data.content);
8103
- /** 首个 <history 出现位置(无则非压缩日志)。 */
8104
8006
  const idx = text.indexOf(`<${HISTORY_TAG}`);
8105
8007
  if (idx === -1) return void 0;
8106
- /** 开标签校验:<history> 或 <history tip=…>(排除 </history> 等)。 */
8107
8008
  const rest = text.slice(idx + 7 + 1);
8108
8009
  if (!rest.startsWith(">") && !rest.startsWith(" tip=")) return void 0;
8109
8010
  return text;
8110
8011
  }
8111
8012
  /** 提取 <history> 块内文(去掉开/闭标签与首尾空白;非块文本原样返回)。 */
8112
8013
  function historyInnerText(text) {
8113
- /** 闭标签。 */
8114
8014
  const closeTag = `</${HISTORY_TAG}>`;
8115
8015
  const close = text.lastIndexOf(closeTag);
8116
8016
  if (close === -1) return text;
@@ -8121,44 +8021,12 @@ function historyInnerText(text) {
8121
8021
  return text.slice(gt + 1, close).trim();
8122
8022
  }
8123
8023
  /**
8124
- * 未压缩消息 token 估算:最后一个 <history> 块之后的表层节点合计
8125
- * (其前含自身视为已压缩,不计入观察阈值衡量对象;无压缩日志时计全部)。
8126
- * 系统消息(user/message 中非 kind:user 的部分,如宿主注入的上下文)不计入阈值。
8127
- */
8128
- function measureUncompressedTokens(session, meter) {
8129
- /** 压缩边界(最后一个 history 块的 seq;无则 undefined)。 */
8130
- const { boundarySeq } = historySection(session);
8131
- /** token 合计。 */
8132
- let total = 0;
8133
- /** 是否已越过压缩边界(开始计入)。 */
8134
- let measuring = boundarySeq === void 0;
8135
- for (const seq of session.surface.nodes) {
8136
- if (!measuring) {
8137
- if (seq === boundarySeq) measuring = true;
8138
- continue;
8139
- }
8140
- /** 事件对应的消息(用于 token 估算)。 */
8141
- const event = session.events[seq];
8142
- const message = event ? session.deriveEventMessage(event) : null;
8143
- if (!message) continue;
8144
- if (event?.type === "user/message") {
8145
- if (event.data.source?.kind !== "user") continue;
8146
- }
8147
- total += meter.estimateMessage(message);
8148
- }
8149
- return total;
8150
- }
8151
- /**
8152
- * 判定表层节点 seq 之后的切点是否 tool-call/result 配对平衡(与宿主
8153
- * dsh-compaction 的 toolPairingBalancedAfter 同语义):按表层顺序折叠未闭合的
8154
- * 工具调用数,处理到 seq 后计数为 0 即平衡。pre-step 时日志 call-result 完备,
8155
- * 该检查作为区间边界的安全网(防止把助手 tool-call 与其结果切到两侧)。
8024
+ * 判定表层节点 seq 之后的切点是否 tool-call/result 配对平衡:按表层顺序折叠未闭合的
8025
+ * 工具调用数,处理到 seq 后计数为 0 即平衡(防止把 tool-call 与其结果切到两侧)。
8156
8026
  */
8157
8027
  function isPairBalancedAfter(session, seq) {
8158
- /** 未闭合工具调用计数。 */
8159
8028
  let inProgress = 0;
8160
8029
  for (const node of session.surface.nodes) {
8161
- /** 当前表层事件。 */
8162
8030
  const event = session.events[node];
8163
8031
  if (event?.type === "assistant/message") inProgress += event.data.message.content.filter((block) => block.type === "tool-call").length;
8164
8032
  else if (event?.type === "tool/result") inProgress -= 1;
@@ -8167,35 +8035,25 @@ function isPairBalancedAfter(session, seq) {
8167
8035
  return false;
8168
8036
  }
8169
8037
  /**
8170
- * 观察压缩区间:pre-step 触发时日志 call-result 完备,区间不再受 turn/end 封顶——
8171
- * 头部 → 表层长度-1-tailCount(尾部保留 tailCount 条不压缩),当前 turn 中已完备的
8172
- * 消息同样可压缩;区间终点回退到 tool-call/result 配对平衡点(不切段)。
8038
+ * 观察压缩区间:压缩边界后的首个表层节点 表层长度-1-tailCount(尾部保留 tailCount
8039
+ * 条不压缩);区间终点回退到 tool-call/result 配对平衡点(不切段)。无可行区间返回
8040
+ * undefined。返回区间起止表层 seq 与被遮蔽 seq 列表。
8173
8041
  */
8174
8042
  function computeCompressRange(session, tailCount) {
8175
- /** 当前表层节点(按日志顺序)。 */
8176
8043
  const surface = [...session.surface.nodes];
8177
8044
  if (surface.length === 0) return void 0;
8178
- /** 压缩边界(最后一个 <history> 块;其前含自身已压缩,起点在其后)。 */
8179
8045
  const { boundarySeq } = historySection(session);
8180
- /** 区间起点表层下标:无压缩日志时为 0;否则为压缩边界(最后一个 history 块)在表层
8181
- * (消息日志顺序)中的后继第一条消息。注意不能按 seq 比较取「边界之后」——替换块是
8182
- * 追加到日志末尾的,其 seq 大于被遮蔽的消息;表层顺序才是消息的逻辑日志顺序。 */
8183
8046
  const startIdx = boundarySeq === void 0 ? 0 : surface.indexOf(boundarySeq) + 1;
8184
8047
  if (startIdx >= surface.length) return void 0;
8185
- /** 区间末表层节点下标(尾部保留 tailCount 条不压缩)。 */
8186
8048
  let endIdx = surface.length - 1 - tailCount;
8187
8049
  if (endIdx < startIdx) return void 0;
8188
- /** 区间终点回退到配对平衡点(不切断 tool-call/result 配对)。 */
8189
8050
  while (endIdx >= startIdx) {
8190
- /** 当前候选终点节点(表层节点序列稠密,防御性判空)。 */
8191
8051
  const node = surface[endIdx];
8192
8052
  if (node === void 0 || isPairBalancedAfter(session, node)) break;
8193
8053
  endIdx -= 1;
8194
8054
  }
8195
8055
  if (endIdx < startIdx) return void 0;
8196
- /** 区间起点(压缩边界后的首个节点)。 */
8197
8056
  const start = surface[startIdx];
8198
- /** 区间终点(表层节点 seq)。 */
8199
8057
  const end = surface[endIdx];
8200
8058
  if (start === void 0 || end === void 0) return void 0;
8201
8059
  return {
@@ -8206,17 +8064,14 @@ function computeCompressRange(session, tailCount) {
8206
8064
  }
8207
8065
  /**
8208
8066
  * 压缩边界(historySection):扫描表层全部节点,收集所有合法 <history> 压缩日志块
8209
- * (内文 + seq,按表层顺序),并以最后一个块的 seq 作为压缩边界——
8210
- * 边界之后的消息视为未压缩;其前(含边界自身)视为已压缩(不重复压缩、不计入观察阈值)。
8211
- * 无压缩日志时 blocks 为空、boundarySeq 为 undefined(全部视为未压缩)。
8067
+ * (内文 + seq,按表层顺序),并以最后一个块的 seq 作为压缩边界——边界之后的消息视为
8068
+ * 未压缩,其前(含边界自身)视为已压缩。无压缩日志时 blocks 为空、boundarySeq 为
8069
+ * undefined
8212
8070
  */
8213
8071
  function historySection(session) {
8214
- /** 收集结果(按表层顺序)。 */
8215
8072
  const blocks = [];
8216
- /** 最后一个压缩日志块的 seq(无则 undefined)。 */
8217
8073
  let boundarySeq;
8218
8074
  for (const seq of session.surface.nodes) {
8219
- /** <history> 内文(非压缩日志消息时为 undefined)。 */
8220
8075
  const text = historyTextOf(session.events[seq]);
8221
8076
  if (text === void 0) continue;
8222
8077
  blocks.push({
@@ -8230,9 +8085,8 @@ function historySection(session) {
8230
8085
  boundarySeq
8231
8086
  };
8232
8087
  }
8233
- /** 当前打开中的 turn 号(最近 turn/start 且未被 turn/end 关闭);无则 null(跨轮次场景)。 */
8088
+ /** 当前打开中的 turn 号(最近 turn/start 且未被 turn/end 关闭);无则 null */
8234
8089
  function openTurnOf(session) {
8235
- /** 折叠结果(turn/start 打开、turn/end 关闭)。 */
8236
8090
  let turn = null;
8237
8091
  for (const event of session.events) if (event.type === "turn/start") turn = event.data.turn;
8238
8092
  else if (event.type === "turn/end") turn = null;
@@ -8242,9 +8096,8 @@ function openTurnOf(session) {
8242
8096
  function newCompactionId() {
8243
8097
  return uuid();
8244
8098
  }
8245
- /** 追加 compaction/start(log-only:仅标记生命周期开始,不进入表层),返回事件 seq。 */
8099
+ /** 追加 compaction/start(log-only;载荷带 phase 区分观察/反思),返回事件 seq。 */
8246
8100
  function appendCompactionStart(session, lifecycle, phase) {
8247
- /** start 事件载荷(宿主类型 + 插件扩展 phase;宿主 append 不做 schema 剥离,扩展字段原样持久化)。 */
8248
8101
  const payload = {
8249
8102
  compactionId: lifecycle.compactionId,
8250
8103
  turn: lifecycle.turn,
@@ -8257,7 +8110,6 @@ function appendCompactionStart(session, lifecycle, phase) {
8257
8110
  * summary 为完整合并后的 <history> 内文;usage 由摘要调用提取(无则省略)。
8258
8111
  */
8259
8112
  function appendCompactionSummary(session, data) {
8260
- /** summary 事件载荷(宿主类型 + 插件扩展 shadowedCharCount/attemptCount;宿主 append 不做 schema 剥离,扩展字段原样持久化)。 */
8261
8113
  const payload = {
8262
8114
  compactionId: data.lifecycle.compactionId,
8263
8115
  summary: [{
@@ -8270,7 +8122,7 @@ function appendCompactionSummary(session, data) {
8270
8122
  shadowedCharCount: data.shadowedCharCount,
8271
8123
  provider: data.provider,
8272
8124
  model: data.model,
8273
- maxTokens: data.maxTokens,
8125
+ ...data.maxTokens === void 0 ? {} : { maxTokens: data.maxTokens },
8274
8126
  attemptCount: data.attemptCount,
8275
8127
  ...data.usage === void 0 ? {} : { usage: data.usage }
8276
8128
  };
@@ -8284,10 +8136,8 @@ function appendCompactionEnd(session, lifecycle, error) {
8284
8136
  ...error === void 0 ? {} : { error }
8285
8137
  }).seq;
8286
8138
  }
8287
- /** 追加 <history> 压缩日志消息(surfaceOp 替换遮蔽区间,source 标记插件自产 + compactionId 关联压缩生命周期)。 */
8139
+ /** 追加 <history> 压缩日志消息(surfaceOp 替换遮蔽区间,source 标记插件自产 + compactionId)。 */
8288
8140
  function appendHistoryMessage(session, content, sourceEventSeqs, surfaceOp, compactionId) {
8289
- /** 压缩替换消息(内容即 <history> 标签块,不再附加前缀句;对 AI 的提醒在块开标签 tip 属性上;
8290
- * source 标记插件标识(plugin: 'dsh-plugin-om')+ compactionId 供 UI 关联)。 */
8291
8141
  const message = {
8292
8142
  id: uuid(),
8293
8143
  role: "user",
@@ -8307,49 +8157,37 @@ function appendHistoryMessage(session, content, sourceEventSeqs, surfaceOp, comp
8307
8157
  });
8308
8158
  }
8309
8159
  /**
8310
- * 反思:全部 <history> 块 tokens 合计 ≥ reflectThresholdTokens 时,摘要调用
8311
- * 精简合并(多个块拼接为输入,与观察共用同一套提示词),把整个块区段替换为一条
8312
- * 更紧凑的摘要。失败不产生部分替换。
8160
+ * 反思:全部 <history> 块 token 合计 ≥ reflectThresholdTokens 时,摘要调用把整个块
8161
+ * 区段合并替换为一条更紧凑的摘要。失败不产生部分替换;摘要尝试全部耗尽返回
8162
+ * 失败结果(error = 最后一次尝试的实际报错/具体问题)。
8313
8163
  */
8314
8164
  async function reflectPass(ctx, agent, config, target, signal) {
8315
- /** 当前会话。 */
8316
8165
  const session = agent.session;
8317
- /** 插件日志门面。 */
8318
8166
  const logger = makeLogger(ctx, config.debug);
8319
8167
  logger.step(`反思检查(反思阈值 ${config.reflectThresholdTokens} tokens)`);
8320
- /** 全部 <history> 压缩日志块(按表层顺序;无则跳过)。 */
8321
8168
  const { blocks } = historySection(session);
8322
8169
  if (blocks.length === 0) {
8323
8170
  logger.step("反思:无 <history> 压缩日志,跳过");
8324
- return;
8171
+ return { failed: false };
8325
8172
  }
8326
- /** 反思阈值(配置的绝对 token 数)。 */
8327
8173
  const threshold = config.reflectThresholdTokens;
8328
- /** 全部块 token 估算合计(摘要总长)。 */
8329
8174
  const tokens = blocks.reduce((total, block) => total + estimateTextTokens(block.text), 0);
8330
- /** 被压缩块区段的字符数合计(压缩前内文长度;UI 标题统计用,与观察路径一致只计内容不计标签)。 */
8331
8175
  const shadowedCharCount = blocks.reduce((total, block) => total + historyInnerText(block.text).length, 0);
8332
8176
  if (tokens < threshold) {
8333
8177
  logger.step(`反思:摘要 ${tokens} tokens < 阈值 ${threshold},跳过`);
8334
- return;
8178
+ return { failed: false };
8335
8179
  }
8336
8180
  logger.step(`反思:摘要 ${tokens} tokens ≥ 阈值 ${threshold},触发精简合并(${blocks.length} 个块)`);
8337
- /** 块区段首尾(historySection 只收集表层节点,天然在表层)。 */
8338
8181
  const first = blocks[0];
8339
8182
  const last = blocks[blocks.length - 1];
8340
8183
  if (first === void 0 || last === void 0) {
8341
8184
  logger.step("反思:块区段缺失,跳过");
8342
- return;
8185
+ return { failed: false };
8343
8186
  }
8344
- /** 被替换块区段的 seq 列表。 */
8345
8187
  const blockSeqs = blocks.map((block) => block.seq);
8346
- /** 共享提示词(观察/反思同一套)。 */
8347
8188
  const instruction = buildHistoryPrompt();
8348
- /** 渲染输入(全部 <history> 块拼接)。 */
8349
8189
  const contextText = blocks.map((block) => block.text).join("\n");
8350
- /** 反思输出应覆盖输入块引用的全部 index(解析输入条目取最大 end;无条目则只校验起点)。 */
8351
8190
  const expectedEnd = reflectExpectedEnd(contextText);
8352
- /** 本次压缩生命周期(compactionId + 当前轮次;start 在摘要调用前开启,UI 压缩中提示)。 */
8353
8191
  const lifecycle = {
8354
8192
  compactionId: newCompactionId(),
8355
8193
  turn: openTurnOf(session)
@@ -8358,12 +8196,10 @@ async function reflectPass(ctx, agent, config, target, signal) {
8358
8196
  logger.step("反思:追加 compaction/start(摘要调用前开启压缩中提示)");
8359
8197
  appendCompactionStart(session, lifecycle, "reflect");
8360
8198
  } catch (error) {
8361
- /** 启动失败信息(统一字符串)。 */
8362
8199
  const message = error instanceof Error ? error.message : String(error);
8363
8200
  logger.warn(`反思压缩启动失败: ${message}`);
8364
- return;
8201
+ return { failed: false };
8365
8202
  }
8366
- /** 反思摘要结果(null 表示失败/跳过)。 */
8367
8203
  const summaryResult = await runSummarySubagent(ctx, agent, instruction, contextText, config.compressMaxTokens, target, config.debug, signal, {
8368
8204
  maxAttempts: config.compressRetryCount + 1,
8369
8205
  expected: expectedEnd < 0 ? { start: 0 } : {
@@ -8372,18 +8208,20 @@ async function reflectPass(ctx, agent, config, target, signal) {
8372
8208
  },
8373
8209
  rateLimitWaitMs: config.rateLimitWaitMs
8374
8210
  });
8375
- if (summaryResult === null || summaryResult.text.trim().length === 0) {
8376
- logger.step("反思:摘要调用失败/无输出,追加 compaction/end(error)");
8211
+ if (!summaryResult.ok) {
8212
+ logger.warn(`反思:摘要调用失败(${summaryResult.error}),追加 compaction/end(error)`);
8377
8213
  try {
8378
- appendCompactionEnd(session, lifecycle, "摘要调用失败/无输出");
8214
+ appendCompactionEnd(session, lifecycle, summaryResult.error);
8379
8215
  } catch {}
8380
- return;
8216
+ return {
8217
+ failed: true,
8218
+ error: summaryResult.error,
8219
+ aborted: summaryResult.aborted
8220
+ };
8381
8221
  }
8382
- /** 反思摘要文本。 */
8383
8222
  const report = summaryResult.text;
8384
8223
  try {
8385
8224
  logger.step("反思提交:追加 compaction/summary(影子价格认领)");
8386
- /** compaction/summary 事件 seq(承担影子价格认领,紧随其后的替换消息消费)。 */
8387
8225
  const summarySeq = appendCompactionSummary(session, {
8388
8226
  lifecycle,
8389
8227
  summary: report,
@@ -8409,67 +8247,93 @@ async function reflectPass(ctx, agent, config, target, signal) {
8409
8247
  logger.step("反思提交:追加 compaction/end");
8410
8248
  appendCompactionEnd(session, lifecycle);
8411
8249
  logger.info(`反思完成(摘要 ${tokens} tokens ≥ 阈值 ${threshold},合并 ${blocks.length} 个块为一条)`);
8250
+ return { failed: false };
8412
8251
  } catch (error) {
8413
- /** 提交失败信息(统一字符串)。 */
8414
8252
  const message = error instanceof Error ? error.message : String(error);
8415
8253
  logger.warn(`反思提交失败: ${message}`);
8416
8254
  try {
8417
8255
  appendCompactionEnd(session, lifecycle, message);
8418
8256
  } catch {}
8257
+ return { failed: false };
8258
+ }
8259
+ }
8260
+ /**
8261
+ * 估算系统提示词 tokens:按 agent 作用域组装并渲染系统提示词,按长度/4 启发式计。
8262
+ * 宿主未提供 systemPrompt 服务(无 assemble)或组装/渲染失败时按 0 计——只影响
8263
+ * 观察触发时机(偏早触发),不产生错误。
8264
+ */
8265
+ async function estimateSystemPromptTokens(ctx, agent, logger, signal) {
8266
+ const systemPrompt = ctx.systemPrompt;
8267
+ if (typeof systemPrompt?.assemble !== "function") return 0;
8268
+ try {
8269
+ const agentCtx = agent.ctx;
8270
+ const scope = agentCtx === void 0 ? void 0 : scopeOf(agentCtx);
8271
+ const assembleContext = { agent };
8272
+ if (scope !== void 0) assembleContext.scope = scope;
8273
+ if (signal !== void 0) assembleContext.signal = signal;
8274
+ const assembly = await systemPrompt.assemble(assembleContext);
8275
+ return estimateTextTokens(renderPrompt(assembly));
8276
+ } catch (error) {
8277
+ const message = error instanceof Error ? error.message : String(error);
8278
+ logger.warn(`系统提示词 tokens 估算失败,按 0 计: ${message}`);
8279
+ return 0;
8419
8280
  }
8420
8281
  }
8421
8282
  /**
8422
- * 观察:未压缩消息 tokens observeThresholdTokens 时,摘要调用把未压缩消息压缩为
8423
- * 观察日志,追加到旧摘要并替换被压缩消息区间。失败不产生部分替换。
8283
+ * 估算请求工具定义 tokens:按会话请求头 tools(assembled tool schemas)JSON 序列化
8284
+ * 长度/4 启发式计(与宿主 dsh-token-meter 对工具 schema 的启发式一致)。会话请求头
8285
+ * 缺失或无 tools 时按 0 计——只影响观察触发时机(偏早触发),不产生错误。
8286
+ */
8287
+ function estimateToolsTokens(session) {
8288
+ const header = session.requestHeader();
8289
+ if (header?.tools === void 0 || header.tools.length === 0) return 0;
8290
+ return estimateTextTokens(JSON.stringify(header.tools));
8291
+ }
8292
+ /**
8293
+ * 观察:净压力 tokens(上下文压力 − 已压缩 <history> 块 token 合计 − 系统提示词
8294
+ * token 估算 − 工具定义 token 估算)≥ observeThresholdTokens 时,摘要调用把未压缩
8295
+ * 消息压缩为观察日志,追加到旧摘要并替换被压缩消息区间。失败不产生部分替换;
8296
+ * 摘要尝试全部耗尽返回失败结果(error = 最后一次尝试的实际报错/具体问题)。
8424
8297
  */
8425
8298
  async function observePass(ctx, agent, config, tailCount, target, signal) {
8426
- /** 当前会话。 */
8427
8299
  const session = agent.session;
8428
- /** 插件日志门面。 */
8429
8300
  const logger = makeLogger(ctx, config.debug);
8430
8301
  logger.step(`观察检查(观察阈值 ${config.observeThresholdTokens} tokens,尾部保留 ${tailCount} 条)`);
8431
- /** 观察阈值(配置的绝对 token 数)。 */
8432
8302
  const threshold = config.observeThresholdTokens;
8433
- /** 未压缩消息 token 估算(最后一个 <history> 块之后;其前视为已压缩)。 */
8434
- const uncompressedTokens = measureUncompressedTokens(session, ctx.tokenMeter);
8435
- if (uncompressedTokens < threshold) {
8436
- logger.step(`观察:未压缩消息 ${uncompressedTokens} tokens < 阈值 ${threshold},跳过`);
8437
- return;
8438
- }
8439
- logger.step(`观察:未压缩消息 ${uncompressedTokens} tokens ≥ 阈值 ${threshold},触发压缩`);
8440
- /** 观察压缩区间(压缩边界之后;尾部保留 tailCount 条不压缩;无可行区间则跳过)。 */
8303
+ const pressureTokens = ctx.tokenMeter.measure(session).totalTokens;
8304
+ const { blocks } = historySection(session);
8305
+ const historyTokens = blocks.reduce((total, block) => total + estimateTextTokens(block.text), 0);
8306
+ const systemTokens = await estimateSystemPromptTokens(ctx, agent, logger, signal);
8307
+ const toolsTokens = estimateToolsTokens(session);
8308
+ const netTokens = pressureTokens - historyTokens - systemTokens - toolsTokens;
8309
+ if (netTokens < threshold) {
8310
+ logger.step(`观察:净压力 ${netTokens} tokens(上下文压力 ${pressureTokens} − 已压缩块 ${historyTokens} − 系统提示词 ${systemTokens} − 工具定义 ${toolsTokens})< 阈值 ${threshold},跳过`);
8311
+ return { failed: false };
8312
+ }
8313
+ logger.step(`观察:净压力 ${netTokens} tokens(上下文压力 ${pressureTokens} − 已压缩块 ${historyTokens} − 系统提示词 ${systemTokens} − 工具定义 ${toolsTokens})≥ 阈值 ${threshold},触发压缩`);
8441
8314
  const range = computeCompressRange(session, tailCount);
8442
8315
  if (!range) {
8443
8316
  logger.step("观察:无可行压缩区间(边界后消息过短或配对无法平衡),跳过");
8444
- return;
8317
+ return { failed: false };
8445
8318
  }
8446
8319
  logger.step(`观察:压缩区间 [${range.start}..${range.end}],遮蔽 ${range.shadowedSeqs.length} 个表层节点`);
8447
- /** 旧压缩日志块(保留不替换、不进新消息遮蔽)。 */
8448
- const { blocks } = historySection(session);
8449
- /** 实际被替换的新消息表层节点(压缩边界之后;旧块不进入遮蔽、不被重写)。 */
8450
8320
  const replaceSeqs = range.shadowedSeqs;
8451
- /** 替换区间起点(首个新消息节点;区间内全部为块时无新消息可压缩)。 */
8452
8321
  const replaceStart = replaceSeqs[0];
8453
8322
  if (replaceStart === void 0) {
8454
8323
  logger.step("观察:区间内无新消息(全部为压缩日志块),跳过");
8455
- return;
8324
+ return { failed: false };
8456
8325
  }
8457
- /** 被替换 seq 集合(计算新消息 index 覆盖区间)。 */
8458
8326
  const shadowedSet = new Set(replaceSeqs);
8459
- /** 压缩区间内的完整消息(连续性校验的预期覆盖区间)。 */
8460
8327
  const inRangeCms = indexCompleteMessages(session).filter((cm) => cm.seqs.every((seq) => shadowedSet.has(seq)));
8461
8328
  if (inRangeCms.length === 0) {
8462
8329
  logger.step("观察:区间内无完整消息,跳过");
8463
- return;
8330
+ return { failed: false };
8464
8331
  }
8465
- /** 新消息起始/结束完整消息 index(模型输入携带绝对 index,校验按此区间约束)。 */
8466
8332
  const startIndex = inRangeCms[0]?.index ?? 0;
8467
8333
  const endIndex = inRangeCms[inRangeCms.length - 1]?.index ?? startIndex;
8468
8334
  logger.step(`观察:保留旧块 ${blocks.length} 条,替换新消息 [${replaceSeqs[0]}..${range.end}](${replaceSeqs.length} 条),尾部保留 ${tailCount} 条(不压缩、不进日志),新消息 index ${startIndex}..${endIndex}`);
8469
- /** observeChunkTokens 边界把完整消息分块(完整消息不跨块;每块独立并行压缩)。 */
8470
- const chunks = chunkCompleteMessages(session, inRangeCms, config.observeChunkTokens, (message) => ctx.tokenMeter.estimateMessage(message));
8471
- logger.step(`观察:${inRangeCms.length} 条完整消息按 ${config.observeChunkTokens} tokens 边界分为 ${chunks.length} 块(最多 ${config.observeChunkParallelism} 块并行,每块摘要 maxTokens ${config.observeChunkMaxTokens},除最后一块外要求简单摘要)`);
8472
- /** 本次压缩生命周期(compactionId + 当前轮次;start 在摘要调用前开启,UI 压缩中提示)。 */
8335
+ const instruction = buildHistoryPrompt();
8336
+ const contextText = renderMessages(session, replaceSeqs);
8473
8337
  const lifecycle = {
8474
8338
  compactionId: newCompactionId(),
8475
8339
  turn: openTurnOf(session)
@@ -8478,63 +8342,44 @@ async function observePass(ctx, agent, config, tailCount, target, signal) {
8478
8342
  logger.step("观察:追加 compaction/start(摘要调用前开启压缩中提示)");
8479
8343
  appendCompactionStart(session, lifecycle, "observe");
8480
8344
  } catch (error) {
8481
- /** 启动失败信息(统一字符串)。 */
8482
8345
  const message = error instanceof Error ? error.message : String(error);
8483
8346
  logger.warn(`观察压缩启动失败: ${message}`);
8484
- return;
8485
- }
8486
- /** 各块摘要结果(有界并发池,最多 observeChunkParallelism 块同时进行;null = 该块失败;结果按块顺序)。 */
8487
- const chunkResults = await runWithConcurrency(chunks, config.observeChunkParallelism, async (chunk, index) => {
8488
- /** 该块提示词(较早块简单摘要;最后一块越往后越细)。 */
8489
- const instruction = buildHistoryPrompt(index === chunks.length - 1 ? "detailed" : "summary");
8490
- /** 该块渲染输入(块内完整消息的 seqs;合法 <history> 块,含绝对 index)。 */
8491
- const contextText = renderMessages(session, chunk.flatMap((cm) => cm.seqs));
8492
- /** 该块覆盖的完整消息 index 区间(连续性校验预期)。 */
8493
- const start = chunk[0]?.index ?? 0;
8494
- const end = chunk[chunk.length - 1]?.index ?? start;
8495
- return runSummarySubagent(ctx, agent, instruction, contextText, config.observeChunkMaxTokens, target, config.debug, signal, {
8496
- maxAttempts: config.compressRetryCount + 1,
8497
- expected: {
8498
- start,
8499
- end
8500
- },
8501
- rateLimitWaitMs: config.rateLimitWaitMs
8502
- });
8347
+ return { failed: false };
8348
+ }
8349
+ const summaryResult = await runSummarySubagent(ctx, agent, instruction, contextText, config.compressMaxTokens, target, config.debug, signal, {
8350
+ maxAttempts: config.compressRetryCount + 1,
8351
+ expected: {
8352
+ start: startIndex,
8353
+ end: endIndex
8354
+ },
8355
+ rateLimitWaitMs: config.rateLimitWaitMs
8503
8356
  });
8504
- if (chunkResults.find((r) => r === null || r.text.trim().length === 0) !== void 0) {
8505
- logger.step("观察:分块摘要存在失败/无输出,不产生部分替换,追加 compaction/end(error)");
8357
+ if (!summaryResult.ok) {
8358
+ logger.warn(`观察:摘要调用失败(${summaryResult.error}),追加 compaction/end(error)`);
8506
8359
  try {
8507
- appendCompactionEnd(session, lifecycle, "摘要调用失败/无输出");
8360
+ appendCompactionEnd(session, lifecycle, summaryResult.error);
8508
8361
  } catch {}
8509
- return;
8510
- }
8511
- /** 成功结果(上方已排除 null/空输出;每块已独立校验并规范化)。 */
8512
- const okResults = chunkResults;
8513
- /** 合并各块摘要为单个带 tip 的 <history> 块(剥离各块外壳拼接内层;不再整体校验)。 */
8514
- const report = mergeChunkReports(okResults.map((r) => r.text));
8515
- /** 重试次数合计(各块重试次数之和;每块重试次数 = 该块成功尝试次数 - 1)。 */
8516
- const attemptCount = okResults.reduce((sum, r) => sum + (r.attemptCount - 1), 0);
8517
- /** token usage 合计(各块求和;全部缺省为 undefined)。 */
8518
- const usage = mergeUsage(okResults.map((r) => r.usage).filter((u) => u !== void 0));
8519
- /** 被替换表层节点的 token 估算合计(仅新消息区间)。 */
8362
+ return {
8363
+ failed: true,
8364
+ error: summaryResult.error,
8365
+ aborted: summaryResult.aborted
8366
+ };
8367
+ }
8368
+ const report = summaryResult.text;
8369
+ const attemptCount = summaryResult.attemptCount - 1;
8370
+ const usage = summaryResult.usage;
8520
8371
  const shadowedTokenCount = replaceSeqs.reduce((total, seq) => {
8521
- /** 当前表层事件。 */
8522
8372
  const event = session.events[seq];
8523
- /** 事件对应的消息(用于 token 估算)。 */
8524
8373
  const message = event ? session.deriveEventMessage(event) : null;
8525
8374
  return total + (message ? ctx.tokenMeter.estimateMessage(message) : 0);
8526
8375
  }, 0);
8527
- /** 被替换消息的字符数合计(压缩前文本长度;UI 标题统计用,递归计入 tool-result 内嵌文本)。 */
8528
8376
  const shadowedCharCount = replaceSeqs.reduce((total, seq) => {
8529
- /** 当前表层事件。 */
8530
8377
  const event = session.events[seq];
8531
- /** 事件对应的消息(取文本块字符数)。 */
8532
8378
  const message = event ? session.deriveEventMessage(event) : null;
8533
8379
  return total + (message ? textCharCount(message) : 0);
8534
8380
  }, 0);
8535
8381
  try {
8536
8382
  logger.step("观察提交:追加 compaction/summary(影子价格认领)");
8537
- /** compaction/summary 事件 seq(承担影子价格认领,紧随其后的替换消息消费)。 */
8538
8383
  const summarySeq = appendCompactionSummary(session, {
8539
8384
  lifecycle,
8540
8385
  summary: report,
@@ -8547,7 +8392,7 @@ async function observePass(ctx, agent, config, tailCount, target, signal) {
8547
8392
  shadowedCharCount,
8548
8393
  provider: target.provider,
8549
8394
  model: target.model,
8550
- maxTokens: config.observeChunkMaxTokens,
8395
+ maxTokens: config.compressMaxTokens,
8551
8396
  attemptCount,
8552
8397
  ...usage === void 0 ? {} : { usage }
8553
8398
  });
@@ -8559,68 +8404,63 @@ async function observePass(ctx, agent, config, tailCount, target, signal) {
8559
8404
  }, lifecycle.compactionId);
8560
8405
  logger.step("观察提交:追加 compaction/end");
8561
8406
  appendCompactionEnd(session, lifecycle);
8562
- logger.info(`观察压缩完成(未压缩 ${uncompressedTokens} tokens 阈值 ${threshold},替换 ${replaceSeqs.length} 个表层节点,约 ${shadowedTokenCount} tokens)`);
8407
+ logger.info(`观察压缩完成(净压力 ${netTokens} tokens(上下文压力 ${pressureTokens} − 已压缩块 ${historyTokens})≥ 阈值 ${threshold},替换 ${replaceSeqs.length} 个表层节点,约 ${shadowedTokenCount} tokens)`);
8408
+ return { failed: false };
8563
8409
  } catch (error) {
8564
- /** 提交失败信息(统一字符串)。 */
8565
8410
  const message = error instanceof Error ? error.message : String(error);
8566
8411
  logger.warn(`观察压缩提交失败: ${message}`);
8567
8412
  try {
8568
8413
  appendCompactionEnd(session, lifecycle, message);
8569
8414
  } catch {}
8415
+ return { failed: false };
8570
8416
  }
8571
8417
  }
8572
8418
  /**
8573
- * 压力检查 + 两级压缩:先反思(压缩过往摘要,有必要才做),后观察(压缩新消息,
8574
- * 有必要才做)。在 pre-step 阻塞串行执行(避免压缩失败或重复压缩)。仅主会话生效。
8419
+ * 压力检查 + 两级压缩入口:先反思后观察,pre-step 阻塞串行执行;仅主会话生效。
8420
+ * 反思摘要耗尽失败时直接返回失败结果(观察无需继续,本轮将被拒绝);观察失败同样
8421
+ * 传播失败结果,由 pre-step 拒绝本 step 中断当前 turn。
8575
8422
  */
8576
8423
  async function maybeCompress(ctx, agent, config, signal) {
8577
- /** 当前会话。 */
8578
8424
  const session = agent.session;
8579
- /** 插件日志门面。 */
8580
8425
  const logger = makeLogger(ctx, config.debug);
8581
- /** omEnabled=false:关闭自动压缩(观察/反思均不触发,recall 工具不受影响)。 */
8582
8426
  if (!config.omEnabled) {
8583
8427
  logger.step("omEnabled=false,跳过压缩");
8584
- return;
8428
+ return { failed: false };
8585
8429
  }
8586
- /** 会话路由目标(未路由无法查询容量)。 */
8587
8430
  const target = routedTarget(session);
8588
8431
  if (target === void 0) {
8589
8432
  logger.step("会话未路由(无 provider/model),跳过压缩");
8590
- return;
8433
+ return { failed: false };
8591
8434
  }
8592
8435
  logger.step(`会话路由:provider ${target.provider},model ${target.model}`);
8593
- /** 尾部保留条数(config.tailMessageCount,缺省 10)。 */
8594
8436
  const tailCount = config.tailMessageCount;
8595
8437
  logger.step("反思 pass 开始");
8596
- await reflectPass(ctx, agent, config, target, signal);
8438
+ const reflect = await reflectPass(ctx, agent, config, target, signal);
8439
+ if (reflect.failed) {
8440
+ logger.step("反思 pass 失败(本轮将被拒绝),跳过观察 pass");
8441
+ return reflect;
8442
+ }
8597
8443
  logger.step("反思 pass 结束,观察 pass 开始");
8598
- await observePass(ctx, agent, config, tailCount, target, signal);
8444
+ const observe = await observePass(ctx, agent, config, tailCount, target, signal);
8599
8445
  logger.step("观察 pass 结束,压缩流程完成");
8446
+ return observe;
8600
8447
  }
8601
8448
  //#endregion
8602
8449
  //#region src/model-download.ts
8603
8450
  /**
8604
- * 嵌入模型下载:recall-semantic 运行时按需下载 + 开发手动预下载共用。
8605
- *
8606
- * - 模型:Xenova/paraphrase-multilingual-MiniLM-L12-v2(量化 ONNX ~113MB,超过
8607
- * GitHub 单文件 100MB 限制,不能进入 git 仓库;仅小文件随仓库提交)。
8608
- * - 下载目标:<modelDir>/onnx/model_quantized.onnx(modelDir 默认 = 跨版本共享目录
8609
- * $DSH_HOME/plugin-data/dsh-plugin-om/models/<id>/,见 embedding.ts sharedModelDir;
8610
- * 自定义 modelDir 同样生效)。
8611
- * - 直连 huggingface.co 受限时设置环境变量 HF_ENDPOINT=https://hf-mirror.com 走镜像。
8612
- * - 原子落盘:先写 .tmp 再改名,避免半截文件被当成完整模型加载。
8613
- * - 日志:下载开始/结束(含跳过)经 log 回调输出(默认 console.log);失败时
8614
- * 错误消息附带 HF_ENDPOINT=https://hf-mirror.com 镜像建议。
8615
- *
8616
- * 运行时编排见 embedding.ts 的 ensureModelReady(下载单飞、未就绪不阻塞);
8617
- * 本模块只提供纯下载原语(供 src 与 scripts/download-model.mjs CLI 复用)。
8451
+ * 嵌入模型下载原语:recall-semantic 运行时按需下载与开发手动预下载(scripts/
8452
+ * download-model.mjs)共用。导出 EMBEDDING_MODEL_ID / ONNX_REL / MODEL_SMALL_FILES /
8453
+ * modelSourceUrl / modelTargetPath / needsDownload / downloadModel 及 DownloadResult /
8454
+ * ModelFetch 类型。
8455
+ * 模型为量化 ONNX(~113MB,不进 git,仅小文件随仓库/包分发);下载目标
8456
+ * <modelDir>/onnx/model_quantized.onnx;直连受限时设置 HF_ENDPOINT 走镜像;
8457
+ * 原子落盘(先写 .tmp 再改名)。运行时编排见 embedding.ts 的 ensureModelReady。
8618
8458
  */
8619
8459
  /** 模型标识(models/ 下目录名 = 模型 id;transformers.js 加载时的模型名)。 */
8620
8460
  const EMBEDDING_MODEL_ID = "paraphrase-multilingual-MiniLM-L12-v2";
8621
- /** transformers.js 转换仓库(Xenova org)中量化 ONNX 文件的相对路径(相对模型目录)。 */
8461
+ /** 量化 ONNX 文件的相对路径(相对模型目录)。 */
8622
8462
  const ONNX_REL = "onnx/model_quantized.onnx";
8623
- /** 随 npm 包分发的小模型文件(相对模型目录;onnx 二进制不随包分发,见 tests/package-pack.test.ts)。 */
8463
+ /** 随 npm 包分发的小模型文件(相对模型目录;onnx 二进制不随包分发)。 */
8624
8464
  const MODEL_SMALL_FILES = [
8625
8465
  "config.json",
8626
8466
  "special_tokens_map.json",
@@ -8629,19 +8469,16 @@ const MODEL_SMALL_FILES = [
8629
8469
  ];
8630
8470
  /**
8631
8471
  * HuggingFace 原始文件 URL(resolve 会 302 到 CDN,fetch 自动跟随重定向)。
8632
- * base 默认 huggingface.co,可用环境变量 HF_ENDPOINT 覆盖(直连受限时改用镜像,如 https://hf-mirror.com)。
8472
+ * base 可用环境变量 HF_ENDPOINT 覆盖(直连受限时改用镜像,如 https://hf-mirror.com)。
8633
8473
  */
8634
8474
  function modelSourceUrl(id = EMBEDDING_MODEL_ID, rel = ONNX_REL, base = process.env.HF_ENDPOINT || "https://huggingface.co") {
8635
8475
  return `${base}/Xenova/${id}/resolve/main/${rel}`;
8636
8476
  }
8637
- /** 本地目标文件绝对路径(<modelDir>/<rel>,缺省 <modelDir>/onnx/model_quantized.onnx)。 */
8477
+ /** 本地目标文件绝对路径(<modelDir>/<rel>)。 */
8638
8478
  function modelTargetPath(modelDir, rel = ONNX_REL) {
8639
8479
  return path.join(modelDir, rel);
8640
8480
  }
8641
- /**
8642
- * 是否需要下载(纯函数,供测试)。
8643
- * force 强制下载;否则目标已存在即跳过。
8644
- */
8481
+ /** 是否需要下载:force 强制;否则目标已存在即跳过。 */
8645
8482
  function needsDownload(target, { force = false } = {}) {
8646
8483
  if (force) return true;
8647
8484
  try {
@@ -8653,8 +8490,7 @@ function needsDownload(target, { force = false } = {}) {
8653
8490
  }
8654
8491
  /**
8655
8492
  * 下载模型到 <modelDir>/onnx/model_quantized.onnx。
8656
- * 开始/结束(含跳过)经 log 回调输出(默认 console.log);失败时清理临时文件,
8657
- * 并在错误消息中附带镜像建议(直连受限时设置 HF_ENDPOINT=https://hf-mirror.com)。
8493
+ * 开始/结束(含跳过)经 log 回调输出;失败时清理临时文件并在错误消息中附带镜像建议。
8658
8494
  * 可注入 fetchImpl / log(供测试)。
8659
8495
  */
8660
8496
  async function downloadModel(modelDir, { force = false, fetchImpl = fetch, log = console.log } = {}) {
@@ -8692,44 +8528,34 @@ async function downloadModel(modelDir, { force = false, fetchImpl = fetch, log =
8692
8528
  //#endregion
8693
8529
  //#region src/embedding.ts
8694
8530
  /**
8695
- * 本地语义嵌入(recall-semantic 的向量引擎)。
8696
- *
8697
- * - 模型:Xenova/paraphrase-multilingual-MiniLM-L12-v2(量化 ONNX,多语言,
8698
- * 中英 + 代码均可处理)。模型目录默认 = $DSH_HOME/plugin-data/dsh-plugin-om/
8699
- * models/<模型id>/(跨插件版本共享):随包小文件(config/tokenizer 等)缺失时
8700
- * 从打包目录复制补齐(离线可用),onnx 二进制按需下载。
8701
- * - 运行时下载:模型 onnx 缺失时由 ensureModelReady 后台下载(不阻塞、单飞、
8702
- * 失败自动重试),下载逻辑见 model-download.ts;就绪前工具告知模型。
8703
- * - 懒加载:首次调用才 `import('@huggingface/transformers')` 并加载 pipeline,
8704
- * 插件启动不阻塞;加载结果单例缓存(resetEmbedder 供测试重置)。
8705
- * - 批处理:一次推理多条文本(mean pooling + L2 归一化),输出每条的向量。
8706
- * - 相似度:cosineSimilarity 纯函数(归一化向量点积即余弦)。
8707
- *
8708
- * 依赖说明:@huggingface/transformers 为运行时依赖(v4,dtype q8 加载
8709
- * model_quantized.onnx),node 侧使用 onnxruntime-node 原生绑定。
8531
+ * 本地语义嵌入(recall-semantic 的向量引擎):Xenova/paraphrase-multilingual-MiniLM-L12-v2
8532
+ * 量化 ONNX,懒加载 + 批量推理 + cosine 相似度,模型缺失时按需后台下载。
8533
+ * 导出 BUNDLED_MODEL_DIR / EMBEDDING_MODEL_ID / resolveDshHome / sharedModelDir /
8534
+ * ensureModelSmallFiles / getEmbedder / resetEmbedder / ensureModelReady /
8535
+ * resetModelDownloads / cosineSimilarity 及 EmbedFn / ModelStatus 类型。
8536
+ * 模型目录默认为跨版本共享目录($DSH_HOME/plugin-data/dsh-plugin-om/models/<id>):
8537
+ * 随包小文件缺失时从打包目录复制补齐(离线可用),onnx 二进制按需下载
8538
+ * (见 model-download.ts)。@huggingface/transformers 为运行时依赖(dtype q8 加载
8539
+ * model_quantized.onnx,node 侧使用 onnxruntime-node 原生绑定)。
8710
8540
  */
8711
8541
  /** 当前模块所在路径(dist/ 或 src/,models 在其上一级)。 */
8712
8542
  const here = path.dirname(fileURLToPath(import.meta.url));
8713
8543
  /** 打包模型目录:<包根>/models/<model-id>/。 */
8714
8544
  const BUNDLED_MODEL_DIR = path.join(here, "..", "models", EMBEDDING_MODEL_ID);
8715
- /** DSH 用户数据根目录:$DSH_HOME 优先(空白视为未设置),缺省 ~/.dsh(与宿主解析规则一致)。 */
8545
+ /** DSH 用户数据根目录:$DSH_HOME 优先(空白视为未设置),缺省 ~/.dsh */
8716
8546
  function resolveDshHome() {
8717
8547
  const fromEnv = process.env.DSH_HOME;
8718
8548
  if (fromEnv !== void 0 && fromEnv.trim().length > 0) return path.resolve(fromEnv);
8719
8549
  return path.join(homedir(), ".dsh");
8720
8550
  }
8721
- /**
8722
- * 跨插件版本共享的默认模型目录:$DSH_HOME/plugin-data/dsh-plugin-om/models/<模型id>。
8723
- * 不随插件包(版本)变化,升级/重装插件后仍复用同一份已下载模型(onnx 不重复下载)。
8724
- */
8551
+ /** 跨插件版本共享的默认模型目录:$DSH_HOME/plugin-data/dsh-plugin-om/models/<模型id>。 */
8725
8552
  function sharedModelDir() {
8726
8553
  return path.join(resolveDshHome(), "plugin-data", "dsh-plugin-om", "models", EMBEDDING_MODEL_ID);
8727
8554
  }
8728
8555
  /**
8729
- * 补齐模型目录的随包小文件(config/tokenizer 等):modelDir 与打包目录不同
8730
- * (如共享目录)时,把打包目录中缺失的小文件复制过去,保证模型可离线加载。
8731
- * 已存在的文件不覆盖(同一模型文件跨版本稳定);打包目录本身缺失则跳过。
8732
- * 幂等、无网络。onnx 二进制不在此列(按需下载,见 model-download.ts)。
8556
+ * 补齐模型目录的随包小文件(config/tokenizer 等):modelDir 与打包目录不同时,
8557
+ * 把打包目录中缺失的小文件复制过去(已存在不覆盖;打包目录缺失则跳过)。
8558
+ * 幂等、无网络;onnx 二进制不在此列。
8733
8559
  */
8734
8560
  function ensureModelSmallFiles(modelDir, bundledDir = BUNDLED_MODEL_DIR) {
8735
8561
  if (path.resolve(modelDir) === path.resolve(bundledDir)) return;
@@ -8746,7 +8572,7 @@ const BATCH_SIZE = 32;
8746
8572
  /** 懒加载中的 pipeline 单例(null = 尚未加载/已重置)。 */
8747
8573
  let pipelinePromise = null;
8748
8574
  /**
8749
- * 获取(或加载)嵌入函数。首次调用动态 import transformers 并加载本地模型;
8575
+ * 获取(或加载)嵌入函数。首次调用动态 import transformers 并加载本地模型,
8750
8576
  * 之后复用同一 pipeline。失败时抛出可读错误(调用方自行降级)。
8751
8577
  */
8752
8578
  function getEmbedder(modelDir = BUNDLED_MODEL_DIR) {
@@ -8766,7 +8592,6 @@ function getEmbedder(modelDir = BUNDLED_MODEL_DIR) {
8766
8592
  pooling: "mean",
8767
8593
  normalize: true
8768
8594
  });
8769
- /** 结果张量:dims = [batch, hidden],data 为扁平数组。 */
8770
8595
  const dims = result.dims;
8771
8596
  const hidden = dims.length > 1 ? dims[1] : 0;
8772
8597
  const data = result.data;
@@ -8784,12 +8609,8 @@ function getEmbedder(modelDir = BUNDLED_MODEL_DIR) {
8784
8609
  const inflightDownloads = /* @__PURE__ */ new Map();
8785
8610
  /**
8786
8611
  * 确保模型就绪(运行时按需下载编排)。
8787
- * - 本地 onnx 已存在 → 'ready'(不触发下载)。
8788
- * - 缺失 → 启动后台下载(不阻塞,单飞)并返回 'downloading';下载失败仅调用
8789
- * warn 记录日志并结束本次尝试,下次调用会重新触发下载(自动重试)。
8790
- * - warn 可选:下载失败时的日志回调(默认静默;apply 注入 ctx.logger.warn)。
8791
- * - log 可选:下载开始/结束日志回调(默认静默;apply 注入 console.log + ctx.logger.info 双通道)。
8792
- * - fetchImpl 可注入(测试传替身,默认全局 fetch)。
8612
+ * 本地 onnx 已存在 → 'ready';缺失 → 启动后台下载(不阻塞、单飞,失败经 warn 记录,
8613
+ * 下次调用自动重试)并返回 'downloading'。warn/log/fetchImpl 可注入(测试传替身)。
8793
8614
  */
8794
8615
  function ensureModelReady(modelDir = BUNDLED_MODEL_DIR, warn = () => {}, fetchImpl, log = () => {}) {
8795
8616
  ensureModelSmallFiles(modelDir);
@@ -8807,18 +8628,12 @@ function ensureModelReady(modelDir = BUNDLED_MODEL_DIR, warn = () => {}, fetchIm
8807
8628
  }
8808
8629
  return Promise.resolve("downloading");
8809
8630
  }
8810
- /**
8811
- * 两个向量的余弦相似度(向量已 L2 归一化时点积即余弦;这里兜底再归一化)。
8812
- * 任一向量为零向量返回 0。
8813
- */
8631
+ /** 两个向量的余弦相似度(向量已 L2 归一化时点积即余弦;这里兜底再归一化)。零向量返回 0。 */
8814
8632
  function cosineSimilarity(a, b) {
8815
8633
  const len = Math.min(a.length, b.length);
8816
8634
  if (len === 0) return 0;
8817
- /** 点积累加。 */
8818
8635
  let dot = 0;
8819
- /** a 的模平方。 */
8820
8636
  let normA = 0;
8821
- /** b 的模平方。 */
8822
8637
  let normB = 0;
8823
8638
  for (let i = 0; i < len; i += 1) {
8824
8639
  const x = a[i];
@@ -8833,39 +8648,34 @@ function cosineSimilarity(a, b) {
8833
8648
  //#endregion
8834
8649
  //#region src/config.ts
8835
8650
  /**
8836
- * 插件配置:默认值与合并(preset 行 config 可覆盖全部键)。
8837
- * 宽松校验:不合法的键被忽略、不合法的值回退默认值——配置错误不影响插件加载。
8651
+ * 插件配置:默认值与合并。
8652
+ * 导出 PluginConfig / DEFAULT_CONFIG / resolveConfig;宽松校验——未知键忽略、
8653
+ * 非法值回退默认值,配置错误不影响插件加载。
8838
8654
  */
8839
8655
  /** 默认配置(冻结对象,resolveConfig 合并的基底;debug 缺省值在解析时按 NODE_ENV 判定)。 */
8840
8656
  const DEFAULT_CONFIG = Object.freeze({
8841
- observeThresholdTokens: 1e5,
8842
- reflectThresholdTokens: 3e4,
8843
- compressMaxTokens: 1e4,
8844
- observeChunkTokens: 3e4,
8845
- observeChunkMaxTokens: 5e3,
8846
- observeChunkParallelism: 2,
8657
+ observeThresholdTokens: 45e3,
8658
+ reflectThresholdTokens: 12e4,
8659
+ compressMaxTokens: void 0,
8847
8660
  rateLimitWaitMs: 6e4,
8848
- tailMessageCount: 10,
8849
- compressRetryCount: 10,
8661
+ tailMessageCount: 5,
8662
+ compressRetryCount: 5,
8850
8663
  omEnabled: true,
8851
8664
  debug: false,
8852
8665
  recallEnabled: true,
8853
8666
  semanticRecallEnabled: true,
8854
8667
  modelDir: sharedModelDir()
8855
8668
  });
8856
- /** 数值键校验参数表:键名 + [integer]。不限制取值区间——用户提供的值按原样接受(便于调试)。 */
8669
+ /** 数值键校验参数表:键名 + [是否必须为整数] */
8857
8670
  const NUMBER_KEYS = [
8858
8671
  ["observeThresholdTokens", true],
8859
8672
  ["reflectThresholdTokens", true],
8860
8673
  ["compressMaxTokens", true],
8861
- ["observeChunkTokens", true],
8862
- ["observeChunkMaxTokens", true],
8863
- ["observeChunkParallelism", true],
8864
8674
  ["rateLimitWaitMs", true],
8865
8675
  ["tailMessageCount", true],
8866
8676
  ["compressRetryCount", true]
8867
8677
  ];
8868
- /** 归一化原始配置输入:缺省 / null / 空串(含空白串)/ 非对象 视为空对象(全部用默认值)。 */
8678
+ /** 归一化原始配置输入:缺省 / null / 空串 / 非对象视为空对象。 */
8869
8679
  function normalizeConfigInput(raw) {
8870
8680
  if (raw === void 0 || raw === null) return {};
8871
8681
  if (typeof raw === "string" && raw.trim() === "") return {};
@@ -8879,17 +8689,11 @@ function resolveBoolean(raw, defaultValue) {
8879
8689
  if (typeof raw === "boolean") return raw;
8880
8690
  return defaultValue;
8881
8691
  }
8882
- /**
8883
- * 解析合并配置:未知键忽略、不合法值回退默认值,返回冻结的完整配置。
8884
- * 允许所有配置留空——缺省 / null / 空串的键回退默认值,未给出的键亦取默认值。
8885
- */
8692
+ /** 解析合并配置:未知键忽略、不合法值回退默认值,返回冻结的完整配置。 */
8886
8693
  function resolveConfig(raw) {
8887
- /** 原始输入(留空 / 不合法视为空对象)。 */
8888
8694
  const input = normalizeConfigInput(raw);
8889
- /** 合并结果(以默认值为基底)。 */
8890
8695
  const config = { ...DEFAULT_CONFIG };
8891
8696
  for (const [key, integer] of NUMBER_KEYS) {
8892
- /** 该键的原始值(留空 / 不合法则跳过,保持默认值)。 */
8893
8697
  const value = input[key];
8894
8698
  if (value === void 0 || value === null) continue;
8895
8699
  if (typeof value === "string" && value.trim() === "") continue;
@@ -8897,15 +8701,10 @@ function resolveConfig(raw) {
8897
8701
  if (integer && !Number.isInteger(value)) continue;
8898
8702
  config[key] = value;
8899
8703
  }
8900
- /** OM 自动压缩开关(缺省启用;false 关闭自动压缩,recall 工具不受影响)。 */
8901
8704
  config.omEnabled = resolveBoolean(input.omEnabled, true);
8902
- /** 步骤级日志开关(缺省按 NODE_ENV !== 'production' 判定)。 */
8903
8705
  config.debug = resolveBoolean(input.debug, process.env.NODE_ENV !== "production");
8904
- /** recall 工具开关(缺省启用)。 */
8905
8706
  config.recallEnabled = resolveBoolean(input.recallEnabled, true);
8906
- /** recall-semantic 工具开关(缺省启用)。 */
8907
8707
  config.semanticRecallEnabled = resolveBoolean(input.semanticRecallEnabled, true);
8908
- /** modelDir 原始值(留空 / 非空串以外回退默认;非空串必须为字符串)。 */
8909
8708
  const modelDir = input.modelDir;
8910
8709
  if (typeof modelDir === "string" && modelDir.trim() !== "") config.modelDir = modelDir;
8911
8710
  return Object.freeze(config);
@@ -8913,48 +8712,115 @@ function resolveConfig(raw) {
8913
8712
  //#endregion
8914
8713
  //#region src/json-schema.ts
8915
8714
  /**
8916
- * zod schema → 工具 wire 参数 JSON Schema 的转换工具。
8917
- *
8918
- * 官方 API(如 api.deepseek.com /chat/completions)要求 tools[].function.parameters
8919
- * 为根级带 type:'object' 的标准 JSON Schema;工具的参数定义统一由 zod 的 toJSONSchema
8920
- * 生成,避免手写属性 map 与执行期解析 schema 双份定义漂移。剥除 $schema 元键与
8921
- * additionalProperties:未知键不在 wire 层拒绝,执行期仍由 zod 解析剥离。
8715
+ * zod schema → 工具 wire 参数 JSON Schema 的转换工具。导出 parametersFromZod。
8716
+ * 工具参数定义统一由 zod 的 toJSONSchema 生成(.describe() 描述透传),剥除
8717
+ * $schema 元键与 additionalProperties(未知键在执行期由 zod 解析剥离)。
8922
8718
  */
8923
- /** 由 zod schema 生成 wire 参数 JSON Schema(根 type:'object',含 properties/required 与 describe 描述)。 */
8719
+ /** 由 zod schema 生成 wire 参数 JSON Schema(根 type:'object',含 properties/required)。 */
8924
8720
  function parametersFromZod(schema) {
8925
8721
  const { $schema: _schema, additionalProperties: _additional, ...rest } = toJSONSchema(schema);
8926
8722
  return rest;
8927
8723
  }
8928
8724
  //#endregion
8725
+ //#region src/recall-output.ts
8726
+ /** 纯文本结果(无图片)的便捷构造。 */
8727
+ function textOnly(text) {
8728
+ return {
8729
+ text,
8730
+ images: []
8731
+ };
8732
+ }
8733
+ /** 一张图片的文本标注行:[图片附件:name(mediaType W×H,N bytes)]。 */
8734
+ function imageNote(ref) {
8735
+ return `[图片附件${ref.name ? `:${ref.name}` : ""}(${ref.mediaType} ${ref.width}×${ref.height},${ref.bytes} bytes)]`;
8736
+ }
8737
+ /** 输出值 wire schema:宿主按此校验 execute 返回值。 */
8738
+ const RECALL_OUTPUT_SCHEMA = {
8739
+ type: "object",
8740
+ additionalProperties: false,
8741
+ properties: {
8742
+ text: {
8743
+ type: "string",
8744
+ description: "完整消息内容的文本部分(带图位置以 [图片附件:…] 标注行提示)。"
8745
+ },
8746
+ images: {
8747
+ type: "array",
8748
+ description: "文本中引用的图片附件元数据(按出现顺序;渲染为随文本之后的 image 内容块,宿主按 attachmentId 解析字节)。",
8749
+ items: {
8750
+ type: "object",
8751
+ additionalProperties: false,
8752
+ properties: {
8753
+ attachmentId: {
8754
+ type: "string",
8755
+ description: "附件服务的持久图片 id。"
8756
+ },
8757
+ mediaType: {
8758
+ type: "string",
8759
+ description: "图片媒体类型(如 image/png)。"
8760
+ },
8761
+ bytes: {
8762
+ type: "integer",
8763
+ description: "字节数。"
8764
+ },
8765
+ width: {
8766
+ type: "integer",
8767
+ description: "固有显示宽度(px)。"
8768
+ },
8769
+ height: {
8770
+ type: "integer",
8771
+ description: "固有显示高度(px)。"
8772
+ },
8773
+ name: {
8774
+ type: "string",
8775
+ description: "原始文件名(可选)。"
8776
+ }
8777
+ },
8778
+ required: [
8779
+ "attachmentId",
8780
+ "mediaType",
8781
+ "bytes",
8782
+ "width",
8783
+ "height"
8784
+ ]
8785
+ }
8786
+ }
8787
+ },
8788
+ required: ["text", "images"]
8789
+ };
8790
+ /** 把输出值投影为模型内容:text 块在前,images 逐个投影为 image 块。 */
8791
+ function renderRecallOutput(value) {
8792
+ const blocks = [{
8793
+ type: "text",
8794
+ text: value.text
8795
+ }];
8796
+ for (const ref of value.images) {
8797
+ const block = {
8798
+ type: "image",
8799
+ attachment: ref
8800
+ };
8801
+ blocks.push(block);
8802
+ }
8803
+ return blocks;
8804
+ }
8805
+ //#endregion
8929
8806
  //#region src/recall.ts
8930
8807
  /**
8931
- * recall 工具:按「完整消息」序号(index)回看原始会话(start/end 为完整消息 index,
8932
- * offset 为相对 start 的完整消息步数)。完整消息即 `用户消息`(user_message kind 为 user
8933
- * 的部分)、`系统消息`(user_message 中其余 kind 的部分)、`模型输出文本`和`具有result的
8934
- * toolcall`;首条完整消息的 index 为 0,后续递增、会话内全局稳定,与摘要日志条目同一套编号。
8935
- * recall 自身不设输出上限:超大的工具结果由 tool-result-pruner 裁剪(pruneContent),
8936
- * 输出 token 由 pruner 配置控制。
8937
- *
8938
- * 参数由 zod schema(recallArgsSchema)统一描述:wire 参数 JSON Schema(根 type:'object',
8939
- * 各字段描述来自 .describe())直接发给模型,execute 入口再做运行时校验(start 必填;
8940
- * end/offset 至少提供一个;非法参数抛出可读错误)。
8808
+ * recall 工具:按完整消息 index 区间回看原始会话(含被压缩内容)。
8809
+ * 导出 recallArgsSchema / RecallArgs / parseRecallArgs / buildRecallTool。
8810
+ * 输出值为 { text, images }(契约见 recall-output.ts);recall 自身不设输出上限,
8811
+ * 超大工具结果由 tool-result-pruner 裁剪。
8941
8812
  */
8942
8813
  /**
8943
- * recall 工具参数 schema:start 必填(number);end 与 offset 至少提供一个
8944
- * (二者同时给出时 end 优先,与 execute 语义一致);未知键自动剥离。
8945
- * 各字段描述经 .describe() 透传到 wire JSON Schema(生成见 json-schema.ts)。
8814
+ * recall 工具参数 schema:start 必填;end 与 offset 至少提供一个(同时给出时 end 优先)。
8815
+ * 各字段描述经 .describe() 透传到 wire JSON Schema。
8946
8816
  */
8947
8817
  const recallArgsSchema = z.object({
8948
8818
  start: z.number().describe("完整消息序号(index),作为基准边界,和 end 或 offset 配合,指定区间;end 与 offset 至少提供一个(同时给出时 end 优先)。"),
8949
8819
  end: z.number().optional().describe("与 offset 互斥,指定区间的另一个边界。"),
8950
8820
  offset: z.number().optional().describe("与 end 互斥。相对 start 的步数:正数向后、负数向前。")
8951
8821
  }).refine((args) => args.end !== void 0 || args.offset !== void 0, { message: "end 与 offset 至少提供一个" });
8952
- /**
8953
- * 解析并校验 recall 调用参数:校验失败时抛出首个校验问题的可读消息
8954
- * (普通 Error 而非 ZodError,兼容 SDK 展示)。
8955
- */
8822
+ /** 解析并校验 recall 调用参数:失败时抛出首个校验问题的可读消息(普通 Error,兼容 SDK 展示)。 */
8956
8823
  function parseRecallArgs(raw) {
8957
- /** safeParse 结果(成功时 data 为 RecallArgs,失败时 error 携带问题列表)。 */
8958
8824
  const result = recallArgsSchema.safeParse(raw);
8959
8825
  if (!result.success) throw new Error(result.error.issues.map((issue) => `${issue.path.join(".") || "args"}: ${issue.message}`).join("; "));
8960
8826
  return result.data;
@@ -8966,83 +8832,70 @@ function parseRecallArgs(raw) {
8966
8832
  function buildRecallTool(getPruner) {
8967
8833
  return {
8968
8834
  name: "recall",
8969
- description: `${COMPLETE_MESSAGE_DEFINITION}此工具可以精确查询完整消息。用index指定一个区间,返回区间内所有完整消息的内容。`,
8835
+ description: `${COMPLETE_MESSAGE_DEFINITION}此工具可以精确查询完整消息。用index指定一个区间,返回区间内所有完整消息的内容;完整消息携带的图片附件随结果保留(文本段以 [图片附件:…] 标注,随后附 image 内容块)。`,
8970
8836
  parameters: parametersFromZod(recallArgsSchema),
8971
8837
  output: {
8972
- schema: { type: "string" },
8973
- render: (_args, value) => [{
8974
- type: "text",
8975
- text: String(value)
8976
- }]
8838
+ schema: RECALL_OUTPUT_SCHEMA,
8839
+ render: (_args, value) => renderRecallOutput(value)
8977
8840
  },
8978
8841
  async execute(args, exec) {
8979
- /** 解析并校验后的调用参数(不满足 schema 时抛出可读错误)。 */
8980
8842
  const { start, end, offset } = parseRecallArgs(args);
8981
- /** 当前会话(缺失则无法回看)。 */
8982
8843
  const session = exec.agent?.session;
8983
- if (!session) return "会话异常";
8984
- if (!isMainSession(session)) return "recall 仅主会话可用";
8985
- /** 完整消息索引(index = 数组下标,0 起)。 */
8844
+ if (!session) return textOnly("会话异常");
8845
+ if (!isMainSession(session)) return textOnly("recall 仅主会话可用");
8986
8846
  const cms = indexCompleteMessages(session);
8987
- /** 起始下标(非整数 floor;越界返回提示)。 */
8988
8847
  const startIndex = Number.isFinite(start) ? Math.floor(start) : 0;
8989
- if (startIndex < 0 || startIndex >= cms.length) return `start ${start} 越界(完整消息共 ${cms.length} 条,index 范围 0..${cms.length - 1})`;
8990
- /** 终点下标(end 优先,否则 startIndex + offset)。 */
8848
+ if (startIndex < 0 || startIndex >= cms.length) return textOnly(`start ${start} 越界(完整消息共 ${cms.length} 条,index 范围 0..${cms.length - 1})`);
8991
8849
  let endIndex;
8992
8850
  if (end !== void 0) {
8993
- /** end 取整(非整数 floor)。 */
8994
8851
  const e = Number.isFinite(end) ? Math.floor(end) : 0;
8995
- if (e < 0 || e >= cms.length) return `end ${end} 越界(完整消息共 ${cms.length} 条,index 范围 0..${cms.length - 1})`;
8852
+ if (e < 0 || e >= cms.length) return textOnly(`end ${end} 越界(完整消息共 ${cms.length} 条,index 范围 0..${cms.length - 1})`);
8996
8853
  endIndex = e;
8997
8854
  } else {
8998
- /** offset 数值(refine 保证此分支 offset 已提供,schema 保证为 number)。 */
8999
8855
  const raw = offset ?? 0;
9000
8856
  endIndex = startIndex + (Number.isFinite(raw) ? Math.floor(raw) : 0);
9001
8857
  }
9002
- /** 区间下界(钳制到 [0, len-1])。 */
9003
8858
  const lo = Math.max(0, Math.min(startIndex, endIndex));
9004
- /** 区间上界(钳制到 [0, len-1])。 */
9005
8859
  const hi = Math.min(cms.length - 1, Math.max(startIndex, endIndex));
9006
- /** tool-result-pruner(可选,裁剪超大工具结果)。 */
9007
8860
  const pruner = getPruner?.();
9008
- /** 渲染结果缓冲(每条完整消息一段,标 index + 类型)。 */
9009
8861
  const parts = [];
8862
+ const images = [];
9010
8863
  for (let i = lo; i <= hi; i += 1) {
9011
- /** 当前完整消息。 */
9012
8864
  const cm = cms[i];
9013
8865
  if (!cm) continue;
9014
- /** 该条完整消息的呈现文本(pruner 裁剪超大结果;单条失败不影响整体)。 */
9015
8866
  let text = "";
8867
+ let cmImages = [];
9016
8868
  try {
9017
- text = renderCompleteMessage(session, cm, pruner);
8869
+ const rendered = renderCompleteMessageParts(session, cm, pruner);
8870
+ text = rendered.text;
8871
+ cmImages = rendered.images;
9018
8872
  } catch {}
9019
- /** 类型标注(toolcall 附调用 id,便于模型关联)。 */
9020
8873
  const callAttr = cm.type === "toolcall" && cm.callId ? ` callId=${cm.callId}` : "";
9021
- parts.push(`-- [index ${cm.index}] ${cm.type}${callAttr} --\n${text}`);
8874
+ let body = text;
8875
+ if (cmImages.length > 0) {
8876
+ body += (body === "" ? "" : "\n") + cmImages.map(imageNote).join("\n");
8877
+ images.push(...cmImages);
8878
+ }
8879
+ parts.push(`-- [index ${cm.index}] ${cm.type}${callAttr} --\n${body}`);
9022
8880
  }
9023
- if (parts.length === 0) return "指定区间没有完整消息";
9024
- return parts.join("\n\n");
8881
+ if (parts.length === 0) return textOnly("指定区间没有完整消息");
8882
+ return {
8883
+ text: parts.join("\n\n"),
8884
+ images
8885
+ };
9025
8886
  }
9026
8887
  };
9027
8888
  }
9028
8889
  //#endregion
9029
8890
  //#region src/semantic-recall.ts
9030
8891
  /**
9031
- * recall-semantic 工具:按自然语言 query 对会话全部完整消息(含被压缩/遮蔽的)做语义
9032
- * 检索,返回最匹配的完整消息与匹配说明。完整消息即 `用户消息`(user_message 中 kind 为 user
9033
- * 的部分)、`系统消息`(user_message 中其余 kind 的部分)、`模型输出文本`和`具有result的
9034
- * toolcall`;首条完整消息的 index 0,后续递增、会话内全局稳定。
9035
- *
9036
- * - 参数:query 必填;top_k(默认 3,1-10);start/end/offset 限定检索区间
9037
- * (意义同 recall:start 为基准边界,end offset 二选一;end 优先)。
9038
- * wire 参数 JSON Schema 由本 schema 经 toJSONSchema 生成(各字段描述来自 .describe(),
9039
- * 见 json-schema.ts)。
9040
- * - 区间缺省(start 未提供)→ 检索全部消息;区间不合法(start/end 越界等)→
9041
- * 不报错,回退全量检索并在输出中明确告知(模型可见)。
9042
- * - 向量:本地 ONNX embedding(embedding.ts,懒加载 + 批量);相似度 = cosine。
9043
- * - 输出:命中消息的 index/类型(toolcall 附调用 id)+ 完整渲染文本 + 匹配说明(相似度、
9044
- * 命中的关键词);超大结果由 tool-result-pruner 裁剪(同 recall)。
9045
- * - 仅主会话可用(subagent 拒绝,与 recall 一致)。
8892
+ * recall-semantic 工具:按自然语言 query 对会话全部完整消息(含被压缩/遮蔽的)做
8893
+ * 语义检索,返回最匹配的完整消息与匹配说明。
8894
+ * 导出 semanticRecallArgsSchema / SemanticRecallArgs / parseSemanticRecallArgs /
8895
+ * resolveSemanticRange / tokenize / matchExplanation / buildSemanticRecallTool。
8896
+ * 向量为本地 ONNX 嵌入(cosine 相似度);只匹配文本,纯图片消息不进候选池;
8897
+ * 区间缺省检索全部、区间不合法回退全量并在输出中告知;输出契约同 recall
8898
+ * ({ text, images },超大结果由 tool-result-pruner 裁剪);仅主会话可用。
9046
8899
  */
9047
8900
  /** recall-semantic 工具参数 schema:query 必填;top_k 默认 3(1-10);区间参数均可选。 */
9048
8901
  const semanticRecallArgsSchema = z.object({
@@ -9054,16 +8907,14 @@ const semanticRecallArgsSchema = z.object({
9054
8907
  }).refine((args) => args.query.trim().length > 0, { message: "query 不能为空" });
9055
8908
  /** 解析并校验参数:失败时抛出可读错误(普通 Error 而非 ZodError)。 */
9056
8909
  function parseSemanticRecallArgs(raw) {
9057
- /** safeParse 结果。 */
9058
8910
  const result = semanticRecallArgsSchema.safeParse(raw);
9059
8911
  if (!result.success) throw new Error(result.error.issues.map((issue) => `${issue.path.join(".") || "args"}:${issue.message}`).join("; "));
9060
8912
  return result.data;
9061
8913
  }
9062
8914
  /**
9063
8915
  * 解析检索区间(完整消息 index,含端点)。
9064
- * - start 未提供 → 全量(fallback=false)。
9065
- * - start 提供:end/offset 计算终点(语义同 recall,end 优先);
9066
- * start/end 越界或区间为空 → fallback=true(全量 + 提示)。
8916
+ * start 未提供 → 全量(fallback=false);start/end 越界或区间为空 → fallback=true
8917
+ * (回退全量并在输出中告知)。
9067
8918
  */
9068
8919
  function resolveSemanticRange(total, args) {
9069
8920
  if (total <= 0) return {
@@ -9076,17 +8927,14 @@ function resolveSemanticRange(total, args) {
9076
8927
  hi: total - 1,
9077
8928
  fallback: false
9078
8929
  };
9079
- /** start 取整(非有限数按 -1 处理,走越界回退)。 */
9080
8930
  const start = Number.isFinite(args.start) ? Math.floor(args.start) : -1;
9081
8931
  if (start < 0 || start >= total) return {
9082
8932
  lo: 0,
9083
8933
  hi: total - 1,
9084
8934
  fallback: true
9085
8935
  };
9086
- /** 终点 index(end 优先;否则 start + offset 取整)。 */
9087
8936
  let end;
9088
8937
  if (args.end !== void 0) {
9089
- /** end 取整(非有限数按 -1 处理,走越界回退)。 */
9090
8938
  const found = Number.isFinite(args.end) ? Math.floor(args.end) : -1;
9091
8939
  if (found < 0 || found >= total) return {
9092
8940
  lo: 0,
@@ -9096,9 +8944,7 @@ function resolveSemanticRange(total, args) {
9096
8944
  end = found;
9097
8945
  } else if (args.offset !== void 0) end = start + (Number.isFinite(args.offset) ? Math.floor(args.offset) : 0);
9098
8946
  else end = start;
9099
- /** 区间下界(钳制)。 */
9100
8947
  const lo = Math.max(0, Math.min(start, end));
9101
- /** 区间上界(钳制)。 */
9102
8948
  const hi = Math.min(total - 1, Math.max(start, end));
9103
8949
  if (lo > hi) return {
9104
8950
  lo: 0,
@@ -9113,70 +8959,53 @@ function resolveSemanticRange(total, args) {
9113
8959
  }
9114
8960
  /** 简易分词:按非字母数字切分为小写词元(匹配要点用,非检索核心)。 */
9115
8961
  function tokenize(text) {
9116
- /** 词元集合。 */
9117
8962
  const tokens = /* @__PURE__ */ new Set();
9118
8963
  for (const part of text.toLowerCase().split(/[^\p{L}\p{N}]+/u)) if (part.length > 0) tokens.add(part);
9119
8964
  return tokens;
9120
8965
  }
9121
8966
  /** 命中消息的匹配说明行:相似度 + 与 query 共有的关键词(最多 8 个)。 */
9122
8967
  function matchExplanation(query, text, score) {
9123
- /** query 词元。 */
9124
8968
  const qTokens = tokenize(query);
9125
- /** 命中消息文本词元。 */
9126
8969
  const mTokens = tokenize(text);
9127
- /** 共有词元(交集,按 query 词序)。 */
9128
8970
  const shared = [];
9129
8971
  for (const token of qTokens) {
9130
8972
  if (mTokens.has(token)) shared.push(token);
9131
8973
  if (shared.length >= 8) break;
9132
8974
  }
9133
- /** 关键词部分(无共有词则省略)。 */
9134
8975
  const keywords = shared.length > 0 ? `,命中关键词: ${shared.join(" ")}` : "";
9135
8976
  return `相似度 ${score.toFixed(3)}${keywords}`;
9136
8977
  }
9137
- /** 模型未就绪时返回给模型的文案(告知即可;下载完成后无需另行通知,直接再次调用)。 */
8978
+ /** 模型未就绪时返回给模型的文案(下载完成后直接再次调用即可)。 */
9138
8979
  const SEMANTIC_MODEL_NOT_READY_MESSAGE = "语义检索暂不可用,本地向量模型尚未就绪。";
9139
8980
  /** 构建 recall-semantic 工具定义(embedder/modelStatus 可注入,测试传替身;缺省用本地模型)。 */
9140
8981
  function buildSemanticRecallTool(options) {
9141
- /** pruner 获取器(可选,裁剪超大工具结果)。 */
9142
8982
  const getPruner = options?.getPruner ?? (() => void 0);
9143
- /** 嵌入函数(注入替身或懒加载本地模型)。 */
9144
8983
  const embed = options?.embedder ?? ((texts) => getEmbedder().then((fn) => fn(texts)));
9145
8984
  return {
9146
8985
  name: "recall-semantic",
9147
- description: `${COMPLETE_MESSAGE_DEFINITION}此工具可以按自然语言含义,检索最符合的完整消息。默认在全消息范围搜索,可以指定区间。`,
8986
+ description: `${COMPLETE_MESSAGE_DEFINITION}此工具可以按自然语言含义,检索最符合的完整消息。默认在全消息范围搜索,可以指定区间。注意:语义检索只匹配文本,图片内容不参与匹配(纯图片消息无法命中,也不能按图片内容检索);命中的完整消息携带的图片附件随结果保留(文本段以 [图片附件:…] 标注,随后附 image 内容块),与 recall 相同。`,
9148
8987
  parameters: parametersFromZod(semanticRecallArgsSchema),
9149
8988
  output: {
9150
- schema: { type: "string" },
9151
- render: (_args, value) => [{
9152
- type: "text",
9153
- text: String(value)
9154
- }]
8989
+ schema: RECALL_OUTPUT_SCHEMA,
8990
+ render: (_args, value) => renderRecallOutput(value)
9155
8991
  },
9156
8992
  async execute(args, exec) {
9157
- /** 解析并校验后的调用参数。 */
9158
8993
  const { query, top_k, start, end, offset } = parseSemanticRecallArgs(args);
9159
- /** 当前会话。 */
9160
8994
  const session = exec.agent?.session;
9161
- if (!session) return "会话异常";
9162
- if (!isMainSession(session)) return "recall-semantic 仅主会话可用";
9163
- if ((await options?.modelStatus?.() ?? "ready") !== "ready") return SEMANTIC_MODEL_NOT_READY_MESSAGE;
9164
- /** 完整消息索引(全部事件,含被压缩/遮蔽)。 */
8995
+ if (!session) return textOnly("会话异常");
8996
+ if (!isMainSession(session)) return textOnly("recall-semantic 仅主会话可用");
8997
+ if ((await options?.modelStatus?.() ?? "ready") !== "ready") return textOnly(SEMANTIC_MODEL_NOT_READY_MESSAGE);
9165
8998
  const cms = indexCompleteMessages(session);
9166
- if (cms.length === 0) return "会话中没有可检索的消息";
9167
- /** 检索区间(完整消息 index;区间不合法时回退全量并标记)。 */
8999
+ if (cms.length === 0) return textOnly("会话中没有可检索的消息");
9168
9000
  const range = resolveSemanticRange(cms.length, {
9169
9001
  start,
9170
9002
  end,
9171
9003
  offset
9172
9004
  });
9173
- /** 候选完整消息渲染文本(区间内;跳过渲染失败/空文本)。 */
9174
9005
  const candidates = [];
9175
9006
  for (let i = range.lo; i <= range.hi; i += 1) {
9176
- /** 当前完整消息。 */
9177
9007
  const cm = cms[i];
9178
9008
  if (!cm) continue;
9179
- /** 渲染文本(嵌入用完整文本,输出时再裁剪)。 */
9180
9009
  let text = "";
9181
9010
  try {
9182
9011
  text = renderCompleteMessage(session, cm);
@@ -9186,67 +9015,55 @@ function buildSemanticRecallTool(options) {
9186
9015
  text
9187
9016
  });
9188
9017
  }
9189
- if (candidates.length === 0) return "指定范围内没有可检索的消息";
9190
- /** 查询向量(单条)。 */
9018
+ if (candidates.length === 0) return textOnly("指定范围内没有可检索的消息");
9191
9019
  const [queryVec] = await embed([query]);
9192
- if (!queryVec) return "语义检索失败:无法生成查询向量";
9193
- /** 候选文本批量嵌入。 */
9020
+ if (!queryVec) return textOnly("语义检索失败:无法生成查询向量");
9194
9021
  const vectors = await embed(candidates.map((c) => c.text));
9195
- /** 打分结果(相似度 + 候选)。 */
9196
9022
  const scored = candidates.map((c, i) => ({
9197
9023
  cm: c.cm,
9198
9024
  text: c.text,
9199
9025
  score: cosineSimilarity(queryVec, vectors[i] ?? /* @__PURE__ */ new Float32Array(0))
9200
9026
  }));
9201
9027
  scored.sort((a, b) => b.score - a.score);
9202
- /** 返回条数(top_k 默认 3)。 */
9203
9028
  const limit = top_k ?? 3;
9204
9029
  const hits = scored.slice(0, limit);
9205
- /** 结果缓冲。 */
9206
9030
  const parts = [];
9207
- /** 范围描述(回退时明确告知模型)。 */
9031
+ const images = [];
9208
9032
  const rangeNote = range.fallback ? "指定区间不合法(start/end 越界等),已回退检索全部消息" : start === void 0 ? `检索全部消息(${candidates.length} 条可嵌入)` : `检索区间 [${range.lo}..${range.hi}](${candidates.length} 条可嵌入)`;
9209
9033
  parts.push(`查询: ${query}`);
9210
9034
  parts.push(rangeNote);
9211
9035
  parts.push(`匹配 TOP-${hits.length}(共 ${scored.length} 条候选):`);
9212
9036
  for (let i = 0; i < hits.length; i += 1) {
9213
- /** 当前命中。 */
9214
9037
  const hit = hits[i];
9215
9038
  if (!hit) continue;
9216
- /** 类型标注(toolcall 附调用 id,与 recall 输出一致)。 */
9217
9039
  const callAttr = hit.cm.type === "toolcall" && hit.cm.callId ? ` callId=${hit.cm.callId}` : "";
9218
- /** 输出文本(pruner 裁剪超大内容;裁剪失败保留嵌入用文本)。 */
9219
9040
  let text = hit.text;
9041
+ let hitImages = [];
9220
9042
  try {
9221
- /** tool-result-pruner(可选)。 */
9222
9043
  const pruner = getPruner();
9223
- text = renderCompleteMessage(session, hit.cm, pruner);
9044
+ const rendered = renderCompleteMessageParts(session, hit.cm, pruner);
9045
+ text = rendered.text;
9046
+ hitImages = rendered.images;
9224
9047
  } catch {}
9225
9048
  parts.push(`-- [${i + 1}] index ${hit.cm.index} ${hit.cm.type}${callAttr} — ${matchExplanation(query, hit.text, hit.score)} --`);
9226
- parts.push(text);
9049
+ parts.push(hitImages.length > 0 ? [text, ...hitImages.map(imageNote)].join("\n") : text);
9050
+ images.push(...hitImages);
9227
9051
  }
9228
- return parts.join("\n\n");
9052
+ return {
9053
+ text: parts.join("\n\n"),
9054
+ images
9055
+ };
9229
9056
  }
9230
9057
  };
9231
9058
  }
9232
9059
  //#endregion
9233
9060
  //#region src/index.ts
9234
9061
  /**
9235
- * dsh-plugin-om Observational Memory(OM)上下文压缩 + recall 检索插件。
9236
- * 不依赖特定 tool mode(native / code / both 均可运行)。
9237
- *
9238
- * 模块:
9239
- * - recall.ts recall({ start, end?, offset? }) 工具:按完整消息 index 回看原始会话
9240
- * - semantic-recall.ts recall-semantic({ query, top_k?, start?, end?, offset? }) 工具:
9241
- * 按语义在全部完整消息(含被压缩/遮蔽)中检索,返回最匹配的完整消息与匹配说明
9242
- * (本地 ONNX embedding,模型随插件打包,懒加载)
9243
- * - compress.ts 自动压缩(OM 观察/反思两级阈值):pre-step 阻塞串行执行——
9244
- * 反思(<history> 块 tokens 合计 ≥ reflectThresholdTokens 时摘要调用精简合并)、
9245
- * 观察(未压缩消息 tokens ≥ observeThresholdTokens 时摘要调用压缩为观察日志并追加)
9246
- *
9247
- * 约束:不引入自定义会话事件类型——压缩复用宿主已知的 compaction/* 生命周期事件
9248
- * (start/summary/end)与 checkpoint 标记,结果写入消息记录与轨迹。
9249
- * 仅主会话生效(subagent 不压缩、recall 拒绝)。
9062
+ * dsh-plugin-om 入口(tsdown 打包入口):导出 name / inject / apply。
9063
+ * apply 注册 recall / recall-semantic 工具,并接线 agent/pre-step 自动压缩
9064
+ * (先反思后观察,仅主会话生效)。压缩摘要尝试全部耗尽时拒绝本 step 中断当前
9065
+ * turn(signal 中止除外)。压缩与检索的实现见 compress.ts / recall.ts /
9066
+ * semantic-recall.ts
9250
9067
  */
9251
9068
  /** 插件名(Loader 识别入口的稳定标识)。 */
9252
9069
  const name = "dsh-plugin-om";
@@ -9257,25 +9074,15 @@ const inject = [
9257
9074
  "tokenMeter",
9258
9075
  "sessions"
9259
9076
  ];
9260
- /**
9261
- * 插件激活入口:注册 recall / recall-semantic 工具(由配置键 recallEnabled /
9262
- * semanticRecallEnabled 控制),并在 agent/pre-step 阻塞触发两级自动压缩
9263
- * (先反思后观察)。仅主会话生效。
9264
- */
9077
+ /** 插件激活入口:解析配置、注册工具、接线 pre-step 自动压缩。 */
9265
9078
  function apply(ctx, config) {
9266
- /** 解析后的插件配置(默认值合并 + 校验)。 */
9267
9079
  const resolved = resolveConfig(config);
9268
- /** 插件日志门面(step=debug 按配置 debug 开关输出;info/warn 始终输出)。 */
9269
9080
  const logger = makeLogger(ctx, resolved.debug);
9270
- logger.step(`apply 启动:observeThresholdTokens=${String(resolved.observeThresholdTokens)} reflectThresholdTokens=${String(resolved.reflectThresholdTokens)} compressMaxTokens=${String(resolved.compressMaxTokens)} tailMessageCount=${String(resolved.tailMessageCount)} omEnabled=${String(resolved.omEnabled)} debug=${String(resolved.debug)}`);
9081
+ logger.step(`apply 启动:observeThresholdTokens=${String(resolved.observeThresholdTokens)} reflectThresholdTokens=${String(resolved.reflectThresholdTokens)} compressMaxTokens=${resolved.compressMaxTokens === void 0 ? "未设置" : String(resolved.compressMaxTokens)} tailMessageCount=${String(resolved.tailMessageCount)} omEnabled=${String(resolved.omEnabled)} debug=${String(resolved.debug)}`);
9271
9082
  if (resolved.recallEnabled) ctx.tools.register(buildRecallTool(() => ctx.get("toolResultPruner")));
9272
9083
  if (resolved.semanticRecallEnabled) {
9273
9084
  const warnModel = (message) => ctx.logger.warn(`dsh-plugin-om: ${message}`);
9274
- /** 下载开始/结束日志:console + 插件日志双通道(失败经 warnModel 输出)。 */
9275
- const logModel = (message) => {
9276
- console.log(message);
9277
- logger.info(message);
9278
- };
9085
+ const logModel = (message) => logger.info(message);
9279
9086
  ensureModelReady(resolved.modelDir, warnModel, void 0, logModel);
9280
9087
  ctx.tools.register(buildSemanticRecallTool({
9281
9088
  getPruner: () => ctx.get("toolResultPruner"),
@@ -9284,16 +9091,21 @@ function apply(ctx, config) {
9284
9091
  }));
9285
9092
  }
9286
9093
  ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
9094
+ let outcome = { failed: false };
9287
9095
  try {
9288
9096
  if (signal.aborted) logger.step("pre-step 已中止(signal aborted),跳过压缩");
9289
9097
  else if (!isMainSession(agent.session)) logger.step("subagent 会话,跳过压缩(仅主会话生效)");
9290
9098
  else {
9291
9099
  logger.step(`pre-step 触发压缩(会话 ${agent.session.id})`);
9292
- await maybeCompress(ctx, agent, resolved, signal);
9100
+ outcome = await maybeCompress(ctx, agent, resolved, signal);
9293
9101
  }
9294
9102
  } catch (error) {
9295
9103
  logger.warn(`pre-step 处理失败: ${error instanceof Error ? error.message : String(error)}`);
9296
9104
  }
9105
+ if (outcome.failed && !outcome.aborted) {
9106
+ logger.warn(`上下文压缩失败,拒绝本 step 中断当前 turn:${outcome.error}`);
9107
+ return { kind: "reject" };
9108
+ }
9297
9109
  return next();
9298
9110
  });
9299
9111
  }