dsh-plugin-om 0.0.6 → 0.0.7

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,6 +84,174 @@ 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
257
  const OBSERVER_PERSONA = "你是 dsh-plugin-om 的上下文观察者(Observer,机制参考 Mastra Observational Memory):把会话中尚未压缩的消息压缩为一份观察日志。不用工具、不展示思考、不评价代码、不输出多余文字。";
@@ -161,14 +260,12 @@ const REFLECTOR_PERSONA = "你是 dsh-plugin-om 的上下文反思者(Reflecto
161
260
  /**
162
261
  * 构建观察指令主体:任务声明(fork:停止任务/禁止工具;new:说明总结日志)+ 模式相关
163
262
  * 的上下文定位与压缩范围(fork:上方完整会话记录——尾部已在输入中实际截断,提示词不含
164
- * 尾部规则;new:下方消息即压缩对象)+ 规则(用户消息完整保留原文 / AI 消息模块化压缩
165
- * ——工具调用按目的聚合——不限于 run_code / 倾向于新消息 / 中断标注 / 未完成写进度与
166
- * 下一步)+ 输出格式(合法 XML)+ 对照表 + 中断标记 + 追加说明。persona 由调用方拼接
167
- * 到指令开头。
263
+ * 尾部规则;new:下方消息即压缩对象)+ 完整消息与 index 定义(三类合并规则、起始编号)+
264
+ * 规则(用户消息完整保留原文 / AI 消息模块化压缩——工具调用按目的聚合——不限于 run_code /
265
+ * 倾向于新消息 / 中断标注 / 未完成写进度与下一步)+ 输出格式(合法 XML)+ 中断标记 +
266
+ * 追加说明。persona 由调用方拼接到指令开头。
168
267
  */
169
268
  function buildObservePrompt(options) {
170
- /** 对照表段落(无则标注「无」)。 */
171
- const tableSection = options.table.length > 0 ? options.table : ["(无)"];
172
269
  /** 中断标记段落(无则标注「无」)。 */
173
270
  const interruptionSection = options.interruptions.length > 0 ? options.interruptions : ["(无)"];
174
271
  return [
@@ -180,25 +277,30 @@ function buildObservePrompt(options) {
180
277
  `如果消息记录里还没有 <${HISTORY_TAG}> 块,则除本指令外的全部消息都是压缩对象。`
181
278
  ] : ["下方的消息记录是本次要压缩的全部消息(上一个 <om-history> 块之后的新消息;不含旧压缩日志、不含尾部)。", "你的压缩结果会作为新的 <om-history> 块追加到已有压缩日志之后。"],
182
279
  "",
280
+ "【完整消息与 index】",
281
+ "- 完整消息是压缩日志与 recall 共用的定位单位,每一条占一个 index(从 0 起、按会话顺序递增、全局稳定)。完整消息分三类:用户消息占一条;AI 文本占一条;工具调用及其结果占一条(每个 tool-call 与其 result 各一条,同一条 AI 消息里的文本与工具调用拆开)。",
282
+ `- 本次要压缩的新消息从 index ${options.startIndex} 开始编号,按日志顺序逐条递增;旧 <${HISTORY_TAG}> 中已有的 index 无需理会,也不要改动旧条目。`,
283
+ "",
183
284
  "【规则】",
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 回看。",
285
+ "- 用户消息完整保留原文,输出为 <user_message index=\"N\"> 条目(N 为该条完整消息的 index,内容为消息原文,不概括、不省略)。",
286
+ "- AI 消息划分模块压缩(按模块聚合 assistant 的方式):所有工具调用(run_code 与其他工具同等对待,不限于 run_code)按调用目的聚合为一行 toolcall index:<该条完整消息的 index> purpose:<聚合目的> summary:<行为与结果摘要>;相邻的 AI 侧完整消息(文本与工具调用)聚合为一个 <assistant> 模块,start/end 标注模块覆盖的 index 区间(start 为首条 index、end 为末条 index);重要工具调用(关键决策、写文件等)单独用 <assistant index=\"N\"> 呈现。工具组内部细节(参数、完整输出)不保留,需要原文时用 recall 按 index 回看(start/end 定位)。",
287
+ "- 条目须按 index 顺序覆盖本次压缩的全部完整消息,不遗漏。",
186
288
  "- 总结时倾向于新消息,旧消息一句话带过即可;新旧消息冲突时强调新消息,不修改旧日志条目。",
187
289
  "- 若【中断标记】非空,在对应位置明确写出中断(例如「被用户打断,因此上一段工作未完成」),帮助后续理解用户为何再次输入消息、为何不延续之前的工作。",
188
290
  "- 若当前工作看起来未完成(最后一次工具调用没有结果、或对话被中断/异常结束),在日志末尾说明当前进度与下一步要做什么。",
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
+ "(压缩模块:toolcall 聚合行等,按模块聚合 assistant 的方式)",
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
304
  "【中断标记】",
203
305
  ...interruptionSection,
204
306
  "",
@@ -214,17 +316,22 @@ function buildReflectPrompt(mode) {
214
316
  ...mode === "new" ? ["下方的消息记录包含当前的 <om-history> 压缩日志(最后一次 <om-history> 块)。", "只对这份压缩日志做精简合并;不要涉及日志之外的消息。"] : ["上方的消息记录是主会话的完整历史,其中包含当前的 <om-history> 压缩日志(最后一次 <om-history> 块)。", "只对这份压缩日志做精简合并;不要涉及日志之外的消息。"],
215
317
  "",
216
318
  "【规则】",
217
- "- 用户消息保留要点与 message_id(格式:<user_message id=\"(message_id)\"> 要点 </user_message>);可省略的条目删除。",
218
- "- toolcall 条目按调用目的进一步聚合,保留组内最后一条消息的 message_id;不重要的条目 summary 写「(略)」。",
319
+ "- 完整消息是压缩日志的定位单位(用户消息 / AI 文本 / 工具调用及其结果各占一个 index,从 0 起、全局稳定);合并时单个完整消息的 index 不重新编号,被删除的条目 index 空缺。",
320
+ "- 用户消息保留要点与 index(格式:<user_message index=\"(index)\"> 要点 </user_message>);可省略的条目删除。",
321
+ "- toolcall 条目按调用目的进一步聚合,保留 index;不重要的条目 summary 写「(略)」。",
322
+ "- 相邻的 assistant 条目可合并为一个 <assistant> 模块,start/end 随合并后的区间变化。",
219
323
  "- 保留中断说明与未完成说明(若原日志中有)。",
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
+ "(压缩模块:toolcall 聚合行等,按模块聚合 assistant 的方式)",
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\"> 定位连续区间(含两端)。需要原文时用 recall(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 启发式一致)。 */
@@ -602,39 +686,6 @@ function extractHistoryText(session, shadowedSeqs) {
602
686
  }
603
687
  return found;
604
688
  }
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
689
  /** 当前打开中的 turn 号(最近 turn/start 且未被 turn/end 关闭);无则 null(跨轮次场景)。 */
639
690
  function openTurnOf(session) {
640
691
  /** 折叠结果(turn/start 打开、turn/end 关闭)。 */
@@ -814,19 +865,21 @@ async function observePass(ctx, agent, config, window, tailCount, target, signal
814
865
  const history = extractHistoryText(session, range.shadowedSeqs);
815
866
  /** 中断标记行。 */
816
867
  const interruptions = scanInterruptions(session, Math.min(...range.shadowedSeqs), range.lastEndSeq);
817
- /** message_id 对照表行。 */
818
- const table = buildMessageIdTable(session, range.shadowedSeqs);
819
868
  const actualTailCount = [...session.surface.nodes].length - range.shadowedSeqs.length;
820
- logger.step(`观察:实际保留尾部 ${actualTailCount} 条(不压缩、不进日志),中断标记 ${interruptions.length} 条,message_id 对照表 ${table.length} 行`);
869
+ /** 遮蔽 seq 集合(计算新消息起始 index)。 */
870
+ const shadowedSet = new Set(range.shadowedSeqs);
871
+ /** 压缩区间内第一个完整消息的 index(新消息起始编号;区间内无完整消息则 0)。 */
872
+ const startIndex = indexCompleteMessages(session).find((cm) => cm.seqs.every((seq) => shadowedSet.has(seq)))?.index ?? 0;
873
+ logger.step(`观察:实际保留尾部 ${actualTailCount} 条(不压缩、不进日志),中断标记 ${interruptions.length} 条,新消息起始 index ${startIndex}`);
821
874
  /** 观察指令(persona + 规则主体)。 */
822
875
  const prompt = buildObservePrompt({
823
- table,
876
+ startIndex,
824
877
  interruptions,
825
878
  hasOldHistory: history !== void 0,
826
879
  mode: config.summaryMode
827
880
  });
828
881
  /** 观察摘要结果(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);
882
+ 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
883
  if (summaryResult === null || summaryResult.text.trim().length === 0) {
831
884
  logger.step("观察:摘要调用失败/无输出,不产生替换");
832
885
  return;
@@ -1211,22 +1264,24 @@ function resolveConfig(raw) {
1211
1264
  //#endregion
1212
1265
  //#region src/recall.ts
1213
1266
  /**
1214
- * recall 工具:按 message_id 回看原始会话(start_id/end_id 为消息 idoffset 为相对
1215
- * start_id 的消息步数)。recall 自身不设输出上限:超大的工具结果由 tool-result-pruner
1216
- * 裁剪(pruneContent),输出 token pruner 配置控制。
1267
+ * recall 工具:按「完整消息」序号(index)回看原始会话(start/end 为完整消息 index
1268
+ * offset 为相对 start 的完整消息步数)。完整消息分三类(用户消息 / AI 文本 / 单个工具
1269
+ * 调用及其结果),各占一个 index(从 0 起、会话内全局稳定),与摘要日志条目同一套编号。
1270
+ * recall 自身不设输出上限:超大的工具结果由 tool-result-pruner 裁剪(pruneContent),
1271
+ * 输出 token 由 pruner 配置控制。
1217
1272
  *
1218
- * 参数由 zod schema(recallArgsSchema)在 execute 入口校验:start_id 必填且非空,
1219
- * end_id/offset 至少提供一个;非法参数抛出可读错误。
1273
+ * 参数由 zod schema(recallArgsSchema)在 execute 入口校验:start 必填(number),
1274
+ * end/offset 至少提供一个;非法参数抛出可读错误。
1220
1275
  */
1221
1276
  /**
1222
- * recall 工具参数 schema:start_id 必填且非空;end_id 与 offset 至少提供一个
1223
- * (二者同时给出时 end_id 优先,与 execute 语义一致);未知键自动剥离。
1277
+ * recall 工具参数 schema:start 必填(number);end 与 offset 至少提供一个
1278
+ * (二者同时给出时 end 优先,与 execute 语义一致);未知键自动剥离。
1224
1279
  */
1225
1280
  const recallArgsSchema = z.object({
1226
- start_id: z.string(),
1227
- end_id: z.string().optional(),
1281
+ start: z.number(),
1282
+ end: z.number().optional(),
1228
1283
  offset: z.number().optional()
1229
- }).refine((args) => args.end_id !== void 0 || args.offset !== void 0, { message: "end_id 与 offset 至少提供一个" });
1284
+ }).refine((args) => args.end !== void 0 || args.offset !== void 0, { message: "end 与 offset 至少提供一个" });
1230
1285
  /**
1231
1286
  * 解析并校验 recall 调用参数:校验失败时抛出首个校验问题的可读消息
1232
1287
  * (普通 Error 而非 ZodError,兼容 SDK 展示)。
@@ -1238,26 +1293,26 @@ function parseRecallArgs(raw) {
1238
1293
  return result.data;
1239
1294
  }
1240
1295
  /**
1241
- * 构建 recall 工具定义:按 start_id/end_id/offset 定位消息区间并渲染原始内容。
1296
+ * 构建 recall 工具定义:按 start/end/offset 定位完整消息区间并渲染原始内容(标 index)。
1242
1297
  * getPruner 返回 tool-result-pruner(可选),用于裁剪超大工具结果。
1243
1298
  */
1244
1299
  function buildRecallTool(getPruner) {
1245
1300
  return {
1246
1301
  name: "recall",
1247
- description: "根据message_id,回看指定区间的过往消息。start_id必须传入,是区间的基准。end_id 和 offset 二选一,end_id 用于指定另一个边界,offset用于指定区间包含的消息数量。",
1302
+ description: "根据完整消息序号(index),回看指定区间的过往消息。完整消息是定位单位,分三类:用户消息、AI 文本、单个工具调用及其结果(每个 tool-call 与其 result 一条),各占一个 index(从 0 起、会话内全局稳定)。start 必须传入,是区间的基准;end 和 offset 二选一:end 指定另一个边界(含两端,与 start 的位置关系不影响结果),offset 指定区间包含的完整消息数量(正数向后、负数向前)。",
1248
1303
  parameters: {
1249
- start_id: {
1250
- type: "string",
1251
- description: "message_id(uuid),区间的基准边界",
1304
+ start: {
1305
+ type: "number",
1306
+ description: "完整消息序号(index),区间的基准边界",
1252
1307
  required: true
1253
1308
  },
1254
- end_id: {
1255
- type: "string",
1256
- description: "message_id(uuid),与 offset 互斥,指定区间的另一个边界。与 start_id 的位置关系不影响结果。"
1309
+ end: {
1310
+ type: "number",
1311
+ description: " offset 互斥,指定区间的另一个边界(含)。与 start 的位置关系不影响结果。"
1257
1312
  },
1258
1313
  offset: {
1259
1314
  type: "number",
1260
- description: "与 end_id 互斥,指定区间包含的消息数量。传入正数查看start_id之后的若干条消息,负数则是之前的。"
1315
+ description: "与 end 互斥,指定区间包含的完整消息数量。传入正数查看 start 之后的若干条,负数则是之前的。"
1261
1316
  }
1262
1317
  },
1263
1318
  output: {
@@ -1269,23 +1324,23 @@ function buildRecallTool(getPruner) {
1269
1324
  },
1270
1325
  async execute(args, exec) {
1271
1326
  /** 解析并校验后的调用参数(不满足 schema 时抛出可读错误)。 */
1272
- const { start_id, end_id, offset } = parseRecallArgs(args);
1327
+ const { start, end, offset } = parseRecallArgs(args);
1273
1328
  /** 当前会话(缺失则无法回看)。 */
1274
1329
  const session = exec.agent?.session;
1275
1330
  if (!session) return "会话异常";
1276
1331
  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)。 */
1332
+ /** 完整消息索引(index = 数组下标,0 起)。 */
1333
+ const cms = indexCompleteMessages(session);
1334
+ /** 起始下标(非整数 floor;越界返回提示)。 */
1335
+ const startIndex = Number.isFinite(start) ? Math.floor(start) : 0;
1336
+ if (startIndex < 0 || startIndex >= cms.length) return `start ${start} 越界(完整消息共 ${cms.length} 条,index 范围 0..${cms.length - 1})`;
1337
+ /** 终点下标(end 优先,否则 startIndex + offset)。 */
1283
1338
  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;
1339
+ if (end !== void 0) {
1340
+ /** end 取整(非整数 floor)。 */
1341
+ const e = Number.isFinite(end) ? Math.floor(end) : 0;
1342
+ if (e < 0 || e >= cms.length) return `end ${end} 越界(完整消息共 ${cms.length} 条,index 范围 0..${cms.length - 1})`;
1343
+ endIndex = e;
1289
1344
  } else {
1290
1345
  /** offset 数值(refine 保证此分支 offset 已提供,schema 保证为 number)。 */
1291
1346
  const raw = offset ?? 0;
@@ -1294,36 +1349,25 @@ function buildRecallTool(getPruner) {
1294
1349
  /** 区间下界(钳制到 [0, len-1])。 */
1295
1350
  const lo = Math.max(0, Math.min(startIndex, endIndex));
1296
1351
  /** 区间上界(钳制到 [0, len-1])。 */
1297
- const hi = Math.min(messages.length - 1, Math.max(startIndex, endIndex));
1352
+ const hi = Math.min(cms.length - 1, Math.max(startIndex, endIndex));
1298
1353
  /** tool-result-pruner(可选,裁剪超大工具结果)。 */
1299
1354
  const pruner = getPruner?.();
1300
- /** 渲染结果缓冲(每条消息一段)。 */
1355
+ /** 渲染结果缓冲(每条完整消息一段,标 index + 类型)。 */
1301
1356
  const parts = [];
1302
1357
  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
- /** 本条消息的呈现文本。 */
1358
+ /** 当前完整消息。 */
1359
+ const cm = cms[i];
1360
+ if (!cm) continue;
1361
+ /** 该条完整消息的呈现文本(pruner 裁剪超大结果;单条失败不影响整体)。 */
1310
1362
  let text = "";
1311
1363
  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) : "";
1364
+ text = renderCompleteMessage(session, cm, pruner);
1323
1365
  } catch {}
1324
- parts.push(`-- [seq ${node.seq}] ${event.type} --\n${text}`);
1366
+ /** 类型标注(toolcall 附调用 id,便于模型关联)。 */
1367
+ const callAttr = cm.type === "toolcall" && cm.callId ? ` callId=${cm.callId}` : "";
1368
+ parts.push(`-- [index ${cm.index}] ${cm.type}${callAttr} --\n${text}`);
1325
1369
  }
1326
- if (parts.length === 0) return "指定区间没有消息";
1370
+ if (parts.length === 0) return "指定区间没有完整消息";
1327
1371
  return parts.join("\n\n");
1328
1372
  }
1329
1373
  };
@@ -1331,15 +1375,15 @@ function buildRecallTool(getPruner) {
1331
1375
  //#endregion
1332
1376
  //#region src/semantic-recall.ts
1333
1377
  /**
1334
- * recall-semantic 工具:按自然语言 query 对会话全部消息日志(含被压缩/遮蔽的
1335
- * user/assistant/tool-result 事件)做语义检索,返回最匹配的完整消息与匹配说明。
1378
+ * recall-semantic 工具:按自然语言 query 对会话全部完整消息(含被压缩/遮蔽的)做语义
1379
+ * 检索,返回最匹配的完整消息与匹配说明。
1336
1380
  *
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 不存在等)→
1381
+ * - 参数:query 必填;top_k(默认 3,1-10);start/end/offset 限定检索区间
1382
+ * (意义同 recall:start 为基准边界,end 与 offset 二选一;end 优先)。
1383
+ * - 区间缺省(start 未提供)→ 检索全部消息;区间不合法(start/end 越界等)→
1340
1384
  * 不报错,回退全量检索并在输出中明确告知(模型可见)。
1341
1385
  * - 向量:本地 ONNX embedding(embedding.ts,懒加载 + 批量);相似度 = cosine。
1342
- * - 输出:命中消息的 message_id/seq/类型 + 完整渲染文本 + 匹配说明(相似度、
1386
+ * - 输出:命中消息的 index/类型(toolcall 附调用 id)+ 完整渲染文本 + 匹配说明(相似度、
1343
1387
  * 命中的关键词);超大结果由 tool-result-pruner 裁剪(同 recall)。
1344
1388
  * - 仅主会话可用(subagent 拒绝,与 recall 一致)。
1345
1389
  */
@@ -1347,8 +1391,8 @@ function buildRecallTool(getPruner) {
1347
1391
  const semanticRecallArgsSchema = z.object({
1348
1392
  query: z.string(),
1349
1393
  top_k: z.number().int().min(1).max(10).optional(),
1350
- start_id: z.string().optional(),
1351
- end_id: z.string().optional(),
1394
+ start: z.number().optional(),
1395
+ end: z.number().optional(),
1352
1396
  offset: z.number().optional()
1353
1397
  }).refine((args) => args.query.trim().length > 0, { message: "query 不能为空" });
1354
1398
  /** 解析并校验参数:失败时抛出可读错误(普通 Error 而非 ZodError)。 */
@@ -1359,36 +1403,35 @@ function parseSemanticRecallArgs(raw) {
1359
1403
  return result.data;
1360
1404
  }
1361
1405
  /**
1362
- * 解析检索区间(消息序列下标,含端点)。
1363
- * - start_id 未提供 → 全量(fallback=false)。
1364
- * - start_id 提供:end_id/offset 计算终点(语义同 recall,end_id 优先);
1365
- * start_id/end_id 不存在或区间为空 → fallback=true(全量 + 提示)。
1406
+ * 解析检索区间(完整消息 index,含端点)。
1407
+ * - start 未提供 → 全量(fallback=false)。
1408
+ * - start 提供:end/offset 计算终点(语义同 recall,end 优先);
1409
+ * start/end 越界或区间为空 → fallback=true(全量 + 提示)。
1366
1410
  */
1367
- function resolveSemanticRange(index, args) {
1368
- const total = index.messages.length;
1369
- if (total === 0) return {
1411
+ function resolveSemanticRange(total, args) {
1412
+ if (total <= 0) return {
1370
1413
  lo: 0,
1371
1414
  hi: -1,
1372
1415
  fallback: false
1373
1416
  };
1374
- if (args.start_id === void 0) return {
1417
+ if (args.start === void 0) return {
1375
1418
  lo: 0,
1376
1419
  hi: total - 1,
1377
1420
  fallback: false
1378
1421
  };
1379
- /** start_id 在消息序列中的下标。 */
1380
- const start = index.byId.get(args.start_id);
1381
- if (start === void 0) return {
1422
+ /** start 取整(非有限数按 -1 处理,走越界回退)。 */
1423
+ const start = Number.isFinite(args.start) ? Math.floor(args.start) : -1;
1424
+ if (start < 0 || start >= total) return {
1382
1425
  lo: 0,
1383
1426
  hi: total - 1,
1384
1427
  fallback: true
1385
1428
  };
1386
- /** 终点下标(end_id 优先;否则 start + offset 取整)。 */
1429
+ /** 终点 index(end 优先;否则 start + offset 取整)。 */
1387
1430
  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 {
1431
+ if (args.end !== void 0) {
1432
+ /** end 取整(非有限数按 -1 处理,走越界回退)。 */
1433
+ const found = Number.isFinite(args.end) ? Math.floor(args.end) : -1;
1434
+ if (found < 0 || found >= total) return {
1392
1435
  lo: 0,
1393
1436
  hi: total - 1,
1394
1437
  fallback: true
@@ -1435,7 +1478,7 @@ function matchExplanation(query, text, score) {
1435
1478
  return `相似度 ${score.toFixed(3)}${keywords}`;
1436
1479
  }
1437
1480
  /** 模型未就绪时返回给模型的文案(告知即可;下载完成后无需另行通知,直接再次调用)。 */
1438
- const SEMANTIC_MODEL_NOT_READY_MESSAGE = "语义检索暂不可用:本地嵌入模型尚未就绪(正在后台下载约 113MB;下载完成后无需提示,直接再次调用本工具即可。若下载失败,下次调用会自动重试)。可稍后重试,或先用 recall 工具按 message_id 精确检索。";
1481
+ const SEMANTIC_MODEL_NOT_READY_MESSAGE = "语义检索暂不可用:本地嵌入模型尚未就绪(正在后台下载约 113MB;下载完成后无需提示,直接再次调用本工具即可。若下载失败,下次调用会自动重试)。可稍后重试,或先用 recall 工具按完整消息 index 精确检索。";
1439
1482
  /** 构建 recall-semantic 工具定义(embedder/modelStatus 可注入,测试传替身;缺省用本地模型)。 */
1440
1483
  function buildSemanticRecallTool(options) {
1441
1484
  /** pruner 获取器(可选,裁剪超大工具结果)。 */
@@ -1444,7 +1487,7 @@ function buildSemanticRecallTool(options) {
1444
1487
  const embed = options?.embedder ?? ((texts) => getEmbedder().then((fn) => fn(texts)));
1445
1488
  return {
1446
1489
  name: "recall-semantic",
1447
- description: "按语义(自然语言含义)在会话全部消息中检索:用一句话描述你要找的内容(可混用中英文与代码术语)。返回最匹配的若干条完整消息、message_id 与匹配说明;如需精确定位某个 message_id 周边的消息,请再用 recall 工具。",
1490
+ description: "按语义(自然语言含义)在会话全部完整消息中检索:用一句话描述你要找的内容(可混用中英文与代码术语)。返回最匹配的若干条完整消息、index 与匹配说明;如需精确定位某个 index 周边的消息,请再用 recall 工具。",
1448
1491
  parameters: {
1449
1492
  query: {
1450
1493
  type: "string",
@@ -1453,19 +1496,19 @@ function buildSemanticRecallTool(options) {
1453
1496
  },
1454
1497
  top_k: {
1455
1498
  type: "number",
1456
- description: "返回最匹配的消息条数(1-10,默认 3)。"
1499
+ description: "返回最匹配的完整消息条数(1-10,默认 3)。"
1457
1500
  },
1458
- start_id: {
1459
- type: "string",
1460
- description: "message_id(uuid),可选。限定检索区间:以该消息为基准边界(意义同 recall)。缺省检索全部消息。区间不合法时自动回退全量检索并在结果中说明。"
1501
+ start: {
1502
+ type: "number",
1503
+ description: "完整消息序号(index),可选。限定检索区间:以该条为基准边界(意义同 recall)。缺省检索全部消息。区间不合法(越界)时自动回退全量检索并在结果中说明。"
1461
1504
  },
1462
- end_id: {
1463
- type: "string",
1464
- description: "message_id(uuid),可选,与 offset 互斥,限定区间的另一个边界(意义同 recall)。"
1505
+ end: {
1506
+ type: "number",
1507
+ description: " offset 互斥,限定区间的另一个边界(含;意义同 recall)。"
1465
1508
  },
1466
1509
  offset: {
1467
1510
  type: "number",
1468
- description: "可选,与 end_id 互斥。相对 start_id 的步数:正数向后、负数向前(意义同 recall)。"
1511
+ description: " end 互斥。相对 start 的步数:正数向后、负数向前(意义同 recall)。"
1469
1512
  }
1470
1513
  },
1471
1514
  output: {
@@ -1477,39 +1520,34 @@ function buildSemanticRecallTool(options) {
1477
1520
  },
1478
1521
  async execute(args, exec) {
1479
1522
  /** 解析并校验后的调用参数。 */
1480
- const { query, top_k, start_id, end_id, offset } = parseSemanticRecallArgs(args);
1523
+ const { query, top_k, start, end, offset } = parseSemanticRecallArgs(args);
1481
1524
  /** 当前会话。 */
1482
1525
  const session = exec.agent?.session;
1483
1526
  if (!session) return "会话异常";
1484
1527
  if (!isMainSession(session)) return "recall-semantic 仅主会话可用";
1485
1528
  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,
1529
+ /** 完整消息索引(全部事件,含被压缩/遮蔽)。 */
1530
+ const cms = indexCompleteMessages(session);
1531
+ if (cms.length === 0) return "会话中没有可检索的消息";
1532
+ /** 检索区间(完整消息 index;区间不合法时回退全量并标记)。 */
1533
+ const range = resolveSemanticRange(cms.length, {
1534
+ start,
1535
+ end,
1493
1536
  offset
1494
1537
  });
1495
- /** 候选消息渲染文本(区间内;跳过渲染失败/空文本)。 */
1538
+ /** 候选完整消息渲染文本(区间内;跳过渲染失败/空文本)。 */
1496
1539
  const candidates = [];
1497
1540
  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;
1541
+ /** 当前完整消息。 */
1542
+ const cm = cms[i];
1543
+ if (!cm) continue;
1544
+ /** 渲染文本(嵌入用完整文本,输出时再裁剪)。 */
1545
+ let text = "";
1506
1546
  try {
1507
- message = session.deriveEventMessage(event);
1547
+ text = renderCompleteMessage(session, cm);
1508
1548
  } catch {}
1509
- /** 渲染文本。 */
1510
- const text = message ? renderMessageText(message) : "";
1511
1549
  if (text.trim().length > 0) candidates.push({
1512
- node,
1550
+ cm,
1513
1551
  text
1514
1552
  });
1515
1553
  }
@@ -1521,7 +1559,7 @@ function buildSemanticRecallTool(options) {
1521
1559
  const vectors = await embed(candidates.map((c) => c.text));
1522
1560
  /** 打分结果(相似度 + 候选)。 */
1523
1561
  const scored = candidates.map((c, i) => ({
1524
- node: c.node,
1562
+ cm: c.cm,
1525
1563
  text: c.text,
1526
1564
  score: cosineSimilarity(queryVec, vectors[i] ?? /* @__PURE__ */ new Float32Array(0))
1527
1565
  }));
@@ -1532,7 +1570,7 @@ function buildSemanticRecallTool(options) {
1532
1570
  /** 结果缓冲。 */
1533
1571
  const parts = [];
1534
1572
  /** 范围描述(回退时明确告知模型)。 */
1535
- const rangeNote = range.fallback ? "指定区间不合法(start_id/end_id 不存在等),已回退检索全部消息" : start_id === void 0 ? `检索全部消息(${candidates.length} 条可嵌入)` : `检索区间 [${range.lo}..${range.hi}](${candidates.length} 条可嵌入)`;
1573
+ const rangeNote = range.fallback ? "指定区间不合法(start/end 越界等),已回退检索全部消息" : start === void 0 ? `检索全部消息(${candidates.length} 条可嵌入)` : `检索区间 [${range.lo}..${range.hi}](${candidates.length} 条可嵌入)`;
1536
1574
  parts.push(`查询: ${query}`);
1537
1575
  parts.push(rangeNote);
1538
1576
  parts.push(`匹配 TOP-${hits.length}(共 ${scored.length} 条候选):`);
@@ -1540,30 +1578,16 @@ function buildSemanticRecallTool(options) {
1540
1578
  /** 当前命中。 */
1541
1579
  const hit = hits[i];
1542
1580
  if (!hit) continue;
1543
- /** 消息事件(用于类型与 pruner)。 */
1544
- const event = session.events[hit.node.seq];
1545
- /** 命中消息的 id(缺失则省略)。 */
1546
- const id = event ? messageIdOfEvent(event) ?? "" : "";
1547
- /** 命中消息文本(pruner 裁剪超大内容后渲染)。 */
1581
+ /** 类型标注(toolcall 附调用 id,与 recall 输出一致)。 */
1582
+ const callAttr = hit.cm.type === "toolcall" && hit.cm.callId ? ` callId=${hit.cm.callId}` : "";
1583
+ /** 输出文本(pruner 裁剪超大内容;裁剪失败保留嵌入用文本)。 */
1548
1584
  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)} --`);
1585
+ try {
1586
+ /** tool-result-pruner(可选)。 */
1587
+ const pruner = getPruner();
1588
+ text = renderCompleteMessage(session, hit.cm, pruner);
1589
+ } catch {}
1590
+ parts.push(`-- [${i + 1}] index ${hit.cm.index} ${hit.cm.type}${callAttr} — ${matchExplanation(query, hit.text, hit.score)} --`);
1567
1591
  parts.push(text);
1568
1592
  }
1569
1593
  return parts.join("\n\n");
@@ -1577,9 +1601,9 @@ function buildSemanticRecallTool(options) {
1577
1601
  * 不依赖特定 tool mode(native / code / both 均可运行)。
1578
1602
  *
1579
1603
  * 模块:
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
- * 按语义在全部消息日志(含被压缩/遮蔽)中检索,返回最匹配的完整消息与匹配说明
1604
+ * - recall.ts recall({ start, end?, offset? }) 工具:按完整消息 index 回看原始会话
1605
+ * - semantic-recall.ts recall-semantic({ query, top_k?, start?, end?, offset? }) 工具:
1606
+ * 按语义在全部完整消息(含被压缩/遮蔽)中检索,返回最匹配的完整消息与匹配说明
1583
1607
  * (本地 ONNX embedding,模型随插件打包,懒加载)
1584
1608
  * - compress.ts 自动压缩(OM 观察/反思两级阈值):pre-step 阻塞串行执行——
1585
1609
  * 反思(摘要 ≥ 窗口 × historyMergeRatio 时摘要调用精简合并 <om-history>)、