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