@eddyskywalker/dsh-chatgpt-subscription 0.45.0 → 0.46.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/lib/client.js +19 -0
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +856 -151
  5. package/lib/types/client/kimi-code/KimiCodeSection.d.ts.map +1 -1
  6. package/lib/types/client/kimi-code/locales.d.ts +11 -2
  7. package/lib/types/client/kimi-code/locales.d.ts.map +1 -1
  8. package/lib/types/client/locales-ru/kimi-code.d.ts.map +1 -1
  9. package/lib/types/compat.d.ts +1 -1
  10. package/lib/types/host/cache-warmup/budget.d.ts +19 -4
  11. package/lib/types/host/cache-warmup/budget.d.ts.map +1 -1
  12. package/lib/types/host/cache-warmup/contracts.d.ts +13 -0
  13. package/lib/types/host/cache-warmup/contracts.d.ts.map +1 -1
  14. package/lib/types/host/cache-warmup/index.d.ts +2 -2
  15. package/lib/types/host/cache-warmup/index.d.ts.map +1 -1
  16. package/lib/types/host/cache-warmup/install.d.ts +14 -6
  17. package/lib/types/host/cache-warmup/install.d.ts.map +1 -1
  18. package/lib/types/host/cache-warmup/projection.d.ts.map +1 -1
  19. package/lib/types/host/cache-warmup/store.d.ts.map +1 -1
  20. package/lib/types/host/cache-warmup/usage.d.ts +10 -0
  21. package/lib/types/host/cache-warmup/usage.d.ts.map +1 -1
  22. package/lib/types/host/cline-pass/adapter.d.ts.map +1 -1
  23. package/lib/types/host/cline-pass/mapper.d.ts +10 -0
  24. package/lib/types/host/cline-pass/mapper.d.ts.map +1 -1
  25. package/lib/types/host/kimi-code/adapter.d.ts.map +1 -1
  26. package/lib/types/host/kimi-code/routes.d.ts.map +1 -1
  27. package/lib/types/host/kimi-code/session-cache-state.d.ts +12 -0
  28. package/lib/types/host/kimi-code/session-cache-state.d.ts.map +1 -1
  29. package/lib/types/host/opendesign/adapter.d.ts +15 -0
  30. package/lib/types/host/opendesign/adapter.d.ts.map +1 -1
  31. package/lib/types/host/opendesign/mapper.d.ts +50 -3
  32. package/lib/types/host/opendesign/mapper.d.ts.map +1 -1
  33. package/lib/types/index.d.ts.map +1 -1
  34. package/lib/types/shared/kimi-code-contracts.d.ts +13 -0
  35. package/lib/types/shared/kimi-code-contracts.d.ts.map +1 -1
  36. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -141,7 +141,7 @@ const ROUTE_PREFIX$4 = "/api/dsh-chatgpt-subscription";
141
141
  * `test/responses-client.test.ts` asserts the two agree, so a release that
142
142
  * forgets this constant fails the suite instead of shipping quietly.
143
143
  */
144
- const PLUGIN_VERSION = "0.45.0";
144
+ const PLUGIN_VERSION = "0.46.0";
145
145
  const CODEX_CHATGPT_PROVIDER_ID = "codex-chatgpt";
146
146
  const CODEX_API_BASE = "https://chatgpt.com/backend-api/codex";
147
147
  const CODEX_RESPONSES_URL = `${CODEX_API_BASE}/responses`;
@@ -24710,6 +24710,19 @@ const CACHE_WARMUP_DEFAULTS = {
24710
24710
  hitRatioThreshold: .97,
24711
24711
  /** 退出预热期需要连续命中的报告条数。 */
24712
24712
  consecutiveHitsRequired: 2,
24713
+ /**
24714
+ * 稳态第二轨(短会话分母效应兜底):已命中的缓存前缀至少达到该 token 规模,
24715
+ * 且相比上一条报告一字未掉(`cacheReadTokens >= prev.cacheReadTokens`),
24716
+ * 同时本轮新增未命中部分不超过 `retainedPrefixMaxFreshTokens` 时,同样计为一次有效命中。
24717
+ */
24718
+ retainedPrefixMinCachedTokens: 1024,
24719
+ /** 稳态第二轨允许的单轮最大新增未命中 token 数(与 Kimi 256-token 缓存块对齐)。 */
24720
+ retainedPrefixMaxFreshTokens: 256,
24721
+ /**
24722
+ * 缓存存活窗口(毫秒,25 分钟,与 Kimi 实测全热上限一致)。
24723
+ * 距离最近一次报告超过该时长时,预算计算自动预判缓存已冷、退回预热期保守预算。
24724
+ */
24725
+ cacheTtlMs: 1500 * 1e3,
24713
24726
  /** 单结果原文上限(字节)。 */
24714
24727
  maxResultBytes: 8 * 1024 * 1024,
24715
24728
  /** 每 session 原文总量上限(字节)。 */
@@ -24773,12 +24786,13 @@ function cellOf(value) {
24773
24786
  return "";
24774
24787
  }
24775
24788
  }
24776
- /** JSON Lines:每一行都是一个对象,且至少两行。 */
24789
+ /** JSON Lines:每一行都是一个结构一致的表格式对象,且至少 5 行(防止 2~3 个异构 tool 结果对象被当成一张表压掉)。 */
24777
24790
  function parseJsonLines(lines) {
24778
- if (lines.length < 2) return void 0;
24791
+ if (lines.length < 5) return void 0;
24779
24792
  const fields = [];
24780
24793
  const seen = /* @__PURE__ */ new Set();
24781
24794
  const rows = [];
24795
+ let firstKeySet;
24782
24796
  for (const line of lines) {
24783
24797
  let parsed;
24784
24798
  try {
@@ -24788,6 +24802,10 @@ function parseJsonLines(lines) {
24788
24802
  }
24789
24803
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
24790
24804
  const record = parsed;
24805
+ const keys = Object.keys(record);
24806
+ if (keys.length === 0) return void 0;
24807
+ if (firstKeySet === void 0) firstKeySet = new Set(keys);
24808
+ else if (!keys.some((k) => firstKeySet.has(k))) return void 0;
24791
24809
  const row = {};
24792
24810
  for (const [key, value] of Object.entries(record)) {
24793
24811
  if (!seen.has(key)) {
@@ -24804,7 +24822,7 @@ function parseJsonLines(lines) {
24804
24822
  rows
24805
24823
  };
24806
24824
  }
24807
- /** 分隔符表格:首行表头 + 至少两行同列数的数据。 */
24825
+ /** 分隔符表格:首行合法表头(标识符列名,非 grep 路径行号或代码行)+ 至少两行同列数的数据。 */
24808
24826
  function parseDelimitedRows(lines) {
24809
24827
  if (lines.length < 3) return void 0;
24810
24828
  const header = lines[0];
@@ -24812,6 +24830,7 @@ function parseDelimitedRows(lines) {
24812
24830
  if (delimiter === void 0) return void 0;
24813
24831
  const fields = splitDelimitedLine(header, delimiter).map((cell) => cell.trim());
24814
24832
  if (fields.length < 2 || fields.some((field) => field === "")) return void 0;
24833
+ if (fields.some((field) => field.length > 64 || /[:;{}()[\]\\/*'"`]/.test(field) || /^\d+$/.test(field))) return;
24815
24834
  if (new Set(fields).size !== fields.length) return void 0;
24816
24835
  const rows = [];
24817
24836
  for (const line of lines.slice(1)) {
@@ -24916,12 +24935,40 @@ function clampMaxBytes(maxBytes) {
24916
24935
  if (rounded < 1) return 1;
24917
24936
  return Math.min(rounded, CACHE_WARMUP_DEFAULTS.pageMaxBytesMax);
24918
24937
  }
24919
- /** 一段文本的行数(末尾没有换行也算一行)。 */
24920
- function countLines(text) {
24938
+ /**
24939
+ * 判断一段文本是否为不含真实换行(或几乎全在一行内)的 JSON 转义文本。
24940
+ *
24941
+ * 当 `run_code` 返回 `JSON.stringify({ stdout: "a\nb\nc..." })` 时,原文逐字保存在
24942
+ * 存储里(不改任何字节),但整份文本里没有真 `\n`,只有字面量两字符序列 `\n`。
24943
+ * 若只数真 `\n`,`limit: 64` 行会退化成一次吐完 `maxBytes`。识别出这种形态后,
24944
+ * `pageText` 和 `countLines` 把未转义的 `\n`(即前面有奇数个 `\` 的 `n`)也算作逻辑换行,
24945
+ * 既 100% 保持「翻页拼起来逐字节等于原文」,又让 `limit` 行上限对单行 JSON 同样生效。
24946
+ */
24947
+ function usesEscapedNewlines(text) {
24948
+ if (text.length < 4) return false;
24949
+ let realNewlines = 0;
24950
+ for (let i = 0; i < text.length; i += 1) if (text.charCodeAt(i) === 10) {
24951
+ realNewlines += 1;
24952
+ if (realNewlines > 2) return false;
24953
+ }
24954
+ return text.includes("\\n");
24955
+ }
24956
+ /** 判断 `text[index]`(已知为 `'n'`)是否构成一个 JSON 转义换行 `\n`(前面紧邻奇数个 `\`)。 */
24957
+ function isEscapedNewlineAt(text, index) {
24958
+ let backslashes = 0;
24959
+ for (let cursor = index - 1; cursor >= 0 && text.charCodeAt(cursor) === 92; cursor -= 1) backslashes += 1;
24960
+ return (backslashes & 1) === 1;
24961
+ }
24962
+ /** 一段文本的行数(末尾没有换行也算一行;支持单行 JSON 内的转义 `\n`)。 */
24963
+ function countLines(text, escapedMode = usesEscapedNewlines(text)) {
24921
24964
  if (text === "") return 0;
24922
24965
  let lines = 0;
24923
- for (const char of text) if (char === "\n") lines += 1;
24924
- return text.endsWith("\n") ? lines : lines + 1;
24966
+ for (let index = 0; index < text.length; index += 1) {
24967
+ const code = text.charCodeAt(index);
24968
+ if (code === 10) lines += 1;
24969
+ else if (escapedMode && code === 110 && isEscapedNewlineAt(text, index)) lines += 1;
24970
+ }
24971
+ return text.endsWith("\n") || escapedMode && text.endsWith("n") && isEscapedNewlineAt(text, text.length - 1) ? lines : lines + 1;
24925
24972
  }
24926
24973
  /**
24927
24974
  * 按 UTF-8 字节安全分页。
@@ -24937,6 +24984,7 @@ function pageText(args) {
24937
24984
  if (!Number.isInteger(offset) || offset < 0) throw new CacheWarmupStoreError("offset must be a non-negative integer byte offset", "OFFSET_OUT_OF_RANGE");
24938
24985
  if (offset > totalBytes) throw new CacheWarmupStoreError(`offset ${offset} is past the ${totalBytes}-byte result`, "OFFSET_OUT_OF_RANGE");
24939
24986
  if (offset < totalBytes && (buffer[offset] & 192) === 128) throw new CacheWarmupStoreError("offset is not on a UTF-8 character boundary", "OFFSET_INVALID");
24987
+ const escapedMode = usesEscapedNewlines(text);
24940
24988
  const rest = offset === 0 ? text : new TextDecoder("utf-8").decode(buffer.subarray(offset));
24941
24989
  let content = "";
24942
24990
  let contentBytes = 0;
@@ -24960,6 +25008,7 @@ function pageText(args) {
24960
25008
  content += char;
24961
25009
  contentBytes += charBytes;
24962
25010
  if (char === "\n") lines += 1;
25011
+ else if (escapedMode && char === "n" && isEscapedNewlineAt(content, content.length - 1)) lines += 1;
24963
25012
  }
24964
25013
  const nextOffset = offset + Buffer.byteLength(content, "utf8");
24965
25014
  const complete = nextOffset >= totalBytes;
@@ -24975,7 +25024,7 @@ function pageText(args) {
24975
25024
  omitted: {
24976
25025
  reason: complete ? "none" : stoppedByBytes ? "bytes" : stoppedByLines ? "lines" : "bytes",
24977
25026
  chars: remaining.length,
24978
- lines: countLines(remaining),
25027
+ lines: countLines(remaining, escapedMode),
24979
25028
  rows: 0,
24980
25029
  bytes: Buffer.byteLength(remaining, "utf8")
24981
25030
  },
@@ -25309,6 +25358,28 @@ function cacheWarmupHitRatio(usage) {
25309
25358
  */
25310
25359
  /** Kimi 对每个请求都会报告的生命周期状态;只有其余值才表示真实前缀漂移。 */
25311
25360
  const BENIGN_LIFECYCLE_CAUSES = /* @__PURE__ */ new Set(["stable", "first-request"]);
25361
+ /**
25362
+ * 判断一条报告是否算作「预热命中」(双轨判据):
25363
+ *
25364
+ * 1. **第一轨(比例轨)**:`hitRatio >= 0.97`;
25365
+ * 2. **第二轨(前缀完整延续轨)**:解决短会话的分母效应(如 `1792 cached + 84 input = 95.52%`)。
25366
+ * 当本轮 `cacheReadTokens >= 1024`(已形成实质缓存前缀)、存在紧邻的上一条无真实漂移报告、
25367
+ * 本轮命中缓存没有比上一轮缩水(`cacheReadTokens >= previous.cacheReadTokens`),
25368
+ * 且本轮新增未命中部分 `inputTokens <= 256`(一个缓存块的正常尾巴)时,同样视为有效命中。
25369
+ */
25370
+ function isWarmupHitReport(report, previous) {
25371
+ if (cacheWarmupHitRatio(report) >= CACHE_WARMUP_DEFAULTS.hitRatioThreshold) return true;
25372
+ if (previous === void 0 || previous === null) return false;
25373
+ const prevDrift = previous.driftCause;
25374
+ if (typeof prevDrift === "string" && prevDrift.trim() !== "" && !BENIGN_LIFECYCLE_CAUSES.has(prevDrift)) return false;
25375
+ const cached = finiteCount(report.cacheReadTokens);
25376
+ const prevCached = finiteCount(previous.cacheReadTokens);
25377
+ const fresh = finiteCount(report.inputTokens);
25378
+ if (cached < CACHE_WARMUP_DEFAULTS.retainedPrefixMinCachedTokens) return false;
25379
+ if (prevCached < CACHE_WARMUP_DEFAULTS.retainedPrefixMinCachedTokens) return false;
25380
+ if (cached < prevCached) return false;
25381
+ return fresh <= CACHE_WARMUP_DEFAULTS.retainedPrefixMaxFreshTokens;
25382
+ }
25312
25383
  function consecutiveWarmupHits(reports) {
25313
25384
  try {
25314
25385
  if (!Array.isArray(reports)) return 0;
@@ -25318,7 +25389,7 @@ function consecutiveWarmupHits(reports) {
25318
25389
  if (report === void 0 || report === null) break;
25319
25390
  const drift = report.driftCause;
25320
25391
  if (typeof drift === "string" && drift.trim() !== "" && !BENIGN_LIFECYCLE_CAUSES.has(drift)) break;
25321
- if (cacheWarmupHitRatio(report) < CACHE_WARMUP_DEFAULTS.hitRatioThreshold) break;
25392
+ if (!isWarmupHitReport(report, index > 0 ? reports[index - 1] : void 0)) break;
25322
25393
  hits += 1;
25323
25394
  }
25324
25395
  return hits;
@@ -25447,21 +25518,41 @@ const WARMUP_BUDGET = Object.freeze({
25447
25518
  * 当前会话的预览预算。
25448
25519
  *
25449
25520
  * 规则(约束 4):预热期 `maxChars = 1200`(约 600-750 tokens);连续
25450
- * `consecutiveHitsRequired`(2) 次命中率 ≥ 0.97 且期间没有 drift,才进入稳态
25451
- * `maxChars = 8000`。任何一次 drift 都清零连续计数,于是下一条请求立刻回落到预热预算。
25521
+ * `consecutiveHitsRequired`(2) 次命中(比例 ≥ 0.97 或前缀完整延续且新增 ≤ 256 tokens)
25522
+ * 且期间没有 drift,才进入稳态 `maxChars = 8000`。任何一次 drift 都清零连续计数,
25523
+ * 于是下一条请求立刻回落到预热预算。
25524
+ *
25525
+ * 空闲 TTL 预判:若距离最近一条报告已超过 `cacheTtlMs`(25 分钟,Kimi 实测缓存
25526
+ * 全热上限),说明上游缓存大概率已冷,无需等下一轮请求失败,当场退回预热期保守预算。
25452
25527
  *
25453
25528
  * **不抛出**:没有会话、没有报告、登记表异常,一律返回预热期的保守预算。
25454
25529
  * @param sessionId - 会话 id;不传则按最保守的预热预算处理。
25530
+ * @param now - 当前时间戳(毫秒),默认 `Date.now()`;测试可注入。
25531
+ * @param durable - 进程重启后尚无内存报告时的持久化回退摘要(可选)。
25455
25532
  * @returns 本次投影可用的预算。
25456
25533
  */
25457
- function cacheWarmupBudget(sessionId) {
25534
+ function cacheWarmupBudget(sessionId, now = Date.now(), durable) {
25458
25535
  try {
25459
25536
  if (typeof sessionId !== "string" || sessionId.trim() === "") return { ...WARMUP_BUDGET };
25460
25537
  const reports = cacheWarmupUsageReports(sessionId);
25461
- if (reports.length === 0) return { ...WARMUP_BUDGET };
25462
- const consecutiveHits = consecutiveWarmupHits(reports);
25538
+ if (reports.length === 0) {
25539
+ if (durable !== void 0 && durable !== null && typeof durable === "object") {
25540
+ const lastAt = durable.lastActiveAt;
25541
+ const hits = typeof lastAt === "number" && Number.isFinite(lastAt) && Number.isFinite(now) && now - lastAt <= CACHE_WARMUP_DEFAULTS.cacheTtlMs && typeof durable.consecutiveHits === "number" && Number.isFinite(durable.consecutiveHits) && durable.consecutiveHits > 0 ? Math.floor(durable.consecutiveHits) : 0;
25542
+ const hitRatio = typeof durable.hitRatio === "number" && Number.isFinite(durable.hitRatio) && durable.hitRatio >= 0 ? Math.min(1, durable.hitRatio) : 0;
25543
+ const warmup = hits < CACHE_WARMUP_DEFAULTS.consecutiveHitsRequired;
25544
+ return {
25545
+ maxChars: warmup ? CACHE_WARMUP_DEFAULTS.warmupMaxChars : CACHE_WARMUP_DEFAULTS.steadyStateMaxChars,
25546
+ warmup,
25547
+ consecutiveHits: hits,
25548
+ hitRatio
25549
+ };
25550
+ }
25551
+ return { ...WARMUP_BUDGET };
25552
+ }
25463
25553
  const last = reports[reports.length - 1];
25464
25554
  const hitRatio = last === void 0 ? 0 : cacheWarmupHitRatio(last);
25555
+ const consecutiveHits = last !== void 0 && typeof last.at === "number" && Number.isFinite(last.at) && Number.isFinite(now) && now - last.at > CACHE_WARMUP_DEFAULTS.cacheTtlMs ? 0 : consecutiveWarmupHits(reports);
25465
25556
  const warmup = consecutiveHits < CACHE_WARMUP_DEFAULTS.consecutiveHitsRequired;
25466
25557
  return {
25467
25558
  maxChars: warmup ? CACHE_WARMUP_DEFAULTS.warmupMaxChars : CACHE_WARMUP_DEFAULTS.steadyStateMaxChars,
@@ -25476,9 +25567,10 @@ function cacheWarmupBudget(sessionId) {
25476
25567
  /**
25477
25568
  * 一份可读快照。**不抛出**,且完全无副作用(只读登记表)。
25478
25569
  * @param sessionId - 会话 id;不传则给全局汇总。
25570
+ * @param now - 当前时间戳(毫秒),默认 `Date.now()`。
25479
25571
  * @returns 快照。
25480
25572
  */
25481
- function cacheWarmupSnapshot(sessionId) {
25573
+ function cacheWarmupSnapshot(sessionId, now = Date.now()) {
25482
25574
  const fallback = {
25483
25575
  budget: { ...WARMUP_BUDGET },
25484
25576
  defaults: CACHE_WARMUP_DEFAULTS,
@@ -25496,7 +25588,7 @@ function cacheWarmupSnapshot(sessionId) {
25496
25588
  sessions: 0
25497
25589
  };
25498
25590
  try {
25499
- const budget = cacheWarmupBudget(sessionId);
25591
+ const budget = cacheWarmupBudget(sessionId, now);
25500
25592
  const reports = typeof sessionId === "string" && sessionId.trim() !== "" ? cacheWarmupUsageReports(sessionId) : [];
25501
25593
  const totals = {
25502
25594
  inputTokens: 0,
@@ -25559,26 +25651,47 @@ function clip(text, max) {
25559
25651
  return text.length <= max ? text : `${text.slice(0, max)}…`;
25560
25652
  }
25561
25653
  /**
25654
+ * 剥离终端 ANSI/CSI 转义序列(如 `\x1b[31mFAIL\x1b[0m`)与回车符 `\r`。
25655
+ *
25656
+ * JSON 解码后,原先源码/日志里的 `\u001b[31m` 会还原成控制字符 `\x1b`;
25657
+ * 如果不剥离,`^FAIL` 锚点匹配会被颜色码挡住,而再次 `JSON.stringify` 时每个
25658
+ * `\x1b` 又会膨胀成 6 个字符 `\u001b`。
25659
+ */
25660
+ const ANSI_ESCAPE = /\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g;
25661
+ function stripAnsi(text) {
25662
+ return text.replace(ANSI_ESCAPE, "").replace(/\r/g, "");
25663
+ }
25664
+ /**
25665
+ * 计算一行文本在最终输出里占用的精确字符宽度:
25666
+ * - 普通纯文本模式:`line.length + 1`(含换行符 `\n`);
25667
+ * - JSON 字符串内嵌模式:`JSON.stringify(line).length`(去首尾引号后 `+ 2` 个字符的 `\\n` 换行转义,
25668
+ * 恰好等于 `JSON.stringify(line).length`,对 `"`、`\`、`\t`、`\u00xx` 的转义膨胀 100% 精确)。
25669
+ */
25670
+ function lineCost(line, jsonEscaped) {
25671
+ return jsonEscaped ? JSON.stringify(line).length : line.length + 1;
25672
+ }
25673
+ /**
25562
25674
  * 按预算装配预览正文。
25563
25675
  *
25564
25676
  * 必需段落要么整段进来,要么整体装不下就返回 undefined(调用方据此保持原文);
25565
25677
  * 可选段落逐段尝试,装不下就整段跳过。这样任何输出都不会出现「半截失败清单」。
25566
25678
  */
25567
- function fitSections(sections, maxChars) {
25679
+ function fitSections(sections, maxChars, jsonEscaped = false) {
25568
25680
  const lines = [];
25569
25681
  let used = 0;
25682
+ const trailingNewlineAllowance = jsonEscaped ? 2 : 1;
25570
25683
  for (const section of sections) {
25571
25684
  if (section.optional === true) continue;
25572
25685
  for (const line of section.lines) {
25573
- used += line.length + 1;
25686
+ used += lineCost(line, jsonEscaped);
25574
25687
  lines.push(line);
25575
25688
  }
25576
25689
  }
25577
- if (used > maxChars + 1) return void 0;
25690
+ if (used > maxChars + trailingNewlineAllowance) return void 0;
25578
25691
  for (const section of sections) {
25579
25692
  if (section.optional !== true) continue;
25580
- const size = section.lines.reduce((sum, line) => sum + line.length + 1, 0);
25581
- if (used + size > maxChars + 1) continue;
25693
+ const size = section.lines.reduce((sum, line) => sum + lineCost(line, jsonEscaped), 0);
25694
+ if (used + size > maxChars + trailingNewlineAllowance) continue;
25582
25695
  for (const line of section.lines) lines.push(line);
25583
25696
  used += size;
25584
25697
  }
@@ -25613,51 +25726,140 @@ function buildTestSections(evidence) {
25613
25726
  }
25614
25727
  /** 一行 shell 错误。用于「完整错误集」,所以匹配宁可宽一点。 */
25615
25728
  const SHELL_ERROR_LINE = /(?:error|fatal|panic|traceback|exception|EACCES|EPERM|ENOENT|EEXIST|ELIFECYCLE|ERR!|FAILED|permission denied|cannot |could not |not found)/i;
25616
- /** 一行 glob 结果的判定:无空白、像路径。 */
25729
+ /** TypeScript 编译器报错首行:`path(line,col): error TSxxxx:` 或 `path:line:col - error TSxxxx:`。 */
25730
+ const TS_DIAGNOSTIC_HEADER = /^(?:.+?\(\d+,\d+\):\s+error\s+TS\d+:|.+?:\d+:\d+\s+-\s+error\s+TS\d+:)/;
25731
+ /**
25732
+ * 紧跟在 TypeScript 诊断首行后面的续行(缩进的类型比对、代码高亮行、波浪线指示行),
25733
+ * 最多连带收集 6 行,使模型无需补读就能看全 `Type A is not assignable to Type B` 的完整原因。
25734
+ */
25735
+ function isTsDiagnosticDetailLine(raw) {
25736
+ if (/^\s{2,}\S/.test(raw)) return true;
25737
+ if (/^\d+\s+\|/.test(raw.trimStart())) return true;
25738
+ if (/^~+$/.test(raw.trim())) return true;
25739
+ return false;
25740
+ }
25741
+ /** 一行 glob 结果的判定:无空白、像文件路径(至少 80% 含路径分隔符或文件扩展名,不含引号/括号/冒号行号)。 */
25617
25742
  function looksLikeGlobResult(text) {
25618
25743
  const lines = text.split("\n").filter((line) => line.trim() !== "");
25619
25744
  if (lines.length < 10) return false;
25620
25745
  let paths = 0;
25746
+ let pathWithSepOrExt = 0;
25621
25747
  for (const line of lines) {
25622
25748
  const trimmed = line.trim();
25623
- if (trimmed.length === 0 || trimmed.length > 400) continue;
25749
+ if (trimmed.length === 0 || trimmed.length > 400) return false;
25750
+ if (GREP_LINE.test(trimmed)) return false;
25751
+ if (/^===.*===$/.test(trimmed)) return false;
25752
+ if (/['"[\]{}()]/.test(trimmed)) return false;
25624
25753
  if (/\s/.test(trimmed)) continue;
25625
25754
  if (/^[-*+]$/.test(trimmed)) continue;
25626
25755
  paths += 1;
25756
+ if (/[\\/]/.test(trimmed) || /\.[A-Za-z0-9]{1,8}$/.test(trimmed)) pathWithSepOrExt += 1;
25757
+ }
25758
+ return paths / lines.length >= .9 && pathWithSepOrExt / lines.length >= .8;
25759
+ }
25760
+ /** 判断一行是否只是单纯的文件路径(含可选引号/逗号,如 `"test\\stream-error.test.ts",`),而非报错消息。 */
25761
+ function isBareFilePathLine(trimmed) {
25762
+ const unquoted = trimmed.replace(/^['"]|['"],?$/g, "").trim();
25763
+ if (unquoted.length === 0 || unquoted.length > 300 || /\s/.test(unquoted)) return false;
25764
+ return /[\\/]/.test(unquoted) && /\.[A-Za-z0-9]{1,8}$/.test(unquoted) && !/:(\d+)/.test(unquoted);
25765
+ }
25766
+ /**
25767
+ * 判断文本是否为混合证据(如包含已投影的 `[cache-warmup preview]`、内嵌大型 JSON 对象行、
25768
+ * 多行 pretty-printed JSON 块、`util.inspect` 字符串拼接对象、分节头等)。
25769
+ */
25770
+ function isMixedEvidenceOutput(text) {
25771
+ if (text.includes("[cache-warmup preview]")) return true;
25772
+ if (/^(?:===|---)\s+.+?(?:\s+(?:===|---))?$/m.test(text) || /^###\s+\S/m.test(text) || /^--\s+.+\s+--$/m.test(text)) return true;
25773
+ const lines = text.split("\n");
25774
+ let inspectStringConcat = 0;
25775
+ let jsonPropertyLines = 0;
25776
+ for (const raw of lines.slice(0, 200)) {
25777
+ const trimmed = raw.trim();
25778
+ if (trimmed === "") continue;
25779
+ if (trimmed.length > 80 && (trimmed.startsWith("{\"") && (trimmed.endsWith("}") || trimmed.includes("\":")) || trimmed.startsWith("[{\"") && trimmed.includes("\":"))) return true;
25780
+ if (/^"[A-Za-z0-9_$-]+"\s*:/.test(trimmed)) {
25781
+ jsonPropertyLines += 1;
25782
+ if (jsonPropertyLines >= 2) return true;
25783
+ }
25784
+ if (/^['"].*\\n['"]\s*\+$/.test(trimmed)) {
25785
+ inspectStringConcat += 1;
25786
+ if (inspectStringConcat >= 2) return true;
25787
+ }
25627
25788
  }
25628
- return paths / lines.length >= .9;
25789
+ return false;
25629
25790
  }
25630
- /** shell 输出判定:有退出码标记,或至少一行错误。 */
25791
+ /**
25792
+ * shell 输出判定:
25793
+ * - 排除混合证据输出(内嵌 JSON 行、`[cache-warmup preview]`、分节头、`util.inspect` 对象);
25794
+ * - 有明确的退出码标记(`[exit code: N]`、`exit code: N`、`EXIT=N`);
25795
+ * - 有 Python `Traceback (most recent call last):` 或 JS 异常头 + 堆栈行(`^\s+at\s+`);
25796
+ * - 若没有任何退出码或堆栈,只有纯文本行时,要求不存在 grep 行,且错误行不是单纯的文件名。
25797
+ */
25631
25798
  function looksLikeShellOutput(text) {
25799
+ if (isMixedEvidenceOutput(text)) return false;
25632
25800
  if (/(?:exit code|exited with code|process exited with code)[:=\s]+-?\d+/i.test(text)) return true;
25633
25801
  if (/\[exit code: -?\d+\]/i.test(text)) return true;
25802
+ if (/^EXIT=-?\d+$/m.test(text)) return true;
25634
25803
  if (/^Traceback \(most recent call last\):/m.test(text)) return true;
25804
+ if (/^(?:[A-Za-z]*Error|Exception): /m.test(text) && /^\s+at\s+\S/m.test(text)) return true;
25635
25805
  const lines = text.split("\n");
25806
+ let grepLikes = 0;
25636
25807
  let errors = 0;
25637
25808
  for (const line of lines) {
25638
- if (line.trim() === "") continue;
25639
- if (/^\s*(?:0|no)\s+(?:errors?|failures?|failed)\b/i.test(line.trim())) continue;
25809
+ const trimmed = line.trim();
25810
+ if (trimmed === "") continue;
25811
+ if (/^(?:PASS\b|[✓✔]|ok\s+\d+\b)/.test(trimmed)) continue;
25812
+ if (/^stdout\s+\|\s+/.test(trimmed)) continue;
25813
+ if (/^\(node:\d+\)\s+\[\w+\]\s+Warning:/.test(trimmed)) continue;
25814
+ if (isBareFilePathLine(trimmed)) continue;
25815
+ if (GREP_LINE.test(trimmed) && !TS_DIAGNOSTIC_HEADER.test(trimmed)) {
25816
+ grepLikes += 1;
25817
+ if (grepLikes >= 2) return false;
25818
+ }
25819
+ if (/^\s*(?:0|no)\s+(?:errors?|failures?|failed)\b/i.test(trimmed)) continue;
25640
25820
  if (SHELL_ERROR_LINE.test(line)) errors += 1;
25641
- if (errors >= 1) return true;
25642
25821
  }
25643
- return false;
25822
+ return errors >= 1;
25644
25823
  }
25645
- /** shell 预览:退出码 + 完整错误集(必需),首尾片段(可选)。 */
25824
+ /** shell 预览:退出码 + 完整错误集(必需,含 TS 多行诊断上下文),首尾片段(可选)。 */
25646
25825
  function buildShellSections(text) {
25647
25826
  const lines = text.split("\n");
25648
25827
  const errors = [];
25649
- for (const raw of lines) {
25650
- const trimmed = raw.trim();
25828
+ let errorCount = 0;
25829
+ for (let index = 0; index < lines.length; index += 1) {
25830
+ const trimmed = lines[index].trim();
25651
25831
  if (trimmed === "") continue;
25652
25832
  if (/^\[exit code: -?\d+\]/i.test(trimmed)) continue;
25653
25833
  if (/^\s*(?:0|no)\s+(?:errors?|failures?|failed)\b/i.test(trimmed)) continue;
25654
- if (SHELL_ERROR_LINE.test(trimmed)) errors.push(` ${clip(trimmed, 240)}`);
25834
+ if (/^(?:PASS\b|[✓✔]|ok\s+\d+\b)/.test(trimmed)) continue;
25835
+ if (/^stdout\s+\|\s+/.test(trimmed)) continue;
25836
+ if (/^\(node:\d+\)\s+\[\w+\]\s+Warning:/.test(trimmed)) continue;
25837
+ if (isBareFilePathLine(trimmed)) continue;
25838
+ if (TS_DIAGNOSTIC_HEADER.test(trimmed)) {
25839
+ errorCount += 1;
25840
+ errors.push(` ${clip(trimmed, 240)}`);
25841
+ let details = 0;
25842
+ while (index + 1 < lines.length && details < 6) {
25843
+ const nextRaw = lines[index + 1];
25844
+ const nextTrimmed = nextRaw.trim();
25845
+ if (nextTrimmed === "" || TS_DIAGNOSTIC_HEADER.test(nextTrimmed) || /^\[exit code: -?\d+\]/i.test(nextTrimmed)) break;
25846
+ if (!isTsDiagnosticDetailLine(nextRaw)) break;
25847
+ errors.push(` ${clip(nextTrimmed, 240)}`);
25848
+ index += 1;
25849
+ details += 1;
25850
+ }
25851
+ continue;
25852
+ }
25853
+ if (SHELL_ERROR_LINE.test(trimmed)) {
25854
+ errorCount += 1;
25855
+ errors.push(` ${clip(trimmed, 360)}`);
25856
+ }
25655
25857
  }
25656
- if (errors.length > 300) return void 0;
25858
+ if (errorCount > 300 || errors.length > 600) return void 0;
25657
25859
  const exitMatch = /(?:exit code|exited with code|process exited with code)[:=\s]+(-?\d+)/i.exec(text) ?? /\[exit code: (-?\d+)\]/i.exec(text);
25658
25860
  const sections = [];
25659
25861
  if (exitMatch !== null) sections.push({ lines: [`exit code: ${exitMatch[1]}`] });
25660
- sections.push({ lines: [`errors: ${errors.length} (complete set)`] });
25862
+ sections.push({ lines: [`errors: ${errorCount} (complete set)`] });
25661
25863
  if (errors.length > 0) sections.push({ lines: errors });
25662
25864
  const nonEmpty = lines.map((line) => line.trimEnd()).filter((line) => line.trim() !== "");
25663
25865
  const head = nonEmpty.slice(0, 5).map((line) => ` ${clip(line.trim(), 160)}`);
@@ -25672,8 +25874,8 @@ function buildShellSections(text) {
25672
25874
  });
25673
25875
  return sections;
25674
25876
  }
25675
- /** 一行 grep 结果:`<path>:<line>[:<column>]:<text>`。 */
25676
- const GREP_LINE = /^(.+?):(\d+):(?:\d+:)?(.*)$/;
25877
+ /** 一行 grep 结果:`<path>:<line>[:<column>]:<text>` 或 `<path>:<line>(?:\t|\||\s{2,})<text>`。 */
25878
+ const GREP_LINE = /^(.+?):(\d+)(?::(?:\d+:)?|\t|\|\s?|\s{2,})(.*)$/;
25677
25879
  /** 抽取 grep 证据;命中线占比不到 80% 就当它不是 grep 输出。 */
25678
25880
  function extractGrepEvidence(text) {
25679
25881
  const lines = text.split("\n").filter((line) => line.trim() !== "");
@@ -25742,36 +25944,58 @@ function buildStructuredSections(text) {
25742
25944
  optional: true
25743
25945
  }];
25744
25946
  }
25947
+ /** 剥离 `read` 工具或 `grep -C` 常见的行号前缀(如 `1: `、`1- `、`1\t`、`1| `、` 12→`)。 */
25948
+ const LINE_NUMBER_PREFIX = /^\s*\d+(?::(?:\s|$)|-\s{2,}|\t|\|(?:\s|$)|→\s?)/;
25949
+ function stripLineNumberPrefix(line) {
25950
+ return line.replace(LINE_NUMBER_PREFIX, "");
25951
+ }
25745
25952
  /** 契约/类型定义:命中两行以上声明就当它是契约,保持原样。 */
25746
25953
  function looksLikeContract(text) {
25747
25954
  let declarations = 0;
25748
- for (const line of text.split("\n").slice(0, 800)) {
25955
+ for (const raw of text.split("\n").slice(0, 800)) {
25956
+ const line = stripLineNumberPrefix(raw);
25749
25957
  if (/^\s*(?:export\s+)?(?:declare\s+)?(?:interface|type)\s+[A-Za-z_$][\w$]*/.test(line)) declarations += 1;
25750
25958
  if (declarations >= 2) return true;
25751
25959
  }
25752
25960
  return false;
25753
25961
  }
25754
- /** 源码判定:代码行占比过半(或含多处 markdown 代码围栏)。 */
25962
+ /**
25963
+ * 源码或混合证据判定:
25964
+ * - 含多处 markdown 代码围栏;
25965
+ * - 含 `git diff` 补丁块(`diff --git` / `@@ -x,y +x,y @@`);
25966
+ * - 含至少 5 行带行号的源码切片(`1: ...` 或 `1\t...`),说明是单文件或多文件混合读取输出;
25967
+ * - 代码行占比 >= 45%。
25968
+ */
25755
25969
  function looksLikeSourceCode(text) {
25970
+ if (/^diff --git\s+/m.test(text) || /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/m.test(text)) return true;
25756
25971
  let fences = 0;
25757
- for (const line of text.split("\n").slice(0, 600)) if (line.trim().startsWith("```")) fences += 1;
25972
+ for (const line of text.split("\n").slice(0, 600)) if (stripLineNumberPrefix(line).trim().startsWith("```")) fences += 1;
25758
25973
  if (fences >= 4) return true;
25759
25974
  let code = 0;
25975
+ let numbered = 0;
25760
25976
  let nonEmpty = 0;
25761
25977
  for (const raw of text.split("\n").slice(0, 600)) {
25762
- const trimmed = raw.trim();
25978
+ if (raw.trim() === "") continue;
25979
+ if (LINE_NUMBER_PREFIX.test(raw)) numbered += 1;
25980
+ const trimmed = stripLineNumberPrefix(raw).trim();
25763
25981
  if (trimmed === "") continue;
25764
25982
  nonEmpty += 1;
25765
- if (/^(?:import|export|from|const|let|var|function|class|interface|type|enum|async|await|return|if|for|while|switch|case|break|continue|try|catch|finally|throw|new|public|private|protected|static|readonly|def|using|namespace|package|#include|#pragma)\b/.test(trimmed) || /^[})\];]/.test(trimmed) || /^(?:\/\/|\/\*|\*)/.test(trimmed)) code += 1;
25983
+ if (/^(?:import|export|from|const|let|var|function|class|interface|type|enum|async|await|return|if|for|while|switch|case|break|continue|try|catch|finally|throw|new|public|private|protected|static|readonly|def|using|namespace|package|#include|#pragma)\b/.test(trimmed) || /^[})\];]/.test(trimmed) || /^(?:\/\/|\/\*|\*)/.test(trimmed) || /^<\/?[A-Za-z][\w.-]*/.test(trimmed)) code += 1;
25766
25984
  }
25985
+ if (numbered >= 5) return true;
25767
25986
  return nonEmpty >= 10 && code / nonEmpty >= .45;
25768
25987
  }
25769
- /** 完整 JSON 文档或被截断的 JSON 对象:可能是批量源码/混合证据,不按裸日志猜测。 */
25988
+ /** 完整 JSON 文档(对象、数组、JSON 字符串)或被截断的 JSON 对象:不按裸日志猜测。 */
25770
25989
  function isJsonDocument(text) {
25771
25990
  const trimmed = text.trim();
25772
25991
  if (trimmed.length < 2) return false;
25773
25992
  const first = trimmed[0];
25774
25993
  const last = trimmed[trimmed.length - 1];
25994
+ if (first === "\"" && last === "\"") try {
25995
+ return typeof JSON.parse(trimmed) === "string";
25996
+ } catch {
25997
+ return false;
25998
+ }
25775
25999
  if (first !== "{" && first !== "[") return false;
25776
26000
  if (first === "{" && last === "}" || first === "[" && last === "]") try {
25777
26001
  const value = JSON.parse(trimmed);
@@ -25784,36 +26008,117 @@ function isJsonDocument(text) {
25784
26008
  }
25785
26009
  return false;
25786
26010
  }
26011
+ /** PTC `run_code` JSON envelope 中允许独立投影日志的字段白名单。 */
26012
+ const JSON_ENVELOPE_LOG_KEYS = [
26013
+ "stdout",
26014
+ "stderr",
26015
+ "output",
26016
+ "logs",
26017
+ "log"
26018
+ ];
26019
+ /** JSON envelope 其余字段允许占用的最大序列化字符数(防止把含批量源码的混合 envelope 压掉)。 */
26020
+ const JSON_ENVELOPE_MAX_OTHER_CHARS = 800;
25787
26021
  /**
25788
- * 决定这一段文本该怎么处理。
26022
+ * 解开一层或两层 JSON 包装(支持顶层 JSON 字符串 `"..."` 或双重序列化的 `"{...}"`)。
26023
+ */
26024
+ function parseJsonCandidate(text) {
26025
+ const trimmed = text.trim();
26026
+ if (trimmed.length < 2) return void 0;
26027
+ const first = trimmed[0];
26028
+ const last = trimmed[trimmed.length - 1];
26029
+ if (first === "\"" && last === "\"") try {
26030
+ const inner = JSON.parse(trimmed);
26031
+ if (typeof inner !== "string") return void 0;
26032
+ const innerTrimmed = inner.trim();
26033
+ if (innerTrimmed.startsWith("{") && innerTrimmed.endsWith("}")) try {
26034
+ const innerObj = JSON.parse(innerTrimmed);
26035
+ if (innerObj !== null && typeof innerObj === "object" && !Array.isArray(innerObj)) return {
26036
+ kind: "object",
26037
+ record: innerObj,
26038
+ wrappedInString: true
26039
+ };
26040
+ } catch {}
26041
+ return {
26042
+ kind: "string",
26043
+ decoded: inner
26044
+ };
26045
+ } catch {
26046
+ return;
26047
+ }
26048
+ if (first === "{" && last === "}") try {
26049
+ const parsed = JSON.parse(trimmed);
26050
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return {
26051
+ kind: "object",
26052
+ record: parsed,
26053
+ wrappedInString: false
26054
+ };
26055
+ } catch {
26056
+ return;
26057
+ }
26058
+ }
26059
+ /**
26060
+ * 尝试从完整 JSON(顶层 JSON 字符串或对象 envelope)中解出可结构化投影的日志正文。
25789
26061
  *
25790
- * 顺序就是「可证明性」的顺序:子代理报告/源码工具/契约先被判成原样,
25791
- * 然后是测试失败集、grep、结构化、glob、shell 这些有明确形状的,
25792
- * 最后落到「无法解析 → 原样」。
26062
+ * 规则刻意保守:
26063
+ * - 截断 JSON、数组一律返回 undefined;
26064
+ * - 对象 envelope 除白名单字段外,其余字段序列化后的总长度不超过 `JSON_ENVELOPE_MAX_OTHER_CHARS`;
26065
+ * - 解码并剥离 ANSI 后的日志文本自身必须能通过非源码/非契约的结构化日志投影计划;
26066
+ * - 返回的 `storeContent` 把解码后的多行日志直接展开(对象 envelope 还会把其余元数据缩进在头部),
26067
+ * 彻底避免单行 minified JSON 因没有真实 `\n` 导致 `store.page` 的按行分页失效。
25793
26068
  */
25794
- function planProjection(text, toolName) {
25795
- if (TOOL_SUBAGENT.test(toolName)) return {
25796
- kind: "verbatim",
25797
- strategy: "verbatim-subagent-report"
25798
- };
25799
- if (TOOL_SOURCE.test(toolName)) return {
25800
- kind: "verbatim",
25801
- strategy: "verbatim-source-code"
26069
+ function extractJsonEnvelopeLogField(text, toolName) {
26070
+ const candidate = parseJsonCandidate(text);
26071
+ if (candidate === void 0) return void 0;
26072
+ if (candidate.kind === "string") {
26073
+ const clean = stripAnsi(candidate.decoded);
26074
+ if (isJsonDocument(clean) || isMixedEvidenceOutput(clean)) return void 0;
26075
+ const plan = planPlainTextProjection(clean, toolName);
26076
+ if (plan.kind !== "preview") return void 0;
26077
+ return { plan };
26078
+ }
26079
+ const { record } = candidate;
26080
+ let candidateKey;
26081
+ let candidateText = "";
26082
+ for (const key of JSON_ENVELOPE_LOG_KEYS) {
26083
+ const val = record[key];
26084
+ if (typeof val === "string" && val.length > candidateText.length) {
26085
+ candidateKey = key;
26086
+ candidateText = val;
26087
+ }
26088
+ }
26089
+ if (candidateKey === void 0 || candidateText.length === 0) return void 0;
26090
+ const rest = {};
26091
+ for (const [key, value] of Object.entries(record)) {
26092
+ if (key === candidateKey) continue;
26093
+ if (typeof value === "string" && (value.length > 400 || looksLikeSourceCode(value) || looksLikeContract(value))) return;
26094
+ rest[key] = value;
26095
+ }
26096
+ if (JSON.stringify(rest).length > JSON_ENVELOPE_MAX_OTHER_CHARS) return void 0;
26097
+ const cleanField = stripAnsi(candidateText);
26098
+ if (isJsonDocument(cleanField) || isMixedEvidenceOutput(cleanField)) return void 0;
26099
+ const plan = planPlainTextProjection(cleanField, toolName);
26100
+ if (plan.kind !== "preview") return void 0;
26101
+ return {
26102
+ envelope: {
26103
+ record,
26104
+ key: candidateKey
26105
+ },
26106
+ plan
25802
26107
  };
26108
+ }
26109
+ /** 对一段纯文本(非 JSON 文档)做投影计划判定。 */
26110
+ function planPlainTextProjection(rawText, toolName) {
26111
+ const text = stripAnsi(rawText);
25803
26112
  if (looksLikeContract(text)) return {
25804
26113
  kind: "verbatim",
25805
26114
  strategy: "verbatim-contract"
25806
26115
  };
25807
- if (isJsonDocument(text)) return {
25808
- kind: "verbatim",
25809
- strategy: "verbatim-unparsed"
25810
- };
25811
26116
  if (looksLikeSourceCode(text)) return {
25812
26117
  kind: "verbatim",
25813
26118
  strategy: "verbatim-source-code"
25814
26119
  };
25815
26120
  const test = extractTestEvidence(text);
25816
- if (test !== void 0) return {
26121
+ if (test !== void 0 && test.failures.length > 0) return {
25817
26122
  kind: "preview",
25818
26123
  strategy: "test-failure",
25819
26124
  sections: buildTestSections(test)
@@ -25852,6 +26157,42 @@ function planProjection(text, toolName) {
25852
26157
  strategy: "verbatim-unparsed"
25853
26158
  };
25854
26159
  }
26160
+ /**
26161
+ * 决定这一段文本该怎么处理。
26162
+ *
26163
+ * 顺序就是「可证明性」的顺序:子代理报告/源码工具/契约先被判成原样,
26164
+ * 然后是 JSON envelope(只允许纯日志字段投影,混合证据一律原样),
26165
+ * 接着是测试失败集、grep、结构化、glob、shell 这些有明确形状的,
26166
+ * 最后落到「无法解析 → 原样」。
26167
+ */
26168
+ function planProjection(text, toolName) {
26169
+ if (TOOL_SUBAGENT.test(toolName)) return {
26170
+ kind: "verbatim",
26171
+ strategy: "verbatim-subagent-report"
26172
+ };
26173
+ if (TOOL_SOURCE.test(toolName)) return {
26174
+ kind: "verbatim",
26175
+ strategy: "verbatim-source-code"
26176
+ };
26177
+ if (looksLikeContract(text)) return {
26178
+ kind: "verbatim",
26179
+ strategy: "verbatim-contract"
26180
+ };
26181
+ if (isJsonDocument(text)) {
26182
+ const extracted = extractJsonEnvelopeLogField(text, toolName);
26183
+ if (extracted !== void 0) return {
26184
+ kind: "preview",
26185
+ strategy: extracted.plan.strategy,
26186
+ sections: extracted.plan.sections,
26187
+ ...extracted.envelope === void 0 ? {} : { envelope: extracted.envelope }
26188
+ };
26189
+ return {
26190
+ kind: "verbatim",
26191
+ strategy: "verbatim-unparsed"
26192
+ };
26193
+ }
26194
+ return planPlainTextProjection(text, toolName);
26195
+ }
25855
26196
  /** 预览尾部:把「怎么读全文」写死在预览里,模型不用猜。 */
25856
26197
  function footerFor(refId) {
25857
26198
  return `[full result: ${refId} · page with offset/limit(default 64 lines)/maxBytes(default 16384 bytes)]`;
@@ -25870,12 +26211,22 @@ async function decide(args) {
25870
26211
  if (input.isError === true && plan.strategy !== "test-failure" && plan.strategy !== "shell-output") return { reason: "verbatim-unparsed" };
25871
26212
  const header = `[cache-warmup preview] strategy=${plan.strategy} input=${text.length}ch`;
25872
26213
  const footerBudget = footerFor(`ref://${input.sessionId}/${"0".repeat(64)}`).length;
25873
- const bodyBudget = budget.maxChars - header.length - footerBudget - 2;
26214
+ const jsonEscaped = plan.envelope !== void 0;
26215
+ const envelopeShellLength = plan.envelope === void 0 ? 0 : JSON.stringify({
26216
+ ...plan.envelope.record,
26217
+ [plan.envelope.key]: ""
26218
+ }).length;
26219
+ const bodyBudget = budget.maxChars - header.length - footerBudget - envelopeShellLength - (jsonEscaped ? 4 : 2);
25874
26220
  if (bodyBudget <= 0) return { reason: "verbatim-budget" };
25875
- const body = fitSections(plan.sections, bodyBudget);
26221
+ const body = fitSections(plan.sections, bodyBudget, jsonEscaped);
25876
26222
  if (body === void 0) return { reason: "verbatim-budget" };
25877
26223
  const store = input.store;
25878
26224
  if (store === void 0) return { reason: "store-unavailable" };
26225
+ const placeholderInner = `${header}\n${body}\n${footerFor(`ref://${input.sessionId}/${"0".repeat(64)}`)}`;
26226
+ if ((plan.envelope === void 0 ? placeholderInner : JSON.stringify({
26227
+ ...plan.envelope.record,
26228
+ [plan.envelope.key]: placeholderInner
26229
+ })).length > budget.maxChars) return { reason: "verbatim-budget" };
25879
26230
  let reference;
25880
26231
  try {
25881
26232
  reference = await store.put({
@@ -25887,7 +26238,11 @@ async function decide(args) {
25887
26238
  } catch {
25888
26239
  return { reason: "store-unavailable" };
25889
26240
  }
25890
- const preview = `${header}\n${body}\n${footerFor(reference.refId)}`;
26241
+ const innerPreview = `${header}\n${body}\n${footerFor(reference.refId)}`;
26242
+ const preview = plan.envelope === void 0 ? innerPreview : JSON.stringify({
26243
+ ...plan.envelope.record,
26244
+ [plan.envelope.key]: innerPreview
26245
+ });
25891
26246
  if (preview.length > budget.maxChars) return { reason: "verbatim-budget" };
25892
26247
  return {
25893
26248
  preview,
@@ -30195,7 +30550,7 @@ function messageToolsOf(message) {
30195
30550
  * lives in the request's declaration list, which is why the two are resolved
30196
30551
  * together rather than the block carrying a schema of its own.
30197
30552
  */
30198
- function toolAdditionNames(message) {
30553
+ function toolAdditionNames$1(message) {
30199
30554
  const names = [];
30200
30555
  for (const block of message.content) {
30201
30556
  if (block.type !== "tool-addition") continue;
@@ -30212,7 +30567,7 @@ function toolAdditionNames(message) {
30212
30567
  * `addition-only` never sees one, because the harness removes the declaration
30213
30568
  * from the list instead.
30214
30569
  */
30215
- function toolRemovalNames(message) {
30570
+ function toolRemovalNames$1(message) {
30216
30571
  const names = [];
30217
30572
  for (const block of message.content) {
30218
30573
  if (block.type !== "tool-removal") continue;
@@ -30229,7 +30584,7 @@ function toolRemovalNames(message) {
30229
30584
  * list (that is what keeps the prefix intact), so the only thing standing
30230
30585
  * between the model and a call that would fail is this sentence.
30231
30586
  */
30232
- function removalNotice(names) {
30587
+ function removalNotice$1(names) {
30233
30588
  const list = names.map((name) => "\"" + name + "\"").join(", ");
30234
30589
  return names.length === 1 ? "[" + list + " is no longer available. Do not call it again: any call will fail.]" : "[" + list + " are no longer available. Do not call them again: any call will fail.]";
30235
30590
  }
@@ -30373,7 +30728,7 @@ function declarationSlots(options, media) {
30373
30728
  let beforeIndex = 0;
30374
30729
  for (const message of options.messages) {
30375
30730
  if (message.role === "developer") {
30376
- const additions = declarationsFor(toolAdditionNames(message), options.tools);
30731
+ const additions = declarationsFor(toolAdditionNames$1(message), options.tools);
30377
30732
  const entries = [];
30378
30733
  if (additions.length > 0) entries.push(media.messageTools === true ? {
30379
30734
  role: "system",
@@ -30382,10 +30737,10 @@ function declarationSlots(options, media) {
30382
30737
  role: "system",
30383
30738
  content: declarationNotice(options.model, additions.length, "capability")
30384
30739
  });
30385
- const removals = toolRemovalNames(message);
30740
+ const removals = toolRemovalNames$1(message);
30386
30741
  if (removals.length > 0) entries.push({
30387
30742
  role: "system",
30388
- content: removalNotice(removals)
30743
+ content: removalNotice$1(removals)
30389
30744
  });
30390
30745
  if (entries.length > 0) slots.push({
30391
30746
  beforeIndex,
@@ -31622,6 +31977,27 @@ var SessionCacheStateStore = class {
31622
31977
  this.schedule();
31623
31978
  }
31624
31979
  /**
31980
+ * Update the warmup consecutive-hit streak on an already-written session record
31981
+ * in place, without issuing a second `write()` call.
31982
+ */
31983
+ noteWarmupStreak(sessionId, consecutiveHits) {
31984
+ const key = sessionId.trim();
31985
+ if (key === "") return;
31986
+ const document = this.load();
31987
+ const existing = document.sessions[key];
31988
+ if (existing === void 0) return;
31989
+ const normalized = count$1(consecutiveHits);
31990
+ if (normalized > 0) document.sessions[key] = {
31991
+ ...existing,
31992
+ warmupConsecutiveHits: normalized
31993
+ };
31994
+ else if (existing.warmupConsecutiveHits !== void 0) {
31995
+ const { warmupConsecutiveHits: _dropped, ...rest } = existing;
31996
+ document.sessions[key] = rest;
31997
+ } else return;
31998
+ this.schedule();
31999
+ }
32000
+ /**
31625
32001
  * Write the document now, synchronously.
31626
32002
  *
31627
32003
  * Exposed for tests and for a caller that is about to lose the process; the
@@ -31698,6 +32074,7 @@ function normalizeRecord(value) {
31698
32074
  const updatedAt = finite(value.updatedAt);
31699
32075
  if (lastActiveAt === void 0 || updatedAt === void 0) return void 0;
31700
32076
  const drift = typeof value.lastDrift === "string" && value.lastDrift !== "" ? value.lastDrift : void 0;
32077
+ const warmupStreak = count$1(value.warmupConsecutiveHits);
31701
32078
  return {
31702
32079
  lastActiveAt,
31703
32080
  updatedAt,
@@ -31709,7 +32086,8 @@ function normalizeRecord(value) {
31709
32086
  cacheWriteTokens: count$1(value.cacheWriteTokens),
31710
32087
  cacheWrite5mTokens: count$1(value.cacheWrite5mTokens),
31711
32088
  cacheWrite1hTokens: count$1(value.cacheWrite1hTokens),
31712
- ...drift === void 0 ? {} : { lastDrift: drift }
32089
+ ...drift === void 0 ? {} : { lastDrift: drift },
32090
+ ...warmupStreak > 0 ? { warmupConsecutiveHits: warmupStreak } : {}
31713
32091
  };
31714
32092
  }
31715
32093
  /**
@@ -32809,7 +33187,7 @@ const KIMI_CODE_REQUEST_IMAGE_MAX_EDGE = 2e3;
32809
33187
  * @param catalog - live catalog, possibly empty when offline.
32810
33188
  * @returns the tool-update mode, or undefined when this route must resend everything.
32811
33189
  */
32812
- function toolUpdateMode(modelId, catalog) {
33190
+ function toolUpdateMode$1(modelId, catalog) {
32813
33191
  if (!dynamicToolsForEntry(modelId, catalog)) return void 0;
32814
33192
  if (wireForCatalogEntry(modelId, catalog) !== "openai") return void 0;
32815
33193
  const raw = (process.env.DSH_KIMI_CODE_TOOL_UPDATE ?? "").trim().toLowerCase();
@@ -32860,7 +33238,7 @@ function noteSessionCacheState(sessionId, state) {
32860
33238
  ...lastDrift === void 0 ? {} : { lastDrift },
32861
33239
  updatedAt: now
32862
33240
  });
32863
- recordCacheWarmupUsage({
33241
+ const warmupState = recordCacheWarmupUsage({
32864
33242
  sessionId,
32865
33243
  inputTokens: state.inputTokens,
32866
33244
  cacheReadTokens: state.cacheReadTokens,
@@ -32869,6 +33247,7 @@ function noteSessionCacheState(sessionId, state) {
32869
33247
  at: now,
32870
33248
  ...lastDrift === void 0 ? {} : { driftCause: lastDrift }
32871
33249
  });
33250
+ sessionCacheState.noteWarmupStreak(sessionId, warmupState.consecutiveHits);
32872
33251
  }
32873
33252
  var KimiCodeAdapter = class extends LlmAdapter {
32874
33253
  store;
@@ -33052,7 +33431,7 @@ var KimiCodeAdapter = class extends LlmAdapter {
33052
33431
  id: modelId,
33053
33432
  name: entry?.name ?? modelId,
33054
33433
  inputModalities: inputModalitiesForEntry(modelId, catalog),
33055
- ...toolUpdateMode(modelId, catalog) === void 0 ? {} : { toolUpdate: toolUpdateMode(modelId, catalog) },
33434
+ ...toolUpdateMode$1(modelId, catalog) === void 0 ? {} : { toolUpdate: toolUpdateMode$1(modelId, catalog) },
33056
33435
  context: { contextWindow: this.contextWindowFor(modelId, entry, settings.contextWindowOverrides) },
33057
33436
  ...efforts.length === 0 ? {} : { reasoning: {
33058
33437
  efforts: efforts.map((effort) => ({
@@ -33849,13 +34228,32 @@ async function getKimiCodeWebStatus(store, modelSettings, preferences, options =
33849
34228
  * answers with the process aggregate, which is the right answer for a settings
33850
34229
  * card that is not showing a specific conversation.
33851
34230
  */
34231
+ function warmupStatusOf(sessionId) {
34232
+ const targetSession = sessionId?.trim() || cacheWarmupUsageSessions()[0]?.sessionId;
34233
+ const durable = sessionCacheState.read(targetSession);
34234
+ const hint = durable === void 0 ? void 0 : {
34235
+ lastActiveAt: durable.lastActiveAt,
34236
+ consecutiveHits: durable.warmupConsecutiveHits ?? 0,
34237
+ hitRatio: durable.cachedTokens + durable.freshTokens > 0 ? durable.cachedTokens / (durable.cachedTokens + durable.freshTokens) : 0
34238
+ };
34239
+ if (targetSession === void 0 && hint === void 0) return void 0;
34240
+ const budget = cacheWarmupBudget(targetSession ?? "durable-latest", Date.now(), hint);
34241
+ return {
34242
+ warmup: budget.warmup,
34243
+ maxChars: budget.maxChars,
34244
+ consecutiveHits: budget.consecutiveHits,
34245
+ requiredHits: CACHE_WARMUP_DEFAULTS.consecutiveHitsRequired
34246
+ };
34247
+ }
33852
34248
  function cacheStatsOrNull(sessionId) {
33853
34249
  const stats = getCacheStats(sessionId);
33854
34250
  if (stats.requests === 0) return durableCacheStats(sessionId);
33855
34251
  const lastDrift = getLastDriftCause(sessionId);
34252
+ const warmup = warmupStatusOf(sessionId);
33856
34253
  return {
33857
34254
  ...stats,
33858
34255
  ...lastDrift === void 0 ? {} : { lastDrift },
34256
+ ...warmup === void 0 ? {} : { warmup },
33859
34257
  cacheWriteTokens: stats.cacheWriteTokens,
33860
34258
  cacheWrite5mTokens: stats.cacheWrite5mTokens,
33861
34259
  cacheWrite1hTokens: stats.cacheWrite1hTokens
@@ -33897,6 +34295,7 @@ function durableCacheStats(sessionId) {
33897
34295
  const durable = sessionCacheState.read(sessionId);
33898
34296
  if (durable === void 0 || durable.requests === 0) return null;
33899
34297
  const lastDrift = driftCauseOf(durable.lastDrift);
34298
+ const warmup = warmupStatusOf(sessionId);
33900
34299
  return {
33901
34300
  requests: durable.requests,
33902
34301
  cachedTokens: durable.cachedTokens,
@@ -33906,7 +34305,8 @@ function durableCacheStats(sessionId) {
33906
34305
  cacheWriteTokens: durable.cacheWriteTokens,
33907
34306
  cacheWrite5mTokens: durable.cacheWrite5mTokens,
33908
34307
  cacheWrite1hTokens: durable.cacheWrite1hTokens,
33909
- ...lastDrift === void 0 ? {} : { lastDrift }
34308
+ ...lastDrift === void 0 ? {} : { lastDrift },
34309
+ ...warmup === void 0 ? {} : { warmup }
33910
34310
  };
33911
34311
  }
33912
34312
  /**
@@ -64949,6 +65349,52 @@ function imagePart$1(block, images) {
64949
65349
  image_url: { url: `data:${inline.mediaType};base64,${inline.data}` }
64950
65350
  };
64951
65351
  }
65352
+ /**
65353
+ * Whether one model id names a DeepSeek model.
65354
+ *
65355
+ * DeepSeek's own Chat Completions contract carries a disabled reasoning level as
65356
+ * `thinking: { type: 'disabled' }` rather than as a `reasoning_effort` value, so
65357
+ * the translation below is scoped to this model family. Read from the model id's
65358
+ * own segment so a provider-prefixed spelling (`cline-pass/...`, or a canonical
65359
+ * `deepseek/...` slug) is recognised either way.
65360
+ */
65361
+ function isDeepSeekModel$1(modelId) {
65362
+ const bare = modelId.slice(modelId.lastIndexOf("/") + 1).trim();
65363
+ return /^deepseek/i.test(bare);
65364
+ }
65365
+ /** The names one developer message declares or revokes, in history order. */
65366
+ function toolNamesOf(blocks, type) {
65367
+ const names = [];
65368
+ for (const block of blocks ?? []) {
65369
+ const record = block;
65370
+ if (record.type !== type) continue;
65371
+ const name = record.toolName;
65372
+ if (typeof name !== "string" || name.trim().length === 0) continue;
65373
+ names.push(name.trim());
65374
+ }
65375
+ return names;
65376
+ }
65377
+ /** One quoted, comma-joined list of tool names. */
65378
+ function quotedList(names) {
65379
+ return names.map((name) => "\"" + name + "\"").join(", ");
65380
+ }
65381
+ /**
65382
+ * The model-facing text of a mid-conversation tool change, or '' when there is none.
65383
+ *
65384
+ * `in-history` means the harness stopped rewriting the declaration list: an
65385
+ * added tool still travels in `tools`, and a removed one keeps its declaration
65386
+ * so the prefix survives. The history note is therefore the only thing that
65387
+ * tells the model a removal happened, which is why the removal sentence is
65388
+ * imperative rather than descriptive.
65389
+ */
65390
+ function toolUpdateNotice(blocks) {
65391
+ const added = toolNamesOf(blocks, "tool-addition");
65392
+ const removed = toolNamesOf(blocks, "tool-removal");
65393
+ const notices = [];
65394
+ if (added.length > 0) notices.push(added.length === 1 ? "[The tool " + quotedList(added) + " is now available.]" : "[The tools " + quotedList(added) + " are now available.]");
65395
+ if (removed.length > 0) notices.push(removed.length === 1 ? "[" + quotedList(removed) + " is no longer available. Do not call it again: any call will fail.]" : "[" + quotedList(removed) + " are no longer available. Do not call them again: any call will fail.]");
65396
+ return notices.join("\n");
65397
+ }
64952
65398
  /** Recursively collect image parts from blocks, including nested tool results. */
64953
65399
  function collectImageParts$1(blocks, images, target = []) {
64954
65400
  if (!Array.isArray(blocks)) return target;
@@ -65001,8 +65447,17 @@ function serializeMessages(messages, images) {
65001
65447
  pendingToolImages = [];
65002
65448
  emit(message);
65003
65449
  };
65004
- /** A tool answer: it closes its own id, and the group when it was the last. */
65450
+ /**
65451
+ * A tool answer: it closes its own id, and the group when it was the last.
65452
+ *
65453
+ * An answer whose call id has no pending `tool_call` is refused here, before
65454
+ * anything is sent. Forwarding it produces an OpenAI message sequence the
65455
+ * gateway rejects (a `tool` message with no declaration in front of it), and
65456
+ * the gateway's 400 names neither the call nor the turn, so the failure is
65457
+ * raised locally with the id that has no question.
65458
+ */
65005
65459
  const answer = (callId, content) => {
65460
+ if (!awaiting.includes(callId)) throw new LlmError(`tool result for call "${callId}" has no tool_call awaiting it: the conversation cannot be expressed as a valid OpenAI message sequence`, "INVALID_REQUEST");
65006
65461
  wire.push({
65007
65462
  role: "tool",
65008
65463
  tool_call_id: callId,
@@ -65015,9 +65470,11 @@ function serializeMessages(messages, images) {
65015
65470
  for (const message of messages) {
65016
65471
  if (message.role === "system" || message.role === "developer") {
65017
65472
  flushToolImages();
65473
+ const content = [toolUpdateNotice(message.content), flattenText$1(message.content)].filter((part) => part.length > 0).join("\n");
65474
+ if (content.length === 0) continue;
65018
65475
  emit({
65019
65476
  role: "system",
65020
- content: flattenText$1(message.content)
65477
+ content
65021
65478
  });
65022
65479
  continue;
65023
65480
  }
@@ -65037,7 +65494,7 @@ function serializeMessages(messages, images) {
65037
65494
  else if (block.type === "image") parts.push(imagePart$1(block, images));
65038
65495
  const hasImage = parts.some((part) => part.type === "image_url");
65039
65496
  const text = parts.filter((part) => part.type === "text").map((part) => String(part.text)).join("");
65040
- if (hasImage || text.length > 0 || toolResults.length === 0) {
65497
+ if (hasImage || text.length > 0) {
65041
65498
  flushToolImages();
65042
65499
  emit({
65043
65500
  role: "user",
@@ -65072,6 +65529,7 @@ function buildClinePassBody(options, connection, images) {
65072
65529
  }
65073
65530
  }));
65074
65531
  const maxTokens = options.maxTokens ?? connection.maxTokens;
65532
+ const thinkingOff = options.reasoningEffort !== void 0 && String(options.reasoningEffort) === "none" && isDeepSeekModel$1(options.model);
65075
65533
  return {
65076
65534
  model: options.model,
65077
65535
  messages,
@@ -65081,7 +65539,8 @@ function buildClinePassBody(options, connection, images) {
65081
65539
  ...options.temperature === void 0 ? {} : { temperature: options.temperature },
65082
65540
  ...maxTokens === void 0 || maxTokens <= 0 ? {} : { max_tokens: maxTokens },
65083
65541
  ...options.stop === void 0 ? {} : { stop: options.stop },
65084
- ...options.reasoningEffort === void 0 ? {} : { reasoning_effort: String(options.reasoningEffort) }
65542
+ ...options.reasoningEffort === void 0 || thinkingOff ? {} : { reasoning_effort: String(options.reasoningEffort) },
65543
+ ...thinkingOff ? { thinking: { type: "disabled" } } : {}
65085
65544
  };
65086
65545
  }
65087
65546
  /** Map an OpenAI `finish_reason` to the harness finish reason. */
@@ -65242,12 +65701,47 @@ function closeBlock$1(block) {
65242
65701
  }
65243
65702
  }
65244
65703
  /**
65704
+ * Whether one failure is the SSE parser's own "the body ended without [DONE]".
65705
+ *
65706
+ * Structural rather than `instanceof`: what has to match is the code the parser
65707
+ * assigns, and a second copy of this module (or a harness generation that ships
65708
+ * its own LlmError class) would fail an identity test on a value that is
65709
+ * otherwise exactly the signal this decision is about.
65710
+ */
65711
+ function isMissingSentinel$1(error) {
65712
+ return typeof error === "object" && error !== null && error.code === "STREAM_CLOSED";
65713
+ }
65714
+ /**
65715
+ * End the payload iteration instead of throwing when a body stops cleanly.
65716
+ *
65717
+ * The parser can only report "no [DONE] arrived" by throwing, and that is NOT a
65718
+ * failure on its own: the gateway sometimes closes the response right after the
65719
+ * terminal frame. Whether the response actually completed is decided by the
65720
+ * frames that were seen, which is knowledge this generator does not have — so it
65721
+ * only removes the transport signal and leaves the verdict to {@link translate}.
65722
+ * Every other failure passes through untouched, including the error frames a 200
65723
+ * body can carry.
65724
+ */
65725
+ async function* tolerateMissingSentinel$1(payloads) {
65726
+ try {
65727
+ for await (const payload of payloads) yield payload;
65728
+ } catch (error) {
65729
+ if (!isMissingSentinel$1(error)) throw error;
65730
+ }
65731
+ }
65732
+ /**
65245
65733
  * Translate decoded SSE payloads into harness stream chunks.
65246
65734
  *
65247
65735
  * Deltas stream through as they arrive; block ends, usage and the finish reason
65248
65736
  * are all deferred to the `[DONE]` sentinel, so no chunk ever follows `finish`.
65249
65737
  * A completion with no content at all becomes an `EMPTY_RESPONSE` error finish
65250
65738
  * rather than a silent empty message.
65739
+ *
65740
+ * A missing `[DONE]` is not a failure by itself. Once a valid `finish_reason`
65741
+ * has been seen the response IS complete, and it closes exactly as a `[DONE]`
65742
+ * one does — block ends, usage and finish all preserved. A body that carried
65743
+ * content but never named a finish reason is a truncation, and still fails with
65744
+ * `STREAM_CLOSED`.
65251
65745
  */
65252
65746
  async function* translate(payloads) {
65253
65747
  let nextIndex = 0;
@@ -65274,38 +65768,48 @@ async function* translate(payloads) {
65274
65768
  order.push(block);
65275
65769
  return block;
65276
65770
  };
65277
- for await (const payload of payloads) {
65771
+ /**
65772
+ * The chunks that close a completed response: every open block, then usage,
65773
+ * then the single finish. Both terminal paths use this one assembly, so a
65774
+ * response read from a clean EOF and one read from `[DONE]` cannot drift.
65775
+ */
65776
+ const closing = () => {
65777
+ const chunks = [];
65778
+ for (const block of order) chunks.push({
65779
+ type: "block-end",
65780
+ index: block.index,
65781
+ block: closeBlock$1(block)
65782
+ });
65783
+ if (pendingUsage !== void 0) chunks.push({
65784
+ type: "usage",
65785
+ usage: pendingUsage
65786
+ });
65787
+ const reason = pendingFinish ?? {
65788
+ type: "finish",
65789
+ reason: { kind: "stop" }
65790
+ };
65791
+ const hasVisibleAnswer = toolBlocks.size > 0 || textBlock !== void 0 && textBlock.text.trim() !== "";
65792
+ chunks.push({
65793
+ type: "finish",
65794
+ reason: reason.reason.kind === "stop" && order.length === 0 ? {
65795
+ kind: "error",
65796
+ failure: {
65797
+ message: "model returned a completed response with no content",
65798
+ code: EMPTY_RESPONSE_CODE
65799
+ }
65800
+ } : reason.reason.kind === "stop" && !hasVisibleAnswer ? {
65801
+ kind: "error",
65802
+ failure: {
65803
+ message: "Cline Pass ended the turn without an answer or a tool call — the response stopped before it did anything",
65804
+ code: "PROVIDER_ERROR"
65805
+ }
65806
+ } : reason.reason
65807
+ });
65808
+ return chunks;
65809
+ };
65810
+ for await (const payload of tolerateMissingSentinel$1(payloads)) {
65278
65811
  if (payload === "[DONE]") {
65279
- for (const block of order) yield {
65280
- type: "block-end",
65281
- index: block.index,
65282
- block: closeBlock$1(block)
65283
- };
65284
- if (pendingUsage !== void 0) yield {
65285
- type: "usage",
65286
- usage: pendingUsage
65287
- };
65288
- const reason = pendingFinish ?? {
65289
- type: "finish",
65290
- reason: { kind: "stop" }
65291
- };
65292
- const hasVisibleAnswer = toolBlocks.size > 0 || textBlock !== void 0 && textBlock.text.trim() !== "";
65293
- yield {
65294
- type: "finish",
65295
- reason: reason.reason.kind === "stop" && order.length === 0 ? {
65296
- kind: "error",
65297
- failure: {
65298
- message: "model returned a completed response with no content",
65299
- code: EMPTY_RESPONSE_CODE
65300
- }
65301
- } : reason.reason.kind === "stop" && !hasVisibleAnswer ? {
65302
- kind: "error",
65303
- failure: {
65304
- message: "Cline Pass ended the turn without an answer or a tool call — the response stopped before it did anything",
65305
- code: "PROVIDER_ERROR"
65306
- }
65307
- } : reason.reason
65308
- };
65812
+ for (const chunk of closing()) yield chunk;
65309
65813
  return;
65310
65814
  }
65311
65815
  const chunk = unwrapEnvelope(payload);
@@ -65379,6 +65883,10 @@ async function* translate(payloads) {
65379
65883
  }
65380
65884
  if (isRecord$11(chunk.usage)) pendingUsage = mapUsage$1(chunk.usage);
65381
65885
  }
65886
+ if (pendingFinish !== void 0) {
65887
+ for (const chunk of closing()) yield chunk;
65888
+ return;
65889
+ }
65382
65890
  throw new LlmError("stream ended without [DONE]", "STREAM_CLOSED");
65383
65891
  }
65384
65892
  //#endregion
@@ -65515,6 +66023,7 @@ var ClinePassAdapter = class extends LlmAdapter {
65515
66023
  id: modelId,
65516
66024
  name: info.displayName,
65517
66025
  inputModalities: info.input,
66026
+ toolUpdate: "in-history",
65518
66027
  context: { contextWindow: info.contextWindow },
65519
66028
  ...outputReservation(info.contextWindow, info.maxTokens),
65520
66029
  ...info.reasoning ? { reasoning: { efforts: info.efforts.map((effort) => ({
@@ -65535,11 +66044,12 @@ var ClinePassAdapter = class extends LlmAdapter {
65535
66044
  }
65536
66045
  async *requestStream(rawOptions, settings, signal, activity) {
65537
66046
  if (settings.enabled === false) throw new LlmError(`${PROVIDER_NAME$4} is disabled in Settings > Subscription. Enable the line there, then retry.`, "INVALID_REQUEST");
65538
- const options = normalizeGenerateOptions(rawOptions);
65539
- const images = await resolveRequestImages$1(options, this.options.attachments, signal).catch((error) => {
66047
+ const normalized = normalizeGenerateOptions(rawOptions);
66048
+ const images = await resolveRequestImages$1(normalized, this.options.attachments, signal).catch((error) => {
65540
66049
  if (signal.aborted) throw new LlmError("Cline Pass request aborted by caller", "ABORTED", { cause: error });
65541
66050
  return NO_RESOLVED_IMAGES$3;
65542
66051
  });
66052
+ const options = offloadOldestRequestImages$1(normalized, void 0, images);
65543
66053
  const body = buildClinePassBody(options, {
65544
66054
  baseURL: settings.baseURL,
65545
66055
  displayName: PROVIDER_NAME$4,
@@ -76386,6 +76896,52 @@ function collectImageParts(blocks, images, target = []) {
76386
76896
  }
76387
76897
  return target;
76388
76898
  }
76899
+ /** Names of the tools one developer message adds at its own history position. */
76900
+ function toolAdditionNames(message) {
76901
+ const names = [];
76902
+ for (const block of message.content) {
76903
+ if (block.type !== "tool-addition") continue;
76904
+ const name = block.toolName;
76905
+ if (typeof name !== "string" || name.trim() === "") continue;
76906
+ names.push(name.trim());
76907
+ }
76908
+ return names;
76909
+ }
76910
+ /** Names of the tools one developer message revokes at its own history position. */
76911
+ function toolRemovalNames(message) {
76912
+ const names = [];
76913
+ for (const block of message.content) {
76914
+ if (block.type !== "tool-removal") continue;
76915
+ const name = block.toolName;
76916
+ if (typeof name !== "string" || name.trim() === "") continue;
76917
+ names.push(name.trim());
76918
+ }
76919
+ return names;
76920
+ }
76921
+ /**
76922
+ * The model-facing note that announces tools a conversation just gained.
76923
+ *
76924
+ * This route declares `toolUpdate: 'in-history'`, so the harness states an
76925
+ * addition as a block at its own history position instead of rewriting the whole
76926
+ * request's tool list. OpenAI has no message-level tool declaration, so the
76927
+ * change travels as this sentence: it costs one short system turn and leaves the
76928
+ * top-level `tools` array — and therefore the prefix cache — intact.
76929
+ */
76930
+ function additionNotice(names) {
76931
+ const list = names.map((name) => "\"" + name + "\"").join(", ");
76932
+ return names.length === 1 ? "[" + list + " is now available. Call it when it helps with the task.]" : "[" + list + " are now available. Call them when they help with the task.]";
76933
+ }
76934
+ /**
76935
+ * The model-facing note that revokes one or more tools.
76936
+ *
76937
+ * Imperative and unambiguous on purpose: the tool is still in the declaration
76938
+ * list, so this sentence is the only thing between the model and a call that
76939
+ * would fail.
76940
+ */
76941
+ function removalNotice(names) {
76942
+ const list = names.map((name) => "\"" + name + "\"").join(", ");
76943
+ return names.length === 1 ? "[" + list + " is no longer available. Do not call it again: any call will fail.]" : "[" + list + " are no longer available. Do not call them again: any call will fail.]";
76944
+ }
76389
76945
  /**
76390
76946
  * Convert the harness conversation to wire messages.
76391
76947
  *
@@ -76397,7 +76953,15 @@ function collectImageParts(blocks, images, target = []) {
76397
76953
  * message keeps a parallel group's answers contiguous.
76398
76954
  *
76399
76955
  * `developer` becomes `system`: the newer OpenAI spelling is not accepted by
76400
- * every OpenAI-compatible gateway, while `system` is.
76956
+ * every OpenAI-compatible gateway, while `system` is. A tool addition or
76957
+ * removal carried by such a message is replayed as its own system sentence —
76958
+ * the `in-history` shape this route declares — and ordinary text is kept
76959
+ * beside it. A message that contributes neither is dropped rather than sent as
76960
+ * an empty system turn.
76961
+ *
76962
+ * A user message that carries no text and no image contributes nothing to a
76963
+ * chat request, so it is dropped too; a pure tool-result message never took that
76964
+ * path, because its answers travel as `tool` messages.
76401
76965
  */
76402
76966
  function serializeMessages$1(messages, images) {
76403
76967
  const wire = [];
@@ -76430,6 +76994,7 @@ function serializeMessages$1(messages, images) {
76430
76994
  };
76431
76995
  /** A tool answer: it closes its own id, and the group when it was the last. */
76432
76996
  const answer = (callId, content) => {
76997
+ if (!awaiting.includes(callId)) throw new LlmError(`OpenDesign request is invalid: the tool result for call id "${callId}" has no pending tool call to answer, so the message list would carry a tool message that no assistant tool_calls message introduced. Nothing was sent.`, "INVALID_REQUEST");
76433
76998
  wire.push({
76434
76999
  role: "tool",
76435
77000
  tool_call_id: callId,
@@ -76442,9 +77007,20 @@ function serializeMessages$1(messages, images) {
76442
77007
  for (const message of messages) {
76443
77008
  if (message.role === "system" || message.role === "developer") {
76444
77009
  flushToolImages();
76445
- emit({
77010
+ const text = flattenText(message.content);
77011
+ if (text.length > 0) emit({
77012
+ role: "system",
77013
+ content: text
77014
+ });
77015
+ const additions = toolAdditionNames(message);
77016
+ if (additions.length > 0) emit({
77017
+ role: "system",
77018
+ content: additionNotice(additions)
77019
+ });
77020
+ const removals = toolRemovalNames(message);
77021
+ if (removals.length > 0) emit({
76446
77022
  role: "system",
76447
- content: flattenText(message.content)
77023
+ content: removalNotice(removals)
76448
77024
  });
76449
77025
  continue;
76450
77026
  }
@@ -76464,7 +77040,7 @@ function serializeMessages$1(messages, images) {
76464
77040
  else if (block.type === "image") parts.push(imagePart(block, images));
76465
77041
  const hasImage = parts.some((part) => part.type === "image_url");
76466
77042
  const text = parts.filter((part) => part.type === "text").map((part) => String(part.text)).join("");
76467
- if (hasImage || text.length > 0 || toolResults.length === 0) {
77043
+ if (hasImage || text.length > 0) {
76468
77044
  flushToolImages();
76469
77045
  emit({
76470
77046
  role: "user",
@@ -76478,6 +77054,21 @@ function serializeMessages$1(messages, images) {
76478
77054
  return wire;
76479
77055
  }
76480
77056
  /**
77057
+ * Whether one model id is a DeepSeek model.
77058
+ *
77059
+ * The vendor prefix is stripped before the compare, so the bare wire id this
77060
+ * line ships (`deepseek-v4.1-flash`) and a vendor-qualified spelling
77061
+ * (`deepseek/deepseek-v4-pro`, as a sibling registry writes it) both match,
77062
+ * while an id that merely mentions the word — a gateway alias such as
77063
+ * `my-deepseek-proxy` — does not. Only DeepSeek's own chat endpoint documents
77064
+ * the `thinking` field, which is why the translation below is scoped to it
77065
+ * rather than applied to every model that offers a `none` level.
77066
+ */
77067
+ function isDeepSeekModel(model) {
77068
+ const id = String(model ?? "").trim().toLowerCase();
77069
+ return id.slice(Math.max(id.lastIndexOf("/"), id.lastIndexOf(":")) + 1).startsWith("deepseek");
77070
+ }
77071
+ /**
76481
77072
  * Build the wire request for one harness call.
76482
77073
  * @param options - the harness request (model, system, history, tools, sampling).
76483
77074
  * @param connection - resolved connection facts (default output budget).
@@ -76499,6 +77090,8 @@ function buildOpenDesignBody(options, connection, images) {
76499
77090
  }
76500
77091
  }));
76501
77092
  const maxTokens = options.maxTokens ?? connection.maxTokens;
77093
+ const reasoningEffort = options.reasoningEffort === void 0 ? void 0 : String(options.reasoningEffort);
77094
+ const thinkingOff = reasoningEffort !== void 0 && reasoningEffort.toLowerCase() === "none" && isDeepSeekModel(options.model);
76502
77095
  return {
76503
77096
  model: options.model,
76504
77097
  messages,
@@ -76508,7 +77101,8 @@ function buildOpenDesignBody(options, connection, images) {
76508
77101
  ...options.temperature === void 0 ? {} : { temperature: options.temperature },
76509
77102
  ...maxTokens === void 0 || maxTokens <= 0 ? {} : { max_tokens: maxTokens },
76510
77103
  ...options.stop === void 0 ? {} : { stop: options.stop },
76511
- ...options.reasoningEffort === void 0 ? {} : { reasoning_effort: String(options.reasoningEffort) }
77104
+ ...thinkingOff || reasoningEffort === void 0 ? {} : { reasoning_effort: reasoningEffort },
77105
+ ...thinkingOff ? { thinking: { type: "disabled" } } : {}
76512
77106
  };
76513
77107
  }
76514
77108
  /** Map an OpenAI `finish_reason` to the harness finish reason. */
@@ -76627,8 +77221,16 @@ function streamErrorCode(message, status) {
76627
77221
  }
76628
77222
  /**
76629
77223
  * Parse an SSE body into decoded JSON frames.
77224
+ *
77225
+ * The parser's contract is the sibling line's and stays unsoftened: a body that
77226
+ * ends without `[DONE]` is reported by THROWING `STREAM_CLOSED` after its last
77227
+ * frame. An SSE parser cannot tell a gateway that closes politely after the
77228
+ * terminal frame from a truncated response, because it never interprets
77229
+ * `finish_reason` — so it must not decide that question for its callers either.
77230
+ * That verdict belongs to {@link translate}, which has the frames, and absorbs
77231
+ * this one code from its input.
76630
77232
  * @param body - a web ReadableStream of UTF-8 bytes.
76631
- * @returns every `data:` payload up to `[DONE]`.
77233
+ * @returns every `data:` payload up to `[DONE]`, then throws `STREAM_CLOSED`.
76632
77234
  */
76633
77235
  async function* parseServerSentEvents(body) {
76634
77236
  const decoder = new TextDecoder();
@@ -76674,6 +77276,36 @@ async function* parseServerSentEvents(body) {
76674
77276
  }
76675
77277
  throw new LlmError("stream ended without [DONE]", "STREAM_CLOSED");
76676
77278
  }
77279
+ /**
77280
+ * Whether one failure is the SSE parser's own "the body ended without [DONE]".
77281
+ *
77282
+ * Structural rather than `instanceof`: what has to match is the CODE the parser
77283
+ * assigns, and a second copy of this module (or a harness generation that ships
77284
+ * its own LlmError class) would fail an identity test on a value that is
77285
+ * otherwise exactly the signal this decision is about.
77286
+ */
77287
+ function isMissingSentinel(error) {
77288
+ return typeof error === "object" && error !== null && error.code === "STREAM_CLOSED";
77289
+ }
77290
+ /**
77291
+ * End the payload iteration instead of throwing when a body stops without the
77292
+ * sentinel.
77293
+ *
77294
+ * The parser can only report "no [DONE] arrived" by throwing, and that is NOT a
77295
+ * failure on its own: the gateway sometimes closes the response right after the
77296
+ * terminal frame. Whether the response actually completed is decided by the
77297
+ * frames that were seen, which is knowledge this generator does not have — so it
77298
+ * only removes the transport signal and leaves the verdict to {@link translate}.
77299
+ * Every other failure passes through untouched, including the error frames a 200
77300
+ * body can carry.
77301
+ */
77302
+ async function* tolerateMissingSentinel(payloads) {
77303
+ try {
77304
+ for await (const payload of payloads) yield payload;
77305
+ } catch (error) {
77306
+ if (!isMissingSentinel(error)) throw error;
77307
+ }
77308
+ }
76677
77309
  /** Assemble the terminal content block for one open block. */
76678
77310
  function closeBlock(block) {
76679
77311
  switch (block.kind) {
@@ -76697,9 +77329,21 @@ function closeBlock(block) {
76697
77329
  * Translate decoded SSE payloads into harness stream chunks.
76698
77330
  *
76699
77331
  * Deltas stream through as they arrive; block ends, usage and the finish reason
76700
- * are all deferred to the `[DONE]` sentinel, so no chunk ever follows `finish`.
77332
+ * are all deferred to the end of the answer, so no chunk ever follows `finish`.
76701
77333
  * A completion with no content at all becomes an `EMPTY_RESPONSE` error finish
76702
77334
  * rather than a silent empty message.
77335
+ *
77336
+ * The end of the answer is either the `[DONE]` sentinel or the end of the body
77337
+ * AFTER a terminal `finish_reason` arrived. Some gateways close the socket
77338
+ * instead of writing the sentinel, and failing such an answer threw away content
77339
+ * the caller had already received.
77340
+ *
77341
+ * The parser still raises `STREAM_CLOSED` for a body with no sentinel — that is
77342
+ * its contract and it is not widened here. The signal is absorbed where the
77343
+ * frames are available instead (see {@link tolerateMissingSentinel}), and only
77344
+ * then does the finish reason decide: a body that stated one closes like any
77345
+ * other, and a body that never stated one is truncation and still fails with
77346
+ * `STREAM_CLOSED`. No other input failure is absorbed.
76703
77347
  */
76704
77348
  async function* translate$1(payloads) {
76705
77349
  let nextIndex = 0;
@@ -76726,38 +77370,44 @@ async function* translate$1(payloads) {
76726
77370
  order.push(block);
76727
77371
  return block;
76728
77372
  };
76729
- for await (const payload of payloads) {
77373
+ /** The terminal chunks of one completed answer, in the order the harness accepts. */
77374
+ const terminal = (reason) => {
77375
+ const chunks = [];
77376
+ for (const block of order) chunks.push({
77377
+ type: "block-end",
77378
+ index: block.index,
77379
+ block: closeBlock(block)
77380
+ });
77381
+ if (pendingUsage !== void 0) chunks.push({
77382
+ type: "usage",
77383
+ usage: pendingUsage
77384
+ });
77385
+ const hasVisibleAnswer = toolBlocks.size > 0 || textBlock !== void 0 && textBlock.text.trim() !== "";
77386
+ chunks.push({
77387
+ type: "finish",
77388
+ reason: reason.reason.kind === "stop" && order.length === 0 ? {
77389
+ kind: "error",
77390
+ failure: {
77391
+ message: "model returned a completed response with no content",
77392
+ code: EMPTY_RESPONSE_CODE
77393
+ }
77394
+ } : reason.reason.kind === "stop" && !hasVisibleAnswer ? {
77395
+ kind: "error",
77396
+ failure: {
77397
+ message: "OpenDesign ended the turn without an answer or a tool call — the response stopped before it did anything",
77398
+ code: "PROVIDER_ERROR"
77399
+ }
77400
+ } : reason.reason
77401
+ });
77402
+ return chunks;
77403
+ };
77404
+ for await (const payload of tolerateMissingSentinel(payloads)) {
76730
77405
  if (payload === "[DONE]") {
76731
- for (const block of order) yield {
76732
- type: "block-end",
76733
- index: block.index,
76734
- block: closeBlock(block)
76735
- };
76736
- if (pendingUsage !== void 0) yield {
76737
- type: "usage",
76738
- usage: pendingUsage
76739
- };
76740
77406
  const reason = pendingFinish ?? {
76741
77407
  type: "finish",
76742
77408
  reason: { kind: "stop" }
76743
77409
  };
76744
- const hasVisibleAnswer = toolBlocks.size > 0 || textBlock !== void 0 && textBlock.text.trim() !== "";
76745
- yield {
76746
- type: "finish",
76747
- reason: reason.reason.kind === "stop" && order.length === 0 ? {
76748
- kind: "error",
76749
- failure: {
76750
- message: "model returned a completed response with no content",
76751
- code: EMPTY_RESPONSE_CODE
76752
- }
76753
- } : reason.reason.kind === "stop" && !hasVisibleAnswer ? {
76754
- kind: "error",
76755
- failure: {
76756
- message: "OpenDesign ended the turn without an answer or a tool call — the response stopped before it did anything",
76757
- code: "PROVIDER_ERROR"
76758
- }
76759
- } : reason.reason
76760
- };
77410
+ for (const chunk of terminal(reason)) yield chunk;
76761
77411
  return;
76762
77412
  }
76763
77413
  if (isErrorPayload(payload)) throw new LlmError(errorText(payload.error), streamErrorCode(errorText(payload.error)));
@@ -76831,6 +77481,10 @@ async function* translate$1(payloads) {
76831
77481
  }
76832
77482
  if (isRecord$2(chunk.usage)) pendingUsage = mapUsage(chunk.usage);
76833
77483
  }
77484
+ if (pendingFinish !== void 0) {
77485
+ for (const chunk of terminal(pendingFinish)) yield chunk;
77486
+ return;
77487
+ }
76834
77488
  throw new LlmError("stream ended without [DONE]", "STREAM_CLOSED");
76835
77489
  }
76836
77490
  //#endregion
@@ -76891,6 +77545,23 @@ const TRANSPORT_RETRY_BASE_MS = 400;
76891
77545
  * but the ladder still prefers published data for anything the tables know.
76892
77546
  */
76893
77547
  const REASONING_EFFORT_IDS = DEFAULT_EFFORTS;
77548
+ /**
77549
+ * How this route carries a mid-conversation tool change, or undefined for none.
77550
+ *
77551
+ * `in-history`, not `addition-only`: both append an addition, but only
77552
+ * `in-history` can also express a REMOVAL. Under `addition-only` a revoked
77553
+ * tool is deleted from the declaration list, the top-level `tools` array
77554
+ * shrinks, and the gateway's prefix match is thrown away for the whole prompt.
77555
+ *
77556
+ * The field is spread into the descriptor only when this route wants one, so a
77557
+ * harness generation that does not know it simply never reads it — no version
77558
+ * check, and no change to the message shape either way: mapper.ts renders each
77559
+ * change as a system sentence and never introduces a native dynamic-tool
77560
+ * declaration, which an OpenAI-compatible gateway has no field for.
77561
+ */
77562
+ function toolUpdateMode() {
77563
+ return "in-history";
77564
+ }
76894
77565
  var OpenDesignAdapter = class extends LlmAdapter {
76895
77566
  modelSettings;
76896
77567
  options;
@@ -76949,6 +77620,7 @@ var OpenDesignAdapter = class extends LlmAdapter {
76949
77620
  id: modelId,
76950
77621
  name: info.displayName,
76951
77622
  inputModalities: info.input,
77623
+ ...toolUpdateMode() === void 0 ? {} : { toolUpdate: toolUpdateMode() },
76952
77624
  context: { contextWindow: info.contextWindow },
76953
77625
  ...outputReservation(info.contextWindow, info.maxTokens),
76954
77626
  ...info.reasoning && info.efforts.length > 0 ? { reasoning: {
@@ -76976,11 +77648,12 @@ var OpenDesignAdapter = class extends LlmAdapter {
76976
77648
  }
76977
77649
  async *requestStream(rawOptions, settings, signal, activity) {
76978
77650
  if (settings.enabled === false) throw new LlmError(`${PROVIDER_NAME$2} is disabled in Settings > Subscription. Enable the line there, then retry.`, "INVALID_REQUEST");
76979
- const options = normalizeGenerateOptions(rawOptions);
76980
- const images = await resolveRequestImages$1(options, this.options.attachments, signal).catch((error) => {
77651
+ const normalized = normalizeGenerateOptions(rawOptions);
77652
+ const images = await resolveRequestImages$1(normalized, this.options.attachments, signal).catch((error) => {
76981
77653
  if (signal.aborted) throw new LlmError("OpenDesign request aborted by caller", "ABORTED", { cause: error });
76982
77654
  return NO_RESOLVED_IMAGES$3;
76983
77655
  });
77656
+ const options = offloadOldestRequestImages$1(normalized, MAX_REQUEST_IMAGE_BYTES$1, images);
76984
77657
  const body = buildOpenDesignBody(options, { maxTokens: (await this.modelInfo(options.model)).maxTokens }, images);
76985
77658
  const refused = /* @__PURE__ */ new Set();
76986
77659
  let lastError = null;
@@ -82700,6 +83373,20 @@ function createCacheWarmupResultTool(deps) {
82700
83373
  * 预览只有坏处没有好处,所以这里写死一个 provider 名而不是做成开关。
82701
83374
  */
82702
83375
  const CACHE_WARMUP_PROVIDER = "kimi-code";
83376
+ /**
83377
+ * 允许进入结果投影的工具集合。
83378
+ *
83379
+ * 除了 PTC 的 `run_code`,宿主的独立 Shell 与后台任务输出工具(`win_bash`、
83380
+ * `bash`、`pwsh`、`job_output`)同样经常吐出整屏测试/构建日志。源码读取/编辑类
83381
+ * 工具(`read`、`edit`、`grep`、`glob`、CodeGraph 等)不在此列,保持 100% 原样。
83382
+ */
83383
+ const CACHE_WARMUP_SOURCE_TOOLS = /* @__PURE__ */ new Set([
83384
+ "run_code",
83385
+ "win_bash",
83386
+ "bash",
83387
+ "pwsh",
83388
+ "job_output"
83389
+ ]);
82703
83390
  /** 提示词分节名,全局唯一(重复注册会抛错)。 */
82704
83391
  const CACHE_WARMUP_SECTION_NAME = "plugin:cache-warmup";
82705
83392
  /**
@@ -82943,7 +83630,7 @@ function installCacheWarmup(ctx, options) {
82943
83630
  const projectDecision = async (exec, result, decision) => {
82944
83631
  if (decision.kind !== "accept") return decision;
82945
83632
  if (decision.value !== void 0) return decision;
82946
- if (exec.name !== "run_code") return decision;
83633
+ if (!CACHE_WARMUP_SOURCE_TOOLS.has(exec.name)) return decision;
82947
83634
  if (!options.settings().enabled) return decision;
82948
83635
  const route = cacheWarmupRouteOf(exec.agent);
82949
83636
  if (route.provider !== "kimi-code") return decision;
@@ -82954,7 +83641,13 @@ function installCacheWarmup(ctx, options) {
82954
83641
  const blocks = decision.content ?? result.content;
82955
83642
  if (blocks.length === 0) return decision;
82956
83643
  const content = blocks;
82957
- const budget = cacheWarmupBudget(sessionId);
83644
+ let durable;
83645
+ try {
83646
+ durable = options.durableHint?.(sessionId);
83647
+ } catch {
83648
+ durable = void 0;
83649
+ }
83650
+ const budget = durable === void 0 ? cacheWarmupBudget(sessionId) : cacheWarmupBudget(sessionId, Date.now(), durable);
82958
83651
  const projected = await projectToolResult({
82959
83652
  toolName: exec.name,
82960
83653
  provider: route.provider,
@@ -83191,7 +83884,19 @@ function apply(ctx, pluginConfig = {}) {
83191
83884
  return dispose;
83192
83885
  }, "dsh-chatgpt-subscription: no-answer resume");
83193
83886
  }
83194
- ctx.effect(() => installCacheWarmup(ctx, { settings: () => resolveCacheWarmupSettings(pluginConfig) }), "dsh-chatgpt-subscription: cache warmup");
83887
+ ctx.effect(() => installCacheWarmup(ctx, {
83888
+ settings: () => resolveCacheWarmupSettings(pluginConfig),
83889
+ durableHint: (sessionId) => {
83890
+ const record = sessionCacheState.read(sessionId);
83891
+ if (record === void 0) return void 0;
83892
+ const total = record.cachedTokens + record.freshTokens;
83893
+ return {
83894
+ lastActiveAt: record.lastActiveAt,
83895
+ consecutiveHits: record.warmupConsecutiveHits ?? 0,
83896
+ hitRatio: total > 0 ? record.cachedTokens / total : 0
83897
+ };
83898
+ }
83899
+ }), "dsh-chatgpt-subscription: cache warmup");
83195
83900
  const probeEnvFile = relayProbeEnvFile();
83196
83901
  if (relayProbeEnabled(process.env, { envFile: probeEnvFile })) {
83197
83902
  const probePath = relayProbeLogPath(process.env, { envFile: probeEnvFile });