dsh-plugin-om 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,782 @@
1
+ import { z } from "zod";
2
+ //#region src/constants.ts
3
+ /**
4
+ * 共享常量:集中定义插件级魔法字符串与事件名,避免散落各模块。
5
+ */
6
+ /** 插件标识:压缩消息 source.plugin 取值与日志前缀。 */
7
+ const PLUGIN_LABEL = "dsh-plugin-om";
8
+ /** 压缩日志标签名:<om-history>...</om-history> 包裹观察/反思日志块。 */
9
+ const HISTORY_TAG = "om-history";
10
+ /** 影子价格认领事件类型:token-meter 据此识别被替换(遮蔽)的表层节点。 */
11
+ const CLAIM_EVENT = "compaction/prune";
12
+ //#endregion
13
+ //#region src/log-index.ts
14
+ /**
15
+ * 查找 seq 在表层节点序列中的下标(不在则返回 -1)。
16
+ * 表层节点按日志顺序排列,用于压缩边界定位与遮蔽范围计算。
17
+ */
18
+ function surfaceIndexOf(nodes, seq) {
19
+ for (let i = 0; i < nodes.length; i += 1) if (nodes[i] === seq) return i;
20
+ return -1;
21
+ }
22
+ /** 提取一条消息事件的 message_id(user/assistant/tool-result 消息均有稳定 id;其余事件无)。 */
23
+ function messageIdOfEvent(event) {
24
+ if (!event) return void 0;
25
+ if (event.type === "user/message") return String(event.data.id ?? "");
26
+ if (event.type === "assistant/message") return String(event.data.message.id ?? "");
27
+ if (event.type === "tool/result") return String(event.data.message.id ?? "");
28
+ }
29
+ /** 消息索引:按日志顺序列出全部消息事件,并按 message_id 定位其在消息序列中的下标。 */
30
+ function indexMessages(session) {
31
+ /** 按日志顺序的消息事件列表。 */
32
+ const messages = [];
33
+ /** message_id → 序列下标映射。 */
34
+ const byId = /* @__PURE__ */ new Map();
35
+ /** 会话全部事件。 */
36
+ const events = session.events;
37
+ for (let seq = 0; seq < events.length; seq += 1) {
38
+ /** 当前待检查事件。 */
39
+ const event = events[seq];
40
+ if (!event) continue;
41
+ if (event.type !== "user/message" && event.type !== "assistant/message" && event.type !== "tool/result") continue;
42
+ /** 消息 id(缺失则跳过该事件)。 */
43
+ const id = messageIdOfEvent(event);
44
+ if (!id) continue;
45
+ /** 本条消息在消息序列中的下标。 */
46
+ const index = messages.length;
47
+ messages.push({
48
+ seq,
49
+ id,
50
+ type: event.type
51
+ });
52
+ byId.set(id, index);
53
+ }
54
+ return {
55
+ messages,
56
+ byId
57
+ };
58
+ }
59
+ //#endregion
60
+ //#region src/utils.ts
61
+ /** 判断值是否为普通对象:typeof object 且非 null 且非数组(类型收窄用)。 */
62
+ function isRecord(value) {
63
+ return typeof value === "object" && value !== null && !Array.isArray(value);
64
+ }
65
+ /** 抛出带插件前缀的错误(never 返回:调用后控制流终止)。 */
66
+ function fail(message) {
67
+ throw new Error(`dsh-plugin-om: ${message}`);
68
+ }
69
+ /**
70
+ * 校验配置数值:必须是有限数且在 [min, max] 内(可选整数约束),
71
+ * 不满足时抛出带插件前缀的错误。
72
+ */
73
+ function assertNumber(name, value, { min = 0, max = Infinity, integer = false } = {}) {
74
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) fail(`config ${name} must be a finite number in [${min}, ${max}]`);
75
+ if (integer && !Number.isInteger(value)) fail(`config ${name} must be an integer`);
76
+ }
77
+ /** 生成 uuid:优先 crypto.randomUUID,回退为时间戳+随机串拼接(保证唯一性)。 */
78
+ function uuid() {
79
+ /** 全局 crypto 对象(提供 randomUUID 的现代环境才存在)。 */
80
+ const cryptoObj = globalThis.crypto;
81
+ if (cryptoObj && typeof cryptoObj.randomUUID === "function") return cryptoObj.randomUUID();
82
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
83
+ }
84
+ /** 提取 content 数组中的纯文本块(type === 'text' 的 block.text 拼接)。 */
85
+ function blocksToText(blocks) {
86
+ if (!Array.isArray(blocks)) return "";
87
+ /** 拼接结果缓冲区。 */
88
+ const out = [];
89
+ for (const block of blocks) if (isRecord(block) && block.type === "text" && typeof block.text === "string") out.push(block.text);
90
+ return out.join("");
91
+ }
92
+ /** 面向 recall 的完整消息呈现:text 原样;tool-call 展开(参数=代码);tool-result 取文本。 */
93
+ function renderMessageText(message) {
94
+ if (!message || !Array.isArray(message.content)) return "";
95
+ /** 各内容块呈现后的拼接缓冲区。 */
96
+ const parts = [];
97
+ for (const block of message.content) if (block.type === "text") parts.push(block.text);
98
+ else if (block.type === "tool-call") parts.push(`[tool-call ${block.name} id=${String(block.id)}]
99
+ ${safeJson(block.arguments)}`);
100
+ else if (block.type === "tool-result") parts.push(blocksToText(block.content));
101
+ return parts.join("\n");
102
+ }
103
+ /** 安全 JSON 序列化:序列化失败时退回 String 呈现(避免渲染异常)。 */
104
+ function safeJson(value) {
105
+ try {
106
+ return JSON.stringify(value, null, 2);
107
+ } catch {
108
+ return String(value);
109
+ }
110
+ }
111
+ /** 主会话判定:subagent 会话 header.origin === 'subagent'(压缩/recall 仅主会话生效)。 */
112
+ function isMainSession(session) {
113
+ return session.header?.origin !== "subagent";
114
+ }
115
+ /** 解析会话路由目标(provider/model),未路由时返回 undefined。 */
116
+ function routedTarget(session) {
117
+ try {
118
+ /** 会话请求头中的路由配置。 */
119
+ const config = session.requestHeader()?.config;
120
+ if (config?.provider && config.model) return {
121
+ provider: config.provider,
122
+ model: config.model
123
+ };
124
+ } catch {}
125
+ }
126
+ //#endregion
127
+ //#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
+ /** 观察者 persona:只针对未压缩消息产出观察日志,不用工具、不展示思考。 */
141
+ const OBSERVER_PERSONA = "你是 dsh-plugin-om 的上下文观察者(Observer,机制参考 Mastra Observational Memory):把会话中尚未压缩的消息压缩为一份观察日志。不用工具、不展示思考、不评价代码、不输出多余文字。";
142
+ /** 反思者 persona:只精简合并当前摘要,不用工具、不展示思考。 */
143
+ const REFLECTOR_PERSONA = "你是 dsh-plugin-om 的上下文反思者(Reflector,机制参考 Mastra Observational Memory):把当前 <om-history> 压缩日志精简合并为一份更紧凑的日志。不用工具、不展示思考、不输出多余文字。";
144
+ /**
145
+ * 构建观察提示词:规则(消息概括为要点 / 工具调用按目的聚合——不限于 run_code /
146
+ * 仅关键消息保留 message_id / 中断标注 / 未完成写进度与下一步)+ message_id 对照表 +
147
+ * 中断标记 + 追加说明。全文由继承上下文提供。
148
+ */
149
+ function buildObservePrompt(options) {
150
+ /** 对照表段落(无则标注「无」)。 */
151
+ const tableSection = options.table.length > 0 ? options.table : ["(无)"];
152
+ /** 中断标记段落(无则标注「无」)。 */
153
+ const interruptionSection = options.interruptions.length > 0 ? options.interruptions : ["(无)"];
154
+ return [
155
+ `你继承的会话上下文中,最后一次 <${HISTORY_TAG}> 块之后的全部消息都是「未压缩消息」;只对这些消息做压缩,忽略更早的历史。`,
156
+ `如果上下文里还没有 <${HISTORY_TAG}> 块,则除本消息外的全部消息都是未压缩消息。`,
157
+ "",
158
+ "【规则】",
159
+ "- 用户消息概括为 user_message 条目(保留需求要点与关键事实:数字、路径、命令、决定),仅对关键消息保留 message_id(格式:user_message message_id:<id> text:<要点>)。关键消息指开启新任务/提出需求的请求、包含关键决策或不可再得事实的输入;普通消息(寒暄、重复、可推断内容)可以省略 message_id。",
160
+ "- 所有工具调用(run_code 与其他工具同等对待,不限于 run_code)按调用目的聚合为一行 toolcall message_id:<该组最后一条消息的 message_id> purpose:<聚合目的> summary:<行为与结果摘要>;工具组内部细节(参数、完整输出)不保留,需要原文时用 recall 按 message_id 回看。",
161
+ "- 若【中断标记】非空,在对应位置明确写出中断(例如「被用户打断,因此上一段工作未完成」),帮助后续理解用户为何再次输入消息、为何不延续之前的工作。",
162
+ "- 若当前工作看起来未完成(最后一次工具调用没有结果、或对话被中断/异常结束),在日志末尾说明当前进度与下一步要做什么。",
163
+ `- 只输出日志条目本身;不要 <${HISTORY_TAG}> 标签、不要解释、不要复述规则。`,
164
+ "",
165
+ "【message_id 对照表】(按顺序对应你上下文中的消息,用于产出正确的 message_id)",
166
+ ...tableSection,
167
+ "",
168
+ "【中断标记】",
169
+ ...interruptionSection,
170
+ "",
171
+ ...options.hasOldHistory ? [`【说明】你的压缩结果会被直接追加到上一次压缩产物(<${HISTORY_TAG}>)的末尾,条目格式须与上一条目保持一致。`] : [`【说明】你的压缩结果将成为第一条 <${HISTORY_TAG}> 压缩日志。`]
172
+ ].join("\n");
173
+ }
174
+ /** 构建反思提示词:精简合并当前 <om-history>;全文由继承上下文提供。 */
175
+ function buildReflectPrompt() {
176
+ return [
177
+ `你继承的会话上下文中包含当前的 <${HISTORY_TAG}> 压缩日志(最后一次 <${HISTORY_TAG}> 块)。`,
178
+ "只对这份压缩日志做精简合并;不要涉及日志之外的消息。",
179
+ "",
180
+ "【规则】",
181
+ "- 用户消息保留要点,仅保留关键 message_id(格式:user_message message_id:<id> text:<要点>);可省略的 message_id 删除。",
182
+ "- toolcall 条目按调用目的进一步聚合,保留组内最后一条消息的 message_id;不重要的条目 summary 写「(略)」。",
183
+ "- 保留中断说明与未完成说明(若原日志中有)。",
184
+ "- 过时事实丢弃,不逐字复制旧文本。",
185
+ `- 只输出合并后的日志条目本身;不要 <${HISTORY_TAG}> 标签、不要解释、不要复述规则。`,
186
+ "",
187
+ `【说明】你的合并结果会替换当前的 <${HISTORY_TAG}> 块内容。`
188
+ ].join("\n");
189
+ }
190
+ /**
191
+ * Fork 子会话执行一次摘要(观察或反思),返回文本;失败或无法 fork 返回 null。
192
+ * 工具被 toolFilter 禁用,输出长度受 maxTokens 限制。
193
+ */
194
+ async function runSummarySubagent(ctx, agent, persona, prompt, maxTokens, signal) {
195
+ /** subagents 服务(缺失则无法分叉摘要)。 */
196
+ const subagents = ctx.get("subagents");
197
+ if (!subagents) {
198
+ ctx.logger.warn("dsh-plugin-om: 未找到 subagents 服务,跳过摘要分叉");
199
+ return null;
200
+ }
201
+ if (!subagents.getProvider("fork")) {
202
+ ctx.logger.warn("dsh-plugin-om: fork 子代理提供方未注册,跳过摘要分叉");
203
+ return null;
204
+ }
205
+ /** 本次 fork 运行的句柄(用于取结果与 dispose)。 */
206
+ let run;
207
+ try {
208
+ run = await subagents.start("fork", {
209
+ label: "om-summary",
210
+ prompt: [{
211
+ type: "text",
212
+ text: prompt
213
+ }],
214
+ parent: agent,
215
+ persona,
216
+ toolFilter: { allow: [] },
217
+ agentOptions: { maxTokens },
218
+ ...signal !== void 0 ? { signal } : {}
219
+ });
220
+ /** 子代理运行结果。 */
221
+ const result = await run.result;
222
+ /** 拼接、去标签、去首尾空白的摘要文本。 */
223
+ const text = blocksToText(Array.isArray(result.output) ? result.output : []).trim().replace(new RegExp(`</?${HISTORY_TAG}>`, "g"), "").trim();
224
+ if (result.stopReason !== "completed" || text.length === 0) {
225
+ ctx.logger.warn("dsh-plugin-om: 摘要未完成(" + (result.stopReason === "completed" ? "无输出" : String(result.stopReason)) + "),忽略本次摘要");
226
+ return null;
227
+ }
228
+ return text;
229
+ } catch (error) {
230
+ /** 错误信息(统一为字符串)。 */
231
+ const message = error instanceof Error ? error.message : String(error);
232
+ ctx.logger.warn(`dsh-plugin-om: 摘要子代理失败: ${message},忽略`);
233
+ return null;
234
+ } finally {
235
+ if (run && typeof run.dispose === "function") try {
236
+ await run.dispose();
237
+ } catch {}
238
+ }
239
+ }
240
+ //#endregion
241
+ //#region src/compress.ts
242
+ /** 历史文本 token 估算:4 字符 ≈ 1 token(与宿主 dsh-token-meter 启发式一致)。 */
243
+ function estimateTextTokens(text) {
244
+ return Math.ceil(text.length / 4);
245
+ }
246
+ /** 提取 <om-history> 压缩日志消息的内文(去掉标签);非压缩日志消息返回 undefined。 */
247
+ function historyTextOf(event) {
248
+ if (event?.type !== "user/message") return void 0;
249
+ /** 消息纯文本。 */
250
+ const text = blocksToText(event.data.content);
251
+ return text.includes(`<om-history>`) ? text.replace(new RegExp(`</?${HISTORY_TAG}>`, "g"), "").trim() : void 0;
252
+ }
253
+ /**
254
+ * 未压缩消息 token 估算:表层节点合计,不含 <om-history> 摘要节点
255
+ * (观察阈值衡量对象)。
256
+ */
257
+ function measureUncompressedTokens(session, meter) {
258
+ /** token 合计。 */
259
+ let total = 0;
260
+ for (const seq of session.surface.nodes) {
261
+ /** 当前表层事件(摘要节点不计入未压缩消息)。 */
262
+ const event = session.events[seq];
263
+ if (historyTextOf(event) !== void 0) continue;
264
+ /** 事件对应的消息(用于 token 估算)。 */
265
+ const message = event ? session.deriveEventMessage(event) : null;
266
+ total += message ? meter.estimateMessage(message) : 0;
267
+ }
268
+ return total;
269
+ }
270
+ /** 定位日志中最后一次 <om-history> 压缩日志(内文 + 事件 seq);无则 undefined。 */
271
+ function findLatestHistory(session) {
272
+ /** 会话事件(仅追加,从后向前扫描)。 */
273
+ const events = session.events;
274
+ for (let seq = events.length - 1; seq >= 0; seq -= 1) {
275
+ /** <om-history> 内文(非压缩日志消息时为 undefined)。 */
276
+ const text = historyTextOf(events[seq]);
277
+ if (text !== void 0) return {
278
+ text,
279
+ seq
280
+ };
281
+ }
282
+ }
283
+ /**
284
+ * 观察压缩区间:尾部保留 tailCount 条消息不压缩,区间封顶在最后一个已结束 turn 的
285
+ * 表层节点(fork seed 截断于最后一个 turn/end,当前 turn 消息不可压缩)。
286
+ */
287
+ function computeCompressRange(session, tailCount) {
288
+ /** 当前表层节点(按日志顺序)。 */
289
+ const surface = [...session.surface.nodes];
290
+ if (surface.length === 0) return void 0;
291
+ /** 最后一个 turn/end(fork seed 截断于此;无则无可压缩内容)。 */
292
+ const lastEnd = session.events.findLast((event) => event.type === "turn/end");
293
+ if (!lastEnd) return void 0;
294
+ /** seed 覆盖的最后表层节点下标(其后为当前 turn 消息,不可压缩)。 */
295
+ let seedIdx = -1;
296
+ for (let i = surface.length - 1; i >= 0; i -= 1) {
297
+ const node = surface[i];
298
+ if (node !== void 0 && node <= lastEnd.seq) {
299
+ seedIdx = i;
300
+ break;
301
+ }
302
+ }
303
+ if (seedIdx === -1) return void 0;
304
+ /** 区间末表层节点下标(尾部保留与 seed 封顶取小)。 */
305
+ const endIdx = Math.min(surface.length - 1 - tailCount, seedIdx);
306
+ if (endIdx < 0) return void 0;
307
+ /** 区间起点(表层首节点)。 */
308
+ const start = surface[0];
309
+ /** 区间终点(表层节点 seq)。 */
310
+ const end = surface[endIdx];
311
+ if (start === void 0 || end === void 0) return void 0;
312
+ return {
313
+ start,
314
+ end,
315
+ shadowedSeqs: surface.slice(0, endIdx + 1),
316
+ lastEndSeq: lastEnd.seq
317
+ };
318
+ }
319
+ /**
320
+ * 中断标记行:范围内 turn/end 以 aborted(含 cause 类型)或 interrupted 结束的轮次
321
+ * (标记用途:让摘要 AI 理解中断原因)。
322
+ */
323
+ function scanInterruptions(session, fromSeq, toSeq) {
324
+ /** 标记行缓冲区。 */
325
+ const marks = [];
326
+ for (let seq = fromSeq + 1; seq <= toSeq; seq += 1) {
327
+ /** 当前待检查事件。 */
328
+ const event = session.events[seq];
329
+ if (event?.type !== "turn/end") continue;
330
+ /** 结束原因(判别 kind)。 */
331
+ const reason = event.data.reason;
332
+ if (!reason || typeof reason !== "object") continue;
333
+ if (reason.kind === "aborted") {
334
+ /** 取消来源(user/parent/hook/disposed;未知标记 unknown)。 */
335
+ const cause = reason.reason?.kind ?? "unknown";
336
+ marks.push(`[interrupted] turn ${String(event.data.turn)} 被中断(aborted,原因 ${cause})`);
337
+ } else if (reason.kind === "interrupted") marks.push(`[interrupted] turn ${String(event.data.turn)} 因崩溃恢复中断(interrupted)`);
338
+ }
339
+ return marks;
340
+ }
341
+ /**
342
+ * 提取遮蔽区间内最后一次 <om-history> 压缩日志(内文 + seq)。
343
+ * 按表层顺序(shadowedSeqs)扫描:单节点替换(反思)后摘要节点 seq 可能大于
344
+ * 被压缩消息的 seq,按 seq 区间扫描会漏(start > end)。
345
+ */
346
+ function extractHistoryText(session, shadowedSeqs) {
347
+ /** 区间内找到的最近一次压缩日志。 */
348
+ let found;
349
+ for (const seq of shadowedSeqs) {
350
+ /** <om-history> 内文。 */
351
+ const text = historyTextOf(session.events[seq]);
352
+ if (text !== void 0) found = {
353
+ text,
354
+ seq
355
+ };
356
+ }
357
+ return found;
358
+ }
359
+ /**
360
+ * message_id 对照表:遮蔽区间内消息事件按表层顺序产出 id 行(插件自产 user/message
361
+ * 如运行时上下文快照与 <om-history> 不入表;观察子会话据此产出正确的 message_id)。
362
+ * 按表层顺序(shadowedSeqs)扫描:与 extractHistoryText 同理,seq 区间扫描会漏。
363
+ */
364
+ function buildMessageIdTable(session, shadowedSeqs) {
365
+ /** 对照表行缓冲区。 */
366
+ const rows = [];
367
+ for (const seq of shadowedSeqs) {
368
+ /** 当前待检查事件。 */
369
+ const event = session.events[seq];
370
+ if (!event) continue;
371
+ if (event.type === "user/message") {
372
+ if (event.data.source?.kind === "plugin") continue;
373
+ /** 用户消息 id。 */
374
+ const id = messageIdOfEvent(event);
375
+ if (id) rows.push(`[user] message_id=${id}`);
376
+ } else if (event.type === "assistant/message") {
377
+ /** 助手消息 id。 */
378
+ const id = messageIdOfEvent(event);
379
+ if (id) rows.push(`[assistant] message_id=${id}`);
380
+ } else if (event.type === "tool/result") {
381
+ /** 结果消息 id。 */
382
+ const id = messageIdOfEvent(event);
383
+ if (id) {
384
+ /** 关联调用 id(供子会话按 callId 定位代码与结果)。 */
385
+ const callId = String(event.data.message.source.callId ?? "");
386
+ rows.push(`[tool/result callId=${callId}] message_id=${id}`);
387
+ }
388
+ }
389
+ }
390
+ return rows;
391
+ }
392
+ /** 追加影子价格认领事件(遮蔽范围 + 遮蔽 seq 列表 + 遮蔽 token 数),返回事件 seq。 */
393
+ function appendClaim(session, data) {
394
+ return session.append(CLAIM_EVENT, data).seq;
395
+ }
396
+ /** 追加 <om-history> 压缩日志消息(surfaceOp 替换遮蔽区间,source 标记插件来源)。 */
397
+ function appendHistoryMessage(session, content, sourceEventSeqs, surfaceOp) {
398
+ /** 压缩替换消息(<om-history> 包裹摘要,source 标记插件来源)。 */
399
+ const message = {
400
+ id: uuid(),
401
+ role: "user",
402
+ content: [{
403
+ type: "text",
404
+ text: [
405
+ "以下是过往会话的压缩日志(<om-history>),为已确立背景:直接继续,不要复述。",
406
+ "",
407
+ `<${HISTORY_TAG}>`,
408
+ content,
409
+ `</${HISTORY_TAG}>`
410
+ ].join("\n")
411
+ }],
412
+ source: {
413
+ kind: "plugin",
414
+ plugin: PLUGIN_LABEL
415
+ }
416
+ };
417
+ session.append("user/message", message, {
418
+ surfaceOp,
419
+ sourceEventSeqs
420
+ });
421
+ }
422
+ /**
423
+ * 反思:摘要 tokens ≥ 窗口 × historyMergeRatio 时,fork 子会话精简合并摘要,
424
+ * 替换单个 <om-history> 节点。失败不产生部分替换。
425
+ */
426
+ async function reflectPass(ctx, agent, config, window, signal) {
427
+ /** 当前会话。 */
428
+ const session = agent.session;
429
+ /** 当前摘要(最后一次 <om-history>;无则跳过)。 */
430
+ const history = findLatestHistory(session);
431
+ if (!history) return;
432
+ /** 反思阈值(窗口 × historyMergeRatio 向下取整)。 */
433
+ const threshold = Math.floor(window * config.historyMergeRatio);
434
+ /** 摘要 token 估算。 */
435
+ const tokens = estimateTextTokens(history.text);
436
+ if (tokens < threshold) return;
437
+ /** 摘要节点须仍在表层才可替换。 */
438
+ if (surfaceIndexOf([...session.surface.nodes], history.seq) === -1) return;
439
+ /** 反思子会话输出(null 表示失败/跳过)。 */
440
+ const report = await runSummarySubagent(ctx, agent, REFLECTOR_PERSONA, buildReflectPrompt(), config.compressMaxTokens, signal);
441
+ if (report === null || report.trim().length === 0) return;
442
+ try {
443
+ appendHistoryMessage(session, report, [appendClaim(session, {
444
+ shadowedRange: {
445
+ start: history.seq,
446
+ end: history.seq
447
+ },
448
+ shadowedSeqs: [history.seq],
449
+ shadowedTokenCount: tokens
450
+ }), history.seq], {
451
+ op: "replace",
452
+ start: history.seq,
453
+ end: history.seq
454
+ });
455
+ ctx.logger.info("dsh-plugin-om: 反思完成(摘要 " + tokens + " tokens ≥ 阈值 " + threshold + ",替换摘要节点)");
456
+ } catch (error) {
457
+ ctx.logger.warn("dsh-plugin-om: 反思提交失败: " + (error instanceof Error ? error.message : String(error)));
458
+ }
459
+ }
460
+ /**
461
+ * 观察:未压缩消息 tokens ≥ 窗口 × thresholdRatio 时,fork 子会话把未压缩消息压缩为
462
+ * 观察日志,追加到旧摘要并替换被压缩消息区间。失败不产生部分替换。
463
+ */
464
+ async function observePass(ctx, agent, config, window, tailCount, signal) {
465
+ /** 当前会话。 */
466
+ const session = agent.session;
467
+ /** 观察阈值(窗口 × thresholdRatio 向下取整)。 */
468
+ const threshold = Math.floor(window * config.thresholdRatio);
469
+ /** 未压缩消息 token 估算(不含 <om-history> 摘要节点)。 */
470
+ const uncompressedTokens = measureUncompressedTokens(session, ctx.tokenMeter);
471
+ if (uncompressedTokens < threshold) return;
472
+ /** 观察压缩区间(尾部保留 tailCount 条 + seed 封顶;无可行区间则跳过)。 */
473
+ const range = computeCompressRange(session, tailCount);
474
+ if (!range) return;
475
+ /** 区间内旧摘要(追加基准;无则首次压缩)。 */
476
+ const history = extractHistoryText(session, range.shadowedSeqs);
477
+ /** 中断标记行。 */
478
+ const interruptions = scanInterruptions(session, Math.min(...range.shadowedSeqs), range.lastEndSeq);
479
+ /** 观察提示词。 */
480
+ const prompt = buildObservePrompt({
481
+ table: buildMessageIdTable(session, range.shadowedSeqs),
482
+ interruptions,
483
+ hasOldHistory: history !== void 0
484
+ });
485
+ /** 观察子会话输出(null 表示失败/跳过)。 */
486
+ const report = await runSummarySubagent(ctx, agent, OBSERVER_PERSONA, prompt, config.compressMaxTokens, signal);
487
+ if (report === null || report.trim().length === 0) return;
488
+ /** 合并后的摘要(旧摘要原文保留,新观察日志追加在末尾)。 */
489
+ const combined = [history?.text, report].filter(Boolean).join("\n");
490
+ /** 被遮蔽表层节点的 token 估算合计。 */
491
+ const shadowedTokenCount = range.shadowedSeqs.reduce((total, seq) => {
492
+ /** 当前表层事件。 */
493
+ const event = session.events[seq];
494
+ /** 事件对应的消息(用于 token 估算)。 */
495
+ const message = event ? session.deriveEventMessage(event) : null;
496
+ return total + (message ? ctx.tokenMeter.estimateMessage(message) : 0);
497
+ }, 0);
498
+ try {
499
+ appendHistoryMessage(session, combined, [appendClaim(session, {
500
+ shadowedRange: {
501
+ start: range.start,
502
+ end: range.end
503
+ },
504
+ shadowedSeqs: range.shadowedSeqs,
505
+ shadowedTokenCount
506
+ }), ...range.shadowedSeqs], {
507
+ op: "replace",
508
+ start: range.start,
509
+ end: range.end
510
+ });
511
+ ctx.logger.info("dsh-plugin-om: 观察压缩完成(未压缩 " + uncompressedTokens + " tokens ≥ 阈值 " + threshold + ",遮蔽 " + range.shadowedSeqs.length + " 个表层节点,约 " + shadowedTokenCount + " tokens)");
512
+ } catch (error) {
513
+ ctx.logger.warn("dsh-plugin-om: 观察压缩提交失败: " + (error instanceof Error ? error.message : String(error)));
514
+ }
515
+ }
516
+ /**
517
+ * 压力检查 + 两级压缩:先反思(压缩过往摘要,有必要才做),后观察(压缩新消息,
518
+ * 有必要才做)。在 pre-step 阻塞串行执行(避免压缩失败或重复压缩)。仅主会话生效。
519
+ */
520
+ async function maybeCompress(ctx, agent, config, signal) {
521
+ /** 当前会话。 */
522
+ const session = agent.session;
523
+ /** 会话路由目标(未路由无法查询容量)。 */
524
+ const target = routedTarget(session);
525
+ if (target === void 0) return;
526
+ /** 模型容量信息(contextWindow 决定两级阈值)。 */
527
+ let info;
528
+ try {
529
+ info = await ctx.llm.resolveModelInfo(target.provider, target.model, signal);
530
+ } catch (error) {
531
+ ctx.logger.warn("dsh-plugin-om: 解析模型容量失败: " + (error instanceof Error ? error.message : String(error)));
532
+ return;
533
+ }
534
+ /** 模型上下文窗口大小(非法值视为无法压缩)。 */
535
+ const window = info.context?.contextWindow;
536
+ if (typeof window !== "number" || !Number.isFinite(window) || window <= 0) return;
537
+ /** 尾部保留条数(config.tailMessageCount,缺省 10)。 */
538
+ const tailCount = config.tailMessageCount;
539
+ await reflectPass(ctx, agent, config, window, signal);
540
+ await observePass(ctx, agent, config, window, tailCount, signal);
541
+ }
542
+ //#endregion
543
+ //#region src/config.ts
544
+ /**
545
+ * 插件配置:默认值、键校验与合并(preset 行 config 可覆盖全部键)。
546
+ * 手写校验,保持零运行时外部依赖。
547
+ */
548
+ /** 默认配置(冻结对象,resolveConfig 合并的基底)。 */
549
+ const DEFAULT_CONFIG = Object.freeze({
550
+ thresholdRatio: .5,
551
+ historyMergeRatio: .2,
552
+ compressMaxTokens: 4096,
553
+ tailMessageCount: 10
554
+ });
555
+ /** 合法配置键集合(未知键直接拒绝)。 */
556
+ const CONFIG_KEYS = /* @__PURE__ */ new Set([
557
+ "thresholdRatio",
558
+ "historyMergeRatio",
559
+ "compressMaxTokens",
560
+ "tailMessageCount"
561
+ ]);
562
+ /** 数值键校验参数表:键名 + [min, max, integer]。 */
563
+ const NUMBER_KEYS = [
564
+ [
565
+ "thresholdRatio",
566
+ .01,
567
+ 1,
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
+ ]
588
+ ];
589
+ /** 归一化原始配置输入:缺省 / null / 空串(含空白串)视为空对象(全部用默认值)。 */
590
+ function normalizeConfigInput(raw) {
591
+ if (raw === void 0 || raw === null) return {};
592
+ if (typeof raw === "string" && raw.trim() === "") return {};
593
+ if (!isRecord(raw)) fail("config must be an object");
594
+ return raw;
595
+ }
596
+ /**
597
+ * 解析合并配置:校验未知键与数值类型,返回冻结的完整配置。
598
+ * 允许所有配置留空——缺省 / null / 空串的键回退默认值,未给出的键亦取默认值。
599
+ */
600
+ function resolveConfig(raw) {
601
+ /** 原始输入(留空视为空对象)。 */
602
+ const input = normalizeConfigInput(raw);
603
+ for (const key of Object.keys(input)) if (!CONFIG_KEYS.has(key)) fail(`unknown config key "${key}"`);
604
+ /** 合并结果(以默认值为基底)。 */
605
+ const config = { ...DEFAULT_CONFIG };
606
+ for (const [key, min, max, integer] of NUMBER_KEYS) {
607
+ /** 该键的原始值(留空则跳过,保持默认值)。 */
608
+ const value = input[key];
609
+ if (value === void 0 || value === null) continue;
610
+ if (typeof value === "string" && value.trim() === "") continue;
611
+ assertNumber(key, value, {
612
+ min,
613
+ max,
614
+ integer
615
+ });
616
+ config[key] = value;
617
+ }
618
+ return Object.freeze(config);
619
+ }
620
+ //#endregion
621
+ //#region src/recall.ts
622
+ /**
623
+ * recall 工具:按 message_id 回看原始会话(start_id/end_id 为消息 id,offset 为相对
624
+ * start_id 的消息步数)。recall 自身不设输出上限:超大的工具结果由 tool-result-pruner
625
+ * 裁剪(pruneContent),输出 token 由 pruner 配置控制。
626
+ *
627
+ * 参数由 zod schema(recallArgsSchema)在 execute 入口校验:start_id 必填且非空,
628
+ * end_id/offset 至少提供一个;非法参数抛出可读错误。
629
+ */
630
+ /**
631
+ * recall 工具参数 schema:start_id 必填且非空;end_id 与 offset 至少提供一个
632
+ * (二者同时给出时 end_id 优先,与 execute 语义一致);未知键自动剥离。
633
+ */
634
+ const recallArgsSchema = z.object({
635
+ start_id: z.string(),
636
+ end_id: z.string().optional(),
637
+ offset: z.number().optional()
638
+ }).refine((args) => args.end_id !== void 0 || args.offset !== void 0, { message: "end_id 与 offset 至少提供一个" });
639
+ /**
640
+ * 解析并校验 recall 调用参数:校验失败时抛出首个校验问题的可读消息
641
+ * (普通 Error 而非 ZodError,兼容 SDK 展示)。
642
+ */
643
+ function parseRecallArgs(raw) {
644
+ /** safeParse 结果(成功时 data 为 RecallArgs,失败时 error 携带问题列表)。 */
645
+ const result = recallArgsSchema.safeParse(raw);
646
+ if (!result.success) throw new Error(result.error.issues.map((issue) => `${issue.path.join(".") || "args"}: ${issue.message}`).join("; "));
647
+ return result.data;
648
+ }
649
+ /**
650
+ * 构建 recall 工具定义:按 start_id/end_id/offset 定位消息区间并渲染原始内容。
651
+ * getPruner 返回 tool-result-pruner(可选),用于裁剪超大工具结果。
652
+ */
653
+ function buildRecallTool(getPruner) {
654
+ return {
655
+ name: "recall",
656
+ description: "根据message_id,回看指定区间的过往消息。start_id必须传入,是区间的基准。end_id 和 offset 二选一,end_id 用于指定另一个边界,offset用于指定区间包含的消息数量。",
657
+ parameters: {
658
+ start_id: {
659
+ type: "string",
660
+ description: "message_id(uuid),区间的基准边界",
661
+ required: true
662
+ },
663
+ end_id: {
664
+ type: "string",
665
+ description: "message_id(uuid),与 offset 互斥,指定区间的另一个边界。与 start_id 的位置关系不影响结果。"
666
+ },
667
+ offset: {
668
+ type: "number",
669
+ description: "与 end_id 互斥,指定区间包含的消息数量。传入正数查看start_id之后的若干条消息,负数则是之前的。"
670
+ }
671
+ },
672
+ output: {
673
+ schema: { type: "string" },
674
+ render: (_args, value) => [{
675
+ type: "text",
676
+ text: String(value)
677
+ }]
678
+ },
679
+ async execute(args, exec) {
680
+ /** 解析并校验后的调用参数(不满足 schema 时抛出可读错误)。 */
681
+ const { start_id, end_id, offset } = parseRecallArgs(args);
682
+ /** 当前会话(缺失则无法回看)。 */
683
+ const session = exec.agent?.session;
684
+ if (!session) return "会话异常";
685
+ if (!isMainSession(session)) return "recall 仅主会话可用";
686
+ /** 会话消息索引(序列 + byId 映射)。 */
687
+ const { messages, byId } = indexMessages(session);
688
+ /** start_id 在消息序列中的下标。 */
689
+ const startIndex = byId.get(start_id);
690
+ if (startIndex === void 0) return `start_id "${start_id}" 不存在`;
691
+ /** 终点下标(end_id 优先,否则 startIndex + offset)。 */
692
+ let endIndex;
693
+ if (end_id !== void 0) {
694
+ /** end_id 在消息序列中的下标。 */
695
+ const found = byId.get(end_id);
696
+ if (found === void 0) return `end_id "${end_id}" 不存在`;
697
+ endIndex = found;
698
+ } else {
699
+ /** offset 数值(refine 保证此分支 offset 已提供,schema 保证为 number)。 */
700
+ const raw = offset ?? 0;
701
+ endIndex = startIndex + (Number.isFinite(raw) ? Math.floor(raw) : 0);
702
+ }
703
+ /** 区间下界(钳制到 [0, len-1])。 */
704
+ const lo = Math.max(0, Math.min(startIndex, endIndex));
705
+ /** 区间上界(钳制到 [0, len-1])。 */
706
+ const hi = Math.min(messages.length - 1, Math.max(startIndex, endIndex));
707
+ /** tool-result-pruner(可选,裁剪超大工具结果)。 */
708
+ const pruner = getPruner?.();
709
+ /** 渲染结果缓冲(每条消息一段)。 */
710
+ const parts = [];
711
+ for (let i = lo; i <= hi; i += 1) {
712
+ /** 当前消息节点。 */
713
+ const node = messages[i];
714
+ if (!node) continue;
715
+ /** 节点对应的会话事件。 */
716
+ const event = session.events[node.seq];
717
+ if (!event) continue;
718
+ /** 本条消息的呈现文本。 */
719
+ let text = "";
720
+ try {
721
+ /** 从事件派生的消息对象。 */
722
+ let message = session.deriveEventMessage(event);
723
+ if (message && event.type === "tool/result" && pruner?.pruneContent) {
724
+ /** 裁剪后的内容块(返回 null 表示不裁剪)。 */
725
+ const pruned = pruner.pruneContent(message.content);
726
+ if (pruned) message = {
727
+ ...message,
728
+ content: pruned
729
+ };
730
+ }
731
+ text = message ? renderMessageText(message) : "";
732
+ } catch {}
733
+ parts.push(`-- [seq ${node.seq}] ${event.type} --\n${text}`);
734
+ }
735
+ if (parts.length === 0) return "指定区间没有消息";
736
+ return parts.join("\n\n");
737
+ }
738
+ };
739
+ }
740
+ //#endregion
741
+ //#region src/index.ts
742
+ /**
743
+ * dsh-plugin-om — Observational Memory(OM)上下文压缩 + recall 检索插件。
744
+ * 不依赖特定 tool mode(native / code / both 均可运行)。
745
+ *
746
+ * 模块:
747
+ * - recall.ts recall({ start_id, end_id?, offset? }) 工具:按 message_id 回看原始会话
748
+ * - compress.ts 自动压缩(OM 观察/反思两级阈值):pre-step 阻塞串行执行——
749
+ * 反思(摘要 ≥ 窗口 × historyMergeRatio 时 fork 精简合并 <om-history>)、
750
+ * 观察(未压缩消息 ≥ 窗口 × thresholdRatio 时 fork 压缩为观察日志并追加)
751
+ *
752
+ * 约束:不引入自定义会话事件类型——压缩复用宿主已知的 'compaction/prune' 影子价格事件。
753
+ * 仅主会话生效(subagent 不压缩、recall 拒绝)。
754
+ */
755
+ /** 插件名(Loader 识别入口的稳定标识)。 */
756
+ const name = "dsh-plugin-om";
757
+ /** 插件注入的服务依赖(tools/llm/tokenMeter/sessions),由宿主按序注入。 */
758
+ const inject = [
759
+ "tools",
760
+ "llm",
761
+ "tokenMeter",
762
+ "sessions"
763
+ ];
764
+ /**
765
+ * 插件激活入口:注册 recall 工具,并在 agent/pre-step 阻塞触发两级自动压缩
766
+ * (先反思后观察)。仅主会话生效。
767
+ */
768
+ function apply(ctx, config) {
769
+ /** 解析后的插件配置(默认值合并 + 校验)。 */
770
+ const resolved = resolveConfig(config);
771
+ ctx.tools.register(buildRecallTool(() => ctx.get("toolResultPruner")));
772
+ ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
773
+ try {
774
+ if (!signal.aborted && isMainSession(agent.session)) await maybeCompress(ctx, agent, resolved, signal);
775
+ } catch (error) {
776
+ ctx.logger.warn("dsh-plugin-om: pre-step 处理失败: " + (error instanceof Error ? error.message : String(error)));
777
+ }
778
+ return next();
779
+ });
780
+ }
781
+ //#endregion
782
+ export { apply, inject, name };