dsh-plugin-om 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,5 +1,13 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
1
4
  import { z } from "zod";
2
5
  //#region src/constants.ts
6
+ /**
7
+ * 共享常量:集中定义插件级魔法字符串与事件名,避免散落各模块。
8
+ */
9
+ /** 插件标识:压缩消息 source.plugin 取值与日志前缀。 */
10
+ const PLUGIN_LABEL = "dsh-plugin-om";
3
11
  /** 压缩日志标签名:<om-history>...</om-history> 包裹观察/反思日志块。 */
4
12
  const HISTORY_TAG = "om-history";
5
13
  /** 宿主压缩 checkpoint 标记的 plugin 名(dsh-compaction-basic 的 COMPACT_CHECKPOINT_MARKER.plugin)。 */
@@ -52,6 +60,28 @@ function indexMessages(session) {
52
60
  };
53
61
  }
54
62
  //#endregion
63
+ //#region src/logger.ts
64
+ /**
65
+ * 日志辅助:步骤级(debug)日志默认仅 dev(非 production)输出,便于开发调试;
66
+ * 失败等关键日志始终输出(不受 debug 开关影响)。
67
+ * 开关配置键 debug:true 强制开启、false 强制关闭,缺省按 NODE_ENV !== 'production' 判定
68
+ * (默认值由 resolveConfig 解析,见 config.ts)。
69
+ */
70
+ /** 构建插件日志门面:统一加 PLUGIN_LABEL 前缀,step 按 debug 开关过滤。 */
71
+ function makeLogger(ctx, debug) {
72
+ return {
73
+ step(message) {
74
+ if (debug) ctx.logger.debug(`${PLUGIN_LABEL}: ${message}`);
75
+ },
76
+ info(message) {
77
+ ctx.logger.info(`${PLUGIN_LABEL}: ${message}`);
78
+ },
79
+ warn(message) {
80
+ ctx.logger.warn(`${PLUGIN_LABEL}: ${message}`);
81
+ }
82
+ };
83
+ }
84
+ //#endregion
55
85
  //#region src/utils.ts
56
86
  /** 判断值是否为普通对象:typeof object 且非 null 且非数组(类型收窄用)。 */
57
87
  function isRecord(value) {
@@ -62,13 +92,17 @@ function fail(message) {
62
92
  throw new Error(`dsh-plugin-om: ${message}`);
63
93
  }
64
94
  /**
65
- * 校验配置数值:必须是有限数且在 [min, max] 内(可选整数约束),
95
+ * 校验配置数值:必须是有限数(可选整数约束),不限制取值区间;
66
96
  * 不满足时抛出带插件前缀的错误。
67
97
  */
68
- function assertNumber(name, value, { min = 0, max = Infinity, integer = false } = {}) {
69
- if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) fail(`config ${name} must be a finite number in [${min}, ${max}]`);
98
+ function assertNumber(name, value, { integer = false } = {}) {
99
+ if (typeof value !== "number" || !Number.isFinite(value)) fail(`config ${name} must be a finite number`);
70
100
  if (integer && !Number.isInteger(value)) fail(`config ${name} must be an integer`);
71
101
  }
102
+ /** 校验配置字符串:必须是非空(含非空白)字符串,否则抛出带插件前缀的错误。 */
103
+ function assertNonEmptyString(name, value) {
104
+ if (typeof value !== "string" || value.trim() === "") fail(`config ${name} must be a non-empty string`);
105
+ }
72
106
  /** 生成 uuid:优先 crypto.randomUUID,回退为时间戳+随机串拼接(保证唯一性)。 */
73
107
  function uuid() {
74
108
  /** 全局 crypto 对象(提供 randomUUID 的现代环境才存在)。 */
@@ -120,26 +154,17 @@ function routedTarget(session) {
120
154
  }
121
155
  //#endregion
122
156
  //#region src/summarize.ts
123
- /**
124
- * 摘要子会话(OM 观察/反思):两级阈值下分别 fork 子会话——
125
- * - observe:把「上下文中最后一次 <om-history> 之后」的未压缩消息压缩为观察日志
126
- * (结果追加到旧摘要末尾,替换被压缩消息区间);
127
- * - reflect:把当前 <om-history> 精简合并(结果替换单个摘要节点)。
128
- *
129
- * 提示词不内嵌消息/摘要全文:fork 子会话继承父会话日志前缀(seed 截断于最后一个
130
- * turn/end,宿主 fork 提供方语义),按上下文中的 <om-history> 定位待处理部分;
131
- * message_id 对照表与中断标记由插件从日志计算后内嵌(id 非原文,保留关键 id 供
132
- * recall 检索)。摘要以工具调用为核心节点,不限于 run_code。
133
- * 仅主会话生效(index.ts 守卫)。
134
- */
135
157
  /** 观察者 persona:只针对未压缩消息产出观察日志,不用工具、不展示思考。 */
136
158
  const OBSERVER_PERSONA = "你是 dsh-plugin-om 的上下文观察者(Observer,机制参考 Mastra Observational Memory):把会话中尚未压缩的消息压缩为一份观察日志。不用工具、不展示思考、不评价代码、不输出多余文字。";
137
159
  /** 反思者 persona:只精简合并当前摘要,不用工具、不展示思考。 */
138
160
  const REFLECTOR_PERSONA = "你是 dsh-plugin-om 的上下文反思者(Reflector,机制参考 Mastra Observational Memory):把当前 <om-history> 压缩日志精简合并为一份更紧凑的日志。不用工具、不展示思考、不输出多余文字。";
139
161
  /**
140
- * 构建观察提示词:规则(消息概括为要点 / 工具调用按目的聚合——不限于 run_code /
141
- * 仅关键消息保留 message_id / 中断标注 / 未完成写进度与下一步)+ message_id 对照表 +
142
- * 中断标记 + 追加说明。全文由继承上下文提供。
162
+ * 构建观察指令主体:任务声明(fork:停止任务/禁止工具;new:说明总结日志)+ 模式相关
163
+ * 的上下文定位与压缩范围(fork:上方完整会话记录——尾部已在输入中实际截断,提示词不含
164
+ * 尾部规则;new:下方消息即压缩对象)+ 规则(用户消息完整保留原文 / AI 消息模块化压缩
165
+ * ——工具调用按目的聚合——不限于 run_code / 倾向于新消息 / 中断标注 / 未完成写进度与
166
+ * 下一步)+ 输出格式(合法 XML)+ 对照表 + 中断标记 + 追加说明。persona 由调用方拼接
167
+ * 到指令开头。
143
168
  */
144
169
  function buildObservePrompt(options) {
145
170
  /** 对照表段落(无则标注「无」)。 */
@@ -147,134 +172,277 @@ function buildObservePrompt(options) {
147
172
  /** 中断标记段落(无则标注「无」)。 */
148
173
  const interruptionSection = options.interruptions.length > 0 ? options.interruptions : ["(无)"];
149
174
  return [
150
- `你继承的会话上下文中,最后一次 <${HISTORY_TAG}> 块之后的全部消息都是「未压缩消息」;只对这些消息做压缩,忽略更早的历史。`,
151
- `如果上下文里还没有 <${HISTORY_TAG}> 块,则除本消息外的全部消息都是未压缩消息。`,
175
+ options.mode === "fork" ? "停止一切现有任务,禁止调用任何工具,改为将过往消息总结为一份日志。" : "将过往消息总结为一份日志。",
176
+ "",
177
+ ...options.mode === "fork" ? [
178
+ "上方的消息记录是主会话的完整历史(系统提示词与全部消息)。",
179
+ `总结范围:最后一次 <${HISTORY_TAG}> 块之后的全部消息;只对这些消息做压缩,忽略更早的历史。`,
180
+ `如果消息记录里还没有 <${HISTORY_TAG}> 块,则除本指令外的全部消息都是压缩对象。`
181
+ ] : ["下方的消息记录是本次要压缩的全部消息(上一个 <om-history> 块之后的新消息;不含旧压缩日志、不含尾部)。", "你的压缩结果会作为新的 <om-history> 块追加到已有压缩日志之后。"],
152
182
  "",
153
183
  "【规则】",
154
- "- 用户消息概括为 user_message 条目(保留需求要点与关键事实:数字、路径、命令、决定),仅对关键消息保留 message_id(格式:user_message message_id:<id> text:<要点>)。关键消息指开启新任务/提出需求的请求、包含关键决策或不可再得事实的输入;普通消息(寒暄、重复、可推断内容)可以省略 message_id",
155
- "- 所有工具调用(run_code 与其他工具同等对待,不限于 run_code)按调用目的聚合为一行 toolcall message_id:<该组最后一条消息的 message_id> purpose:<聚合目的> summary:<行为与结果摘要>;工具组内部细节(参数、完整输出)不保留,需要原文时用 recall 按 message_id 回看。",
184
+ "- 用户消息完整保留原文,输出为 <user_message> 条目(id 为该消息的 message_id,内容为消息原文,不概括、不省略)。",
185
+ "- AI 消息划分模块压缩(与现状一致):所有工具调用(run_code 与其他工具同等对待,不限于 run_code)按调用目的聚合为一行 toolcall message_id:<该组最后一条消息的 message_id> purpose:<聚合目的> summary:<行为与结果摘要>;目的相同、关联度高的连续行为聚合为一个 <assistant> 模块,模块内最后一条消息的 message_id 作为 last_id;工具组内部细节(参数、完整输出)不保留,需要原文时用 recall 按 message_id 回看。",
186
+ "- 总结时倾向于新消息,旧消息一句话带过即可;新旧消息冲突时强调新消息,不修改旧日志条目。",
156
187
  "- 若【中断标记】非空,在对应位置明确写出中断(例如「被用户打断,因此上一段工作未完成」),帮助后续理解用户为何再次输入消息、为何不延续之前的工作。",
157
188
  "- 若当前工作看起来未完成(最后一次工具调用没有结果、或对话被中断/异常结束),在日志末尾说明当前进度与下一步要做什么。",
158
- `- 只输出日志条目本身;不要 <${HISTORY_TAG}> 标签、不要解释、不要复述规则。`,
189
+ "【输出格式】只输出一个 <om-history> 包裹的合法 XML 日志块,不要解释、不要复述规则:",
190
+ `<${HISTORY_TAG}>`,
191
+ "<user_message id=\"(message_id)\">",
192
+ "(user 消息原文)",
193
+ "</user_message>",
194
+ "<assistant last_id=\"(该组最后一条消息的 message_id)\">",
195
+ "(压缩模块:toolcall 聚合行等,与现有格式一致)",
196
+ "</assistant>",
197
+ `</${HISTORY_TAG}>`,
159
198
  "",
160
- "【message_id 对照表】(按顺序对应你上下文中的消息,用于产出正确的 message_id)",
199
+ "【message_id 对照表】(按顺序对应消息记录中的未压缩消息,用于产出正确的 message_id)",
161
200
  ...tableSection,
162
201
  "",
163
202
  "【中断标记】",
164
203
  ...interruptionSection,
165
204
  "",
166
- ...options.hasOldHistory ? [`【说明】你的压缩结果会被直接追加到上一次压缩产物(<${HISTORY_TAG}>)的末尾,条目格式须与上一条目保持一致。`] : [`【说明】你的压缩结果将成为第一条 <${HISTORY_TAG}> 压缩日志。`]
205
+ ...options.hasOldHistory ? [`【说明】你的压缩结果会被直接追加到上一次压缩产物(<${HISTORY_TAG}>)的末尾,作为新的 <${HISTORY_TAG}> 块;条目格式与本块一致。`] : [`【说明】你的压缩结果将成为第一条 <${HISTORY_TAG}> 压缩日志。`]
167
206
  ].join("\n");
168
207
  }
169
- /** 构建反思提示词:精简合并当前 <om-history>;全文由继承上下文提供。 */
170
- function buildReflectPrompt() {
208
+ /** 构建反思指令主体:任务声明(fork:停止任务/禁止工具;new:说明总结日志)+ 精简合并
209
+ * 当前 <om-history> 的规则 + 输出格式(合法 XML);消息记录全文由请求(fork)或渲染输入(new)提供。 */
210
+ function buildReflectPrompt(mode) {
171
211
  return [
172
- `你继承的会话上下文中包含当前的 <${HISTORY_TAG}> 压缩日志(最后一次 <${HISTORY_TAG}> 块)。`,
173
- "只对这份压缩日志做精简合并;不要涉及日志之外的消息。",
212
+ mode === "fork" ? "停止一切现有任务,禁止调用任何工具,改为将当前压缩日志精简合并为一份更紧凑的日志。" : "将当前压缩日志精简合并为一份更紧凑的日志。",
213
+ "",
214
+ ...mode === "new" ? ["下方的消息记录包含当前的 <om-history> 压缩日志(最后一次 <om-history> 块)。", "只对这份压缩日志做精简合并;不要涉及日志之外的消息。"] : ["上方的消息记录是主会话的完整历史,其中包含当前的 <om-history> 压缩日志(最后一次 <om-history> 块)。", "只对这份压缩日志做精简合并;不要涉及日志之外的消息。"],
174
215
  "",
175
216
  "【规则】",
176
- "- 用户消息保留要点,仅保留关键 message_id(格式:user_message message_id:<id> text:<要点>);可省略的 message_id 删除。",
217
+ "- 用户消息保留要点与 message_id(格式:<user_message id=\"(message_id)\"> 要点 </user_message>);可省略的条目删除。",
177
218
  "- toolcall 条目按调用目的进一步聚合,保留组内最后一条消息的 message_id;不重要的条目 summary 写「(略)」。",
178
219
  "- 保留中断说明与未完成说明(若原日志中有)。",
179
- "- 过时事实丢弃,不逐字复制旧文本。",
180
- `- 只输出合并后的日志条目本身;不要 <${HISTORY_TAG}> 标签、不要解释、不要复述规则。`,
220
+ "- 过时事实丢弃,不逐字复制旧文本;新旧条目冲突时保留新条目。",
221
+ "【输出格式】只输出一个 <om-history> 包裹的合法 XML 日志块,不要解释、不要复述规则:",
222
+ `<${HISTORY_TAG}>`,
223
+ "<user_message id=\"(message_id)\">",
224
+ "(user 消息要点)",
225
+ "</user_message>",
226
+ "<assistant last_id=\"(该组最后一条消息的 message_id)\">",
227
+ "(压缩模块:toolcall 聚合行等,与现有格式一致)",
228
+ "</assistant>",
229
+ `</${HISTORY_TAG}>`,
181
230
  "",
182
231
  `【说明】你的合并结果会替换当前的 <${HISTORY_TAG}> 块内容。`
183
232
  ].join("\n");
184
233
  }
234
+ /** 流收集器:提取文本输出 + usage + finish(不依赖宿主 BlockAssembler,保持零运行时依赖)。 */
235
+ var StreamCollector = class {
236
+ /** 文本输出缓冲(text-delta 拼接;reasoning 不计入)。 */
237
+ textBuf = "";
238
+ /** usage chunk(无则 undefined)。 */
239
+ _usage;
240
+ /** finish chunk(流结束仍无则视为 stop)。 */
241
+ _finish;
242
+ /** 喂入一个流 chunk(仅消费文本/usage/finish,其余忽略)。 */
243
+ push(chunk) {
244
+ switch (chunk.type) {
245
+ case "text-delta":
246
+ this.textBuf += chunk.text;
247
+ break;
248
+ case "usage":
249
+ this._usage = chunk.usage;
250
+ break;
251
+ case "finish": this._finish = chunk.reason;
252
+ }
253
+ }
254
+ /** 拼接后的文本输出。 */
255
+ get text() {
256
+ return this.textBuf;
257
+ }
258
+ /** 摘要 token usage(无则 undefined)。 */
259
+ get usage() {
260
+ return this._usage;
261
+ }
262
+ /** 终止原因(流未给出 finish 时视为 stop)。 */
263
+ get finish() {
264
+ return this._finish ?? { kind: "stop" };
265
+ }
266
+ };
185
267
  /**
186
- * 提取 fork 子会话(run.localAgent.session)的 token usage:仅统计子会话自身事件
187
- * (firstLiveSeq 之后,不含继承的父会话 seed 前缀),按 turn/step 去重(同 step 的
188
- * usage chunk assistant/message 后写覆盖)后累加四桶;无 usage 返回 undefined。
189
- */
190
- function extractSubagentUsage(agent) {
191
- if (!agent) return void 0;
192
- /** 子会话自身事件的起点(构造 seed 长度;缺省回退 0)。 */
193
- const start = Number.isFinite(agent.session.firstLiveSeq) ? agent.session.firstLiveSeq : 0;
194
- /** 子会话自身事件(跳过继承的父会话前缀)。 */
195
- const ownEvents = agent.session.events.slice(start);
196
- /** turn/step → usage(同 step 后写覆盖,与宿主投影语义一致)。 */
197
- const perStep = /* @__PURE__ */ new Map();
198
- for (const event of ownEvents) if (event.type === "assistant/message" && event.data.usage !== void 0) perStep.set(`${event.data.turn}:${event.data.step}`, event.data.usage);
199
- else if (event.type === "assistant/chunk" && event.data.chunk.type === "usage") perStep.set(`${event.data.turn}:${event.data.step}`, event.data.chunk.usage);
200
- if (perStep.size === 0) return void 0;
201
- /** 累加结果(四桶互斥,不重复累加推理 token)。 */
202
- const total = {
203
- inputTokens: 0,
204
- outputTokens: 0,
205
- cacheReadTokens: 0,
206
- cacheWriteTokens: 0
268
+ * 渲染表层消息记录(new 模式输入):按表层顺序分组为合法 XML——
269
+ * - 用户消息 <user_message id="(message_id)">(原文)</user_message>;
270
+ * - 连续 AI 消息(assistant/message 与其后的 tool/result)聚合为一个
271
+ * <assistant last_id="(组内最后一条消息的 message_id)">(各消息文本)</assistant>。
272
+ * 组内文本沿用 role 头 + message_id + 文本(tool-call 展开参数、tool-result 取文本)。
273
+ */
274
+ function renderMessages(session, seqs) {
275
+ /** 渲染段缓冲区。 */
276
+ const parts = [];
277
+ /** 当前 assistant 组(组内消息文本行 + 最后一条消息 id)。 */
278
+ let group;
279
+ /** 结束当前 assistant 组并输出 <assistant> 块。 */
280
+ const flush = () => {
281
+ if (!group || group.lines.length === 0) return;
282
+ /** last_id 属性(组内最后一条消息 id;缺失/空串则省略)。 */
283
+ const lastAttr = group.lastId ? ` last_id="${group.lastId}"` : "";
284
+ parts.push(`<assistant${lastAttr}>\n${group.lines.join("\n")}\n</assistant>`);
285
+ group = void 0;
207
286
  };
208
- for (const usage of perStep.values()) {
209
- total.inputTokens += usage.inputTokens;
210
- total.outputTokens += usage.outputTokens;
211
- total.cacheReadTokens = (total.cacheReadTokens ?? 0) + (usage.cacheReadTokens ?? 0);
212
- total.cacheWriteTokens = (total.cacheWriteTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
287
+ for (const seq of seqs) {
288
+ /** 当前待渲染事件。 */
289
+ const event = session.events[seq];
290
+ if (!event) continue;
291
+ /** 消息 id(缺失则省略)。 */
292
+ const id = messageIdOfEvent(event);
293
+ /** 消息文本呈现。 */
294
+ const text = renderMessageText(session.deriveEventMessage(event));
295
+ if (event.type === "user/message") {
296
+ flush();
297
+ /** id 属性(缺失/空串则省略)。 */
298
+ const idAttr = id ? ` id="${id}"` : "";
299
+ parts.push(`<user_message${idAttr}>\n${text}\n</user_message>`);
300
+ } else if (event.type === "assistant/message" || event.type === "tool/result") {
301
+ if (!group) group = {
302
+ lines: [],
303
+ lastId: void 0
304
+ };
305
+ /** role 标签(tool/result 属 user 角色但标注为工具结果)。 */
306
+ const role = event.type === "assistant/message" ? "assistant" : "tool/result";
307
+ group.lines.push(`--- ${role}${id ? ` message_id=${id}` : ""} ---\n${text}`);
308
+ if (id) group.lastId = id;
309
+ }
213
310
  }
214
- return total;
311
+ flush();
312
+ return parts.join("\n\n");
313
+ }
314
+ /** 构造插件自产 user 消息(指令或 new 模式的输入消息;id 为品牌类型 MessageId)。 */
315
+ function makePluginUserMessage(text) {
316
+ return {
317
+ id: uuid(),
318
+ role: "user",
319
+ content: [{
320
+ type: "text",
321
+ text
322
+ }],
323
+ source: {
324
+ kind: "plugin",
325
+ plugin: PLUGIN_LABEL
326
+ }
327
+ };
215
328
  }
216
329
  /**
217
- * Fork 子会话执行一次摘要(观察或反思),返回文本与可选 token usage;
218
- * 失败或无法 fork 返回 null。工具被 toolFilter 禁用,输出长度受 maxTokens 限制。
330
+ * 构建摘要请求选项:
331
+ * - fork:system/tools 取自主会话 requestHeader(),messages = 完整派生历史(从尾部
332
+ * tailCount 条消息之前开始,尾部不注入) + 指令 user 消息;
333
+ * - new:system = 指令,messages = 渲染输入(被压缩消息)user 消息。
219
334
  */
220
- async function runSummarySubagent(ctx, agent, persona, prompt, maxTokens, signal) {
221
- /** subagents 服务(缺失则无法分叉摘要)。 */
222
- const subagents = ctx.get("subagents");
223
- if (!subagents) {
224
- ctx.logger.warn("dsh-plugin-om: 未找到 subagents 服务,跳过摘要分叉");
225
- return null;
226
- }
227
- if (!subagents.getProvider("fork")) {
228
- ctx.logger.warn("dsh-plugin-om: fork 子代理提供方未注册,跳过摘要分叉");
229
- return null;
230
- }
231
- /** 本次 fork 运行的句柄(用于取结果与 dispose)。 */
232
- let run;
233
- try {
234
- run = await subagents.start("fork", {
235
- label: "om-summary",
236
- prompt: [{
237
- type: "text",
238
- text: prompt
239
- }],
240
- parent: agent,
241
- persona,
242
- toolFilter: { allow: [] },
243
- agentOptions: { maxTokens },
244
- ...signal !== void 0 ? { signal } : {}
245
- });
246
- /** 子代理运行结果。 */
247
- const result = await run.result;
248
- /** 拼接、去标签、去首尾空白的摘要文本。 */
249
- const text = blocksToText(Array.isArray(result.output) ? result.output : []).trim().replace(new RegExp(`</?${HISTORY_TAG}>`, "g"), "").trim();
250
- if (result.stopReason !== "completed" || text.length === 0) {
251
- ctx.logger.warn("dsh-plugin-om: 摘要未完成(" + (result.stopReason === "completed" ? "无输出" : String(result.stopReason)) + "),忽略本次摘要");
335
+ function buildSummaryOptions(session, instruction, contextText, maxTokens, mode, tailCount, target, signal) {
336
+ /** 公共请求字段(provider/model/输出上限/会话归属/用途/取消)。 */
337
+ const base = {
338
+ provider: target.provider,
339
+ model: target.model,
340
+ maxTokens,
341
+ sessionId: session.id,
342
+ purpose: "compaction",
343
+ ...signal === void 0 ? {} : { signal }
344
+ };
345
+ if (mode === "new") return {
346
+ ...base,
347
+ system: instruction,
348
+ messages: [makePluginUserMessage(contextText ?? "")]
349
+ };
350
+ /** 主会话上次请求的请求头(system/tools 前缀对齐;无则省略)。 */
351
+ const header = session.requestHeader();
352
+ /** 主会话派生历史(从尾部 tailCount 条消息之前开始,实际截断尾部)。 */
353
+ const history = session.deriveMessages();
354
+ const forked = tailCount > 0 && tailCount < history.length ? history.slice(0, history.length - tailCount) : history;
355
+ return {
356
+ ...base,
357
+ ...header?.system === void 0 ? {} : { system: header.system },
358
+ ...header?.tools === void 0 ? {} : { tools: [...header.tools] },
359
+ messages: [...forked, makePluginUserMessage(instruction)]
360
+ };
361
+ }
362
+ /** 产出日志后插入首个 <om-history> 后的格式说明(XML 注释,避免被误读为日志条目)。 */
363
+ const HISTORY_FORMAT_NOTE = "<!-- <user_message>块内包含了用户的原文 id表示该消息的id;<assistant>块是多条ai连续消息的聚合,last_id指向最后一条消息 -->";
364
+ /**
365
+ * AI 摘要输出中提取合法日志(不信任 AI 的总结结果):
366
+ * - 取首个 <om-history> 到最后一个 </om-history>(含两个首尾)切为日志;
367
+ * - 找不到、顺序颠倒(首个开标签在最后一个闭标签之后)或中间内容长度 < MIN_HISTORY_LENGTH
368
+ * 视为不合法(返回 null,调用方按失败重试);
369
+ * - 产出后在首个 <om-history> 后插入格式说明注释(HISTORY_FORMAT_NOTE)。
370
+ */
371
+ function extractSummaryLog(raw) {
372
+ /** 开标签。 */
373
+ const openTag = `<${HISTORY_TAG}>`;
374
+ /** 闭标签。 */
375
+ const closeTag = `</${HISTORY_TAG}>`;
376
+ /** 首个开标签位置(无则 -1)。 */
377
+ const open = raw.indexOf(openTag);
378
+ /** 最后一个闭标签位置(无则 -1)。 */
379
+ const close = raw.lastIndexOf(closeTag);
380
+ if (open === -1 || close === -1 || close < open) return null;
381
+ if (raw.slice(open + openTag.length, close).trim().length < 10) return null;
382
+ return raw.slice(open, close + closeTag.length).replace(openTag, `${openTag}\n${HISTORY_FORMAT_NOTE}`);
383
+ }
384
+ /**
385
+ * 直连 LLM 执行一次摘要(观察或反思),返回文本与可选 token usage。
386
+ * 失败(抛异常 / 空输出 / 非 stop 结束)均记录日志并重试,总共最多尝试
387
+ * SUMMARY_MAX_ATTEMPTS 次;全部尝试失败返回 null(不产生任何日志变更)。
388
+ * 输出长度受 maxTokens 限制。
389
+ */
390
+ async function runSummarySubagent(ctx, agent, instruction, contextText, maxTokens, mode, tailCount, target, debug, signal) {
391
+ /** 当前会话。 */
392
+ const session = agent.session;
393
+ /** 插件日志门面(失败日志始终输出)。 */
394
+ const logger = makeLogger(ctx, debug);
395
+ /** 最后一次失败的原因(error=调用异常 / finish=未完成原因;最终失败日志使用)。 */
396
+ let lastFailure = {};
397
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
398
+ if (signal?.aborted) {
399
+ logger.warn(`摘要调用中止(第 ${attempt}/3 次尝试前 signal 已中止),放弃本次摘要`);
252
400
  return null;
253
401
  }
254
- /** fork 子会话的 token usage(归入主会话记录;无则省略)。 */
255
- const usage = extractSubagentUsage(run.localAgent);
256
- return {
257
- text,
258
- ...usage === void 0 ? {} : { usage }
259
- };
260
- } catch (error) {
261
- /** 错误信息(统一为字符串)。 */
262
- const message = error instanceof Error ? error.message : String(error);
263
- ctx.logger.warn(`dsh-plugin-om: 摘要子代理失败: ${message},忽略`);
264
- return null;
265
- } finally {
266
- if (run && typeof run.dispose === "function") try {
267
- await run.dispose();
268
- } catch {}
402
+ logger.step(`摘要调用开始(第 ${attempt}/3 次,模式 ${mode},provider ${target.provider},model ${target.model},maxTokens ${maxTokens})`);
403
+ try {
404
+ /** 摘要请求选项(按模式组装)。 */
405
+ const options = buildSummaryOptions(session, instruction, contextText, maxTokens, mode, tailCount, target, signal);
406
+ /** 流收集器(文本/usage/finish)。 */
407
+ const collector = new StreamCollector();
408
+ for await (const chunk of ctx.llm.stream(options)) collector.push(chunk);
409
+ /** 提取合法日志(首个 <om-history> 到最后一个 </om-history>,含格式说明注释;不信任 AI 输出)。 */
410
+ const text = extractSummaryLog(collector.text);
411
+ /** 终止原因(仅 stop 视为完成)。 */
412
+ const finish = collector.finish;
413
+ if (finish.kind !== "stop" || text === null) {
414
+ /** 未完成原因(空输出 / 非法日志 / stop 终止原因)。 */
415
+ const reason = finish.kind === "stop" ? collector.text.trim() === "" ? "无输出" : "缺少 <om-history> 块或内容过短" : String(finish.kind);
416
+ lastFailure = { finish: reason };
417
+ logger.warn(`摘要未完成(第 ${attempt}/3 次,${reason})` + (attempt < 3 ? ",将重试" : ",重试耗尽,忽略本次摘要"));
418
+ continue;
419
+ }
420
+ /** 摘要请求的 token usage(归入主会话记录;无则省略)。 */
421
+ const usage = collector.usage;
422
+ logger.step(`摘要调用成功(第 ${attempt}/3 次,输出 ${text.length} 字符` + (usage === void 0 ? "" : `,input ${String(usage.inputTokens ?? "?")} / output ${String(usage.outputTokens ?? "?")} tokens`) + ")");
423
+ return {
424
+ text,
425
+ ...usage === void 0 ? {} : { usage }
426
+ };
427
+ } catch (error) {
428
+ /** 错误信息(统一为字符串)。 */
429
+ const message = error instanceof Error ? error.message : String(error);
430
+ lastFailure = { error: message };
431
+ logger.warn(`摘要调用失败(第 ${attempt}/3 次,${message})` + (attempt < 3 ? ",将重试" : ",重试耗尽,忽略本次摘要"));
432
+ }
269
433
  }
434
+ /** 全部尝试失败:记录最终失败日志(含最后原因,便于诊断)。 */
435
+ logger.warn(`摘要调用最终失败(已尝试 3 次` + (lastFailure.error !== void 0 ? `,最后错误:${lastFailure.error}` : "") + (lastFailure.finish !== void 0 ? `,最后结果:${lastFailure.finish}` : "") + "),忽略本次摘要");
436
+ return null;
270
437
  }
271
438
  //#endregion
272
439
  //#region src/compress.ts
273
440
  /**
274
441
  * 自动压缩(OM 观察/反思两级阈值,思路参考 Mastra Observational Memory):
275
- * - 观察:未压缩消息 tokens ≥ 窗口 × thresholdRatio → fork 子会话把未压缩消息压缩为
276
- * 观察日志,追加到旧摘要(<om-history> 原文保留),替换被压缩消息区间;
277
- * - 反思:摘要 tokens ≥ 窗口 × historyMergeRatio(默认 0.2)→ fork 子会话精简合并摘要,
442
+ * - 观察:未压缩消息 tokens ≥ 窗口 × thresholdRatio → 直连 ctx.llm.stream() 摘要
443
+ * (fork 模式复用主会话请求前缀缓存;new 模式指令作为 system)把未压缩消息压缩为
444
+ * 观察日志,以新的 <om-history> 块追加到旧日志(多块按序拼接),替换被压缩消息区间;
445
+ * - 反思:摘要 tokens ≥ 窗口 × historyMergeRatio(默认 0.2)→ 同上摘要调用精简合并摘要,
278
446
  * 替换单个 <om-history> 节点。
279
447
  * 两级检查在 pre-step 阻塞串行执行(先反思后观察),避免压缩失败或重复压缩。
280
448
  *
@@ -282,24 +450,36 @@ async function runSummarySubagent(ctx, agent, persona, prompt, maxTokens, signal
282
450
  * 替换 <om-history> 消息 → compaction/end),使消息记录(聊天视图压缩卡片)与轨迹视图
283
451
  * 可见;compaction/summary 同时承担影子价格认领(shadowedTokenCount),不再单独发
284
452
  * compaction/prune。替换消息的 source 使用宿主 checkpoint 标记(plugin: 'compact' +
285
- * compactionId),UI 据此关联 summary 与替换消息。生命周期事件在 fork 摘要成功后才写入
453
+ * compactionId),UI 据此关联 summary 与替换消息。生命周期事件在摘要成功后才写入
286
454
  * (失败不产生任何日志变更)。
287
455
  *
288
- * 观察区间:尾部保留 config.tailMessageCount(默认 10)条消息不压缩;
289
- * 其余全部未压缩消息一次压缩。fork seed 截断于最后一个 turn/end,
290
- * 故区间表层节点封顶在最后一个已结束 turn 的表层节点(当前 turn 消息留待下次)。
456
+ * 观察区间:pre-step 触发时日志 call-result 完备,区间不再受 turn/end 封顶——头部 →
457
+ * 表层长度-1-tailCount(尾部保留 config.tailMessageCount 条不压缩,作为摘要模型的
458
+ * 参考尾部),当前 turn 中已完备的消息同样可压缩;区间终点回退到 tool-call/result
459
+ * 配对平衡点(不切段)。
291
460
  * 仅主会话生效。
292
461
  */
293
462
  /** 历史文本 token 估算:4 字符 ≈ 1 token(与宿主 dsh-token-meter 启发式一致)。 */
294
463
  function estimateTextTokens(text) {
295
464
  return Math.ceil(text.length / 4);
296
465
  }
297
- /** 提取 <om-history> 压缩日志消息的内文(去掉标签);非压缩日志消息返回 undefined。 */
466
+ /**
467
+ * 判定消息是否为本插件的压缩日志消息并提取日志文本(D:不通过文本含 <om-history> 判断,
468
+ * 改用 source 标记——plugin 为宿主 checkpoint 标记 'compact' 或插件标识 'dsh-plugin-om')。
469
+ * 日志文本取首个 <om-history> 起的内容(含标签与格式说明注释,多块拼接时返回全部块);
470
+ * 非压缩日志消息返回 undefined。
471
+ */
298
472
  function historyTextOf(event) {
299
473
  if (event?.type !== "user/message") return void 0;
474
+ /** 消息 source(插件自产消息的标记)。 */
475
+ const source = event.data.source;
476
+ if (source?.kind !== "plugin") return void 0;
477
+ if (source.plugin !== "compact" && source.plugin !== "dsh-plugin-om") return void 0;
300
478
  /** 消息纯文本。 */
301
479
  const text = blocksToText(event.data.content);
302
- return text.includes(`<om-history>`) ? text.replace(new RegExp(`</?${HISTORY_TAG}>`, "g"), "").trim() : void 0;
480
+ /** 首个 <om-history> 的位置(日志文本起点;无则整段视为日志)。 */
481
+ const start = text.indexOf(`<${HISTORY_TAG}>`);
482
+ return start === -1 ? text : text.slice(start);
303
483
  }
304
484
  /**
305
485
  * 未压缩消息 token 估算:表层节点合计,不含 <om-history> 摘要节点
@@ -332,30 +512,45 @@ function findLatestHistory(session) {
332
512
  }
333
513
  }
334
514
  /**
335
- * 观察压缩区间:尾部保留 tailCount 条消息不压缩,区间封顶在最后一个已结束 turn
336
- * 表层节点(fork seed 截断于最后一个 turn/end,当前 turn 消息不可压缩)。
515
+ * 判定表层节点 seq 之后的切点是否 tool-call/result 配对平衡(与宿主
516
+ * dsh-compaction toolPairingBalancedAfter 同语义):按表层顺序折叠未闭合的
517
+ * 工具调用数,处理到 seq 后计数为 0 即平衡。pre-step 时日志 call-result 完备,
518
+ * 该检查作为区间边界的安全网(防止把助手 tool-call 与其结果切到两侧)。
519
+ */
520
+ function isPairBalancedAfter(session, seq) {
521
+ /** 未闭合工具调用计数。 */
522
+ let inProgress = 0;
523
+ for (const node of session.surface.nodes) {
524
+ /** 当前表层事件。 */
525
+ const event = session.events[node];
526
+ if (event?.type === "assistant/message") inProgress += event.data.message.content.filter((block) => block.type === "tool-call").length;
527
+ else if (event?.type === "tool/result") inProgress -= 1;
528
+ if (node === seq) return inProgress === 0;
529
+ }
530
+ return false;
531
+ }
532
+ /**
533
+ * 观察压缩区间:pre-step 触发时日志 call-result 完备,区间不再受 turn/end 封顶——
534
+ * 头部 → 表层长度-1-tailCount(尾部保留 tailCount 条不压缩),当前 turn 中已完备的
535
+ * 消息同样可压缩;区间终点回退到 tool-call/result 配对平衡点(不切段)。
536
+ * lastEndSeq 仅为中断扫描提供最后一个已结束 turn 的边界(无则 -1)。
337
537
  */
338
538
  function computeCompressRange(session, tailCount) {
339
539
  /** 当前表层节点(按日志顺序)。 */
340
540
  const surface = [...session.surface.nodes];
341
541
  if (surface.length === 0) return void 0;
342
- /** 最后一个 turn/end(fork seed 截断于此;无则无可压缩内容)。 */
343
- const lastEnd = session.events.findLast((event) => event.type === "turn/end");
344
- if (!lastEnd) return void 0;
345
- /** seed 覆盖的最后表层节点下标(其后为当前 turn 消息,不可压缩)。 */
346
- let seedIdx = -1;
347
- for (let i = surface.length - 1; i >= 0; i -= 1) {
348
- const node = surface[i];
349
- if (node !== void 0 && node <= lastEnd.seq) {
350
- seedIdx = i;
351
- break;
352
- }
542
+ /** 区间末表层节点下标(尾部保留 tailCount 条不压缩)。 */
543
+ let endIdx = surface.length - 1 - tailCount;
544
+ if (endIdx < 0) return void 0;
545
+ /** 区间终点回退到配对平衡点(不切断 tool-call/result 配对)。 */
546
+ while (endIdx >= 0) {
547
+ /** 当前候选终点节点(表层节点序列稠密,防御性判空)。 */
548
+ const node = surface[endIdx];
549
+ if (node === void 0 || isPairBalancedAfter(session, node)) break;
550
+ endIdx -= 1;
353
551
  }
354
- if (seedIdx === -1) return void 0;
355
- /** 区间末表层节点下标(尾部保留与 seed 封顶取小)。 */
356
- const endIdx = Math.min(surface.length - 1 - tailCount, seedIdx);
357
552
  if (endIdx < 0) return void 0;
358
- /** 区间起点(表层首节点)。 */
553
+ /** 区间起点(表层首节点,含旧 <om-history> 时一并合并)。 */
359
554
  const start = surface[0];
360
555
  /** 区间终点(表层节点 seq)。 */
361
556
  const end = surface[endIdx];
@@ -364,7 +559,7 @@ function computeCompressRange(session, tailCount) {
364
559
  start,
365
560
  end,
366
561
  shadowedSeqs: surface.slice(0, endIdx + 1),
367
- lastEndSeq: lastEnd.seq
562
+ lastEndSeq: session.events.findLast((event) => event.type === "turn/end")?.seq ?? -1
368
563
  };
369
564
  }
370
565
  /**
@@ -409,7 +604,7 @@ function extractHistoryText(session, shadowedSeqs) {
409
604
  }
410
605
  /**
411
606
  * message_id 对照表:遮蔽区间内消息事件按表层顺序产出 id 行(插件自产 user/message
412
- * 如运行时上下文快照与 <om-history> 不入表;观察子会话据此产出正确的 message_id)。
607
+ * 如运行时上下文快照与 <om-history> 不入表;观察摘要据此产出正确的 message_id)。
413
608
  * 按表层顺序(shadowedSeqs)扫描:与 extractHistoryText 同理,seq 区间扫描会漏。
414
609
  */
415
610
  function buildMessageIdTable(session, shadowedSeqs) {
@@ -432,7 +627,7 @@ function buildMessageIdTable(session, shadowedSeqs) {
432
627
  /** 结果消息 id。 */
433
628
  const id = messageIdOfEvent(event);
434
629
  if (id) {
435
- /** 关联调用 id(供子会话按 callId 定位代码与结果)。 */
630
+ /** 关联调用 id(供摘要模型按 callId 定位代码与结果)。 */
436
631
  const callId = String(event.data.message.source.callId ?? "");
437
632
  rows.push(`[tool/result callId=${callId}] message_id=${id}`);
438
633
  }
@@ -458,7 +653,7 @@ function appendCompactionStart(session, lifecycle) {
458
653
  }
459
654
  /**
460
655
  * 追加 compaction/summary(log-only,承担影子价格认领:紧随其后的替换消息消费 claim)。
461
- * summary 为完整合并后的 <om-history> 内文;usage 由 fork 子会话提取(无则省略)。
656
+ * summary 为完整合并后的 <om-history> 内文;usage 由摘要调用提取(无则省略)。
462
657
  */
463
658
  function appendCompactionSummary(session, data) {
464
659
  return session.append("compaction/summary", {
@@ -486,7 +681,7 @@ function appendCompactionEnd(session, lifecycle, error) {
486
681
  }
487
682
  /** 追加 <om-history> 压缩日志消息(surfaceOp 替换遮蔽区间,source 为宿主 checkpoint 标记)。 */
488
683
  function appendHistoryMessage(session, content, sourceEventSeqs, surfaceOp, compactionId) {
489
- /** 压缩替换消息(<om-history> 包裹摘要,source 标记宿主 checkpoint 供 UI 关联)。 */
684
+ /** 压缩替换消息(内容已含 <om-history> 标签块,不再额外包裹;source 标记宿主 checkpoint 供 UI 关联)。 */
490
685
  const message = {
491
686
  id: uuid(),
492
687
  role: "user",
@@ -495,9 +690,7 @@ function appendHistoryMessage(session, content, sourceEventSeqs, surfaceOp, comp
495
690
  text: [
496
691
  "以下是过往会话的压缩日志(<om-history>),为已确立背景:直接继续,不要复述。",
497
692
  "",
498
- `<${HISTORY_TAG}>`,
499
- content,
500
- `</${HISTORY_TAG}>`
693
+ content
501
694
  ].join("\n")
502
695
  }],
503
696
  source: {
@@ -512,35 +705,54 @@ function appendHistoryMessage(session, content, sourceEventSeqs, surfaceOp, comp
512
705
  });
513
706
  }
514
707
  /**
515
- * 反思:摘要 tokens ≥ 窗口 × historyMergeRatio 时,fork 子会话精简合并摘要,
708
+ * 反思:摘要 tokens ≥ 窗口 × historyMergeRatio 时,摘要调用精简合并摘要,
516
709
  * 替换单个 <om-history> 节点。失败不产生部分替换。
517
710
  */
518
711
  async function reflectPass(ctx, agent, config, window, target, signal) {
519
712
  /** 当前会话。 */
520
713
  const session = agent.session;
714
+ /** 插件日志门面。 */
715
+ const logger = makeLogger(ctx, config.debug);
716
+ logger.step(`反思检查(窗口 ${window} × historyMergeRatio ${config.historyMergeRatio})`);
521
717
  /** 当前摘要(最后一次 <om-history>;无则跳过)。 */
522
718
  const history = findLatestHistory(session);
523
- if (!history) return;
719
+ if (!history) {
720
+ logger.step("反思:无 <om-history> 压缩日志,跳过");
721
+ return;
722
+ }
524
723
  /** 反思阈值(窗口 × historyMergeRatio 向下取整)。 */
525
724
  const threshold = Math.floor(window * config.historyMergeRatio);
526
725
  /** 摘要 token 估算。 */
527
726
  const tokens = estimateTextTokens(history.text);
528
- if (tokens < threshold) return;
727
+ if (tokens < threshold) {
728
+ logger.step(`反思:摘要 ${tokens} tokens < 阈值 ${threshold},跳过`);
729
+ return;
730
+ }
731
+ logger.step(`反思:摘要 ${tokens} tokens ≥ 阈值 ${threshold},触发精简合并`);
529
732
  /** 摘要节点须仍在表层才可替换。 */
530
- if (surfaceIndexOf([...session.surface.nodes], history.seq) === -1) return;
531
- /** 反思子会话结果(null 表示失败/跳过)。 */
532
- const summaryResult = await runSummarySubagent(ctx, agent, REFLECTOR_PERSONA, buildReflectPrompt(), config.compressMaxTokens, signal);
533
- if (summaryResult === null || summaryResult.text.trim().length === 0) return;
733
+ if (surfaceIndexOf([...session.surface.nodes], history.seq) === -1) {
734
+ logger.step("反思:摘要节点已不在表层,跳过");
735
+ return;
736
+ }
737
+ /** 反思摘要结果(null 表示失败/跳过)。 */
738
+ const summaryResult = await runSummarySubagent(ctx, agent, `${REFLECTOR_PERSONA}\n\n${buildReflectPrompt(config.summaryMode)}`, config.summaryMode === "new" ? history.text : void 0, config.compressMaxTokens, config.summaryMode, 0, target, config.debug, signal);
739
+ if (summaryResult === null || summaryResult.text.trim().length === 0) {
740
+ logger.step("反思:摘要调用失败/无输出,不产生替换");
741
+ return;
742
+ }
534
743
  /** 反思摘要文本。 */
535
744
  const report = summaryResult.text;
536
- /** 本次压缩生命周期(compactionId + 当前轮次;fork 成功后才写入日志)。 */
745
+ /** 本次压缩生命周期(compactionId + 当前轮次;摘要成功后才写入日志)。 */
537
746
  const lifecycle = {
538
747
  compactionId: newCompactionId(),
539
748
  turn: openTurnOf(session)
540
749
  };
541
750
  try {
751
+ logger.step("反思提交:追加 compaction/start");
542
752
  appendCompactionStart(session, lifecycle);
543
- appendHistoryMessage(session, report, [appendCompactionSummary(session, {
753
+ logger.step("反思提交:追加 compaction/summary(影子价格认领)");
754
+ /** compaction/summary 事件 seq(承担影子价格认领,紧随其后的替换消息消费)。 */
755
+ const summarySeq = appendCompactionSummary(session, {
544
756
  lifecycle,
545
757
  summary: report,
546
758
  shadowedRange: {
@@ -553,50 +765,72 @@ async function reflectPass(ctx, agent, config, window, target, signal) {
553
765
  model: target.model,
554
766
  maxTokens: config.compressMaxTokens,
555
767
  ...summaryResult.usage === void 0 ? {} : { usage: summaryResult.usage }
556
- }), history.seq], {
768
+ });
769
+ logger.step("反思提交:替换 <om-history> 摘要节点");
770
+ appendHistoryMessage(session, report, [summarySeq, history.seq], {
557
771
  op: "replace",
558
772
  start: history.seq,
559
773
  end: history.seq
560
774
  }, lifecycle.compactionId);
775
+ logger.step("反思提交:追加 compaction/end");
561
776
  appendCompactionEnd(session, lifecycle);
562
- ctx.logger.info(`dsh-plugin-om: 反思完成(摘要 ${tokens} tokens ≥ 阈值 ${threshold},替换摘要节点)`);
777
+ logger.info(`反思完成(摘要 ${tokens} tokens ≥ 阈值 ${threshold},替换摘要节点)`);
563
778
  } catch (error) {
564
779
  /** 提交失败信息(统一字符串)。 */
565
780
  const message = error instanceof Error ? error.message : String(error);
566
- ctx.logger.warn(`dsh-plugin-om: 反思提交失败: ${message}`);
781
+ logger.warn(`反思提交失败: ${message}`);
567
782
  try {
568
783
  appendCompactionEnd(session, lifecycle, message);
569
784
  } catch {}
570
785
  }
571
786
  }
572
787
  /**
573
- * 观察:未压缩消息 tokens ≥ 窗口 × thresholdRatio 时,fork 子会话把未压缩消息压缩为
788
+ * 观察:未压缩消息 tokens ≥ 窗口 × thresholdRatio 时,摘要调用把未压缩消息压缩为
574
789
  * 观察日志,追加到旧摘要并替换被压缩消息区间。失败不产生部分替换。
575
790
  */
576
791
  async function observePass(ctx, agent, config, window, tailCount, target, signal) {
577
792
  /** 当前会话。 */
578
793
  const session = agent.session;
794
+ /** 插件日志门面。 */
795
+ const logger = makeLogger(ctx, config.debug);
796
+ logger.step(`观察检查(窗口 ${window} × thresholdRatio ${config.thresholdRatio},尾部保留 ${tailCount} 条)`);
579
797
  /** 观察阈值(窗口 × thresholdRatio 向下取整)。 */
580
798
  const threshold = Math.floor(window * config.thresholdRatio);
581
799
  /** 未压缩消息 token 估算(不含 <om-history> 摘要节点)。 */
582
800
  const uncompressedTokens = measureUncompressedTokens(session, ctx.tokenMeter);
583
- if (uncompressedTokens < threshold) return;
584
- /** 观察压缩区间(尾部保留 tailCount + seed 封顶;无可行区间则跳过)。 */
801
+ if (uncompressedTokens < threshold) {
802
+ logger.step(`观察:未压缩消息 ${uncompressedTokens} tokens < 阈值 ${threshold},跳过`);
803
+ return;
804
+ }
805
+ logger.step(`观察:未压缩消息 ${uncompressedTokens} tokens ≥ 阈值 ${threshold},触发压缩`);
806
+ /** 观察压缩区间(尾部保留 tailCount 条不压缩;无可行区间则跳过)。 */
585
807
  const range = computeCompressRange(session, tailCount);
586
- if (!range) return;
808
+ if (!range) {
809
+ logger.step("观察:无可行压缩区间(表层过短或配对无法平衡),跳过");
810
+ return;
811
+ }
812
+ logger.step(`观察:压缩区间 [${range.start}..${range.end}],遮蔽 ${range.shadowedSeqs.length} 个表层节点`);
587
813
  /** 区间内旧摘要(追加基准;无则首次压缩)。 */
588
814
  const history = extractHistoryText(session, range.shadowedSeqs);
589
815
  /** 中断标记行。 */
590
816
  const interruptions = scanInterruptions(session, Math.min(...range.shadowedSeqs), range.lastEndSeq);
591
- /** 观察提示词。 */
817
+ /** message_id 对照表行。 */
818
+ const table = buildMessageIdTable(session, range.shadowedSeqs);
819
+ const actualTailCount = [...session.surface.nodes].length - range.shadowedSeqs.length;
820
+ logger.step(`观察:实际保留尾部 ${actualTailCount} 条(不压缩、不进日志),中断标记 ${interruptions.length} 条,message_id 对照表 ${table.length} 行`);
821
+ /** 观察指令(persona + 规则主体)。 */
592
822
  const prompt = buildObservePrompt({
593
- table: buildMessageIdTable(session, range.shadowedSeqs),
823
+ table,
594
824
  interruptions,
595
- hasOldHistory: history !== void 0
825
+ hasOldHistory: history !== void 0,
826
+ mode: config.summaryMode
596
827
  });
597
- /** 观察子会话结果(null 表示失败/跳过)。 */
598
- const summaryResult = await runSummarySubagent(ctx, agent, OBSERVER_PERSONA, prompt, config.compressMaxTokens, signal);
599
- if (summaryResult === null || summaryResult.text.trim().length === 0) return;
828
+ /** 观察摘要结果(null 表示失败/跳过)。 */
829
+ const summaryResult = await runSummarySubagent(ctx, agent, `${OBSERVER_PERSONA}\n\n${prompt}`, config.summaryMode === "new" ? renderMessages(session, range.shadowedSeqs.filter((seq) => historyTextOf(session.events[seq]) === void 0)) : void 0, config.compressMaxTokens, config.summaryMode, actualTailCount, target, config.debug, signal);
830
+ if (summaryResult === null || summaryResult.text.trim().length === 0) {
831
+ logger.step("观察:摘要调用失败/无输出,不产生替换");
832
+ return;
833
+ }
600
834
  /** 观察摘要文本。 */
601
835
  const report = summaryResult.text;
602
836
  /** 合并后的摘要(旧摘要原文保留,新观察日志追加在末尾)。 */
@@ -609,14 +843,17 @@ async function observePass(ctx, agent, config, window, tailCount, target, signal
609
843
  const message = event ? session.deriveEventMessage(event) : null;
610
844
  return total + (message ? ctx.tokenMeter.estimateMessage(message) : 0);
611
845
  }, 0);
612
- /** 本次压缩生命周期(compactionId + 当前轮次;fork 成功后才写入日志)。 */
846
+ /** 本次压缩生命周期(compactionId + 当前轮次;摘要成功后才写入日志)。 */
613
847
  const lifecycle = {
614
848
  compactionId: newCompactionId(),
615
849
  turn: openTurnOf(session)
616
850
  };
617
851
  try {
852
+ logger.step("观察提交:追加 compaction/start");
618
853
  appendCompactionStart(session, lifecycle);
619
- appendHistoryMessage(session, combined, [appendCompactionSummary(session, {
854
+ logger.step("观察提交:追加 compaction/summary(影子价格认领)");
855
+ /** compaction/summary 事件 seq(承担影子价格认领,紧随其后的替换消息消费)。 */
856
+ const summarySeq = appendCompactionSummary(session, {
620
857
  lifecycle,
621
858
  summary: combined,
622
859
  shadowedRange: {
@@ -629,17 +866,20 @@ async function observePass(ctx, agent, config, window, tailCount, target, signal
629
866
  model: target.model,
630
867
  maxTokens: config.compressMaxTokens,
631
868
  ...summaryResult.usage === void 0 ? {} : { usage: summaryResult.usage }
632
- }), ...range.shadowedSeqs], {
869
+ });
870
+ logger.step("观察提交:替换被压缩消息区间为 <om-history>");
871
+ appendHistoryMessage(session, combined, [summarySeq, ...range.shadowedSeqs], {
633
872
  op: "replace",
634
873
  start: range.start,
635
874
  end: range.end
636
875
  }, lifecycle.compactionId);
876
+ logger.step("观察提交:追加 compaction/end");
637
877
  appendCompactionEnd(session, lifecycle);
638
- ctx.logger.info(`dsh-plugin-om: 观察压缩完成(未压缩 ${uncompressedTokens} tokens ≥ 阈值 ${threshold},遮蔽 ${range.shadowedSeqs.length} 个表层节点,约 ${shadowedTokenCount} tokens)`);
878
+ logger.info(`观察压缩完成(未压缩 ${uncompressedTokens} tokens ≥ 阈值 ${threshold},遮蔽 ${range.shadowedSeqs.length} 个表层节点,约 ${shadowedTokenCount} tokens)`);
639
879
  } catch (error) {
640
880
  /** 提交失败信息(统一字符串)。 */
641
881
  const message = error instanceof Error ? error.message : String(error);
642
- ctx.logger.warn(`dsh-plugin-om: 观察压缩提交失败: ${message}`);
882
+ logger.warn(`观察压缩提交失败: ${message}`);
643
883
  try {
644
884
  appendCompactionEnd(session, lifecycle, message);
645
885
  } catch {}
@@ -652,24 +892,226 @@ async function observePass(ctx, agent, config, window, tailCount, target, signal
652
892
  async function maybeCompress(ctx, agent, config, signal) {
653
893
  /** 当前会话。 */
654
894
  const session = agent.session;
895
+ /** 插件日志门面。 */
896
+ const logger = makeLogger(ctx, config.debug);
897
+ /** disable 模式:关闭自动压缩(观察/反思均不触发,recall 工具不受影响)。 */
898
+ if (config.summaryMode === "disable") {
899
+ logger.step("summaryMode=disable,跳过压缩");
900
+ return;
901
+ }
655
902
  /** 会话路由目标(未路由无法查询容量)。 */
656
903
  const target = routedTarget(session);
657
- if (target === void 0) return;
904
+ if (target === void 0) {
905
+ logger.step("会话未路由(无 provider/model),跳过压缩");
906
+ return;
907
+ }
908
+ logger.step(`会话路由:provider ${target.provider},model ${target.model}`);
658
909
  /** 模型容量信息(contextWindow 决定两级阈值)。 */
659
910
  let info;
660
911
  try {
661
912
  info = await ctx.llm.resolveModelInfo(target.provider, target.model, signal);
662
913
  } catch (error) {
663
- ctx.logger.warn("dsh-plugin-om: 解析模型容量失败: " + (error instanceof Error ? error.message : String(error)));
914
+ logger.warn(`解析模型容量失败: ${error instanceof Error ? error.message : String(error)}`);
664
915
  return;
665
916
  }
666
917
  /** 模型上下文窗口大小(非法值视为无法压缩)。 */
667
918
  const window = info.context?.contextWindow;
668
- if (typeof window !== "number" || !Number.isFinite(window) || window <= 0) return;
919
+ if (typeof window !== "number" || !Number.isFinite(window) || window <= 0) {
920
+ logger.step(`模型上下文窗口非法(${String(window)}),跳过压缩`);
921
+ return;
922
+ }
923
+ logger.step(`模型上下文窗口 ${window} tokens,开始两级压缩(先反思后观察)`);
669
924
  /** 尾部保留条数(config.tailMessageCount,缺省 10)。 */
670
925
  const tailCount = config.tailMessageCount;
926
+ logger.step("反思 pass 开始");
671
927
  await reflectPass(ctx, agent, config, window, target, signal);
928
+ logger.step("反思 pass 结束,观察 pass 开始");
672
929
  await observePass(ctx, agent, config, window, tailCount, target, signal);
930
+ logger.step("观察 pass 结束,压缩流程完成");
931
+ }
932
+ //#endregion
933
+ //#region src/model-download.ts
934
+ /**
935
+ * 嵌入模型下载:recall-semantic 运行时按需下载 + 开发手动预下载共用。
936
+ *
937
+ * - 模型:Xenova/paraphrase-multilingual-MiniLM-L12-v2(量化 ONNX ~113MB,超过
938
+ * GitHub 单文件 100MB 限制,不能进入 git 仓库;仅小文件随仓库提交)。
939
+ * - 下载目标:<modelDir>/onnx/model_quantized.onnx(modelDir 默认 = 插件包内
940
+ * models/<id>/,与 embedding.ts 的本地加载路径一致;自定义 modelDir 同样生效)。
941
+ * - 直连 huggingface.co 受限时设置环境变量 HF_ENDPOINT=https://hf-mirror.com 走镜像。
942
+ * - 原子落盘:先写 .tmp 再改名,避免半截文件被当成完整模型加载。
943
+ *
944
+ * 运行时编排见 embedding.ts 的 ensureModelReady(下载单飞、未就绪不阻塞);
945
+ * 本模块只提供纯下载原语(供 src 与 scripts/download-model.mjs CLI 复用)。
946
+ */
947
+ /** 模型标识(models/ 下目录名 = 模型 id;transformers.js 加载时的模型名)。 */
948
+ const EMBEDDING_MODEL_ID = "paraphrase-multilingual-MiniLM-L12-v2";
949
+ /** transformers.js 转换仓库(Xenova org)中量化 ONNX 文件的相对路径(相对模型目录)。 */
950
+ const ONNX_REL = "onnx/model_quantized.onnx";
951
+ /**
952
+ * HuggingFace 原始文件 URL(resolve 会 302 到 CDN,fetch 自动跟随重定向)。
953
+ * base 默认 huggingface.co,可用环境变量 HF_ENDPOINT 覆盖(直连受限时改用镜像,如 https://hf-mirror.com)。
954
+ */
955
+ function modelSourceUrl(id = EMBEDDING_MODEL_ID, rel = ONNX_REL, base = process.env.HF_ENDPOINT || "https://huggingface.co") {
956
+ return `${base}/Xenova/${id}/resolve/main/${rel}`;
957
+ }
958
+ /** 本地目标文件绝对路径(<modelDir>/<rel>,缺省 <modelDir>/onnx/model_quantized.onnx)。 */
959
+ function modelTargetPath(modelDir, rel = ONNX_REL) {
960
+ return path.join(modelDir, rel);
961
+ }
962
+ /**
963
+ * 是否需要下载(纯函数,供测试)。
964
+ * force 强制下载;否则目标已存在即跳过。
965
+ */
966
+ function needsDownload(target, { force = false } = {}) {
967
+ if (force) return true;
968
+ try {
969
+ statSync(target);
970
+ return false;
971
+ } catch {
972
+ return true;
973
+ }
974
+ }
975
+ /**
976
+ * 下载模型到 <modelDir>/onnx/model_quantized.onnx。
977
+ * 可注入 fetchImpl / log(供测试);失败时清理临时文件并抛出。
978
+ */
979
+ async function downloadModel(modelDir, { force = false, fetchImpl = fetch, log = console.log } = {}) {
980
+ const target = modelTargetPath(modelDir);
981
+ if (!needsDownload(target, { force })) {
982
+ log(`[download-model] 已存在,跳过:${target}`);
983
+ return {
984
+ downloaded: false,
985
+ bytes: 0,
986
+ target
987
+ };
988
+ }
989
+ const url = modelSourceUrl();
990
+ mkdirSync(path.dirname(target), { recursive: true });
991
+ const tmp = `${target}.tmp`;
992
+ try {
993
+ const resp = await fetchImpl(url);
994
+ if (!resp.ok) throw new Error(`下载失败:HTTP ${resp.status} ${resp.statusText}(${url})`);
995
+ const buf = Buffer.from(await resp.arrayBuffer());
996
+ writeFileSync(tmp, buf);
997
+ renameSync(tmp, target);
998
+ log(`[download-model] 已下载 ${buf.length} 字节 → ${target}`);
999
+ return {
1000
+ downloaded: true,
1001
+ bytes: buf.length,
1002
+ target
1003
+ };
1004
+ } catch (err) {
1005
+ rmSync(tmp, { force: true });
1006
+ throw err;
1007
+ }
1008
+ }
1009
+ //#endregion
1010
+ //#region src/embedding.ts
1011
+ /**
1012
+ * 本地语义嵌入(recall-semantic 的向量引擎)。
1013
+ *
1014
+ * - 模型:Xenova/paraphrase-multilingual-MiniLM-L12-v2(量化 ONNX,多语言,
1015
+ * 中英 + 代码均可处理),模型文件在 models/ 下,完全离线、零下载。
1016
+ * - 运行时下载:模型 onnx 缺失时由 ensureModelReady 后台下载(不阻塞、单飞、
1017
+ * 失败自动重试),下载逻辑见 model-download.ts;就绪前工具告知模型。
1018
+ * - 懒加载:首次调用才 `import('@huggingface/transformers')` 并加载 pipeline,
1019
+ * 插件启动不阻塞;加载结果单例缓存(resetEmbedder 供测试重置)。
1020
+ * - 批处理:一次推理多条文本(mean pooling + L2 归一化),输出每条的向量。
1021
+ * - 相似度:cosineSimilarity 纯函数(归一化向量点积即余弦)。
1022
+ *
1023
+ * 依赖说明:@huggingface/transformers 为运行时依赖(v4,dtype q8 加载
1024
+ * model_quantized.onnx),node 侧使用 onnxruntime-node 原生绑定。
1025
+ */
1026
+ /** 当前模块所在路径(dist/ 或 src/,models 在其上一级)。 */
1027
+ const here = path.dirname(fileURLToPath(import.meta.url));
1028
+ /** 打包模型目录:<包根>/models/<model-id>/。 */
1029
+ const BUNDLED_MODEL_DIR = path.join(here, "..", "models", EMBEDDING_MODEL_ID);
1030
+ /** 单批最大文本数(避免单次推理过大)。 */
1031
+ const BATCH_SIZE = 32;
1032
+ /** 懒加载中的 pipeline 单例(null = 尚未加载/已重置)。 */
1033
+ let pipelinePromise = null;
1034
+ /**
1035
+ * 获取(或加载)嵌入函数。首次调用动态 import transformers 并加载本地模型;
1036
+ * 之后复用同一 pipeline。失败时抛出可读错误(调用方自行降级)。
1037
+ */
1038
+ function getEmbedder(modelDir = BUNDLED_MODEL_DIR) {
1039
+ if (pipelinePromise !== null) return pipelinePromise;
1040
+ pipelinePromise = (async () => {
1041
+ const { env, pipeline } = await import("@huggingface/transformers");
1042
+ env.localModelPath = `${path.dirname(modelDir)}${path.sep}`;
1043
+ env.allowLocalModels = true;
1044
+ env.allowRemoteModels = false;
1045
+ const extractor = await pipeline("feature-extraction", EMBEDDING_MODEL_ID, { dtype: "q8" });
1046
+ return async (texts) => {
1047
+ const vectors = [];
1048
+ for (let i = 0; i < texts.length; i += BATCH_SIZE) {
1049
+ const batch = texts.slice(i, i + BATCH_SIZE);
1050
+ const result = await extractor(batch, {
1051
+ pooling: "mean",
1052
+ normalize: true
1053
+ });
1054
+ /** 结果张量:dims = [batch, hidden],data 为扁平数组。 */
1055
+ const dims = result.dims;
1056
+ const hidden = dims.length > 1 ? dims[1] : 0;
1057
+ const data = result.data;
1058
+ for (let j = 0; j < batch.length; j += 1) {
1059
+ const start = j * hidden;
1060
+ if (hidden > 0) vectors.push(Float32Array.from(Array.prototype.slice.call(data, start, start + hidden)));
1061
+ }
1062
+ }
1063
+ return vectors;
1064
+ };
1065
+ })();
1066
+ return pipelinePromise;
1067
+ }
1068
+ /** 每个 modelDir 的在途下载任务(单飞:并发查询只发起一次下载)。 */
1069
+ const inflightDownloads = /* @__PURE__ */ new Map();
1070
+ /**
1071
+ * 确保模型就绪(运行时按需下载编排)。
1072
+ * - 本地 onnx 已存在 → 'ready'(不触发下载)。
1073
+ * - 缺失 → 启动后台下载(不阻塞,单飞)并返回 'downloading';下载失败仅调用
1074
+ * warn 记录日志并结束本次尝试,下次调用会重新触发下载(自动重试)。
1075
+ * - warn 可选:下载失败时的日志回调(默认静默;apply 注入 ctx.logger.warn)。
1076
+ * - fetchImpl 可注入(测试传替身,默认全局 fetch)。
1077
+ */
1078
+ function ensureModelReady(modelDir = BUNDLED_MODEL_DIR, warn = () => {}, fetchImpl) {
1079
+ if (!needsDownload(modelTargetPath(modelDir))) return Promise.resolve("ready");
1080
+ if (!inflightDownloads.has(modelDir)) {
1081
+ const task = downloadModel(modelDir, fetchImpl === void 0 ? { log: () => {} } : {
1082
+ fetchImpl,
1083
+ log: () => {}
1084
+ }).then(() => {}).catch((err) => {
1085
+ warn("[download-model] 下载失败:" + (err instanceof Error ? err.message : String(err)));
1086
+ }).finally(() => {
1087
+ inflightDownloads.delete(modelDir);
1088
+ });
1089
+ inflightDownloads.set(modelDir, task);
1090
+ }
1091
+ return Promise.resolve("downloading");
1092
+ }
1093
+ /**
1094
+ * 两个向量的余弦相似度(向量已 L2 归一化时点积即余弦;这里兜底再归一化)。
1095
+ * 任一向量为零向量返回 0。
1096
+ */
1097
+ function cosineSimilarity(a, b) {
1098
+ const len = Math.min(a.length, b.length);
1099
+ if (len === 0) return 0;
1100
+ /** 点积累加。 */
1101
+ let dot = 0;
1102
+ /** a 的模平方。 */
1103
+ let normA = 0;
1104
+ /** b 的模平方。 */
1105
+ let normB = 0;
1106
+ for (let i = 0; i < len; i += 1) {
1107
+ const x = a[i];
1108
+ const y = b[i];
1109
+ dot += x * y;
1110
+ normA += x * x;
1111
+ normB += y * y;
1112
+ }
1113
+ if (normA === 0 || normB === 0) return 0;
1114
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
673
1115
  }
674
1116
  //#endregion
675
1117
  //#region src/config.ts
@@ -677,46 +1119,36 @@ async function maybeCompress(ctx, agent, config, signal) {
677
1119
  * 插件配置:默认值、键校验与合并(preset 行 config 可覆盖全部键)。
678
1120
  * 手写校验,保持零运行时外部依赖。
679
1121
  */
680
- /** 默认配置(冻结对象,resolveConfig 合并的基底)。 */
1122
+ /** 默认配置(冻结对象,resolveConfig 合并的基底;debug 缺省值在解析时按 NODE_ENV 判定)。 */
681
1123
  const DEFAULT_CONFIG = Object.freeze({
682
1124
  thresholdRatio: .5,
683
1125
  historyMergeRatio: .2,
684
1126
  compressMaxTokens: 4096,
685
- tailMessageCount: 10
1127
+ tailMessageCount: 10,
1128
+ summaryMode: "fork",
1129
+ debug: false,
1130
+ recallEnabled: true,
1131
+ semanticRecallEnabled: true,
1132
+ modelDir: BUNDLED_MODEL_DIR
686
1133
  });
687
1134
  /** 合法配置键集合(未知键直接拒绝)。 */
688
1135
  const CONFIG_KEYS = /* @__PURE__ */ new Set([
689
1136
  "thresholdRatio",
690
1137
  "historyMergeRatio",
691
1138
  "compressMaxTokens",
692
- "tailMessageCount"
1139
+ "tailMessageCount",
1140
+ "summaryMode",
1141
+ "debug",
1142
+ "recallEnabled",
1143
+ "semanticRecallEnabled",
1144
+ "modelDir"
693
1145
  ]);
694
- /** 数值键校验参数表:键名 + [min, max, integer] */
1146
+ /** 数值键校验参数表:键名 + [integer]。不限制取值区间——用户提供的值按原样接受(便于调试)。 */
695
1147
  const NUMBER_KEYS = [
696
- [
697
- "thresholdRatio",
698
- .01,
699
- 1,
700
- false
701
- ],
702
- [
703
- "historyMergeRatio",
704
- .01,
705
- 1,
706
- false
707
- ],
708
- [
709
- "compressMaxTokens",
710
- 1,
711
- Infinity,
712
- true
713
- ],
714
- [
715
- "tailMessageCount",
716
- 1,
717
- Infinity,
718
- true
719
- ]
1148
+ ["thresholdRatio", false],
1149
+ ["historyMergeRatio", false],
1150
+ ["compressMaxTokens", true],
1151
+ ["tailMessageCount", true]
720
1152
  ];
721
1153
  /** 归一化原始配置输入:缺省 / null / 空串(含空白串)视为空对象(全部用默认值)。 */
722
1154
  function normalizeConfigInput(raw) {
@@ -726,7 +1158,24 @@ function normalizeConfigInput(raw) {
726
1158
  return raw;
727
1159
  }
728
1160
  /**
729
- * 解析合并配置:校验未知键与数值类型,返回冻结的完整配置。
1161
+ * 解析摘要模式配置值:缺省 / 空串回退 fork;仅接受 'fork'/'new'/'disable';
1162
+ * 其余值抛错(配置错误须立即可见)。
1163
+ */
1164
+ function resolveSummaryMode(raw) {
1165
+ if (raw === void 0 || raw === null) return "fork";
1166
+ if (typeof raw === "string" && raw.trim() === "") return "fork";
1167
+ if (raw === "fork" || raw === "new" || raw === "disable") return raw;
1168
+ fail("config summaryMode must be \"fork\", \"new\" or \"disable\"");
1169
+ }
1170
+ /** 解析布尔配置值:缺省 / null / 空串回退默认值;必须为 boolean 否则抛错。 */
1171
+ function resolveBoolean(name, raw, defaultValue) {
1172
+ if (raw === void 0 || raw === null) return defaultValue;
1173
+ if (typeof raw === "string" && raw.trim() === "") return defaultValue;
1174
+ if (typeof raw === "boolean") return raw;
1175
+ fail(`config ${name} must be a boolean`);
1176
+ }
1177
+ /**
1178
+ * 解析合并配置:校验未知键与数值/字符串类型,返回冻结的完整配置。
730
1179
  * 允许所有配置留空——缺省 / null / 空串的键回退默认值,未给出的键亦取默认值。
731
1180
  */
732
1181
  function resolveConfig(raw) {
@@ -735,18 +1184,28 @@ function resolveConfig(raw) {
735
1184
  for (const key of Object.keys(input)) if (!CONFIG_KEYS.has(key)) fail(`unknown config key "${key}"`);
736
1185
  /** 合并结果(以默认值为基底)。 */
737
1186
  const config = { ...DEFAULT_CONFIG };
738
- for (const [key, min, max, integer] of NUMBER_KEYS) {
1187
+ for (const [key, integer] of NUMBER_KEYS) {
739
1188
  /** 该键的原始值(留空则跳过,保持默认值)。 */
740
1189
  const value = input[key];
741
1190
  if (value === void 0 || value === null) continue;
742
1191
  if (typeof value === "string" && value.trim() === "") continue;
743
- assertNumber(key, value, {
744
- min,
745
- max,
746
- integer
747
- });
1192
+ assertNumber(key, value, { integer });
748
1193
  config[key] = value;
749
1194
  }
1195
+ /** 摘要模式(缺省 fork;非法值报错)。 */
1196
+ config.summaryMode = resolveSummaryMode(input.summaryMode);
1197
+ /** 步骤级日志开关(缺省按 NODE_ENV !== 'production' 判定)。 */
1198
+ config.debug = resolveBoolean("debug", input.debug, process.env.NODE_ENV !== "production");
1199
+ /** recall 工具开关(缺省启用)。 */
1200
+ config.recallEnabled = resolveBoolean("recallEnabled", input.recallEnabled, true);
1201
+ /** recall-semantic 工具开关(缺省启用)。 */
1202
+ config.semanticRecallEnabled = resolveBoolean("semanticRecallEnabled", input.semanticRecallEnabled, true);
1203
+ /** modelDir 原始值(留空回退默认;非空串必须为字符串)。 */
1204
+ const modelDir = input.modelDir;
1205
+ if (modelDir !== void 0 && modelDir !== null) {
1206
+ if (typeof modelDir === "string" && modelDir.trim() !== "") config.modelDir = modelDir;
1207
+ else assertNonEmptyString("modelDir", modelDir);
1208
+ }
750
1209
  return Object.freeze(config);
751
1210
  }
752
1211
  //#endregion
@@ -870,6 +1329,248 @@ function buildRecallTool(getPruner) {
870
1329
  };
871
1330
  }
872
1331
  //#endregion
1332
+ //#region src/semantic-recall.ts
1333
+ /**
1334
+ * recall-semantic 工具:按自然语言 query 对会话全部消息日志(含被压缩/遮蔽的
1335
+ * user/assistant/tool-result 事件)做语义检索,返回最匹配的完整消息与匹配说明。
1336
+ *
1337
+ * - 参数:query 必填;top_k(默认 3,1-10);start_id/end_id/offset 限定检索区间
1338
+ * (意义同 recall:start_id 为基准边界,end_id 与 offset 二选一;end_id 优先)。
1339
+ * - 区间缺省(start_id 未提供)→ 检索全部消息;区间不合法(id 不存在等)→
1340
+ * 不报错,回退全量检索并在输出中明确告知(模型可见)。
1341
+ * - 向量:本地 ONNX embedding(embedding.ts,懒加载 + 批量);相似度 = cosine。
1342
+ * - 输出:命中消息的 message_id/seq/类型 + 完整渲染文本 + 匹配说明(相似度、
1343
+ * 命中的关键词);超大结果由 tool-result-pruner 裁剪(同 recall)。
1344
+ * - 仅主会话可用(subagent 拒绝,与 recall 一致)。
1345
+ */
1346
+ /** recall-semantic 工具参数 schema:query 必填;top_k 默认 3(1-10);区间参数均可选。 */
1347
+ const semanticRecallArgsSchema = z.object({
1348
+ query: z.string(),
1349
+ top_k: z.number().int().min(1).max(10).optional(),
1350
+ start_id: z.string().optional(),
1351
+ end_id: z.string().optional(),
1352
+ offset: z.number().optional()
1353
+ }).refine((args) => args.query.trim().length > 0, { message: "query 不能为空" });
1354
+ /** 解析并校验参数:失败时抛出可读错误(普通 Error 而非 ZodError)。 */
1355
+ function parseSemanticRecallArgs(raw) {
1356
+ /** safeParse 结果。 */
1357
+ const result = semanticRecallArgsSchema.safeParse(raw);
1358
+ if (!result.success) throw new Error(result.error.issues.map((issue) => `${issue.path.join(".") || "args"}:${issue.message}`).join("; "));
1359
+ return result.data;
1360
+ }
1361
+ /**
1362
+ * 解析检索区间(消息序列下标,含端点)。
1363
+ * - start_id 未提供 → 全量(fallback=false)。
1364
+ * - start_id 提供:end_id/offset 计算终点(语义同 recall,end_id 优先);
1365
+ * start_id/end_id 不存在或区间为空 → fallback=true(全量 + 提示)。
1366
+ */
1367
+ function resolveSemanticRange(index, args) {
1368
+ const total = index.messages.length;
1369
+ if (total === 0) return {
1370
+ lo: 0,
1371
+ hi: -1,
1372
+ fallback: false
1373
+ };
1374
+ if (args.start_id === void 0) return {
1375
+ lo: 0,
1376
+ hi: total - 1,
1377
+ fallback: false
1378
+ };
1379
+ /** start_id 在消息序列中的下标。 */
1380
+ const start = index.byId.get(args.start_id);
1381
+ if (start === void 0) return {
1382
+ lo: 0,
1383
+ hi: total - 1,
1384
+ fallback: true
1385
+ };
1386
+ /** 终点下标(end_id 优先;否则 start + offset 取整)。 */
1387
+ let end;
1388
+ if (args.end_id !== void 0) {
1389
+ /** end_id 在消息序列中的下标。 */
1390
+ const found = index.byId.get(args.end_id);
1391
+ if (found === void 0) return {
1392
+ lo: 0,
1393
+ hi: total - 1,
1394
+ fallback: true
1395
+ };
1396
+ end = found;
1397
+ } else if (args.offset !== void 0) end = start + (Number.isFinite(args.offset) ? Math.floor(args.offset) : 0);
1398
+ else end = start;
1399
+ /** 区间下界(钳制)。 */
1400
+ const lo = Math.max(0, Math.min(start, end));
1401
+ /** 区间上界(钳制)。 */
1402
+ const hi = Math.min(total - 1, Math.max(start, end));
1403
+ if (lo > hi) return {
1404
+ lo: 0,
1405
+ hi: total - 1,
1406
+ fallback: true
1407
+ };
1408
+ return {
1409
+ lo,
1410
+ hi,
1411
+ fallback: false
1412
+ };
1413
+ }
1414
+ /** 简易分词:按非字母数字切分为小写词元(匹配要点用,非检索核心)。 */
1415
+ function tokenize(text) {
1416
+ /** 词元集合。 */
1417
+ const tokens = /* @__PURE__ */ new Set();
1418
+ for (const part of text.toLowerCase().split(/[^\p{L}\p{N}]+/u)) if (part.length > 0) tokens.add(part);
1419
+ return tokens;
1420
+ }
1421
+ /** 命中消息的匹配说明行:相似度 + 与 query 共有的关键词(最多 8 个)。 */
1422
+ function matchExplanation(query, text, score) {
1423
+ /** query 词元。 */
1424
+ const qTokens = tokenize(query);
1425
+ /** 命中消息文本词元。 */
1426
+ const mTokens = tokenize(text);
1427
+ /** 共有词元(交集,按 query 词序)。 */
1428
+ const shared = [];
1429
+ for (const token of qTokens) {
1430
+ if (mTokens.has(token)) shared.push(token);
1431
+ if (shared.length >= 8) break;
1432
+ }
1433
+ /** 关键词部分(无共有词则省略)。 */
1434
+ const keywords = shared.length > 0 ? `,命中关键词: ${shared.join(" ")}` : "";
1435
+ return `相似度 ${score.toFixed(3)}${keywords}`;
1436
+ }
1437
+ /** 模型未就绪时返回给模型的文案(告知即可;下载完成后无需另行通知,直接再次调用)。 */
1438
+ const SEMANTIC_MODEL_NOT_READY_MESSAGE = "语义检索暂不可用:本地嵌入模型尚未就绪(正在后台下载约 113MB;下载完成后无需提示,直接再次调用本工具即可。若下载失败,下次调用会自动重试)。可稍后重试,或先用 recall 工具按 message_id 精确检索。";
1439
+ /** 构建 recall-semantic 工具定义(embedder/modelStatus 可注入,测试传替身;缺省用本地模型)。 */
1440
+ function buildSemanticRecallTool(options) {
1441
+ /** pruner 获取器(可选,裁剪超大工具结果)。 */
1442
+ const getPruner = options?.getPruner ?? (() => void 0);
1443
+ /** 嵌入函数(注入替身或懒加载本地模型)。 */
1444
+ const embed = options?.embedder ?? ((texts) => getEmbedder().then((fn) => fn(texts)));
1445
+ return {
1446
+ name: "recall-semantic",
1447
+ description: "按语义(自然语言含义)在会话全部消息中检索:用一句话描述你要找的内容(可混用中英文与代码术语)。返回最匹配的若干条完整消息、message_id 与匹配说明;如需精确定位某个 message_id 周边的消息,请再用 recall 工具。",
1448
+ parameters: {
1449
+ query: {
1450
+ type: "string",
1451
+ description: "描述要找的内容的自然语言 query(可混用中英文与代码术语,如 \"修复 retry backoff 的逻辑\")。",
1452
+ required: true
1453
+ },
1454
+ top_k: {
1455
+ type: "number",
1456
+ description: "返回最匹配的消息条数(1-10,默认 3)。"
1457
+ },
1458
+ start_id: {
1459
+ type: "string",
1460
+ description: "message_id(uuid),可选。限定检索区间:以该消息为基准边界(意义同 recall)。缺省检索全部消息。区间不合法时自动回退全量检索并在结果中说明。"
1461
+ },
1462
+ end_id: {
1463
+ type: "string",
1464
+ description: "message_id(uuid),可选,与 offset 互斥,限定区间的另一个边界(意义同 recall)。"
1465
+ },
1466
+ offset: {
1467
+ type: "number",
1468
+ description: "可选,与 end_id 互斥。相对 start_id 的步数:正数向后、负数向前(意义同 recall)。"
1469
+ }
1470
+ },
1471
+ output: {
1472
+ schema: { type: "string" },
1473
+ render: (_args, value) => [{
1474
+ type: "text",
1475
+ text: String(value)
1476
+ }]
1477
+ },
1478
+ async execute(args, exec) {
1479
+ /** 解析并校验后的调用参数。 */
1480
+ const { query, top_k, start_id, end_id, offset } = parseSemanticRecallArgs(args);
1481
+ /** 当前会话。 */
1482
+ const session = exec.agent?.session;
1483
+ if (!session) return "会话异常";
1484
+ if (!isMainSession(session)) return "recall-semantic 仅主会话可用";
1485
+ if ((await options?.modelStatus?.() ?? "ready") !== "ready") return SEMANTIC_MODEL_NOT_READY_MESSAGE;
1486
+ /** 会话消息索引(全部消息事件,含被压缩/遮蔽)。 */
1487
+ const index = indexMessages(session);
1488
+ if (index.messages.length === 0) return "会话中没有可检索的消息";
1489
+ /** 检索区间(消息序列下标;区间不合法时回退全量并标记)。 */
1490
+ const range = resolveSemanticRange(index, {
1491
+ start_id,
1492
+ end_id,
1493
+ offset
1494
+ });
1495
+ /** 候选消息渲染文本(区间内;跳过渲染失败/空文本)。 */
1496
+ const candidates = [];
1497
+ for (let i = range.lo; i <= range.hi; i += 1) {
1498
+ /** 当前消息节点。 */
1499
+ const node = index.messages[i];
1500
+ if (!node) continue;
1501
+ /** 节点对应的会话事件。 */
1502
+ const event = session.events[node.seq];
1503
+ if (!event) continue;
1504
+ /** 事件派生的消息对象。 */
1505
+ let message = null;
1506
+ try {
1507
+ message = session.deriveEventMessage(event);
1508
+ } catch {}
1509
+ /** 渲染文本。 */
1510
+ const text = message ? renderMessageText(message) : "";
1511
+ if (text.trim().length > 0) candidates.push({
1512
+ node,
1513
+ text
1514
+ });
1515
+ }
1516
+ if (candidates.length === 0) return "指定范围内没有可检索的消息";
1517
+ /** 查询向量(单条)。 */
1518
+ const [queryVec] = await embed([query]);
1519
+ if (!queryVec) return "语义检索失败:无法生成查询向量";
1520
+ /** 候选文本批量嵌入。 */
1521
+ const vectors = await embed(candidates.map((c) => c.text));
1522
+ /** 打分结果(相似度 + 候选)。 */
1523
+ const scored = candidates.map((c, i) => ({
1524
+ node: c.node,
1525
+ text: c.text,
1526
+ score: cosineSimilarity(queryVec, vectors[i] ?? /* @__PURE__ */ new Float32Array(0))
1527
+ }));
1528
+ scored.sort((a, b) => b.score - a.score);
1529
+ /** 返回条数(top_k 默认 3)。 */
1530
+ const limit = top_k ?? 3;
1531
+ const hits = scored.slice(0, limit);
1532
+ /** 结果缓冲。 */
1533
+ const parts = [];
1534
+ /** 范围描述(回退时明确告知模型)。 */
1535
+ const rangeNote = range.fallback ? "指定区间不合法(start_id/end_id 不存在等),已回退检索全部消息" : start_id === void 0 ? `检索全部消息(${candidates.length} 条可嵌入)` : `检索区间 [${range.lo}..${range.hi}](${candidates.length} 条可嵌入)`;
1536
+ parts.push(`查询: ${query}`);
1537
+ parts.push(rangeNote);
1538
+ parts.push(`匹配 TOP-${hits.length}(共 ${scored.length} 条候选):`);
1539
+ for (let i = 0; i < hits.length; i += 1) {
1540
+ /** 当前命中。 */
1541
+ const hit = hits[i];
1542
+ if (!hit) continue;
1543
+ /** 消息事件(用于类型与 pruner)。 */
1544
+ const event = session.events[hit.node.seq];
1545
+ /** 命中消息的 id(缺失则省略)。 */
1546
+ const id = event ? messageIdOfEvent(event) ?? "" : "";
1547
+ /** 命中消息文本(pruner 裁剪超大内容后渲染)。 */
1548
+ let text = hit.text;
1549
+ /** pruner(可选)。 */
1550
+ const pruner = getPruner();
1551
+ if (pruner?.pruneContent && event) {
1552
+ /** 事件派生的消息对象(裁剪用)。 */
1553
+ let message = null;
1554
+ try {
1555
+ message = session.deriveEventMessage(event);
1556
+ } catch {}
1557
+ if (message) {
1558
+ /** 裁剪后的内容块。 */
1559
+ const pruned = pruner.pruneContent(message.content);
1560
+ if (pruned) text = renderMessageText({
1561
+ ...message,
1562
+ content: pruned
1563
+ });
1564
+ }
1565
+ }
1566
+ parts.push(`-- [${i + 1}] seq ${hit.node.seq} ${hit.node.type} message_id=${id} — ${matchExplanation(query, hit.text, hit.score)} --`);
1567
+ parts.push(text);
1568
+ }
1569
+ return parts.join("\n\n");
1570
+ }
1571
+ };
1572
+ }
1573
+ //#endregion
873
1574
  //#region src/index.ts
874
1575
  /**
875
1576
  * dsh-plugin-om — Observational Memory(OM)上下文压缩 + recall 检索插件。
@@ -877,9 +1578,12 @@ function buildRecallTool(getPruner) {
877
1578
  *
878
1579
  * 模块:
879
1580
  * - recall.ts recall({ start_id, end_id?, offset? }) 工具:按 message_id 回看原始会话
1581
+ * - semantic-recall.ts recall-semantic({ query, top_k?, start_id?, end_id?, offset? }) 工具:
1582
+ * 按语义在全部消息日志(含被压缩/遮蔽)中检索,返回最匹配的完整消息与匹配说明
1583
+ * (本地 ONNX embedding,模型随插件打包,懒加载)
880
1584
  * - compress.ts 自动压缩(OM 观察/反思两级阈值):pre-step 阻塞串行执行——
881
- * 反思(摘要 ≥ 窗口 × historyMergeRatio fork 精简合并 <om-history>)、
882
- * 观察(未压缩消息 ≥ 窗口 × thresholdRatio 时 fork 压缩为观察日志并追加)
1585
+ * 反思(摘要 ≥ 窗口 × historyMergeRatio 时摘要调用精简合并 <om-history>)、
1586
+ * 观察(未压缩消息 ≥ 窗口 × thresholdRatio 时摘要调用压缩为观察日志并追加)
883
1587
  *
884
1588
  * 约束:不引入自定义会话事件类型——压缩复用宿主已知的 compaction/* 生命周期事件
885
1589
  * (start/summary/end)与 checkpoint 标记,结果写入消息记录与轨迹。
@@ -895,18 +1599,36 @@ const inject = [
895
1599
  "sessions"
896
1600
  ];
897
1601
  /**
898
- * 插件激活入口:注册 recall 工具,并在 agent/pre-step 阻塞触发两级自动压缩
1602
+ * 插件激活入口:注册 recall / recall-semantic 工具(由配置键 recallEnabled /
1603
+ * semanticRecallEnabled 控制),并在 agent/pre-step 阻塞触发两级自动压缩
899
1604
  * (先反思后观察)。仅主会话生效。
900
1605
  */
901
1606
  function apply(ctx, config) {
902
1607
  /** 解析后的插件配置(默认值合并 + 校验)。 */
903
1608
  const resolved = resolveConfig(config);
904
- ctx.tools.register(buildRecallTool(() => ctx.get("toolResultPruner")));
1609
+ /** 插件日志门面(step=debug 按配置 debug 开关输出;info/warn 始终输出)。 */
1610
+ const logger = makeLogger(ctx, resolved.debug);
1611
+ logger.step(`apply 启动:thresholdRatio=${String(resolved.thresholdRatio)} historyMergeRatio=${String(resolved.historyMergeRatio)} compressMaxTokens=${String(resolved.compressMaxTokens)} tailMessageCount=${String(resolved.tailMessageCount)} summaryMode=${resolved.summaryMode} debug=${String(resolved.debug)}`);
1612
+ if (resolved.recallEnabled) ctx.tools.register(buildRecallTool(() => ctx.get("toolResultPruner")));
1613
+ if (resolved.semanticRecallEnabled) {
1614
+ const warnModel = (message) => ctx.logger.warn("dsh-plugin-om: " + message);
1615
+ ensureModelReady(resolved.modelDir, warnModel);
1616
+ ctx.tools.register(buildSemanticRecallTool({
1617
+ getPruner: () => ctx.get("toolResultPruner"),
1618
+ modelStatus: () => ensureModelReady(resolved.modelDir, warnModel),
1619
+ embedder: (texts) => getEmbedder(resolved.modelDir).then((embed) => embed(texts))
1620
+ }));
1621
+ }
905
1622
  ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
906
1623
  try {
907
- if (!signal.aborted && isMainSession(agent.session)) await maybeCompress(ctx, agent, resolved, signal);
1624
+ if (signal.aborted) logger.step("pre-step 已中止(signal aborted),跳过压缩");
1625
+ else if (!isMainSession(agent.session)) logger.step("subagent 会话,跳过压缩(仅主会话生效)");
1626
+ else {
1627
+ logger.step(`pre-step 触发压缩(会话 ${agent.session.id})`);
1628
+ await maybeCompress(ctx, agent, resolved, signal);
1629
+ }
908
1630
  } catch (error) {
909
- ctx.logger.warn("dsh-plugin-om: pre-step 处理失败: " + (error instanceof Error ? error.message : String(error)));
1631
+ logger.warn(`pre-step 处理失败: ${error instanceof Error ? error.message : String(error)}`);
910
1632
  }
911
1633
  return next();
912
1634
  });