dsh-plugin-om 0.0.19 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { z } from "zod";
6
+ import { toJSONSchema } from "zod/v4/core";
6
7
  //#region \0rolldown/runtime.js
7
8
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
8
9
  //#endregion
@@ -7368,7 +7369,7 @@ var require_dom_parser = /* @__PURE__ */ __commonJSMin(((exports) => {
7368
7369
  exports.onWarningStopParsing = onWarningStopParsing;
7369
7370
  }));
7370
7371
  //#endregion
7371
- //#region src/summarize.ts
7372
+ //#region src/rate-limit.ts
7372
7373
  var import_lib = (/* @__PURE__ */ __commonJSMin(((exports) => {
7373
7374
  var conventions = require_conventions();
7374
7375
  exports.assign = conventions.assign;
@@ -7409,12 +7410,77 @@ var import_lib = (/* @__PURE__ */ __commonJSMin(((exports) => {
7409
7410
  exports.onWarningStopParsing = domParser.onWarningStopParsing;
7410
7411
  })))();
7411
7412
  /**
7413
+ * 全局限流门(插件进程级共享状态):任一摘要请求遇 429 限流后记录时间戳,
7414
+ * 此后所有摘要请求在发出前先等待到「最近一次 429 + rateLimitWaitMs」之后——
7415
+ * 并行压缩时其他块的下一次请求同样受限。等待期间 signal 中止则立即放弃。
7416
+ *
7417
+ * 门在进入时与等待结束时都会重读最近限流时间戳:等待期间发生新的 429 会顺延
7418
+ * 冷却期(循环等待直到通过或中止)。
7419
+ */
7420
+ /** 最近一次 429 限流的时间戳(ms;null = 未遇过限流,门直接放行)。 */
7421
+ let lastRateLimitAt = null;
7422
+ /**
7423
+ * 判定错误信息是否为 429 限流:匹配 429(独立数字)或 rate limit(空格/连字符/
7424
+ * 下划线分隔与 camelCase 均可),大小写不敏感。
7425
+ */
7426
+ function isRateLimitError(message) {
7427
+ return /\b429\b|rate[\s_-]?limit/i.test(message);
7428
+ }
7429
+ /** 记录一次 429 限流:把冷却期起点更新为当前时间。 */
7430
+ function noteRateLimit() {
7431
+ lastRateLimitAt = Date.now();
7432
+ }
7433
+ /** 可中止延时:等待满 ms 返回 true;等待期间 signal 中止返回 false。 */
7434
+ function delay(ms, signal) {
7435
+ return new Promise((resolve) => {
7436
+ if (signal?.aborted) {
7437
+ resolve(false);
7438
+ return;
7439
+ }
7440
+ /** 定时器句柄(正常到点或中止清理用)。 */
7441
+ const timer = setTimeout(() => {
7442
+ cleanup();
7443
+ resolve(true);
7444
+ }, ms);
7445
+ /** 中止监听(到点立即返回 false)。 */
7446
+ const onAbort = () => {
7447
+ cleanup();
7448
+ resolve(false);
7449
+ };
7450
+ /** 清理定时器与中止监听。 */
7451
+ const cleanup = () => {
7452
+ clearTimeout(timer);
7453
+ signal?.removeEventListener("abort", onAbort);
7454
+ };
7455
+ signal?.addEventListener("abort", onAbort, { once: true });
7456
+ });
7457
+ }
7458
+ /**
7459
+ * 限流等待门:处于 429 冷却期(最近一次 429 + waitMs 未到)时等待到期限,
7460
+ * 通过后返回 true;等待期间 signal 中止返回 false。未遇过限流或冷却期已过
7461
+ * 立即放行。waitMs ≤ 0 视为不限流。
7462
+ */
7463
+ async function gateRateLimit(waitMs, signal) {
7464
+ if (waitMs <= 0) return true;
7465
+ while (lastRateLimitAt !== null) {
7466
+ /** 距冷却期结束的剩余等待(≤ 0 表示已过冷却期)。 */
7467
+ const remaining = lastRateLimitAt + waitMs - Date.now();
7468
+ if (remaining <= 0) return true;
7469
+ if (!await delay(remaining, signal)) return false;
7470
+ }
7471
+ return true;
7472
+ }
7473
+ //#endregion
7474
+ //#region src/summarize.ts
7475
+ /**
7412
7476
  * 共享压缩提示词(观察/反思同一套):定义 history 块(模型消息 + index 的表达形式)、
7413
7477
  * 完整消息定义、要求压缩(完整保留用户消息 / reasoning 仅参考 / 关联 assistant 合并 /
7414
7478
  * index/start/end 连续)、输出格式(一个合法 <history> 块,无 reasoning)、数据源说明。
7479
+ * mode 控制【摘要粒度】:无参为通用提示词(反思调用);'summary' 要求简单摘要(较早分块);
7480
+ * 'detailed' 要求越往后越细(最后一块保留细节)。观察分块时除最后一块外均用 'summary'。
7415
7481
  */
7416
- function buildHistoryPrompt() {
7417
- return [
7482
+ function buildHistoryPrompt(mode) {
7483
+ const lines = [
7418
7484
  "把下方的 <history> 消息记录压缩为一份更紧凑的 <history> 压缩日志。不用工具、不展示思考、不输出多余文字。",
7419
7485
  "",
7420
7486
  "【history 块定义】",
@@ -7450,7 +7516,14 @@ function buildHistoryPrompt() {
7450
7516
  `</${HISTORY_TAG}>`,
7451
7517
  "",
7452
7518
  "【数据源】下方的 <history> 消息记录是本次要压缩的全部消息;压缩结果作为一个新的 <history> 块输出。"
7453
- ].join("\n");
7519
+ ];
7520
+ if (mode !== void 0) {
7521
+ const granularity = mode === "summary" ? "【摘要粒度】本条为较早的消息,做简单摘要即可:概括要点,不必展开细节;但用户消息仍逐条保留原文,不概括、不省略。" : "【摘要粒度】本条为最近的消息,越往后越细:靠近末尾的完整消息保留更多细节(关键文件、改动与结论),前面的可适当从简;但用户消息始终逐条保留原文,不概括、不省略。";
7522
+ const marker = "【输出格式】只输出一个 <history> 包裹的合法 XML 日志块";
7523
+ const markerIndex = lines.findIndex((line) => line.includes(marker));
7524
+ if (markerIndex !== -1) lines.splice(markerIndex, 0, granularity);
7525
+ }
7526
+ return lines.join("\n");
7454
7527
  }
7455
7528
  /** 渲染用户消息条目(DOM 元素):文本块原样;图片/文件等非文本块以注释补充(说明传入了什么)。 */
7456
7529
  function renderUserEntry(doc, session, cm) {
@@ -7803,6 +7876,9 @@ function extractSummaryLog(raw, expected) {
7803
7876
  * 失败(抛异常 / 空输出 / 非 stop 结束 / 校验不通过)均记录日志并重试,总共最多尝试
7804
7877
  * options.maxAttempts 次(默认 SUMMARY_DEFAULT_MAX_ATTEMPTS);全部尝试失败返回 null
7805
7878
  * (不产生任何日志变更)。输出长度受 maxTokens 限制。
7879
+ * 每次请求发出前先过全局限流等待门(gateRateLimit):任一请求遇 429 后,后续所有
7880
+ * 摘要请求(含并行中的其他调用方)在「最近一次 429 + options.rateLimitWaitMs」之前
7881
+ * 不会发出(缺省 RATE_LIMIT_WAIT_MS_DEFAULT)。
7806
7882
  */
7807
7883
  async function runSummarySubagent(ctx, agent, instruction, contextText, maxTokens, target, debug, signal, options) {
7808
7884
  /** 当前会话。 */
@@ -7818,6 +7894,12 @@ async function runSummarySubagent(ctx, agent, instruction, contextText, maxToken
7818
7894
  logger.warn(`摘要调用中止(第 ${attempt}/${maxAttempts} 次尝试前 signal 已中止),放弃本次摘要`);
7819
7895
  return null;
7820
7896
  }
7897
+ /** 限流冷却时长(ms;调用方未传时用默认值)。 */
7898
+ const rateLimitWaitMs = options?.rateLimitWaitMs ?? 6e4;
7899
+ if (!await gateRateLimit(rateLimitWaitMs, signal)) {
7900
+ logger.warn(`摘要调用中止(第 ${attempt}/${maxAttempts} 次尝试前限流等待被 signal 中止),放弃本次摘要`);
7901
+ return null;
7902
+ }
7821
7903
  logger.step(`摘要调用开始(第 ${attempt}/${maxAttempts} 次,provider ${target.provider},model ${target.model},maxTokens ${maxTokens})`);
7822
7904
  try {
7823
7905
  /** 摘要请求选项(new 方式组装)。 */
@@ -7847,6 +7929,10 @@ async function runSummarySubagent(ctx, agent, instruction, contextText, maxToken
7847
7929
  } catch (error) {
7848
7930
  /** 错误信息(统一为字符串)。 */
7849
7931
  const message = error instanceof Error ? error.message : String(error);
7932
+ if (isRateLimitError(message)) {
7933
+ noteRateLimit();
7934
+ logger.warn(`摘要调用触发限流(429,第 ${attempt}/${maxAttempts} 次),下一次请求前至少等待 ${rateLimitWaitMs}ms`);
7935
+ }
7850
7936
  lastFailure = { error: message };
7851
7937
  logger.warn(`摘要调用失败(第 ${attempt}/${maxAttempts} 次,${message})` + (attempt < maxAttempts ? ",将重试" : ",重试耗尽,忽略本次摘要"));
7852
7938
  }
@@ -7859,11 +7945,11 @@ async function runSummarySubagent(ctx, agent, instruction, contextText, maxToken
7859
7945
  //#region src/compress.ts
7860
7946
  /**
7861
7947
  * 自动压缩(OM 观察/反思两级阈值,思路参考 Mastra Observational Memory):
7862
- * - 观察:未压缩消息 tokens ≥ 窗口 × thresholdRatio → 直连 ctx.llm.stream() 摘要
7863
- * new 方式:共享提示词作为 system、被压缩消息渲染为 <history> 块输入)把未压缩消息
7948
+ * - 观察:未压缩消息 tokens ≥ observeThresholdTokens(默认 100000)→ 直连 ctx.llm.stream()
7949
+ * 摘要(new 方式:共享提示词作为 system、被压缩消息渲染为 <history> 块输入)把未压缩消息
7864
7950
  * 压缩为观察日志,作为独立的新 <history> 块,只精确替换被压缩的新消息区间(旧块原地
7865
7951
  * 保留,多块并存按序排列;不再把旧+新合并进一条消息);
7866
- * - 反思:全部 <history> 块 tokens 合计 ≥ 窗口 × historyMergeRatio(默认 0.2)→
7952
+ * - 反思:全部 <history> 块 tokens 合计 ≥ reflectThresholdTokens(默认 30000)→
7867
7953
  * 同上摘要调用精简合并(输入为多个块拼接,共用同一套提示词),把整个块区段合并为一条。
7868
7954
  * 两级检查在 pre-step 阻塞串行执行(先反思后观察),避免压缩失败或重复压缩。
7869
7955
  * 自动压缩由配置键 omEnabled 开关(false 时关闭;recall 工具不受影响)。
@@ -7890,6 +7976,103 @@ function estimateTextTokens(text) {
7890
7976
  return Math.ceil(text.length / 4);
7891
7977
  }
7892
7978
  /**
7979
+ * 有界并发池:最多 limit 个任务同时运行(limit 非法时按 1 处理),任务按 index 顺序
7980
+ * 取用,结果数组与 items 按 index 对齐(任务自身以返回值表达失败,不在此抛出)。
7981
+ * items 为空返回空数组。
7982
+ */
7983
+ async function runWithConcurrency(items, limit, task) {
7984
+ /** 结果数组(按 index 对齐)。 */
7985
+ const results = new Array(items.length);
7986
+ /** 下一个待处理任务的下标(worker 间共享取号)。 */
7987
+ let next = 0;
7988
+ /** worker 数量(clamp 到 [1, items.length])。 */
7989
+ const workerCount = Math.max(1, Math.min(Math.floor(limit), items.length));
7990
+ /** 各 worker:循环取号执行任务直到取尽。 */
7991
+ const workers = Array.from({ length: workerCount }, async () => {
7992
+ while (next < items.length) {
7993
+ /** 本 worker 领取的任务下标。 */
7994
+ const index = next;
7995
+ next += 1;
7996
+ results[index] = await task(items[index], index);
7997
+ }
7998
+ });
7999
+ await Promise.all(workers);
8000
+ return results;
8001
+ }
8002
+ /**
8003
+ * 按 token 边界把完整消息序列分块(观察并行压缩用):完整消息不跨块——
8004
+ * 一条完整消息(含其 thinking/text/toolcall&result 全部 seqs)必然整体落在同一块,
8005
+ * 单条超界的消息独立成块。空输入返回空数组。
8006
+ */
8007
+ function chunkCompleteMessages(session, cms, tokenBoundary, estimateMessage) {
8008
+ if (cms.length === 0) return [];
8009
+ /** 分块结果。 */
8010
+ const chunks = [];
8011
+ /** 当前块。 */
8012
+ let current = [];
8013
+ /** 当前块累计 token(按各完整消息 seqs 的派生消息估算合计)。 */
8014
+ let currentTokens = 0;
8015
+ for (const cm of cms) {
8016
+ /** 当前完整消息的 token 估算(其全部表层 seqs 的派生消息估算之和)。 */
8017
+ let cmTokens = 0;
8018
+ for (const seq of cm.seqs) {
8019
+ /** 表层事件(seq 缺失跳过)。 */
8020
+ const event = session.events[seq];
8021
+ /** 派生消息(用于 token 估算)。 */
8022
+ const message = event ? session.deriveEventMessage(event) : null;
8023
+ if (message) cmTokens += estimateMessage(message);
8024
+ }
8025
+ if (current.length > 0 && currentTokens + cmTokens > tokenBoundary) {
8026
+ chunks.push(current);
8027
+ current = [];
8028
+ currentTokens = 0;
8029
+ }
8030
+ current.push(cm);
8031
+ currentTokens += cmTokens;
8032
+ }
8033
+ if (current.length > 0) chunks.push(current);
8034
+ return chunks;
8035
+ }
8036
+ /**
8037
+ * 合并分块摘要为单个 <history> 块:剥离各块的 <history> 开/闭标签(保留块内全部内容),
8038
+ * 以统一的开标签(带 tip 属性)与闭标签包裹拼接。块内内容原样保留(含格式说明注释)。
8039
+ */
8040
+ function mergeChunkReports(parts) {
8041
+ /** 各块内层内容(剥离外壳失败时原样保留该块文本)。 */
8042
+ const inners = [];
8043
+ for (const part of parts) {
8044
+ /** 首个 <history 开标签位置。 */
8045
+ const open = part.indexOf(`<${HISTORY_TAG}`);
8046
+ /** 开标签右括号位置。 */
8047
+ const gt = open === -1 ? -1 : part.indexOf(">", open);
8048
+ /** 最后一个 </history> 闭标签位置。 */
8049
+ const close = part.lastIndexOf(`</${HISTORY_TAG}>`);
8050
+ if (open === -1 || gt === -1 || close === -1 || close <= gt) {
8051
+ inners.push(part);
8052
+ continue;
8053
+ }
8054
+ inners.push(part.slice(gt + 1, close));
8055
+ }
8056
+ return `<${HISTORY_TAG} tip="${HISTORY_TIP}">\n${inners.join("\n").trim()}\n</${HISTORY_TAG}>`;
8057
+ }
8058
+ /** 合并多块摘要的 token usage(同名数字字段求和;全部为空返回 undefined)。 */
8059
+ function mergeUsage(usages) {
8060
+ if (usages.length === 0) return void 0;
8061
+ /** 合并结果(必填字段从 0 起累加)。 */
8062
+ const out = {
8063
+ inputTokens: 0,
8064
+ outputTokens: 0
8065
+ };
8066
+ for (const u of usages) {
8067
+ out.inputTokens += u.inputTokens;
8068
+ out.outputTokens += u.outputTokens;
8069
+ if (u.cacheReadTokens !== void 0) out.cacheReadTokens = (out.cacheReadTokens ?? 0) + u.cacheReadTokens;
8070
+ if (u.cacheWriteTokens !== void 0) out.cacheWriteTokens = (out.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
8071
+ if (u.reasoningTokens !== void 0) out.reasoningTokens = (out.reasoningTokens ?? 0) + u.reasoningTokens;
8072
+ }
8073
+ return out;
8074
+ }
8075
+ /**
7893
8076
  * 反思输入块引用的最大完整消息 index(解析全部条目取最大 end;无条目返回 -1)。
7894
8077
  * 反思输出必须覆盖输入引用的完整 index 区间(0..max),连续性校验据此约束。
7895
8078
  */
@@ -8124,24 +8307,24 @@ function appendHistoryMessage(session, content, sourceEventSeqs, surfaceOp, comp
8124
8307
  });
8125
8308
  }
8126
8309
  /**
8127
- * 反思:全部 <history> 块 tokens 合计 ≥ 窗口 × historyMergeRatio 时,摘要调用
8310
+ * 反思:全部 <history> 块 tokens 合计 ≥ reflectThresholdTokens 时,摘要调用
8128
8311
  * 精简合并(多个块拼接为输入,与观察共用同一套提示词),把整个块区段替换为一条
8129
8312
  * 更紧凑的摘要。失败不产生部分替换。
8130
8313
  */
8131
- async function reflectPass(ctx, agent, config, window, target, signal) {
8314
+ async function reflectPass(ctx, agent, config, target, signal) {
8132
8315
  /** 当前会话。 */
8133
8316
  const session = agent.session;
8134
8317
  /** 插件日志门面。 */
8135
8318
  const logger = makeLogger(ctx, config.debug);
8136
- logger.step(`反思检查(窗口 ${window} × historyMergeRatio ${config.historyMergeRatio})`);
8319
+ logger.step(`反思检查(反思阈值 ${config.reflectThresholdTokens} tokens)`);
8137
8320
  /** 全部 <history> 压缩日志块(按表层顺序;无则跳过)。 */
8138
8321
  const { blocks } = historySection(session);
8139
8322
  if (blocks.length === 0) {
8140
8323
  logger.step("反思:无 <history> 压缩日志,跳过");
8141
8324
  return;
8142
8325
  }
8143
- /** 反思阈值(窗口 × historyMergeRatio 向下取整)。 */
8144
- const threshold = Math.floor(window * config.historyMergeRatio);
8326
+ /** 反思阈值(配置的绝对 token 数)。 */
8327
+ const threshold = config.reflectThresholdTokens;
8145
8328
  /** 全部块 token 估算合计(摘要总长)。 */
8146
8329
  const tokens = blocks.reduce((total, block) => total + estimateTextTokens(block.text), 0);
8147
8330
  /** 被压缩块区段的字符数合计(压缩前内文长度;UI 标题统计用,与观察路径一致只计内容不计标签)。 */
@@ -8186,7 +8369,8 @@ async function reflectPass(ctx, agent, config, window, target, signal) {
8186
8369
  expected: expectedEnd < 0 ? { start: 0 } : {
8187
8370
  start: 0,
8188
8371
  end: expectedEnd
8189
- }
8372
+ },
8373
+ rateLimitWaitMs: config.rateLimitWaitMs
8190
8374
  });
8191
8375
  if (summaryResult === null || summaryResult.text.trim().length === 0) {
8192
8376
  logger.step("反思:摘要调用失败/无输出,追加 compaction/end(error)");
@@ -8213,7 +8397,7 @@ async function reflectPass(ctx, agent, config, window, target, signal) {
8213
8397
  provider: target.provider,
8214
8398
  model: target.model,
8215
8399
  maxTokens: config.compressMaxTokens,
8216
- attemptCount: summaryResult.attemptCount,
8400
+ attemptCount: summaryResult.attemptCount - 1,
8217
8401
  ...summaryResult.usage === void 0 ? {} : { usage: summaryResult.usage }
8218
8402
  });
8219
8403
  logger.step("反思提交:替换整个 <history> 块区段为合并摘要");
@@ -8235,17 +8419,17 @@ async function reflectPass(ctx, agent, config, window, target, signal) {
8235
8419
  }
8236
8420
  }
8237
8421
  /**
8238
- * 观察:未压缩消息 tokens ≥ 窗口 × thresholdRatio 时,摘要调用把未压缩消息压缩为
8422
+ * 观察:未压缩消息 tokens ≥ observeThresholdTokens 时,摘要调用把未压缩消息压缩为
8239
8423
  * 观察日志,追加到旧摘要并替换被压缩消息区间。失败不产生部分替换。
8240
8424
  */
8241
- async function observePass(ctx, agent, config, window, tailCount, target, signal) {
8425
+ async function observePass(ctx, agent, config, tailCount, target, signal) {
8242
8426
  /** 当前会话。 */
8243
8427
  const session = agent.session;
8244
8428
  /** 插件日志门面。 */
8245
8429
  const logger = makeLogger(ctx, config.debug);
8246
- logger.step(`观察检查(窗口 ${window} × thresholdRatio ${config.thresholdRatio},尾部保留 ${tailCount} 条)`);
8247
- /** 观察阈值(窗口 × thresholdRatio 向下取整)。 */
8248
- const threshold = Math.floor(window * config.thresholdRatio);
8430
+ logger.step(`观察检查(观察阈值 ${config.observeThresholdTokens} tokens,尾部保留 ${tailCount} 条)`);
8431
+ /** 观察阈值(配置的绝对 token 数)。 */
8432
+ const threshold = config.observeThresholdTokens;
8249
8433
  /** 未压缩消息 token 估算(最后一个 <history> 块之后;其前视为已压缩)。 */
8250
8434
  const uncompressedTokens = measureUncompressedTokens(session, ctx.tokenMeter);
8251
8435
  if (uncompressedTokens < threshold) {
@@ -8282,10 +8466,9 @@ async function observePass(ctx, agent, config, window, tailCount, target, signal
8282
8466
  const startIndex = inRangeCms[0]?.index ?? 0;
8283
8467
  const endIndex = inRangeCms[inRangeCms.length - 1]?.index ?? startIndex;
8284
8468
  logger.step(`观察:保留旧块 ${blocks.length} 条,替换新消息 [${replaceSeqs[0]}..${range.end}](${replaceSeqs.length} 条),尾部保留 ${tailCount} 条(不压缩、不进日志),新消息 index ${startIndex}..${endIndex}`);
8285
- /** 共享提示词(观察/反思同一套)。 */
8286
- const instruction = buildHistoryPrompt();
8287
- /** 渲染输入:本次要压缩的完整消息(合法 <history> 块,含绝对 index;不含尾部;系统消息渲染为 <sys> 空块,本插件自产消息不占位)。 */
8288
- const contextText = renderMessages(session, replaceSeqs);
8469
+ /** observeChunkTokens 边界把完整消息分块(完整消息不跨块;每块独立并行压缩)。 */
8470
+ const chunks = chunkCompleteMessages(session, inRangeCms, config.observeChunkTokens, (message) => ctx.tokenMeter.estimateMessage(message));
8471
+ logger.step(`观察:${inRangeCms.length} 条完整消息按 ${config.observeChunkTokens} tokens 边界分为 ${chunks.length} 块(最多 ${config.observeChunkParallelism} 块并行,每块摘要 maxTokens ${config.observeChunkMaxTokens},除最后一块外要求简单摘要)`);
8289
8472
  /** 本次压缩生命周期(compactionId + 当前轮次;start 在摘要调用前开启,UI 压缩中提示)。 */
8290
8473
  const lifecycle = {
8291
8474
  compactionId: newCompactionId(),
@@ -8300,23 +8483,39 @@ async function observePass(ctx, agent, config, window, tailCount, target, signal
8300
8483
  logger.warn(`观察压缩启动失败: ${message}`);
8301
8484
  return;
8302
8485
  }
8303
- /** 观察摘要结果(null 表示失败/跳过)。 */
8304
- const summaryResult = await runSummarySubagent(ctx, agent, instruction, contextText, config.compressMaxTokens, target, config.debug, signal, {
8305
- maxAttempts: config.compressRetryCount + 1,
8306
- expected: {
8307
- start: startIndex,
8308
- end: endIndex
8309
- }
8486
+ /** 各块摘要结果(有界并发池,最多 observeChunkParallelism 块同时进行;null = 该块失败;结果按块顺序)。 */
8487
+ const chunkResults = await runWithConcurrency(chunks, config.observeChunkParallelism, async (chunk, index) => {
8488
+ /** 该块提示词(较早块简单摘要;最后一块越往后越细)。 */
8489
+ const instruction = buildHistoryPrompt(index === chunks.length - 1 ? "detailed" : "summary");
8490
+ /** 该块渲染输入(块内完整消息的 seqs;合法 <history> 块,含绝对 index)。 */
8491
+ const contextText = renderMessages(session, chunk.flatMap((cm) => cm.seqs));
8492
+ /** 该块覆盖的完整消息 index 区间(连续性校验预期)。 */
8493
+ const start = chunk[0]?.index ?? 0;
8494
+ const end = chunk[chunk.length - 1]?.index ?? start;
8495
+ return runSummarySubagent(ctx, agent, instruction, contextText, config.observeChunkMaxTokens, target, config.debug, signal, {
8496
+ maxAttempts: config.compressRetryCount + 1,
8497
+ expected: {
8498
+ start,
8499
+ end
8500
+ },
8501
+ rateLimitWaitMs: config.rateLimitWaitMs
8502
+ });
8310
8503
  });
8311
- if (summaryResult === null || summaryResult.text.trim().length === 0) {
8312
- logger.step("观察:摘要调用失败/无输出,追加 compaction/end(error)");
8504
+ if (chunkResults.find((r) => r === null || r.text.trim().length === 0) !== void 0) {
8505
+ logger.step("观察:分块摘要存在失败/无输出,不产生部分替换,追加 compaction/end(error)");
8313
8506
  try {
8314
8507
  appendCompactionEnd(session, lifecycle, "摘要调用失败/无输出");
8315
8508
  } catch {}
8316
8509
  return;
8317
8510
  }
8318
- /** 观察摘要文本(独立新块;旧块保留,不再合并)。 */
8319
- const report = summaryResult.text;
8511
+ /** 成功结果(上方已排除 null/空输出;每块已独立校验并规范化)。 */
8512
+ const okResults = chunkResults;
8513
+ /** 合并各块摘要为单个带 tip 的 <history> 块(剥离各块外壳拼接内层;不再整体校验)。 */
8514
+ const report = mergeChunkReports(okResults.map((r) => r.text));
8515
+ /** 重试次数合计(各块重试次数之和;每块重试次数 = 该块成功尝试次数 - 1)。 */
8516
+ const attemptCount = okResults.reduce((sum, r) => sum + (r.attemptCount - 1), 0);
8517
+ /** token usage 合计(各块求和;全部缺省为 undefined)。 */
8518
+ const usage = mergeUsage(okResults.map((r) => r.usage).filter((u) => u !== void 0));
8320
8519
  /** 被替换表层节点的 token 估算合计(仅新消息区间)。 */
8321
8520
  const shadowedTokenCount = replaceSeqs.reduce((total, seq) => {
8322
8521
  /** 当前表层事件。 */
@@ -8348,9 +8547,9 @@ async function observePass(ctx, agent, config, window, tailCount, target, signal
8348
8547
  shadowedCharCount,
8349
8548
  provider: target.provider,
8350
8549
  model: target.model,
8351
- maxTokens: config.compressMaxTokens,
8352
- attemptCount: summaryResult.attemptCount,
8353
- ...summaryResult.usage === void 0 ? {} : { usage: summaryResult.usage }
8550
+ maxTokens: config.observeChunkMaxTokens,
8551
+ attemptCount,
8552
+ ...usage === void 0 ? {} : { usage }
8354
8553
  });
8355
8554
  logger.step("观察提交:替换被压缩新消息区间为 <history>(旧块保留)");
8356
8555
  appendHistoryMessage(session, report, [summarySeq, ...replaceSeqs], {
@@ -8391,27 +8590,12 @@ async function maybeCompress(ctx, agent, config, signal) {
8391
8590
  return;
8392
8591
  }
8393
8592
  logger.step(`会话路由:provider ${target.provider},model ${target.model}`);
8394
- /** 模型容量信息(contextWindow 决定两级阈值)。 */
8395
- let info;
8396
- try {
8397
- info = await ctx.llm.resolveModelInfo(target.provider, target.model, signal);
8398
- } catch (error) {
8399
- logger.warn(`解析模型容量失败: ${error instanceof Error ? error.message : String(error)}`);
8400
- return;
8401
- }
8402
- /** 模型上下文窗口大小(非法值视为无法压缩)。 */
8403
- const window = info.context?.contextWindow;
8404
- if (typeof window !== "number" || !Number.isFinite(window) || window <= 0) {
8405
- logger.step(`模型上下文窗口非法(${String(window)}),跳过压缩`);
8406
- return;
8407
- }
8408
- logger.step(`模型上下文窗口 ${window} tokens,开始两级压缩(先反思后观察)`);
8409
8593
  /** 尾部保留条数(config.tailMessageCount,缺省 10)。 */
8410
8594
  const tailCount = config.tailMessageCount;
8411
8595
  logger.step("反思 pass 开始");
8412
- await reflectPass(ctx, agent, config, window, target, signal);
8596
+ await reflectPass(ctx, agent, config, target, signal);
8413
8597
  logger.step("反思 pass 结束,观察 pass 开始");
8414
- await observePass(ctx, agent, config, window, tailCount, target, signal);
8598
+ await observePass(ctx, agent, config, tailCount, target, signal);
8415
8599
  logger.step("观察 pass 结束,压缩流程完成");
8416
8600
  }
8417
8601
  //#endregion
@@ -8654,9 +8838,13 @@ function cosineSimilarity(a, b) {
8654
8838
  */
8655
8839
  /** 默认配置(冻结对象,resolveConfig 合并的基底;debug 缺省值在解析时按 NODE_ENV 判定)。 */
8656
8840
  const DEFAULT_CONFIG = Object.freeze({
8657
- thresholdRatio: .1,
8658
- historyMergeRatio: .2,
8841
+ observeThresholdTokens: 1e5,
8842
+ reflectThresholdTokens: 3e4,
8659
8843
  compressMaxTokens: 1e4,
8844
+ observeChunkTokens: 3e4,
8845
+ observeChunkMaxTokens: 5e3,
8846
+ observeChunkParallelism: 2,
8847
+ rateLimitWaitMs: 6e4,
8660
8848
  tailMessageCount: 10,
8661
8849
  compressRetryCount: 10,
8662
8850
  omEnabled: true,
@@ -8667,9 +8855,13 @@ const DEFAULT_CONFIG = Object.freeze({
8667
8855
  });
8668
8856
  /** 数值键校验参数表:键名 + [integer]。不限制取值区间——用户提供的值按原样接受(便于调试)。 */
8669
8857
  const NUMBER_KEYS = [
8670
- ["thresholdRatio", false],
8671
- ["historyMergeRatio", false],
8858
+ ["observeThresholdTokens", true],
8859
+ ["reflectThresholdTokens", true],
8672
8860
  ["compressMaxTokens", true],
8861
+ ["observeChunkTokens", true],
8862
+ ["observeChunkMaxTokens", true],
8863
+ ["observeChunkParallelism", true],
8864
+ ["rateLimitWaitMs", true],
8673
8865
  ["tailMessageCount", true],
8674
8866
  ["compressRetryCount", true]
8675
8867
  ];
@@ -8719,6 +8911,21 @@ function resolveConfig(raw) {
8719
8911
  return Object.freeze(config);
8720
8912
  }
8721
8913
  //#endregion
8914
+ //#region src/json-schema.ts
8915
+ /**
8916
+ * zod schema → 工具 wire 参数 JSON Schema 的转换工具。
8917
+ *
8918
+ * 官方 API(如 api.deepseek.com 的 /chat/completions)要求 tools[].function.parameters
8919
+ * 为根级带 type:'object' 的标准 JSON Schema;工具的参数定义统一由 zod 的 toJSONSchema
8920
+ * 生成,避免手写属性 map 与执行期解析 schema 双份定义漂移。剥除 $schema 元键与
8921
+ * additionalProperties:未知键不在 wire 层拒绝,执行期仍由 zod 解析剥离。
8922
+ */
8923
+ /** 由 zod schema 生成 wire 参数 JSON Schema(根 type:'object',含 properties/required 与 describe 描述)。 */
8924
+ function parametersFromZod(schema) {
8925
+ const { $schema: _schema, additionalProperties: _additional, ...rest } = toJSONSchema(schema);
8926
+ return rest;
8927
+ }
8928
+ //#endregion
8722
8929
  //#region src/recall.ts
8723
8930
  /**
8724
8931
  * recall 工具:按「完整消息」序号(index)回看原始会话(start/end 为完整消息 index,
@@ -8728,17 +8935,19 @@ function resolveConfig(raw) {
8728
8935
  * recall 自身不设输出上限:超大的工具结果由 tool-result-pruner 裁剪(pruneContent),
8729
8936
  * 输出 token 由 pruner 配置控制。
8730
8937
  *
8731
- * 参数由 zod schema(recallArgsSchema)在 execute 入口校验:start 必填(number),
8732
- * end/offset 至少提供一个;非法参数抛出可读错误。
8938
+ * 参数由 zod schema(recallArgsSchema)统一描述:wire 参数 JSON Schema(根 type:'object',
8939
+ * 各字段描述来自 .describe())直接发给模型,execute 入口再做运行时校验(start 必填;
8940
+ * end/offset 至少提供一个;非法参数抛出可读错误)。
8733
8941
  */
8734
8942
  /**
8735
8943
  * recall 工具参数 schema:start 必填(number);end 与 offset 至少提供一个
8736
8944
  * (二者同时给出时 end 优先,与 execute 语义一致);未知键自动剥离。
8945
+ * 各字段描述经 .describe() 透传到 wire JSON Schema(生成见 json-schema.ts)。
8737
8946
  */
8738
8947
  const recallArgsSchema = z.object({
8739
- start: z.number(),
8740
- end: z.number().optional(),
8741
- offset: z.number().optional()
8948
+ start: z.number().describe("完整消息序号(index),作为基准边界,和 end 或 offset 配合,指定区间;end 与 offset 至少提供一个(同时给出时 end 优先)。"),
8949
+ end: z.number().optional().describe("与 offset 互斥,指定区间的另一个边界。"),
8950
+ offset: z.number().optional().describe("与 end 互斥。相对 start 的步数:正数向后、负数向前。")
8742
8951
  }).refine((args) => args.end !== void 0 || args.offset !== void 0, { message: "end 与 offset 至少提供一个" });
8743
8952
  /**
8744
8953
  * 解析并校验 recall 调用参数:校验失败时抛出首个校验问题的可读消息
@@ -8758,21 +8967,7 @@ function buildRecallTool(getPruner) {
8758
8967
  return {
8759
8968
  name: "recall",
8760
8969
  description: `${COMPLETE_MESSAGE_DEFINITION}此工具可以精确查询完整消息。用index指定一个区间,返回区间内所有完整消息的内容。`,
8761
- parameters: {
8762
- start: {
8763
- type: "number",
8764
- description: "完整消息序号(index),作为基准边界,和end或offset配合,指定区间",
8765
- required: true
8766
- },
8767
- end: {
8768
- type: "number",
8769
- description: "与 offset 互斥,指定区间的另一个边界。"
8770
- },
8771
- offset: {
8772
- type: "number",
8773
- description: "与 end 互斥。相对 start 的步数:正数向后、负数向前。"
8774
- }
8775
- },
8970
+ parameters: parametersFromZod(recallArgsSchema),
8776
8971
  output: {
8777
8972
  schema: { type: "string" },
8778
8973
  render: (_args, value) => [{
@@ -8840,6 +9035,8 @@ function buildRecallTool(getPruner) {
8840
9035
  *
8841
9036
  * - 参数:query 必填;top_k(默认 3,1-10);start/end/offset 限定检索区间
8842
9037
  * (意义同 recall:start 为基准边界,end 与 offset 二选一;end 优先)。
9038
+ * wire 参数 JSON Schema 由本 schema 经 toJSONSchema 生成(各字段描述来自 .describe(),
9039
+ * 见 json-schema.ts)。
8843
9040
  * - 区间缺省(start 未提供)→ 检索全部消息;区间不合法(start/end 越界等)→
8844
9041
  * 不报错,回退全量检索并在输出中明确告知(模型可见)。
8845
9042
  * - 向量:本地 ONNX embedding(embedding.ts,懒加载 + 批量);相似度 = cosine。
@@ -8849,11 +9046,11 @@ function buildRecallTool(getPruner) {
8849
9046
  */
8850
9047
  /** recall-semantic 工具参数 schema:query 必填;top_k 默认 3(1-10);区间参数均可选。 */
8851
9048
  const semanticRecallArgsSchema = z.object({
8852
- query: z.string(),
8853
- top_k: z.number().int().min(1).max(10).optional(),
8854
- start: z.number().optional(),
8855
- end: z.number().optional(),
8856
- offset: z.number().optional()
9049
+ query: z.string().describe("描述要找的内容的自然语言 query(可混用中英文与代码术语,如 \"修复 retry backoff 的逻辑\"),不能为空。"),
9050
+ top_k: z.number().int().min(1).max(10).optional().describe("返回最匹配的完整消息条数(1-10,默认 3)。"),
9051
+ start: z.number().optional().describe("完整消息序号(index),可选。作为基准边界,和end或offset配合,指定搜索区间。"),
9052
+ end: z.number().optional().describe("必须和start配合使用,与 offset 互斥,指定搜索区间的另一个边界。"),
9053
+ offset: z.number().optional().describe("必须和start配合使用,与 end 互斥。相对 start 的步数:正数向后、负数向前。")
8857
9054
  }).refine((args) => args.query.trim().length > 0, { message: "query 不能为空" });
8858
9055
  /** 解析并校验参数:失败时抛出可读错误(普通 Error 而非 ZodError)。 */
8859
9056
  function parseSemanticRecallArgs(raw) {
@@ -8948,29 +9145,7 @@ function buildSemanticRecallTool(options) {
8948
9145
  return {
8949
9146
  name: "recall-semantic",
8950
9147
  description: `${COMPLETE_MESSAGE_DEFINITION}此工具可以按自然语言含义,检索最符合的完整消息。默认在全消息范围搜索,可以指定区间。`,
8951
- parameters: {
8952
- query: {
8953
- type: "string",
8954
- description: "描述要找的内容的自然语言 query(可混用中英文与代码术语,如 \"修复 retry backoff 的逻辑\")。",
8955
- required: true
8956
- },
8957
- top_k: {
8958
- type: "number",
8959
- description: "返回最匹配的完整消息条数(1-10,默认 3)。"
8960
- },
8961
- start: {
8962
- type: "number",
8963
- description: "完整消息序号(index),可选。作为基准边界,和end或offset配合,指定搜索区间。"
8964
- },
8965
- end: {
8966
- type: "number",
8967
- description: "必须和start配合使用,与 offset 互斥,指定搜索区间的另一个边界。"
8968
- },
8969
- offset: {
8970
- type: "number",
8971
- description: "必须和start配合使用,与 end 互斥。相对 start 的步数:正数向后、负数向前。"
8972
- }
8973
- },
9148
+ parameters: parametersFromZod(semanticRecallArgsSchema),
8974
9149
  output: {
8975
9150
  schema: { type: "string" },
8976
9151
  render: (_args, value) => [{
@@ -9066,8 +9241,8 @@ function buildSemanticRecallTool(options) {
9066
9241
  * 按语义在全部完整消息(含被压缩/遮蔽)中检索,返回最匹配的完整消息与匹配说明
9067
9242
  * (本地 ONNX embedding,模型随插件打包,懒加载)
9068
9243
  * - compress.ts 自动压缩(OM 观察/反思两级阈值):pre-step 阻塞串行执行——
9069
- * 反思(摘要 窗口 × historyMergeRatio 时摘要调用精简合并 <history>)、
9070
- * 观察(未压缩消息 ≥ 窗口 × thresholdRatio 时摘要调用压缩为观察日志并追加)
9244
+ * 反思(<history> tokens 合计 reflectThresholdTokens 时摘要调用精简合并)、
9245
+ * 观察(未压缩消息 tokens observeThresholdTokens 时摘要调用压缩为观察日志并追加)
9071
9246
  *
9072
9247
  * 约束:不引入自定义会话事件类型——压缩复用宿主已知的 compaction/* 生命周期事件
9073
9248
  * (start/summary/end)与 checkpoint 标记,结果写入消息记录与轨迹。
@@ -9092,7 +9267,7 @@ function apply(ctx, config) {
9092
9267
  const resolved = resolveConfig(config);
9093
9268
  /** 插件日志门面(step=debug 按配置 debug 开关输出;info/warn 始终输出)。 */
9094
9269
  const logger = makeLogger(ctx, resolved.debug);
9095
- logger.step(`apply 启动:thresholdRatio=${String(resolved.thresholdRatio)} historyMergeRatio=${String(resolved.historyMergeRatio)} compressMaxTokens=${String(resolved.compressMaxTokens)} tailMessageCount=${String(resolved.tailMessageCount)} omEnabled=${String(resolved.omEnabled)} debug=${String(resolved.debug)}`);
9270
+ logger.step(`apply 启动:observeThresholdTokens=${String(resolved.observeThresholdTokens)} reflectThresholdTokens=${String(resolved.reflectThresholdTokens)} compressMaxTokens=${String(resolved.compressMaxTokens)} tailMessageCount=${String(resolved.tailMessageCount)} omEnabled=${String(resolved.omEnabled)} debug=${String(resolved.debug)}`);
9096
9271
  if (resolved.recallEnabled) ctx.tools.register(buildRecallTool(() => ctx.get("toolResultPruner")));
9097
9272
  if (resolved.semanticRecallEnabled) {
9098
9273
  const warnModel = (message) => ctx.logger.warn(`dsh-plugin-om: ${message}`);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * zod schema → 工具 wire 参数 JSON Schema 的转换工具。
3
+ *
4
+ * 官方 API(如 api.deepseek.com 的 /chat/completions)要求 tools[].function.parameters
5
+ * 为根级带 type:'object' 的标准 JSON Schema;工具的参数定义统一由 zod 的 toJSONSchema
6
+ * 生成,避免手写属性 map 与执行期解析 schema 双份定义漂移。剥除 $schema 元键与
7
+ * additionalProperties:未知键不在 wire 层拒绝,执行期仍由 zod 解析剥离。
8
+ */
9
+ import { type $ZodType } from 'zod/v4/core';
10
+ /** 由 zod schema 生成 wire 参数 JSON Schema(根 type:'object',含 properties/required 与 describe 描述)。 */
11
+ export declare function parametersFromZod<T extends $ZodType>(schema: T): Record<string, unknown>;
12
+ //# sourceMappingURL=json-schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"json-schema.d.ts","sourceRoot":"","sources":["../src/json-schema.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,KAAK,QAAQ,EAAgB,MAAM,aAAa,CAAC;AAE1D,gGAAgG;AAChG,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,QAAQ,EAAE,MAAM,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAIxF"}