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/README.md +46 -59
- package/dist/compress.d.ts +0 -13
- package/dist/compress.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.mjs +339 -342
- package/dist/log-index.d.ts +27 -2
- package/dist/log-index.d.ts.map +1 -1
- package/dist/recall.d.ts +12 -10
- package/dist/recall.d.ts.map +1 -1
- package/dist/semantic-recall.d.ts +16 -16
- package/dist/semantic-recall.d.ts.map +1 -1
- package/dist/summarize.d.ts +17 -20
- package/dist/summarize.d.ts.map +1 -1
- package/dist/types.d.ts +17 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
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 = "
|
|
257
|
+
const OBSERVER_PERSONA = "把会话中尚未压缩的消息压缩为一份观察日志。不用工具、不展示思考、不评价代码、不输出多余文字。";
|
|
159
258
|
/** 反思者 persona:只精简合并当前摘要,不用工具、不展示思考。 */
|
|
160
|
-
const REFLECTOR_PERSONA = "
|
|
259
|
+
const REFLECTOR_PERSONA = "把当前 <om-history> 压缩日志精简合并为一份更紧凑的日志。不用工具、不展示思考、不输出多余文字。";
|
|
161
260
|
/**
|
|
162
261
|
* 构建观察指令主体:任务声明(fork:停止任务/禁止工具;new:说明总结日志)+ 模式相关
|
|
163
262
|
* 的上下文定位与压缩范围(fork:上方完整会话记录——尾部已在输入中实际截断,提示词不含
|
|
164
|
-
* 尾部规则;new:下方消息即压缩对象)+
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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
|
-
"-
|
|
185
|
-
"-
|
|
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
|
|
293
|
+
"<user_message index=\"(index)\">",
|
|
192
294
|
"(user 消息原文)",
|
|
193
295
|
"</user_message>",
|
|
194
|
-
"<assistant
|
|
195
|
-
"(
|
|
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
|
-
"-
|
|
218
|
-
"-
|
|
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
|
|
327
|
+
"<user_message index=\"(index)\">",
|
|
224
328
|
"(user 消息要点)",
|
|
225
329
|
"</user_message>",
|
|
226
|
-
"<assistant
|
|
227
|
-
"(
|
|
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
|
-
*
|
|
269
|
-
* -
|
|
270
|
-
* -
|
|
271
|
-
*
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (
|
|
282
|
-
/**
|
|
283
|
-
const
|
|
284
|
-
parts.push(
|
|
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 = "<!--
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
1215
|
-
*
|
|
1216
|
-
*
|
|
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 入口校验:
|
|
1219
|
-
*
|
|
1246
|
+
* 参数由 zod schema(recallArgsSchema)在 execute 入口校验:start 必填(number),
|
|
1247
|
+
* end/offset 至少提供一个;非法参数抛出可读错误。
|
|
1220
1248
|
*/
|
|
1221
1249
|
/**
|
|
1222
|
-
* recall 工具参数 schema:
|
|
1223
|
-
* (二者同时给出时
|
|
1250
|
+
* recall 工具参数 schema:start 必填(number);end 与 offset 至少提供一个
|
|
1251
|
+
* (二者同时给出时 end 优先,与 execute 语义一致);未知键自动剥离。
|
|
1224
1252
|
*/
|
|
1225
1253
|
const recallArgsSchema = z.object({
|
|
1226
|
-
|
|
1227
|
-
|
|
1254
|
+
start: z.number(),
|
|
1255
|
+
end: z.number().optional(),
|
|
1228
1256
|
offset: z.number().optional()
|
|
1229
|
-
}).refine((args) => args.
|
|
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 工具定义:按
|
|
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: "
|
|
1275
|
+
description: "根据完整消息序号(index),回看指定区间的过往消息。完整消息是定位单位,分三类:用户消息、AI 文本、单个工具调用及其结果(每个 tool-call 与其 result 一条),各占一个 index(从 0 起、会话内全局稳定)。start 必须传入,是区间的基准;end 和 offset 二选一:end 指定另一个边界(含两端,与 start 的位置关系不影响结果),offset 指定区间包含的完整消息数量(正数向后、负数向前)。",
|
|
1248
1276
|
parameters: {
|
|
1249
|
-
|
|
1250
|
-
type: "
|
|
1251
|
-
description: "
|
|
1277
|
+
start: {
|
|
1278
|
+
type: "number",
|
|
1279
|
+
description: "完整消息序号(index),区间的基准边界",
|
|
1252
1280
|
required: true
|
|
1253
1281
|
},
|
|
1254
|
-
|
|
1255
|
-
type: "
|
|
1256
|
-
description: "
|
|
1282
|
+
end: {
|
|
1283
|
+
type: "number",
|
|
1284
|
+
description: "与 offset 互斥,指定区间的另一个边界(含)。与 start 的位置关系不影响结果。"
|
|
1257
1285
|
},
|
|
1258
1286
|
offset: {
|
|
1259
1287
|
type: "number",
|
|
1260
|
-
description: "与
|
|
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 {
|
|
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
|
-
/**
|
|
1278
|
-
const
|
|
1279
|
-
/**
|
|
1280
|
-
const startIndex =
|
|
1281
|
-
if (startIndex
|
|
1282
|
-
/** 终点下标(
|
|
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 (
|
|
1285
|
-
/**
|
|
1286
|
-
const
|
|
1287
|
-
if (
|
|
1288
|
-
endIndex =
|
|
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(
|
|
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
|
|
1305
|
-
if (!
|
|
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
|
-
|
|
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
|
-
*
|
|
1351
|
+
* recall-semantic 工具:按自然语言 query 对会话全部完整消息(含被压缩/遮蔽的)做语义
|
|
1352
|
+
* 检索,返回最匹配的完整消息与匹配说明。
|
|
1336
1353
|
*
|
|
1337
|
-
* - 参数:query 必填;top_k(默认 3,1-10);
|
|
1338
|
-
* (意义同 recall:
|
|
1339
|
-
* - 区间缺省(
|
|
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
|
-
* - 输出:命中消息的
|
|
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
|
-
|
|
1351
|
-
|
|
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
|
-
* -
|
|
1364
|
-
* -
|
|
1365
|
-
*
|
|
1379
|
+
* 解析检索区间(完整消息 index,含端点)。
|
|
1380
|
+
* - start 未提供 → 全量(fallback=false)。
|
|
1381
|
+
* - start 提供:end/offset 计算终点(语义同 recall,end 优先);
|
|
1382
|
+
* start/end 越界或区间为空 → fallback=true(全量 + 提示)。
|
|
1366
1383
|
*/
|
|
1367
|
-
function resolveSemanticRange(
|
|
1368
|
-
|
|
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.
|
|
1390
|
+
if (args.start === void 0) return {
|
|
1375
1391
|
lo: 0,
|
|
1376
1392
|
hi: total - 1,
|
|
1377
1393
|
fallback: false
|
|
1378
1394
|
};
|
|
1379
|
-
/**
|
|
1380
|
-
const start =
|
|
1381
|
-
if (start
|
|
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
|
-
/**
|
|
1402
|
+
/** 终点 index(end 优先;否则 start + offset 取整)。 */
|
|
1387
1403
|
let end;
|
|
1388
|
-
if (args.
|
|
1389
|
-
/**
|
|
1390
|
-
const found =
|
|
1391
|
-
if (found
|
|
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
|
|
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: "
|
|
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: "
|
|
1472
|
+
description: "返回最匹配的完整消息条数(1-10,默认 3)。"
|
|
1457
1473
|
},
|
|
1458
|
-
|
|
1459
|
-
type: "
|
|
1460
|
-
description: "
|
|
1474
|
+
start: {
|
|
1475
|
+
type: "number",
|
|
1476
|
+
description: "完整消息序号(index),可选。限定检索区间:以该条为基准边界(意义同 recall)。缺省检索全部消息。区间不合法(越界)时自动回退全量检索并在结果中说明。"
|
|
1461
1477
|
},
|
|
1462
|
-
|
|
1463
|
-
type: "
|
|
1464
|
-
description: "
|
|
1478
|
+
end: {
|
|
1479
|
+
type: "number",
|
|
1480
|
+
description: "与 offset 互斥,限定区间的另一个边界(含;意义同 recall)。"
|
|
1465
1481
|
},
|
|
1466
1482
|
offset: {
|
|
1467
1483
|
type: "number",
|
|
1468
|
-
description: "
|
|
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,
|
|
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
|
|
1488
|
-
if (
|
|
1489
|
-
/**
|
|
1490
|
-
const range = resolveSemanticRange(
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
|
1500
|
-
if (!
|
|
1501
|
-
/**
|
|
1502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ? "指定区间不合法(
|
|
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
|
-
/**
|
|
1544
|
-
const
|
|
1545
|
-
/**
|
|
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
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
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({
|
|
1581
|
-
* - semantic-recall.ts recall-semantic({ query, top_k?,
|
|
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(
|
|
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"),
|