dsh-plugin-om 0.0.6 → 0.0.9

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
@@ -13,75 +13,6 @@ const HISTORY_TAG = "om-history";
13
13
  /** 宿主压缩 checkpoint 标记的 plugin 名(dsh-compaction-basic 的 COMPACT_CHECKPOINT_MARKER.plugin)。 */
14
14
  const COMPACT_CHECKPOINT_PLUGIN = "compact";
15
15
  //#endregion
16
- //#region src/log-index.ts
17
- /**
18
- * 查找 seq 在表层节点序列中的下标(不在则返回 -1)。
19
- * 表层节点按日志顺序排列,用于压缩边界定位与遮蔽范围计算。
20
- */
21
- function surfaceIndexOf(nodes, seq) {
22
- for (let i = 0; i < nodes.length; i += 1) if (nodes[i] === seq) return i;
23
- return -1;
24
- }
25
- /** 提取一条消息事件的 message_id(user/assistant/tool-result 消息均有稳定 id;其余事件无)。 */
26
- function messageIdOfEvent(event) {
27
- if (!event) return void 0;
28
- if (event.type === "user/message") return String(event.data.id ?? "");
29
- if (event.type === "assistant/message") return String(event.data.message.id ?? "");
30
- if (event.type === "tool/result") return String(event.data.message.id ?? "");
31
- }
32
- /** 消息索引:按日志顺序列出全部消息事件,并按 message_id 定位其在消息序列中的下标。 */
33
- function indexMessages(session) {
34
- /** 按日志顺序的消息事件列表。 */
35
- const messages = [];
36
- /** message_id → 序列下标映射。 */
37
- const byId = /* @__PURE__ */ new Map();
38
- /** 会话全部事件。 */
39
- const events = session.events;
40
- for (let seq = 0; seq < events.length; seq += 1) {
41
- /** 当前待检查事件。 */
42
- const event = events[seq];
43
- if (!event) continue;
44
- if (event.type !== "user/message" && event.type !== "assistant/message" && event.type !== "tool/result") continue;
45
- /** 消息 id(缺失则跳过该事件)。 */
46
- const id = messageIdOfEvent(event);
47
- if (!id) continue;
48
- /** 本条消息在消息序列中的下标。 */
49
- const index = messages.length;
50
- messages.push({
51
- seq,
52
- id,
53
- type: event.type
54
- });
55
- byId.set(id, index);
56
- }
57
- return {
58
- messages,
59
- byId
60
- };
61
- }
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
85
16
  //#region src/utils.ts
86
17
  /** 判断值是否为普通对象:typeof object 且非 null 且非数组(类型收窄用)。 */
87
18
  function isRecord(value) {
@@ -153,24 +84,188 @@ function routedTarget(session) {
153
84
  } catch {}
154
85
  }
155
86
  //#endregion
87
+ //#region src/log-index.ts
88
+ /**
89
+ * 查找 seq 在表层节点序列中的下标(不在则返回 -1)。
90
+ * 表层节点按日志顺序排列,用于压缩边界定位与遮蔽范围计算。
91
+ */
92
+ function surfaceIndexOf(nodes, seq) {
93
+ for (let i = 0; i < nodes.length; i += 1) if (nodes[i] === seq) return i;
94
+ return -1;
95
+ }
96
+ /**
97
+ * 完整消息索引:按日志顺序把消息事件折叠为完整消息序列(三类,见文件头)。
98
+ * 工具调用结果按 source.callId 匹配其 tool-call 并入该条;未匹配的 result 独立成条(防御)。
99
+ */
100
+ function indexCompleteMessages(session) {
101
+ /** 完整消息序列(index = 数组下标,0 起)。 */
102
+ const cms = [];
103
+ /** 等待结果的 toolcall 完整消息(callId → cm)。 */
104
+ const pending = /* @__PURE__ */ new Map();
105
+ /** 会话全部事件(仅追加)。 */
106
+ const events = session.events;
107
+ for (let seq = 0; seq < events.length; seq += 1) {
108
+ /** 当前待检查事件。 */
109
+ const event = events[seq];
110
+ if (!event) continue;
111
+ if (event.type === "user/message") {
112
+ if (event.data.source?.kind === "plugin") continue;
113
+ cms.push({
114
+ index: cms.length,
115
+ type: "user",
116
+ seqs: [seq]
117
+ });
118
+ } else if (event.type === "assistant/message") {
119
+ /** 助手消息(含文本与 tool-call 块)。 */
120
+ const message = event.data.message;
121
+ if (!message || !Array.isArray(message.content)) continue;
122
+ /** 是否存在文本块(有则先产出 assistant 条)。 */
123
+ let hasText = false;
124
+ for (const block of message.content) if (block.type === "text") {
125
+ hasText = true;
126
+ break;
127
+ }
128
+ if (hasText) cms.push({
129
+ index: cms.length,
130
+ type: "assistant",
131
+ seqs: [seq]
132
+ });
133
+ for (const block of message.content) {
134
+ if (block.type !== "tool-call") continue;
135
+ /** 工具调用 id(匹配 result 用)。 */
136
+ const callId = String(block.id ?? "");
137
+ /** 本调用对应的完整消息(结果随后并入)。 */
138
+ const cm = {
139
+ index: cms.length,
140
+ type: "toolcall",
141
+ seqs: [seq],
142
+ ...callId === "" ? {} : { callId }
143
+ };
144
+ cms.push(cm);
145
+ if (callId !== "") pending.set(callId, cm);
146
+ }
147
+ } else if (event.type === "tool/result") {
148
+ /** 结果消息的 source(callId 关联调用)。 */
149
+ const source = event.data.message?.source;
150
+ /** 关联的调用 id。 */
151
+ const callId = String(source?.callId ?? "");
152
+ /** 匹配到的 toolcall 完整消息(无则独立成条)。 */
153
+ const cm = callId === "" ? void 0 : pending.get(callId);
154
+ if (cm) {
155
+ cm.seqs.push(seq);
156
+ pending.delete(callId);
157
+ } else cms.push({
158
+ index: cms.length,
159
+ type: "toolcall",
160
+ seqs: [seq],
161
+ ...callId === "" ? {} : { callId }
162
+ });
163
+ }
164
+ }
165
+ return cms;
166
+ }
167
+ /**
168
+ * 渲染一条完整消息的文本(recall 输出 / new 模式输入共用):
169
+ * - user:消息原文;
170
+ * - assistant:仅文本块;
171
+ * - toolcall:调用块(工具名 + 参数)+ 结果文本(pruner 裁剪超大结果)。
172
+ */
173
+ function renderCompleteMessage(session, cm, pruner) {
174
+ if (cm.type === "user") {
175
+ /** 用户消息事件(seq 缺失则无法渲染)。 */
176
+ const seq = cm.seqs[0];
177
+ const event = seq === void 0 ? void 0 : session.events[seq];
178
+ /** 派生的消息对象。 */
179
+ const message = event ? session.deriveEventMessage(event) : null;
180
+ return message ? renderMessageText(message) : "";
181
+ }
182
+ if (cm.type === "assistant") {
183
+ /** 助手消息事件(seq 缺失则无法渲染)。 */
184
+ const seq = cm.seqs[0];
185
+ const event = seq === void 0 ? void 0 : session.events[seq];
186
+ /** 派生的消息对象。 */
187
+ const message = event ? session.deriveEventMessage(event) : null;
188
+ if (!message || !Array.isArray(message.content)) return "";
189
+ /** 文本块拼接缓冲。 */
190
+ const texts = [];
191
+ for (const block of message.content) if (block.type === "text") texts.push(String(block.text));
192
+ return texts.join("\n");
193
+ }
194
+ /** 渲染缓冲。 */
195
+ const parts = [];
196
+ /** 承载调用块的 assistant 事件(seq 缺失则跳过调用块)。 */
197
+ const callSeq = cm.seqs[0];
198
+ const callEvent = callSeq === void 0 ? void 0 : session.events[callSeq];
199
+ if (callEvent?.type === "assistant/message") {
200
+ /** 助手消息对象(取对应调用块)。 */
201
+ const message = session.deriveEventMessage(callEvent);
202
+ if (message && Array.isArray(message.content)) {
203
+ /** 匹配的 tool-call 块。 */
204
+ let call;
205
+ for (const block of message.content) if (block.type === "tool-call" && String(block.id ?? "") === (cm.callId ?? "")) {
206
+ call = block;
207
+ break;
208
+ }
209
+ if (call) parts.push(`[tool-call ${String(call.name ?? "")} id=${String(call.id ?? "")}]\n${safeJson(call.arguments)}`);
210
+ }
211
+ }
212
+ /** 结果事件 seq(无则调用未闭合)。 */
213
+ const resultSeq = cm.seqs[1];
214
+ const resultEvent = resultSeq === void 0 ? void 0 : session.events[resultSeq];
215
+ if (resultEvent?.type === "tool/result") {
216
+ /** 结果消息对象(pruner 裁剪超大内容后渲染)。 */
217
+ let message = session.deriveEventMessage(resultEvent);
218
+ if (message && pruner?.pruneContent) {
219
+ /** 裁剪后的内容块。 */
220
+ const pruned = pruner.pruneContent(message.content);
221
+ if (pruned) message = {
222
+ ...message,
223
+ content: pruned
224
+ };
225
+ }
226
+ /** 结果文本。 */
227
+ const text = message ? renderMessageText(message) : "";
228
+ if (text.trim() !== "") parts.push(`[result]\n${text}`);
229
+ }
230
+ return parts.join("\n");
231
+ }
232
+ //#endregion
233
+ //#region src/logger.ts
234
+ /**
235
+ * 日志辅助:步骤级(debug)日志默认仅 dev(非 production)输出,便于开发调试;
236
+ * 失败等关键日志始终输出(不受 debug 开关影响)。
237
+ * 开关配置键 debug:true 强制开启、false 强制关闭,缺省按 NODE_ENV !== 'production' 判定
238
+ * (默认值由 resolveConfig 解析,见 config.ts)。
239
+ */
240
+ /** 构建插件日志门面:统一加 PLUGIN_LABEL 前缀,step 按 debug 开关过滤。 */
241
+ function makeLogger(ctx, debug) {
242
+ return {
243
+ step(message) {
244
+ if (debug) ctx.logger.debug(`${PLUGIN_LABEL}: ${message}`);
245
+ },
246
+ info(message) {
247
+ ctx.logger.info(`${PLUGIN_LABEL}: ${message}`);
248
+ },
249
+ warn(message) {
250
+ ctx.logger.warn(`${PLUGIN_LABEL}: ${message}`);
251
+ }
252
+ };
253
+ }
254
+ //#endregion
156
255
  //#region src/summarize.ts
157
256
  /** 观察者 persona:只针对未压缩消息产出观察日志,不用工具、不展示思考。 */
158
- const OBSERVER_PERSONA = "你是 dsh-plugin-om 的上下文观察者(Observer,机制参考 Mastra Observational Memory):把会话中尚未压缩的消息压缩为一份观察日志。不用工具、不展示思考、不评价代码、不输出多余文字。";
257
+ const OBSERVER_PERSONA = "把会话中尚未压缩的消息压缩为一份观察日志。不用工具、不展示思考、不评价代码、不输出多余文字。";
159
258
  /** 反思者 persona:只精简合并当前摘要,不用工具、不展示思考。 */
160
- const REFLECTOR_PERSONA = "你是 dsh-plugin-om 的上下文反思者(Reflector,机制参考 Mastra Observational Memory):把当前 <om-history> 压缩日志精简合并为一份更紧凑的日志。不用工具、不展示思考、不输出多余文字。";
259
+ const REFLECTOR_PERSONA = "把当前 <om-history> 压缩日志精简合并为一份更紧凑的日志。不用工具、不展示思考、不输出多余文字。";
161
260
  /**
162
261
  * 构建观察指令主体:任务声明(fork:停止任务/禁止工具;new:说明总结日志)+ 模式相关
163
262
  * 的上下文定位与压缩范围(fork:上方完整会话记录——尾部已在输入中实际截断,提示词不含
164
- * 尾部规则;new:下方消息即压缩对象)+ 规则(用户消息完整保留原文 / AI 消息模块化压缩
165
- * ——工具调用按目的聚合——不限于 run_code / 倾向于新消息 / 中断标注 / 未完成写进度与
166
- * 下一步)+ 输出格式(合法 XML)+ 对照表 + 中断标记 + 追加说明。persona 由调用方拼接
167
- * 到指令开头。
263
+ * 尾部规则;new:下方消息即压缩对象)+ 完整消息与 index 定义(三类合并规则、起始编号)+
264
+ * 规则(用户消息完整保留原文 / AI 消息按模块压缩 / 本指令不入日志 / index/start/end 连续 /
265
+ * 倾向于新消息 / 未完成写进度与下一步)+ 输出格式(合法 XML)+ 追加说明。
266
+ * persona 由调用方拼接到指令开头。
168
267
  */
169
268
  function buildObservePrompt(options) {
170
- /** 对照表段落(无则标注「无」)。 */
171
- const tableSection = options.table.length > 0 ? options.table : ["(无)"];
172
- /** 中断标记段落(无则标注「无」)。 */
173
- const interruptionSection = options.interruptions.length > 0 ? options.interruptions : ["(无)"];
174
269
  return [
175
270
  options.mode === "fork" ? "停止一切现有任务,禁止调用任何工具,改为将过往消息总结为一份日志。" : "将过往消息总结为一份日志。",
176
271
  "",
@@ -180,28 +275,32 @@ function buildObservePrompt(options) {
180
275
  `如果消息记录里还没有 <${HISTORY_TAG}> 块,则除本指令外的全部消息都是压缩对象。`
181
276
  ] : ["下方的消息记录是本次要压缩的全部消息(上一个 <om-history> 块之后的新消息;不含旧压缩日志、不含尾部)。", "你的压缩结果会作为新的 <om-history> 块追加到已有压缩日志之后。"],
182
277
  "",
278
+ "【完整消息与 index】",
279
+ "- 完整消息分三类:用户消息占一条;AI 文本占一条;工具调用及其结果占一条(每个 tool-call 与其 result 各一条,同一条 AI 消息里的文本与工具调用拆开)。每条完整消息有一个 index(从 0 起、按会话顺序递增、全局稳定),index 是该条完整消息的序号。",
280
+ `- 本次要压缩的新消息从 index ${options.startIndex} 开始编号,按日志顺序逐条递增;旧 <${HISTORY_TAG}> 中已有的 index 无需理会,也不要改动旧条目。`,
281
+ "",
183
282
  "【规则】",
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 回看。",
283
+ "- 当前消息仅作为指令,**不得**进入日志。",
284
+ "- 用户消息完整保留原文,输出为 <user_message index=\"N\"> 条目(N 为该条完整消息的 index,内容为消息原文,不概括、不省略)。",
285
+ "- 将AI 消息按照内在关联,划分为**连续消息**组成的模块,消息模块的划分方式不受限制,但内部须有足够的**逻辑连贯性**,应当是AI基于某个目的做出了一系列动作或论述",
286
+ "- 模块内的消息聚合输出为<assistant start=\"\" end=\"\">块,start/end表示当前模块首尾消息的index;块内不描述单条消息的细节,只描述当前模块的**目的、行为、结果**;最后一个<assistant>块,额外包含`下一步计划`",
287
+ "- 对于模块涉及到的具体文件,应当出现在<assistant>块中,多个前缀相同的路径要合并简写",
288
+ "- 对于单条重要的AI完整消息,以<assistant index=\"\">单独呈现,其内容不受限制",
289
+ "- 条目按 index 顺序覆盖本次压缩的全部完整消息,index/start/end 必须连续(区间内 index 连续、相邻条目相接),不跳号、不重叠、不遗漏。",
186
290
  "- 总结时倾向于新消息,旧消息一句话带过即可;新旧消息冲突时强调新消息,不修改旧日志条目。",
187
- "- 若【中断标记】非空,在对应位置明确写出中断(例如「被用户打断,因此上一段工作未完成」),帮助后续理解用户为何再次输入消息、为何不延续之前的工作。",
188
- "- 若当前工作看起来未完成(最后一次工具调用没有结果、或对话被中断/异常结束),在日志末尾说明当前进度与下一步要做什么。",
189
291
  "【输出格式】只输出一个 <om-history> 包裹的合法 XML 日志块,不要解释、不要复述规则:",
190
292
  `<${HISTORY_TAG}>`,
191
- "<user_message id=\"(message_id)\">",
293
+ "<user_message index=\"(index)\">",
192
294
  "(user 消息原文)",
193
295
  "</user_message>",
194
- "<assistant last_id=\"(该组最后一条消息的 message_id)\">",
195
- "(压缩模块:toolcall 聚合行等,与现有格式一致)",
296
+ "<assistant start=\"(起始 index)\" end=\"(结束 index)\">",
297
+ "(模块的目的、行为与结果摘要)",
298
+ "</assistant>",
299
+ "<assistant index=\"(index)\">",
300
+ "(单条完整消息的模块摘要)",
196
301
  "</assistant>",
197
302
  `</${HISTORY_TAG}>`,
198
303
  "",
199
- "【message_id 对照表】(按顺序对应消息记录中的未压缩消息,用于产出正确的 message_id)",
200
- ...tableSection,
201
- "",
202
- "【中断标记】",
203
- ...interruptionSection,
204
- "",
205
304
  ...options.hasOldHistory ? [`【说明】你的压缩结果会被直接追加到上一次压缩产物(<${HISTORY_TAG}>)的末尾,作为新的 <${HISTORY_TAG}> 块;条目格式与本块一致。`] : [`【说明】你的压缩结果将成为第一条 <${HISTORY_TAG}> 压缩日志。`]
206
305
  ].join("\n");
207
306
  }
@@ -214,17 +313,25 @@ function buildReflectPrompt(mode) {
214
313
  ...mode === "new" ? ["下方的消息记录包含当前的 <om-history> 压缩日志(最后一次 <om-history> 块)。", "只对这份压缩日志做精简合并;不要涉及日志之外的消息。"] : ["上方的消息记录是主会话的完整历史,其中包含当前的 <om-history> 压缩日志(最后一次 <om-history> 块)。", "只对这份压缩日志做精简合并;不要涉及日志之外的消息。"],
215
314
  "",
216
315
  "【规则】",
217
- "- 用户消息保留要点与 message_id(格式:<user_message id=\"(message_id)\"> 要点 </user_message>);可省略的条目删除。",
218
- "- toolcall 条目按调用目的进一步聚合,保留组内最后一条消息的 message_id;不重要的条目 summary 写「(略)」。",
219
- "- 保留中断说明与未完成说明(若原日志中有)。",
316
+ "- 当前消息仅作为指令,**不得**进入日志。",
317
+ "- 完整消息分三类(用户消息 / AI 文本 / 单个工具调用及其结果),各占一个 index(从 0 起、会话内全局稳定),index 是该条完整消息的序号。",
318
+ "- 将AI 消息按照内在关联,划分为**连续消息**组成的模块,消息模块的划分方式不受限制,但内部须有足够的**逻辑连贯性**,应当是AI基于某个目的做出了一系列动作或论述",
319
+ "- 模块内的消息聚合输出为<assistant start=\"\" end=\"\">块,start/end表示当前模块首尾消息的index;块内不描述单条消息的细节,只描述当前模块的**目的、行为、结果**;最后一个<assistant>块,额外包含`下一步计划`",
320
+ "- 对于模块涉及到的具体文件,应当出现在<assistant>块中,多个前缀相同的路径要合并简写",
321
+ "- 对于单条重要的AI完整消息,以<assistant index=\"\">单独呈现,其内容不受限制",
322
+ "- 用户消息保留要点与 index(格式:<user_message index=\"(index)\"> 要点 </user_message>);不重要的内容概括为「(略)」。",
323
+ "- 条目按 index 顺序覆盖日志中的全部完整消息,index/start/end 必须连续(区间内 index 连续、相邻条目相接),不跳号、不重叠、不遗漏;合并时条目的 index 不重新编号。",
220
324
  "- 过时事实丢弃,不逐字复制旧文本;新旧条目冲突时保留新条目。",
221
325
  "【输出格式】只输出一个 <om-history> 包裹的合法 XML 日志块,不要解释、不要复述规则:",
222
326
  `<${HISTORY_TAG}>`,
223
- "<user_message id=\"(message_id)\">",
327
+ "<user_message index=\"(index)\">",
224
328
  "(user 消息要点)",
225
329
  "</user_message>",
226
- "<assistant last_id=\"(该组最后一条消息的 message_id)\">",
227
- "(压缩模块:toolcall 聚合行等,与现有格式一致)",
330
+ "<assistant start=\"(起始 index)\" end=\"(结束 index)\">",
331
+ "(模块的目的、行为与结果摘要)",
332
+ "</assistant>",
333
+ "<assistant index=\"(index)\">",
334
+ "(单条完整消息的模块摘要)",
228
335
  "</assistant>",
229
336
  `</${HISTORY_TAG}>`,
230
337
  "",
@@ -265,50 +372,25 @@ var StreamCollector = class {
265
372
  }
266
373
  };
267
374
  /**
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 取文本)。
375
+ * 渲染完整消息记录(new 模式输入):按完整消息渲染为合法 XML,每条带绝对 index——
376
+ * - user → <user_message index="N">(原文)</user_message>;
377
+ * - assistant / toolcall → <assistant index="N">(文本 / 调用参数+结果)</assistant>。
378
+ * 仅渲染 seqs 全部落在给定集合内的完整消息(插件自产消息天然不占位)。
273
379
  */
274
380
  function renderMessages(session, seqs) {
381
+ /** 遮蔽 seq 集合。 */
382
+ const shadowed = new Set(seqs);
275
383
  /** 渲染段缓冲区。 */
276
384
  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;
286
- };
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
- }
385
+ for (const cm of indexCompleteMessages(session)) {
386
+ if (!cm.seqs.every((seq) => shadowed.has(seq))) continue;
387
+ /** 该条完整消息的文本(空内容跳过)。 */
388
+ const text = renderCompleteMessage(session, cm);
389
+ if (text.trim() === "") continue;
390
+ /** 标签(user AI 侧分属两种条目)。 */
391
+ const tag = cm.type === "user" ? "user_message" : "assistant";
392
+ parts.push(`<${tag} index="${cm.index}">\n${text}\n</${tag}>`);
310
393
  }
311
- flush();
312
394
  return parts.join("\n\n");
313
395
  }
314
396
  /** 构造插件自产 user 消息(指令或 new 模式的输入消息;id 为品牌类型 MessageId)。 */
@@ -360,7 +442,7 @@ function buildSummaryOptions(session, instruction, contextText, maxTokens, mode,
360
442
  };
361
443
  }
362
444
  /** 产出日志后插入首个 <om-history> 后的格式说明(XML 注释,避免被误读为日志条目)。 */
363
- const HISTORY_FORMAT_NOTE = "<!-- <user_message>块内包含了用户的原文 id表示该消息的id;<assistant>块是多条ai连续消息的聚合,last_id指向最后一条消息 -->";
445
+ const HISTORY_FORMAT_NOTE = "<!-- 完整消息分三类(用户消息 / AI 文本 / 单个工具调用及其结果),各占一个 index(从 0 起、会话内全局稳定);<user_message index=\"N\"> 与 <assistant index=\"N\"> 表示单条完整消息,<assistant start=\"A\" end=\"B\"> 表示多条完整消息,start/end是首尾消息的index -->";
364
446
  /**
365
447
  * 从 AI 摘要输出中提取合法日志(不信任 AI 的总结结果):
366
448
  * - 取首个 <om-history> 到最后一个 </om-history>(含两个首尾)切为日志;
@@ -457,6 +539,8 @@ async function runSummarySubagent(ctx, agent, instruction, contextText, maxToken
457
539
  * 表层长度-1-tailCount(尾部保留 config.tailMessageCount 条不压缩,作为摘要模型的
458
540
  * 参考尾部),当前 turn 中已完备的消息同样可压缩;区间终点回退到 tool-call/result
459
541
  * 配对平衡点(不切段)。
542
+ * 观察摘要的条目用「完整消息」index 定位(三类定义见 log-index.ts):新消息起始 index
543
+ * 由插件从日志计算后注入提示词,new 模式输入按完整消息渲染绝对 index。
460
544
  * 仅主会话生效。
461
545
  */
462
546
  /** 历史文本 token 估算:4 字符 ≈ 1 token(与宿主 dsh-token-meter 启发式一致)。 */
@@ -533,7 +617,6 @@ function isPairBalancedAfter(session, seq) {
533
617
  * 观察压缩区间:pre-step 触发时日志 call-result 完备,区间不再受 turn/end 封顶——
534
618
  * 头部 → 表层长度-1-tailCount(尾部保留 tailCount 条不压缩),当前 turn 中已完备的
535
619
  * 消息同样可压缩;区间终点回退到 tool-call/result 配对平衡点(不切段)。
536
- * lastEndSeq 仅为中断扫描提供最后一个已结束 turn 的边界(无则 -1)。
537
620
  */
538
621
  function computeCompressRange(session, tailCount) {
539
622
  /** 当前表层节点(按日志顺序)。 */
@@ -558,33 +641,10 @@ function computeCompressRange(session, tailCount) {
558
641
  return {
559
642
  start,
560
643
  end,
561
- shadowedSeqs: surface.slice(0, endIdx + 1),
562
- lastEndSeq: session.events.findLast((event) => event.type === "turn/end")?.seq ?? -1
644
+ shadowedSeqs: surface.slice(0, endIdx + 1)
563
645
  };
564
646
  }
565
647
  /**
566
- * 中断标记行:范围内 turn/end 以 aborted(含 cause 类型)或 interrupted 结束的轮次
567
- * (标记用途:让摘要 AI 理解中断原因)。
568
- */
569
- function scanInterruptions(session, fromSeq, toSeq) {
570
- /** 标记行缓冲区。 */
571
- const marks = [];
572
- for (let seq = fromSeq + 1; seq <= toSeq; seq += 1) {
573
- /** 当前待检查事件。 */
574
- const event = session.events[seq];
575
- if (event?.type !== "turn/end") continue;
576
- /** 结束原因(判别 kind)。 */
577
- const reason = event.data.reason;
578
- if (!reason || typeof reason !== "object") continue;
579
- if (reason.kind === "aborted") {
580
- /** 取消来源(user/parent/hook/disposed;未知标记 unknown)。 */
581
- const cause = reason.reason?.kind ?? "unknown";
582
- marks.push(`[interrupted] turn ${String(event.data.turn)} 被中断(aborted,原因 ${cause})`);
583
- } else if (reason.kind === "interrupted") marks.push(`[interrupted] turn ${String(event.data.turn)} 因崩溃恢复中断(interrupted)`);
584
- }
585
- return marks;
586
- }
587
- /**
588
648
  * 提取遮蔽区间内最后一次 <om-history> 压缩日志(内文 + seq)。
589
649
  * 按表层顺序(shadowedSeqs)扫描:单节点替换(反思)后摘要节点 seq 可能大于
590
650
  * 被压缩消息的 seq,按 seq 区间扫描会漏(start > end)。
@@ -602,39 +662,6 @@ function extractHistoryText(session, shadowedSeqs) {
602
662
  }
603
663
  return found;
604
664
  }
605
- /**
606
- * message_id 对照表:遮蔽区间内消息事件按表层顺序产出 id 行(插件自产 user/message
607
- * 如运行时上下文快照与 <om-history> 不入表;观察摘要据此产出正确的 message_id)。
608
- * 按表层顺序(shadowedSeqs)扫描:与 extractHistoryText 同理,seq 区间扫描会漏。
609
- */
610
- function buildMessageIdTable(session, shadowedSeqs) {
611
- /** 对照表行缓冲区。 */
612
- const rows = [];
613
- for (const seq of shadowedSeqs) {
614
- /** 当前待检查事件。 */
615
- const event = session.events[seq];
616
- if (!event) continue;
617
- if (event.type === "user/message") {
618
- if (event.data.source?.kind === "plugin") continue;
619
- /** 用户消息 id。 */
620
- const id = messageIdOfEvent(event);
621
- if (id) rows.push(`[user] message_id=${id}`);
622
- } else if (event.type === "assistant/message") {
623
- /** 助手消息 id。 */
624
- const id = messageIdOfEvent(event);
625
- if (id) rows.push(`[assistant] message_id=${id}`);
626
- } else if (event.type === "tool/result") {
627
- /** 结果消息 id。 */
628
- const id = messageIdOfEvent(event);
629
- if (id) {
630
- /** 关联调用 id(供摘要模型按 callId 定位代码与结果)。 */
631
- const callId = String(event.data.message.source.callId ?? "");
632
- rows.push(`[tool/result callId=${callId}] message_id=${id}`);
633
- }
634
- }
635
- }
636
- return rows;
637
- }
638
665
  /** 当前打开中的 turn 号(最近 turn/start 且未被 turn/end 关闭);无则 null(跨轮次场景)。 */
639
666
  function openTurnOf(session) {
640
667
  /** 折叠结果(turn/start 打开、turn/end 关闭)。 */
@@ -812,21 +839,20 @@ async function observePass(ctx, agent, config, window, tailCount, target, signal
812
839
  logger.step(`观察:压缩区间 [${range.start}..${range.end}],遮蔽 ${range.shadowedSeqs.length} 个表层节点`);
813
840
  /** 区间内旧摘要(追加基准;无则首次压缩)。 */
814
841
  const history = extractHistoryText(session, range.shadowedSeqs);
815
- /** 中断标记行。 */
816
- const interruptions = scanInterruptions(session, Math.min(...range.shadowedSeqs), range.lastEndSeq);
817
- /** message_id 对照表行。 */
818
- const table = buildMessageIdTable(session, range.shadowedSeqs);
819
842
  const actualTailCount = [...session.surface.nodes].length - range.shadowedSeqs.length;
820
- logger.step(`观察:实际保留尾部 ${actualTailCount} 条(不压缩、不进日志),中断标记 ${interruptions.length} 条,message_id 对照表 ${table.length} 行`);
843
+ /** 遮蔽 seq 集合(计算新消息起始 index)。 */
844
+ const shadowedSet = new Set(range.shadowedSeqs);
845
+ /** 压缩区间内第一个完整消息的 index(新消息起始编号;区间内无完整消息则 0)。 */
846
+ const startIndex = indexCompleteMessages(session).find((cm) => cm.seqs.every((seq) => shadowedSet.has(seq)))?.index ?? 0;
847
+ logger.step(`观察:实际保留尾部 ${actualTailCount} 条(不压缩、不进日志),新消息起始 index ${startIndex}`);
821
848
  /** 观察指令(persona + 规则主体)。 */
822
849
  const prompt = buildObservePrompt({
823
- table,
824
- interruptions,
850
+ startIndex,
825
851
  hasOldHistory: history !== void 0,
826
852
  mode: config.summaryMode
827
853
  });
828
854
  /** 观察摘要结果(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);
855
+ const summaryResult = await runSummarySubagent(ctx, agent, `${OBSERVER_PERSONA}\n\n${prompt}`, config.summaryMode === "new" ? renderMessages(session, range.shadowedSeqs) : void 0, config.compressMaxTokens, config.summaryMode, actualTailCount, target, config.debug, signal);
830
856
  if (summaryResult === null || summaryResult.text.trim().length === 0) {
831
857
  logger.step("观察:摘要调用失败/无输出,不产生替换");
832
858
  return;
@@ -1082,7 +1108,7 @@ function ensureModelReady(modelDir = BUNDLED_MODEL_DIR, warn = () => {}, fetchIm
1082
1108
  fetchImpl,
1083
1109
  log: () => {}
1084
1110
  }).then(() => {}).catch((err) => {
1085
- warn("[download-model] 下载失败:" + (err instanceof Error ? err.message : String(err)));
1111
+ warn(`[download-model] 下载失败:${err instanceof Error ? err.message : String(err)}`);
1086
1112
  }).finally(() => {
1087
1113
  inflightDownloads.delete(modelDir);
1088
1114
  });
@@ -1211,22 +1237,24 @@ function resolveConfig(raw) {
1211
1237
  //#endregion
1212
1238
  //#region src/recall.ts
1213
1239
  /**
1214
- * recall 工具:按 message_id 回看原始会话(start_id/end_id 为消息 idoffset 为相对
1215
- * start_id 的消息步数)。recall 自身不设输出上限:超大的工具结果由 tool-result-pruner
1216
- * 裁剪(pruneContent),输出 token pruner 配置控制。
1240
+ * recall 工具:按「完整消息」序号(index)回看原始会话(start/end 为完整消息 index
1241
+ * offset 为相对 start 的完整消息步数)。完整消息分三类(用户消息 / AI 文本 / 单个工具
1242
+ * 调用及其结果),各占一个 index(从 0 起、会话内全局稳定),与摘要日志条目同一套编号。
1243
+ * recall 自身不设输出上限:超大的工具结果由 tool-result-pruner 裁剪(pruneContent),
1244
+ * 输出 token 由 pruner 配置控制。
1217
1245
  *
1218
- * 参数由 zod schema(recallArgsSchema)在 execute 入口校验:start_id 必填且非空,
1219
- * end_id/offset 至少提供一个;非法参数抛出可读错误。
1246
+ * 参数由 zod schema(recallArgsSchema)在 execute 入口校验:start 必填(number),
1247
+ * end/offset 至少提供一个;非法参数抛出可读错误。
1220
1248
  */
1221
1249
  /**
1222
- * recall 工具参数 schema:start_id 必填且非空;end_id 与 offset 至少提供一个
1223
- * (二者同时给出时 end_id 优先,与 execute 语义一致);未知键自动剥离。
1250
+ * recall 工具参数 schema:start 必填(number);end 与 offset 至少提供一个
1251
+ * (二者同时给出时 end 优先,与 execute 语义一致);未知键自动剥离。
1224
1252
  */
1225
1253
  const recallArgsSchema = z.object({
1226
- start_id: z.string(),
1227
- end_id: z.string().optional(),
1254
+ start: z.number(),
1255
+ end: z.number().optional(),
1228
1256
  offset: z.number().optional()
1229
- }).refine((args) => args.end_id !== void 0 || args.offset !== void 0, { message: "end_id 与 offset 至少提供一个" });
1257
+ }).refine((args) => args.end !== void 0 || args.offset !== void 0, { message: "end 与 offset 至少提供一个" });
1230
1258
  /**
1231
1259
  * 解析并校验 recall 调用参数:校验失败时抛出首个校验问题的可读消息
1232
1260
  * (普通 Error 而非 ZodError,兼容 SDK 展示)。
@@ -1238,26 +1266,26 @@ function parseRecallArgs(raw) {
1238
1266
  return result.data;
1239
1267
  }
1240
1268
  /**
1241
- * 构建 recall 工具定义:按 start_id/end_id/offset 定位消息区间并渲染原始内容。
1269
+ * 构建 recall 工具定义:按 start/end/offset 定位完整消息区间并渲染原始内容(标 index)。
1242
1270
  * getPruner 返回 tool-result-pruner(可选),用于裁剪超大工具结果。
1243
1271
  */
1244
1272
  function buildRecallTool(getPruner) {
1245
1273
  return {
1246
1274
  name: "recall",
1247
- description: "根据message_id,回看指定区间的过往消息。start_id必须传入,是区间的基准。end_id 和 offset 二选一,end_id 用于指定另一个边界,offset用于指定区间包含的消息数量。",
1275
+ description: "根据完整消息序号(index),回看指定区间的过往消息。完整消息是定位单位,分三类:用户消息、AI 文本、单个工具调用及其结果(每个 tool-call 与其 result 一条),各占一个 index(从 0 起、会话内全局稳定)。start 必须传入,是区间的基准;end 和 offset 二选一:end 指定另一个边界(含两端,与 start 的位置关系不影响结果),offset 指定区间包含的完整消息数量(正数向后、负数向前)。",
1248
1276
  parameters: {
1249
- start_id: {
1250
- type: "string",
1251
- description: "message_id(uuid),区间的基准边界",
1277
+ start: {
1278
+ type: "number",
1279
+ description: "完整消息序号(index),区间的基准边界",
1252
1280
  required: true
1253
1281
  },
1254
- end_id: {
1255
- type: "string",
1256
- description: "message_id(uuid),与 offset 互斥,指定区间的另一个边界。与 start_id 的位置关系不影响结果。"
1282
+ end: {
1283
+ type: "number",
1284
+ description: " offset 互斥,指定区间的另一个边界(含)。与 start 的位置关系不影响结果。"
1257
1285
  },
1258
1286
  offset: {
1259
1287
  type: "number",
1260
- description: "与 end_id 互斥,指定区间包含的消息数量。传入正数查看start_id之后的若干条消息,负数则是之前的。"
1288
+ description: "与 end 互斥,指定区间包含的完整消息数量。传入正数查看 start 之后的若干条,负数则是之前的。"
1261
1289
  }
1262
1290
  },
1263
1291
  output: {
@@ -1269,23 +1297,23 @@ function buildRecallTool(getPruner) {
1269
1297
  },
1270
1298
  async execute(args, exec) {
1271
1299
  /** 解析并校验后的调用参数(不满足 schema 时抛出可读错误)。 */
1272
- const { start_id, end_id, offset } = parseRecallArgs(args);
1300
+ const { start, end, offset } = parseRecallArgs(args);
1273
1301
  /** 当前会话(缺失则无法回看)。 */
1274
1302
  const session = exec.agent?.session;
1275
1303
  if (!session) return "会话异常";
1276
1304
  if (!isMainSession(session)) return "recall 仅主会话可用";
1277
- /** 会话消息索引(序列 + byId 映射)。 */
1278
- const { messages, byId } = indexMessages(session);
1279
- /** start_id 在消息序列中的下标。 */
1280
- const startIndex = byId.get(start_id);
1281
- if (startIndex === void 0) return `start_id "${start_id}" 不存在`;
1282
- /** 终点下标(end_id 优先,否则 startIndex + offset)。 */
1305
+ /** 完整消息索引(index = 数组下标,0 起)。 */
1306
+ const cms = indexCompleteMessages(session);
1307
+ /** 起始下标(非整数 floor;越界返回提示)。 */
1308
+ const startIndex = Number.isFinite(start) ? Math.floor(start) : 0;
1309
+ if (startIndex < 0 || startIndex >= cms.length) return `start ${start} 越界(完整消息共 ${cms.length} 条,index 范围 0..${cms.length - 1})`;
1310
+ /** 终点下标(end 优先,否则 startIndex + offset)。 */
1283
1311
  let endIndex;
1284
- if (end_id !== void 0) {
1285
- /** end_id 在消息序列中的下标。 */
1286
- const found = byId.get(end_id);
1287
- if (found === void 0) return `end_id "${end_id}" 不存在`;
1288
- endIndex = found;
1312
+ if (end !== void 0) {
1313
+ /** end 取整(非整数 floor)。 */
1314
+ const e = Number.isFinite(end) ? Math.floor(end) : 0;
1315
+ if (e < 0 || e >= cms.length) return `end ${end} 越界(完整消息共 ${cms.length} 条,index 范围 0..${cms.length - 1})`;
1316
+ endIndex = e;
1289
1317
  } else {
1290
1318
  /** offset 数值(refine 保证此分支 offset 已提供,schema 保证为 number)。 */
1291
1319
  const raw = offset ?? 0;
@@ -1294,36 +1322,25 @@ function buildRecallTool(getPruner) {
1294
1322
  /** 区间下界(钳制到 [0, len-1])。 */
1295
1323
  const lo = Math.max(0, Math.min(startIndex, endIndex));
1296
1324
  /** 区间上界(钳制到 [0, len-1])。 */
1297
- const hi = Math.min(messages.length - 1, Math.max(startIndex, endIndex));
1325
+ const hi = Math.min(cms.length - 1, Math.max(startIndex, endIndex));
1298
1326
  /** tool-result-pruner(可选,裁剪超大工具结果)。 */
1299
1327
  const pruner = getPruner?.();
1300
- /** 渲染结果缓冲(每条消息一段)。 */
1328
+ /** 渲染结果缓冲(每条完整消息一段,标 index + 类型)。 */
1301
1329
  const parts = [];
1302
1330
  for (let i = lo; i <= hi; i += 1) {
1303
- /** 当前消息节点。 */
1304
- const node = messages[i];
1305
- if (!node) continue;
1306
- /** 节点对应的会话事件。 */
1307
- const event = session.events[node.seq];
1308
- if (!event) continue;
1309
- /** 本条消息的呈现文本。 */
1331
+ /** 当前完整消息。 */
1332
+ const cm = cms[i];
1333
+ if (!cm) continue;
1334
+ /** 该条完整消息的呈现文本(pruner 裁剪超大结果;单条失败不影响整体)。 */
1310
1335
  let text = "";
1311
1336
  try {
1312
- /** 从事件派生的消息对象。 */
1313
- let message = session.deriveEventMessage(event);
1314
- if (message && event.type === "tool/result" && pruner?.pruneContent) {
1315
- /** 裁剪后的内容块(返回 null 表示不裁剪)。 */
1316
- const pruned = pruner.pruneContent(message.content);
1317
- if (pruned) message = {
1318
- ...message,
1319
- content: pruned
1320
- };
1321
- }
1322
- text = message ? renderMessageText(message) : "";
1337
+ text = renderCompleteMessage(session, cm, pruner);
1323
1338
  } catch {}
1324
- parts.push(`-- [seq ${node.seq}] ${event.type} --\n${text}`);
1339
+ /** 类型标注(toolcall 附调用 id,便于模型关联)。 */
1340
+ const callAttr = cm.type === "toolcall" && cm.callId ? ` callId=${cm.callId}` : "";
1341
+ parts.push(`-- [index ${cm.index}] ${cm.type}${callAttr} --\n${text}`);
1325
1342
  }
1326
- if (parts.length === 0) return "指定区间没有消息";
1343
+ if (parts.length === 0) return "指定区间没有完整消息";
1327
1344
  return parts.join("\n\n");
1328
1345
  }
1329
1346
  };
@@ -1331,15 +1348,15 @@ function buildRecallTool(getPruner) {
1331
1348
  //#endregion
1332
1349
  //#region src/semantic-recall.ts
1333
1350
  /**
1334
- * recall-semantic 工具:按自然语言 query 对会话全部消息日志(含被压缩/遮蔽的
1335
- * user/assistant/tool-result 事件)做语义检索,返回最匹配的完整消息与匹配说明。
1351
+ * recall-semantic 工具:按自然语言 query 对会话全部完整消息(含被压缩/遮蔽的)做语义
1352
+ * 检索,返回最匹配的完整消息与匹配说明。
1336
1353
  *
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 不存在等)→
1354
+ * - 参数:query 必填;top_k(默认 3,1-10);start/end/offset 限定检索区间
1355
+ * (意义同 recall:start 为基准边界,end 与 offset 二选一;end 优先)。
1356
+ * - 区间缺省(start 未提供)→ 检索全部消息;区间不合法(start/end 越界等)→
1340
1357
  * 不报错,回退全量检索并在输出中明确告知(模型可见)。
1341
1358
  * - 向量:本地 ONNX embedding(embedding.ts,懒加载 + 批量);相似度 = cosine。
1342
- * - 输出:命中消息的 message_id/seq/类型 + 完整渲染文本 + 匹配说明(相似度、
1359
+ * - 输出:命中消息的 index/类型(toolcall 附调用 id)+ 完整渲染文本 + 匹配说明(相似度、
1343
1360
  * 命中的关键词);超大结果由 tool-result-pruner 裁剪(同 recall)。
1344
1361
  * - 仅主会话可用(subagent 拒绝,与 recall 一致)。
1345
1362
  */
@@ -1347,8 +1364,8 @@ function buildRecallTool(getPruner) {
1347
1364
  const semanticRecallArgsSchema = z.object({
1348
1365
  query: z.string(),
1349
1366
  top_k: z.number().int().min(1).max(10).optional(),
1350
- start_id: z.string().optional(),
1351
- end_id: z.string().optional(),
1367
+ start: z.number().optional(),
1368
+ end: z.number().optional(),
1352
1369
  offset: z.number().optional()
1353
1370
  }).refine((args) => args.query.trim().length > 0, { message: "query 不能为空" });
1354
1371
  /** 解析并校验参数:失败时抛出可读错误(普通 Error 而非 ZodError)。 */
@@ -1359,36 +1376,35 @@ function parseSemanticRecallArgs(raw) {
1359
1376
  return result.data;
1360
1377
  }
1361
1378
  /**
1362
- * 解析检索区间(消息序列下标,含端点)。
1363
- * - start_id 未提供 → 全量(fallback=false)。
1364
- * - start_id 提供:end_id/offset 计算终点(语义同 recall,end_id 优先);
1365
- * start_id/end_id 不存在或区间为空 → fallback=true(全量 + 提示)。
1379
+ * 解析检索区间(完整消息 index,含端点)。
1380
+ * - start 未提供 → 全量(fallback=false)。
1381
+ * - start 提供:end/offset 计算终点(语义同 recall,end 优先);
1382
+ * start/end 越界或区间为空 → fallback=true(全量 + 提示)。
1366
1383
  */
1367
- function resolveSemanticRange(index, args) {
1368
- const total = index.messages.length;
1369
- if (total === 0) return {
1384
+ function resolveSemanticRange(total, args) {
1385
+ if (total <= 0) return {
1370
1386
  lo: 0,
1371
1387
  hi: -1,
1372
1388
  fallback: false
1373
1389
  };
1374
- if (args.start_id === void 0) return {
1390
+ if (args.start === void 0) return {
1375
1391
  lo: 0,
1376
1392
  hi: total - 1,
1377
1393
  fallback: false
1378
1394
  };
1379
- /** start_id 在消息序列中的下标。 */
1380
- const start = index.byId.get(args.start_id);
1381
- if (start === void 0) return {
1395
+ /** start 取整(非有限数按 -1 处理,走越界回退)。 */
1396
+ const start = Number.isFinite(args.start) ? Math.floor(args.start) : -1;
1397
+ if (start < 0 || start >= total) return {
1382
1398
  lo: 0,
1383
1399
  hi: total - 1,
1384
1400
  fallback: true
1385
1401
  };
1386
- /** 终点下标(end_id 优先;否则 start + offset 取整)。 */
1402
+ /** 终点 index(end 优先;否则 start + offset 取整)。 */
1387
1403
  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 {
1404
+ if (args.end !== void 0) {
1405
+ /** end 取整(非有限数按 -1 处理,走越界回退)。 */
1406
+ const found = Number.isFinite(args.end) ? Math.floor(args.end) : -1;
1407
+ if (found < 0 || found >= total) return {
1392
1408
  lo: 0,
1393
1409
  hi: total - 1,
1394
1410
  fallback: true
@@ -1435,7 +1451,7 @@ function matchExplanation(query, text, score) {
1435
1451
  return `相似度 ${score.toFixed(3)}${keywords}`;
1436
1452
  }
1437
1453
  /** 模型未就绪时返回给模型的文案(告知即可;下载完成后无需另行通知,直接再次调用)。 */
1438
- const SEMANTIC_MODEL_NOT_READY_MESSAGE = "语义检索暂不可用:本地嵌入模型尚未就绪(正在后台下载约 113MB;下载完成后无需提示,直接再次调用本工具即可。若下载失败,下次调用会自动重试)。可稍后重试,或先用 recall 工具按 message_id 精确检索。";
1454
+ const SEMANTIC_MODEL_NOT_READY_MESSAGE = "语义检索暂不可用:本地嵌入模型尚未就绪(正在后台下载约 113MB;下载完成后无需提示,直接再次调用本工具即可。若下载失败,下次调用会自动重试)。可稍后重试,或先用 recall 工具按完整消息 index 精确检索。";
1439
1455
  /** 构建 recall-semantic 工具定义(embedder/modelStatus 可注入,测试传替身;缺省用本地模型)。 */
1440
1456
  function buildSemanticRecallTool(options) {
1441
1457
  /** pruner 获取器(可选,裁剪超大工具结果)。 */
@@ -1444,7 +1460,7 @@ function buildSemanticRecallTool(options) {
1444
1460
  const embed = options?.embedder ?? ((texts) => getEmbedder().then((fn) => fn(texts)));
1445
1461
  return {
1446
1462
  name: "recall-semantic",
1447
- description: "按语义(自然语言含义)在会话全部消息中检索:用一句话描述你要找的内容(可混用中英文与代码术语)。返回最匹配的若干条完整消息、message_id 与匹配说明;如需精确定位某个 message_id 周边的消息,请再用 recall 工具。",
1463
+ description: "按语义(自然语言含义)在会话全部完整消息中检索:用一句话描述你要找的内容(可混用中英文与代码术语)。返回最匹配的若干条完整消息、index 与匹配说明;如需精确定位某个 index 周边的消息,请再用 recall 工具。",
1448
1464
  parameters: {
1449
1465
  query: {
1450
1466
  type: "string",
@@ -1453,19 +1469,19 @@ function buildSemanticRecallTool(options) {
1453
1469
  },
1454
1470
  top_k: {
1455
1471
  type: "number",
1456
- description: "返回最匹配的消息条数(1-10,默认 3)。"
1472
+ description: "返回最匹配的完整消息条数(1-10,默认 3)。"
1457
1473
  },
1458
- start_id: {
1459
- type: "string",
1460
- description: "message_id(uuid),可选。限定检索区间:以该消息为基准边界(意义同 recall)。缺省检索全部消息。区间不合法时自动回退全量检索并在结果中说明。"
1474
+ start: {
1475
+ type: "number",
1476
+ description: "完整消息序号(index),可选。限定检索区间:以该条为基准边界(意义同 recall)。缺省检索全部消息。区间不合法(越界)时自动回退全量检索并在结果中说明。"
1461
1477
  },
1462
- end_id: {
1463
- type: "string",
1464
- description: "message_id(uuid),可选,与 offset 互斥,限定区间的另一个边界(意义同 recall)。"
1478
+ end: {
1479
+ type: "number",
1480
+ description: " offset 互斥,限定区间的另一个边界(含;意义同 recall)。"
1465
1481
  },
1466
1482
  offset: {
1467
1483
  type: "number",
1468
- description: "可选,与 end_id 互斥。相对 start_id 的步数:正数向后、负数向前(意义同 recall)。"
1484
+ description: " end 互斥。相对 start 的步数:正数向后、负数向前(意义同 recall)。"
1469
1485
  }
1470
1486
  },
1471
1487
  output: {
@@ -1477,39 +1493,34 @@ function buildSemanticRecallTool(options) {
1477
1493
  },
1478
1494
  async execute(args, exec) {
1479
1495
  /** 解析并校验后的调用参数。 */
1480
- const { query, top_k, start_id, end_id, offset } = parseSemanticRecallArgs(args);
1496
+ const { query, top_k, start, end, offset } = parseSemanticRecallArgs(args);
1481
1497
  /** 当前会话。 */
1482
1498
  const session = exec.agent?.session;
1483
1499
  if (!session) return "会话异常";
1484
1500
  if (!isMainSession(session)) return "recall-semantic 仅主会话可用";
1485
1501
  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,
1502
+ /** 完整消息索引(全部事件,含被压缩/遮蔽)。 */
1503
+ const cms = indexCompleteMessages(session);
1504
+ if (cms.length === 0) return "会话中没有可检索的消息";
1505
+ /** 检索区间(完整消息 index;区间不合法时回退全量并标记)。 */
1506
+ const range = resolveSemanticRange(cms.length, {
1507
+ start,
1508
+ end,
1493
1509
  offset
1494
1510
  });
1495
- /** 候选消息渲染文本(区间内;跳过渲染失败/空文本)。 */
1511
+ /** 候选完整消息渲染文本(区间内;跳过渲染失败/空文本)。 */
1496
1512
  const candidates = [];
1497
1513
  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;
1514
+ /** 当前完整消息。 */
1515
+ const cm = cms[i];
1516
+ if (!cm) continue;
1517
+ /** 渲染文本(嵌入用完整文本,输出时再裁剪)。 */
1518
+ let text = "";
1506
1519
  try {
1507
- message = session.deriveEventMessage(event);
1520
+ text = renderCompleteMessage(session, cm);
1508
1521
  } catch {}
1509
- /** 渲染文本。 */
1510
- const text = message ? renderMessageText(message) : "";
1511
1522
  if (text.trim().length > 0) candidates.push({
1512
- node,
1523
+ cm,
1513
1524
  text
1514
1525
  });
1515
1526
  }
@@ -1521,7 +1532,7 @@ function buildSemanticRecallTool(options) {
1521
1532
  const vectors = await embed(candidates.map((c) => c.text));
1522
1533
  /** 打分结果(相似度 + 候选)。 */
1523
1534
  const scored = candidates.map((c, i) => ({
1524
- node: c.node,
1535
+ cm: c.cm,
1525
1536
  text: c.text,
1526
1537
  score: cosineSimilarity(queryVec, vectors[i] ?? /* @__PURE__ */ new Float32Array(0))
1527
1538
  }));
@@ -1532,7 +1543,7 @@ function buildSemanticRecallTool(options) {
1532
1543
  /** 结果缓冲。 */
1533
1544
  const parts = [];
1534
1545
  /** 范围描述(回退时明确告知模型)。 */
1535
- const rangeNote = range.fallback ? "指定区间不合法(start_id/end_id 不存在等),已回退检索全部消息" : start_id === void 0 ? `检索全部消息(${candidates.length} 条可嵌入)` : `检索区间 [${range.lo}..${range.hi}](${candidates.length} 条可嵌入)`;
1546
+ const rangeNote = range.fallback ? "指定区间不合法(start/end 越界等),已回退检索全部消息" : start === void 0 ? `检索全部消息(${candidates.length} 条可嵌入)` : `检索区间 [${range.lo}..${range.hi}](${candidates.length} 条可嵌入)`;
1536
1547
  parts.push(`查询: ${query}`);
1537
1548
  parts.push(rangeNote);
1538
1549
  parts.push(`匹配 TOP-${hits.length}(共 ${scored.length} 条候选):`);
@@ -1540,30 +1551,16 @@ function buildSemanticRecallTool(options) {
1540
1551
  /** 当前命中。 */
1541
1552
  const hit = hits[i];
1542
1553
  if (!hit) continue;
1543
- /** 消息事件(用于类型与 pruner)。 */
1544
- const event = session.events[hit.node.seq];
1545
- /** 命中消息的 id(缺失则省略)。 */
1546
- const id = event ? messageIdOfEvent(event) ?? "" : "";
1547
- /** 命中消息文本(pruner 裁剪超大内容后渲染)。 */
1554
+ /** 类型标注(toolcall 附调用 id,与 recall 输出一致)。 */
1555
+ const callAttr = hit.cm.type === "toolcall" && hit.cm.callId ? ` callId=${hit.cm.callId}` : "";
1556
+ /** 输出文本(pruner 裁剪超大内容;裁剪失败保留嵌入用文本)。 */
1548
1557
  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)} --`);
1558
+ try {
1559
+ /** tool-result-pruner(可选)。 */
1560
+ const pruner = getPruner();
1561
+ text = renderCompleteMessage(session, hit.cm, pruner);
1562
+ } catch {}
1563
+ parts.push(`-- [${i + 1}] index ${hit.cm.index} ${hit.cm.type}${callAttr} — ${matchExplanation(query, hit.text, hit.score)} --`);
1567
1564
  parts.push(text);
1568
1565
  }
1569
1566
  return parts.join("\n\n");
@@ -1577,9 +1574,9 @@ function buildSemanticRecallTool(options) {
1577
1574
  * 不依赖特定 tool mode(native / code / both 均可运行)。
1578
1575
  *
1579
1576
  * 模块:
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
- * 按语义在全部消息日志(含被压缩/遮蔽)中检索,返回最匹配的完整消息与匹配说明
1577
+ * - recall.ts recall({ start, end?, offset? }) 工具:按完整消息 index 回看原始会话
1578
+ * - semantic-recall.ts recall-semantic({ query, top_k?, start?, end?, offset? }) 工具:
1579
+ * 按语义在全部完整消息(含被压缩/遮蔽)中检索,返回最匹配的完整消息与匹配说明
1583
1580
  * (本地 ONNX embedding,模型随插件打包,懒加载)
1584
1581
  * - compress.ts 自动压缩(OM 观察/反思两级阈值):pre-step 阻塞串行执行——
1585
1582
  * 反思(摘要 ≥ 窗口 × historyMergeRatio 时摘要调用精简合并 <om-history>)、
@@ -1611,7 +1608,7 @@ function apply(ctx, config) {
1611
1608
  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
1609
  if (resolved.recallEnabled) ctx.tools.register(buildRecallTool(() => ctx.get("toolResultPruner")));
1613
1610
  if (resolved.semanticRecallEnabled) {
1614
- const warnModel = (message) => ctx.logger.warn("dsh-plugin-om: " + message);
1611
+ const warnModel = (message) => ctx.logger.warn(`dsh-plugin-om: ${message}`);
1615
1612
  ensureModelReady(resolved.modelDir, warnModel);
1616
1613
  ctx.tools.register(buildSemanticRecallTool({
1617
1614
  getPruner: () => ctx.get("toolResultPruner"),