@eddyskywalker/dsh-chatgpt-subscription 0.43.1 → 0.45.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 (46) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cordis.patch.yml +5 -0
  3. package/lib/client.js +94 -8
  4. package/lib/client.js.map +1 -1
  5. package/lib/entry-progress-updates-CkGJr_fe.js +1004 -0
  6. package/lib/index.js +2011 -847
  7. package/lib/progress-updates.js +2 -0
  8. package/lib/types/client/progress-updates/host-source.d.ts +16 -0
  9. package/lib/types/client/progress-updates/host-source.d.ts.map +1 -1
  10. package/lib/types/client/progress-updates/index.d.ts.map +1 -1
  11. package/lib/types/client/progress-updates/settings-binding.d.ts +5 -0
  12. package/lib/types/client/progress-updates/settings-binding.d.ts.map +1 -1
  13. package/lib/types/compat.d.ts +1 -1
  14. package/lib/types/entry-progress-updates.d.ts +43 -0
  15. package/lib/types/entry-progress-updates.d.ts.map +1 -0
  16. package/lib/types/host/cache-warmup/budget.d.ts +64 -0
  17. package/lib/types/host/cache-warmup/budget.d.ts.map +1 -0
  18. package/lib/types/host/cache-warmup/contracts.d.ts +267 -0
  19. package/lib/types/host/cache-warmup/contracts.d.ts.map +1 -0
  20. package/lib/types/host/cache-warmup/index.d.ts +21 -0
  21. package/lib/types/host/cache-warmup/index.d.ts.map +1 -0
  22. package/lib/types/host/cache-warmup/install.d.ts +130 -0
  23. package/lib/types/host/cache-warmup/install.d.ts.map +1 -0
  24. package/lib/types/host/cache-warmup/logging.d.ts +120 -0
  25. package/lib/types/host/cache-warmup/logging.d.ts.map +1 -0
  26. package/lib/types/host/cache-warmup/projection.d.ts +29 -0
  27. package/lib/types/host/cache-warmup/projection.d.ts.map +1 -0
  28. package/lib/types/host/cache-warmup/result-tool.d.ts +87 -0
  29. package/lib/types/host/cache-warmup/result-tool.d.ts.map +1 -0
  30. package/lib/types/host/cache-warmup/settings.d.ts +73 -0
  31. package/lib/types/host/cache-warmup/settings.d.ts.map +1 -0
  32. package/lib/types/host/cache-warmup/store.d.ts +108 -0
  33. package/lib/types/host/cache-warmup/store.d.ts.map +1 -0
  34. package/lib/types/host/cache-warmup/usage.d.ts +86 -0
  35. package/lib/types/host/cache-warmup/usage.d.ts.map +1 -0
  36. package/lib/types/host/kimi-code/adapter.d.ts.map +1 -1
  37. package/lib/types/host/proxy-manager.d.ts +6 -0
  38. package/lib/types/host/proxy-manager.d.ts.map +1 -1
  39. package/lib/types/index.d.ts +32 -25
  40. package/lib/types/index.d.ts.map +1 -1
  41. package/lib/types/shared/model-catalog.d.ts +12 -1
  42. package/lib/types/shared/model-catalog.d.ts.map +1 -1
  43. package/locale/progress-updates/en.json +6 -0
  44. package/locale/progress-updates/zh.json +6 -0
  45. package/package.json +13 -3
  46. package/progress-updates.svg +9 -0
package/lib/index.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { a as resolveProgressUpdatesSettings, n as attachProgressSettings } from "./entry-progress-updates-CkGJr_fe.js";
1
2
  import { createRequire } from "node:module";
2
- import z from "@deepseek-ai/schemastery";
3
3
  import * as LlmModule from "@deepseek-ai/dsh-llm";
4
- import { EMPTY_RESPONSE_CODE, HarnessError, LlmAdapter, LlmError, LlmError as LlmError$1, ProviderRequestId, ReasoningEffortId, ToolCallId, attributionHeaders, createUserMessage, errorChain, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
4
+ import { EMPTY_RESPONSE_CODE, HarnessError, LlmAdapter, LlmError, LlmError as LlmError$1, ProviderRequestId, ReasoningEffortId, ToolCallId, attributionHeaders, errorChain, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
5
+ import { defineTool } from "@deepseek-ai/dsh-tools";
6
+ import z from "@deepseek-ai/schemastery";
5
7
  import fs, { chmod, lstat, mkdir, open, readFile, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
6
8
  import path, { basename, dirname, join, relative, resolve } from "node:path";
7
9
  import os, { homedir, platform, userInfo } from "node:os";
@@ -15,12 +17,10 @@ import { lookup } from "node:dns/promises";
15
17
  import { isIP } from "node:net";
16
18
  import { MAX_TIMER_DELAY_MS, idleWatchdog, timeoutOf } from "@deepseek-ai/dsh-timeout";
17
19
  import { boundContextSummary, createUserMessage as createUserMessage$1 } from "@deepseek-ai/dsh-llm/message";
18
- import { defineTool } from "@deepseek-ai/dsh-tools";
19
20
  import * as SettingsModule from "@deepseek-ai/dsh-settings";
20
21
  import { URL as URL$1, URLSearchParams as URLSearchParams$1, fileURLToPath } from "node:url";
21
22
  import { ProxyAgent, fetch as fetch$1 } from "undici";
22
23
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
23
- import { z as z$1 } from "zod";
24
24
  //#region src/host/common/output-reservation.ts
25
25
  /**
26
26
  * Keep fixed defaults only when they fit DSH compaction-basic's default policy.
@@ -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.43.1";
144
+ const PLUGIN_VERSION = "0.45.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`;
@@ -221,6 +221,18 @@ const CODEX_MODEL_CATALOG = [
221
221
  supportsOutputVerbosity: true,
222
222
  defaultOutputVerbosity: "low"
223
223
  },
224
+ {
225
+ id: "gpt-6.1-terra",
226
+ name: "6.1 Terra",
227
+ contextWindow: GPT_6_DEFAULT_CONTEXT_WINDOW,
228
+ inputModalities: ["text", "image"],
229
+ defaultReasoningEffort: "medium",
230
+ reasoningProfile: "gpt-6",
231
+ supportsReasoningSummary: true,
232
+ maxTokens: GPT_6_MAX_TOKENS,
233
+ supportsOutputVerbosity: true,
234
+ defaultOutputVerbosity: "low"
235
+ },
224
236
  {
225
237
  id: "gpt-6-sol",
226
238
  name: "6 Sol",
@@ -312,6 +324,7 @@ const CODEX_MODEL_CATALOG = [
312
324
  const DEFAULT_VISIBLE_CODEX_MODEL_IDS = [
313
325
  "gpt-6-astra",
314
326
  "gpt-6.1-sol",
327
+ "gpt-6.1-terra",
315
328
  "gpt-6-sol",
316
329
  "gpt-6-luna",
317
330
  "gpt-5.6-sol",
@@ -7007,7 +7020,7 @@ function sanitizeToolCallId(id, fallbackName) {
7007
7020
  function toolCallIdNeeded(modelId, runtimeModel) {
7008
7021
  return modelId.startsWith("claude-") || modelId.startsWith("gpt-oss-") || runtimeModel.startsWith("claude-") || runtimeModel.startsWith("gpt-oss-");
7009
7022
  }
7010
- function parseArguments$2(raw) {
7023
+ function parseArguments$1(raw) {
7011
7024
  if (isRecord$47(raw)) return raw;
7012
7025
  if (raw === void 0 || raw === null || raw === "") return {};
7013
7026
  const parsed = typeof raw === "string" ? safeJsonParse$8(raw) : raw;
@@ -7497,7 +7510,7 @@ function assistantParts(message, model, runtimeModel, toolCalls) {
7497
7510
  parts.push({
7498
7511
  functionCall: {
7499
7512
  name: toolName,
7500
- args: parseArguments$2(block.arguments),
7513
+ args: parseArguments$1(block.arguments),
7501
7514
  ...wireId ? { id: wireId } : {}
7502
7515
  },
7503
7516
  ...effectiveSignature ? { thoughtSignature: effectiveSignature } : {}
@@ -9568,7 +9581,7 @@ function normalizeHostForMatch(hostname) {
9568
9581
  * is a normal way to run a local gateway, and a proxy cannot reach it any
9569
9582
  * better than the process itself can.
9570
9583
  */
9571
- function isLoopbackHostname$1(hostname) {
9584
+ function isLoopbackHostname(hostname) {
9572
9585
  const host = normalizeHostForMatch(hostname);
9573
9586
  if (host === "localhost" || host.endsWith(".localhost")) return true;
9574
9587
  if (host === "::1") return true;
@@ -9622,7 +9635,7 @@ function shouldBypassProxy(target, noProxyList = "") {
9622
9635
  } catch {
9623
9636
  return false;
9624
9637
  }
9625
- if (isLoopbackHostname$1(url.hostname)) return true;
9638
+ if (isLoopbackHostname(url.hostname)) return true;
9626
9639
  if (!noProxyList) return false;
9627
9640
  return noProxyBypasses(url.hostname, url.port || defaultPortFor(url.protocol), noProxyList);
9628
9641
  }
@@ -9708,9 +9721,28 @@ function requestTarget(input) {
9708
9721
  if (typeof input === "string" || input instanceof URL) return input;
9709
9722
  return input.url;
9710
9723
  }
9724
+ /**
9725
+ * Whether an error is undici saying the pooled client under this request was
9726
+ * destroyed — the host surfaces it as `terminated: The client is destroyed`.
9727
+ *
9728
+ * Only that family qualifies: an ordinary connect failure belongs to the other
9729
+ * decision (the agent is healthy, the path is not), so it must not be replayed
9730
+ * here.
9731
+ */
9732
+ function isDestroyedClientError(error) {
9733
+ let current = error;
9734
+ for (let depth = 0; depth < 4; depth += 1) {
9735
+ if (typeof current === "string") return /client is destroyed/i.test(current);
9736
+ if (!(current instanceof Error)) return false;
9737
+ if (/client is destroyed|UND_ERR_DESTROYED/i.test(current.name + ": " + current.message)) return true;
9738
+ current = current.cause;
9739
+ }
9740
+ return false;
9741
+ }
9711
9742
  var ProxyManager = class {
9712
9743
  getPreferences;
9713
9744
  baseFetch;
9745
+ proxyFetchFn;
9714
9746
  systemProxyDetector;
9715
9747
  noProxyReader;
9716
9748
  logger;
@@ -9724,6 +9756,7 @@ var ProxyManager = class {
9724
9756
  constructor(options) {
9725
9757
  this.getPreferences = options.getPreferences;
9726
9758
  this.baseFetch = options.baseFetch ?? fetch;
9759
+ this.proxyFetchFn = options.proxyFetchFn ?? fetch$1;
9727
9760
  this.systemProxyDetector = options.systemProxyDetector ?? (() => detectSystemProxy());
9728
9761
  this.noProxyReader = options.noProxyReader ?? (() => parseNoProxy());
9729
9762
  this.logger = options.logger;
@@ -9803,20 +9836,24 @@ var ProxyManager = class {
9803
9836
  const activeProxy = this.resolveActiveProxyUrl();
9804
9837
  if (!activeProxy) return this.baseFetch(input, init);
9805
9838
  if (shouldBypassProxy(requestTarget(input), this.getNoProxyList())) return this.baseFetch(input, init);
9806
- try {
9839
+ for (let attempt = 0;; attempt += 1) try {
9807
9840
  const agent = this.getOrCreateAgent(activeProxy);
9808
- return await fetch$1(input, {
9841
+ return await this.proxyFetchFn(input, {
9809
9842
  ...init,
9810
9843
  dispatcher: agent
9811
9844
  });
9812
9845
  } catch (error) {
9846
+ if (attempt === 0 && isDestroyedClientError(error)) {
9847
+ this.agents.delete(activeProxy);
9848
+ continue;
9849
+ }
9813
9850
  throw error;
9814
9851
  }
9815
9852
  };
9816
9853
  }
9817
9854
  dispose() {
9818
9855
  this.proxyListeners.clear();
9819
- for (const agent of this.agents.values()) agent.destroy().catch(() => void 0);
9856
+ for (const agent of this.agents.values()) agent.close().catch(() => void 0);
9820
9857
  this.agents.clear();
9821
9858
  }
9822
9859
  };
@@ -22886,7 +22923,7 @@ function toDataUrl(image, defaultMediaType = "image/png") {
22886
22923
  if (image.startsWith("data:") || image.startsWith("http://") || image.startsWith("https://")) return image;
22887
22924
  return `data:${defaultMediaType};base64,${image}`;
22888
22925
  }
22889
- function parseArguments$1(raw) {
22926
+ function parseArguments(raw) {
22890
22927
  if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) return raw;
22891
22928
  if (raw === void 0 || raw === null || raw === "") return {};
22892
22929
  if (typeof raw === "string") {
@@ -22977,7 +23014,7 @@ function buildNativeBody(request) {
22977
23014
  content: message.content,
22978
23015
  tool_calls: message.toolCalls.map((call) => ({ function: {
22979
23016
  name: call.name,
22980
- arguments: parseArguments$1(call.arguments)
23017
+ arguments: parseArguments(call.arguments)
22981
23018
  } }))
22982
23019
  };
22983
23020
  return nativeContent(message);
@@ -24646,6 +24683,1327 @@ var OllamaAccountPool = class extends AccountPoolCore {
24646
24683
  }
24647
24684
  };
24648
24685
  //#endregion
24686
+ //#region src/host/cache-warmup/contracts.ts
24687
+ /**
24688
+ * 缓存预热子包(cache-warmup)的共享契约:类型、默认值与错误码。
24689
+ *
24690
+ * 这一层刻意不 import 任何 `node:` 内置模块:投影与预算的一半逻辑是纯字符串/数字运算,
24691
+ * 保持零依赖才能让消费者(宿主适配层、设置路由、测试)只为了一个类型就拖进文件系统实现。
24692
+ * 唯一的例外是 `parseStructuredResultRows` —— 它是对**已存储原文**的解析器,
24693
+ * 存储分页与投影预览必须共用同一份判定,否则「能分页但预览判断不同」会立刻变成幻觉。
24694
+ *
24695
+ * @module dsh-chatgpt-subscription/host/cache-warmup/contracts
24696
+ */
24697
+ /**
24698
+ * 子包默认值。
24699
+ *
24700
+ * 预热期的字符上限是**按 token 反推的保守字符预算**:中文/代码混排大约每 token 1.6-2.0
24701
+ * 字符,1200 字符落在 512-1024 token 区间的中段,宁可少给也不要在预热期用一个
24702
+ * 还没被验证过的更大预算去撞缓存键。命中率与连续次数见约束 4。
24703
+ */
24704
+ const CACHE_WARMUP_DEFAULTS = {
24705
+ /** 预热期单次预览的字符预算。约 600-750 tokens。 */
24706
+ warmupMaxChars: 1200,
24707
+ /** 稳态(连续两次高命中率且无 drift 之后)的字符预算。 */
24708
+ steadyStateMaxChars: 8e3,
24709
+ /** 视为命中的最低命中率。 */
24710
+ hitRatioThreshold: .97,
24711
+ /** 退出预热期需要连续命中的报告条数。 */
24712
+ consecutiveHitsRequired: 2,
24713
+ /** 单结果原文上限(字节)。 */
24714
+ maxResultBytes: 8 * 1024 * 1024,
24715
+ /** 每 session 原文总量上限(字节)。 */
24716
+ maxSessionBytes: 64 * 1024 * 1024,
24717
+ /** 分页 limit 默认值(行)。 */
24718
+ pageLimitDefault: 64,
24719
+ /** 分页 limit 上限(行)。 */
24720
+ pageLimitMax: 512,
24721
+ /** 分页 maxBytes 默认值(字节)。 */
24722
+ pageMaxBytesDefault: 16 * 1024,
24723
+ /** 分页 maxBytes 上限(字节)。 */
24724
+ pageMaxBytesMax: 64 * 1024,
24725
+ /** usage registry 的 session 数上限。 */
24726
+ maxUsageSessions: 256,
24727
+ /** 每 session 保留的用量报告条数上限。 */
24728
+ maxUsageReportsPerSession: 32
24729
+ };
24730
+ /**
24731
+ * 存储层错误。信息里只描述**请求本身**的问题,绝不回显其他 session 的内容或存在性。
24732
+ */
24733
+ var CacheWarmupStoreError = class extends Error {
24734
+ code;
24735
+ constructor(message, code) {
24736
+ super(message);
24737
+ this.code = code;
24738
+ this.name = "CacheWarmupStoreError";
24739
+ }
24740
+ };
24741
+ /** 一段文本被拆开的一列。 */
24742
+ function splitDelimitedLine(line, delimiter) {
24743
+ const cells = [];
24744
+ let current = "";
24745
+ let quoted = false;
24746
+ for (let index = 0; index < line.length; index += 1) {
24747
+ const char = line[index];
24748
+ if (char === "\"") {
24749
+ if (quoted && line[index + 1] === "\"") {
24750
+ current += "\"";
24751
+ index += 1;
24752
+ } else quoted = !quoted;
24753
+ continue;
24754
+ }
24755
+ if (!quoted && char === delimiter) {
24756
+ cells.push(current);
24757
+ current = "";
24758
+ continue;
24759
+ }
24760
+ current += char;
24761
+ }
24762
+ cells.push(current);
24763
+ return cells;
24764
+ }
24765
+ /** 把任意 JSON 值压成单元格文本;对象/数组保持可读的 JSON。 */
24766
+ function cellOf(value) {
24767
+ if (value === null || value === void 0) return "";
24768
+ if (typeof value === "string") return value;
24769
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
24770
+ try {
24771
+ return JSON.stringify(value) ?? "";
24772
+ } catch {
24773
+ return "";
24774
+ }
24775
+ }
24776
+ /** JSON Lines:每一行都是一个对象,且至少两行。 */
24777
+ function parseJsonLines(lines) {
24778
+ if (lines.length < 2) return void 0;
24779
+ const fields = [];
24780
+ const seen = /* @__PURE__ */ new Set();
24781
+ const rows = [];
24782
+ for (const line of lines) {
24783
+ let parsed;
24784
+ try {
24785
+ parsed = JSON.parse(line);
24786
+ } catch {
24787
+ return;
24788
+ }
24789
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
24790
+ const record = parsed;
24791
+ const row = {};
24792
+ for (const [key, value] of Object.entries(record)) {
24793
+ if (!seen.has(key)) {
24794
+ seen.add(key);
24795
+ fields.push(key);
24796
+ }
24797
+ row[key] = cellOf(value);
24798
+ }
24799
+ rows.push(row);
24800
+ }
24801
+ if (fields.length === 0) return void 0;
24802
+ return {
24803
+ fields,
24804
+ rows
24805
+ };
24806
+ }
24807
+ /** 分隔符表格:首行表头 + 至少两行同列数的数据。 */
24808
+ function parseDelimitedRows(lines) {
24809
+ if (lines.length < 3) return void 0;
24810
+ const header = lines[0];
24811
+ const delimiter = header.includes(" ") ? " " : header.includes(",") ? "," : void 0;
24812
+ if (delimiter === void 0) return void 0;
24813
+ const fields = splitDelimitedLine(header, delimiter).map((cell) => cell.trim());
24814
+ if (fields.length < 2 || fields.some((field) => field === "")) return void 0;
24815
+ if (new Set(fields).size !== fields.length) return void 0;
24816
+ const rows = [];
24817
+ for (const line of lines.slice(1)) {
24818
+ const cells = splitDelimitedLine(line, delimiter);
24819
+ if (cells.length !== fields.length) return void 0;
24820
+ const row = {};
24821
+ fields.forEach((field, index) => {
24822
+ row[field] = cells[index];
24823
+ });
24824
+ rows.push(row);
24825
+ }
24826
+ return rows.length === 0 ? void 0 : {
24827
+ fields,
24828
+ rows
24829
+ };
24830
+ }
24831
+ /**
24832
+ * 把一段文本可靠地解析成结构化行;解析不可靠就返回 undefined。
24833
+ *
24834
+ * 判定刻意保守:宁可返回 undefined 让调用方保持原文,也不要猜出一张表,
24835
+ * 因为下游会把「猜出来的表」当成完整事实。
24836
+ */
24837
+ function parseStructuredResultRows(text) {
24838
+ const lines = text.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim() !== "");
24839
+ if (lines.length < 2) return void 0;
24840
+ return parseJsonLines(lines) ?? parseDelimitedRows(lines);
24841
+ }
24842
+ //#endregion
24843
+ //#region src/host/cache-warmup/store.ts
24844
+ /**
24845
+ * 结果引用存储:内容寻址、原子写入、按字节安全分页。
24846
+ *
24847
+ * 三条不变量:
24848
+ *
24849
+ * 1. **原文逐字保存**。文件名是原文 UTF-8 字节的 sha256,所以同一份内容重复入库是幂等的,
24850
+ * 也不可能有一条引用悄悄指向别的字节。
24851
+ * 2. **历史 preview 与存储生命周期无关**。过期/淘汰/释放只影响「还能不能读到原文」,
24852
+ * 已经写进历史的那段预览文本是常量 —— 本模块从不接触历史。
24853
+ * 3. **只删具体文件**。淘汰按 mtime 逐个 `rm` 文件,绝不递归删除任何目录。
24854
+ *
24855
+ * @module dsh-chatgpt-subscription/host/cache-warmup/store
24856
+ */
24857
+ /** 会话 id 的合法形状:单个路径段,不允许分隔符与 `..`。 */
24858
+ const SESSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
24859
+ /** `ref://<session>/<sha256>`。 */
24860
+ const REFERENCE_PATTERN = /^ref:\/\/([A-Za-z0-9][A-Za-z0-9._-]{0,127})\/([0-9a-f]{64})$/;
24861
+ /** 引用前缀。 */
24862
+ const RESULT_REFERENCE_PREFIX = "ref://";
24863
+ /** 原文文件后缀。 */
24864
+ const RESULT_SUFFIX = ".txt";
24865
+ /** 临时文件标记(原子写入的中转,永不被分页读到)。 */
24866
+ const TMP_MARKER = ".tmp.";
24867
+ /**
24868
+ * 拼一条引用。digest 必须是 64 位小写十六进制,否则分页时会拒绝解析。
24869
+ * @param sessionId - 会话 id。
24870
+ * @param digest - 原文 sha256。
24871
+ * @returns `ref://<session>/<digest>`。
24872
+ */
24873
+ function formatResultRefId(sessionId, digest) {
24874
+ return `${RESULT_REFERENCE_PREFIX}${sessionId}/${digest}`;
24875
+ }
24876
+ /**
24877
+ * 解析一条引用。
24878
+ * @param refId - 待解析的引用。
24879
+ * @returns 会话与 digest;形状不合法时 undefined。
24880
+ */
24881
+ function parseResultRefId(refId) {
24882
+ if (typeof refId !== "string") return void 0;
24883
+ const match = REFERENCE_PATTERN.exec(refId);
24884
+ if (match === null) return void 0;
24885
+ return {
24886
+ sessionId: match[1],
24887
+ digest: match[2]
24888
+ };
24889
+ }
24890
+ /**
24891
+ * 默认根目录。
24892
+ *
24893
+ * 每次调用都重新解析 `DSH_HOME`,所以构造之后再改环境变量(测试、多 profile)
24894
+ * 仍然生效;这也是零参构造能安全地只拿一个惰性默认值的原因。
24895
+ * @returns 默认根目录的绝对路径。
24896
+ */
24897
+ function defaultResultStoreRootDir() {
24898
+ return path.join(dshHomeDir(), "storages", "cache-warmup");
24899
+ }
24900
+ /** 校验会话 id 形状,非法直接拒绝(路径穿越在这里被挡住)。 */
24901
+ function assertSessionId(sessionId) {
24902
+ if (typeof sessionId !== "string" || !SESSION_PATTERN.test(sessionId)) throw new CacheWarmupStoreError("session id is not a single safe path segment", "SESSION_INVALID");
24903
+ return sessionId;
24904
+ }
24905
+ /** 把 limit 收进 [1, 512]。 */
24906
+ function clampLimit(limit) {
24907
+ if (limit === void 0 || !Number.isFinite(limit)) return CACHE_WARMUP_DEFAULTS.pageLimitDefault;
24908
+ const rounded = Math.floor(limit);
24909
+ if (rounded < 1) return 1;
24910
+ return Math.min(rounded, CACHE_WARMUP_DEFAULTS.pageLimitMax);
24911
+ }
24912
+ /** 把 maxBytes 收进 [1, 65536]。 */
24913
+ function clampMaxBytes(maxBytes) {
24914
+ if (maxBytes === void 0 || !Number.isFinite(maxBytes)) return CACHE_WARMUP_DEFAULTS.pageMaxBytesDefault;
24915
+ const rounded = Math.floor(maxBytes);
24916
+ if (rounded < 1) return 1;
24917
+ return Math.min(rounded, CACHE_WARMUP_DEFAULTS.pageMaxBytesMax);
24918
+ }
24919
+ /** 一段文本的行数(末尾没有换行也算一行)。 */
24920
+ function countLines(text) {
24921
+ if (text === "") return 0;
24922
+ let lines = 0;
24923
+ for (const char of text) if (char === "\n") lines += 1;
24924
+ return text.endsWith("\n") ? lines : lines + 1;
24925
+ }
24926
+ /**
24927
+ * 按 UTF-8 字节安全分页。
24928
+ *
24929
+ * `offset` 是字节偏移,必须落在字符边界上(上一页的 `nextOffset` 天然满足);
24930
+ * 每页最多 `limit` 行且不超过 `maxBytes` 字节,任何一页都不会把一个字符切成两半。
24931
+ * 两个上限谁先到就在谁那里收手,`nextOffset` 精确指回同一位置,所以
24932
+ * 「两次翻页拼起来等于原文」是构造出来的性质,不是巧合。
24933
+ */
24934
+ function pageText(args) {
24935
+ const { sessionId, refId, text, buffer, offset, limit, maxBytes } = args;
24936
+ const totalBytes = buffer.length;
24937
+ if (!Number.isInteger(offset) || offset < 0) throw new CacheWarmupStoreError("offset must be a non-negative integer byte offset", "OFFSET_OUT_OF_RANGE");
24938
+ if (offset > totalBytes) throw new CacheWarmupStoreError(`offset ${offset} is past the ${totalBytes}-byte result`, "OFFSET_OUT_OF_RANGE");
24939
+ if (offset < totalBytes && (buffer[offset] & 192) === 128) throw new CacheWarmupStoreError("offset is not on a UTF-8 character boundary", "OFFSET_INVALID");
24940
+ const rest = offset === 0 ? text : new TextDecoder("utf-8").decode(buffer.subarray(offset));
24941
+ let content = "";
24942
+ let contentBytes = 0;
24943
+ let lines = 0;
24944
+ let stoppedByBytes = false;
24945
+ let stoppedByLines = false;
24946
+ for (const char of rest) {
24947
+ const charBytes = Buffer.byteLength(char, "utf8");
24948
+ if (lines >= limit) {
24949
+ stoppedByLines = true;
24950
+ break;
24951
+ }
24952
+ if (contentBytes + charBytes > maxBytes) {
24953
+ if (content === "") {
24954
+ content = char;
24955
+ contentBytes = charBytes;
24956
+ }
24957
+ stoppedByBytes = true;
24958
+ break;
24959
+ }
24960
+ content += char;
24961
+ contentBytes += charBytes;
24962
+ if (char === "\n") lines += 1;
24963
+ }
24964
+ const nextOffset = offset + Buffer.byteLength(content, "utf8");
24965
+ const complete = nextOffset >= totalBytes;
24966
+ const remaining = rest.slice(content.length);
24967
+ return {
24968
+ sessionId,
24969
+ refId,
24970
+ mode: "text",
24971
+ content,
24972
+ offset,
24973
+ nextOffset: complete ? null : nextOffset,
24974
+ complete,
24975
+ omitted: {
24976
+ reason: complete ? "none" : stoppedByBytes ? "bytes" : stoppedByLines ? "lines" : "bytes",
24977
+ chars: remaining.length,
24978
+ lines: countLines(remaining),
24979
+ rows: 0,
24980
+ bytes: Buffer.byteLength(remaining, "utf8")
24981
+ },
24982
+ totalChars: text.length,
24983
+ totalBytes,
24984
+ limit,
24985
+ maxBytes
24986
+ };
24987
+ }
24988
+ /**
24989
+ * 结构化行分页:先可靠解析,再按 `fields`/`equals` 过滤,最后按行取一页。
24990
+ *
24991
+ * 解析不可信就抛 `NOT_STRUCTURED`,而不是退回文本模式 —— 调用方要的是「按字段取」,
24992
+ * 悄悄给回一段原文等于让它以为字段过滤生效了。
24993
+ */
24994
+ function pageStructured(args) {
24995
+ const { sessionId, refId, text, buffer, offset, limit, maxBytes } = args;
24996
+ const parsed = parseStructuredResultRows(text);
24997
+ if (parsed === void 0) throw new CacheWarmupStoreError("stored result is not reliably structured rows", "NOT_STRUCTURED");
24998
+ const requested = args.fields === void 0 ? [...parsed.fields] : [...args.fields];
24999
+ for (const field of requested) if (!parsed.fields.includes(field)) throw new CacheWarmupStoreError(`unknown field "${field}"`, "FIELD_UNKNOWN");
25000
+ const equals = args.equals ?? {};
25001
+ for (const key of Object.keys(equals)) if (!parsed.fields.includes(key)) throw new CacheWarmupStoreError(`unknown field "${key}"`, "FIELD_UNKNOWN");
25002
+ const matched = parsed.rows.filter((row) => Object.entries(equals).every(([key, value]) => (row[key] ?? "") === value));
25003
+ if (!Number.isInteger(offset) || offset < 0) throw new CacheWarmupStoreError("offset must be a non-negative integer row index", "OFFSET_OUT_OF_RANGE");
25004
+ if (offset > matched.length) throw new CacheWarmupStoreError(`offset ${offset} is past the ${matched.length}-row result`, "OFFSET_OUT_OF_RANGE");
25005
+ const project = (row) => {
25006
+ const out = {};
25007
+ for (const field of requested) out[field] = row[field] ?? "";
25008
+ return out;
25009
+ };
25010
+ const serialized = (row) => JSON.stringify(project(row));
25011
+ const selected = [];
25012
+ let bytes = 0;
25013
+ let index = offset;
25014
+ let hitByteCap = false;
25015
+ while (index < matched.length && selected.length < limit) {
25016
+ const line = serialized(matched[index]);
25017
+ const lineBytes = Buffer.byteLength(line, "utf8") + (selected.length === 0 ? 0 : 1);
25018
+ if (bytes + lineBytes > maxBytes && selected.length > 0) {
25019
+ hitByteCap = true;
25020
+ break;
25021
+ }
25022
+ if (lineBytes > maxBytes) hitByteCap = true;
25023
+ selected.push(line);
25024
+ bytes += lineBytes;
25025
+ index += 1;
25026
+ }
25027
+ const remainingRows = matched.slice(index);
25028
+ const remainingText = remainingRows.map(serialized).join("\n");
25029
+ const complete = index >= matched.length;
25030
+ return {
25031
+ sessionId,
25032
+ refId,
25033
+ mode: "structured",
25034
+ content: selected.join("\n"),
25035
+ offset,
25036
+ nextOffset: complete ? null : index,
25037
+ complete,
25038
+ omitted: {
25039
+ reason: complete ? "none" : hitByteCap ? "bytes" : "rows",
25040
+ chars: remainingText.length,
25041
+ lines: remainingRows.length,
25042
+ rows: remainingRows.length,
25043
+ bytes: Buffer.byteLength(remainingText, "utf8")
25044
+ },
25045
+ totalChars: text.length,
25046
+ totalBytes: buffer.length,
25047
+ limit,
25048
+ maxBytes,
25049
+ fields: requested,
25050
+ totalRows: parsed.rows.length,
25051
+ matchedRows: matched.length
25052
+ };
25053
+ }
25054
+ /**
25055
+ * 文件系统结果存储。
25056
+ *
25057
+ * 目录布局:`<rootDir>/<sessionId>/<sha256>.txt`。会话 id 已经是单个安全路径段,
25058
+ * 所以「另一个 session 的引用」在路径上就构造不出来;即便如此 page 仍然先做归属校验,
25059
+ * 免得将来换布局时丢掉这道闸。
25060
+ */
25061
+ var FileResultReferenceStore = class {
25062
+ options;
25063
+ resolvedRootDir;
25064
+ /** @param options - 根目录与上限;全部可选。 */
25065
+ constructor(options = {}) {
25066
+ this.options = { ...options };
25067
+ }
25068
+ /** 当前生效的根目录。 */
25069
+ rootDir() {
25070
+ const configured = this.options.rootDir;
25071
+ if (configured !== void 0 && configured.trim() !== "") return path.resolve(configured);
25072
+ if (this.resolvedRootDir === void 0) this.resolvedRootDir = path.resolve(defaultResultStoreRootDir());
25073
+ return this.resolvedRootDir;
25074
+ }
25075
+ /** 单结果字节上限。 */
25076
+ maxResultBytes() {
25077
+ const value = this.options.maxResultBytes;
25078
+ return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : CACHE_WARMUP_DEFAULTS.maxResultBytes;
25079
+ }
25080
+ /** 每 session 字节总量上限。 */
25081
+ maxSessionBytes() {
25082
+ const value = this.options.maxSessionBytes;
25083
+ return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : CACHE_WARMUP_DEFAULTS.maxSessionBytes;
25084
+ }
25085
+ /** 当前时钟。 */
25086
+ now() {
25087
+ return this.options.now === void 0 ? Date.now() : this.options.now();
25088
+ }
25089
+ /** 一个会话的目录。 */
25090
+ sessionDir(sessionId) {
25091
+ return path.join(this.rootDir(), sessionId);
25092
+ }
25093
+ /** 一个会话里一个 digest 的文件路径。 */
25094
+ fileFor(sessionId, digest) {
25095
+ return path.join(this.sessionDir(sessionId), digest + RESULT_SUFFIX);
25096
+ }
25097
+ /**
25098
+ * 保存原文。
25099
+ *
25100
+ * 幂等:文件已存在就直接返回同一个 refId,不重写、不改 mtime。
25101
+ * 原子:先写同目录的临时文件再 rename,读到一半的引用不可能存在。
25102
+ * 权限:写 0600(Windows 上是尽力而为,测试按平台跳过权限断言)。
25103
+ */
25104
+ async put(input) {
25105
+ const sessionId = assertSessionId(input.sessionId);
25106
+ if (typeof input.content !== "string") throw new CacheWarmupStoreError("content must be a string", "CONTENT_INVALID");
25107
+ const bytes = Buffer.from(input.content, "utf8");
25108
+ const maxResultBytes = this.maxResultBytes();
25109
+ if (bytes.length > maxResultBytes) throw new CacheWarmupStoreError(`result is ${bytes.length} bytes, above the ${maxResultBytes}-byte single-result limit`, "RESULT_TOO_LARGE");
25110
+ const digest = createHash("sha256").update(bytes).digest("hex");
25111
+ const refId = formatResultRefId(sessionId, digest);
25112
+ const at = input.at ?? this.now();
25113
+ const target = this.fileFor(sessionId, digest);
25114
+ const existing = await fs.stat(target).catch(() => void 0);
25115
+ if (existing !== void 0 && existing.isFile()) return {
25116
+ refId,
25117
+ sessionId,
25118
+ digest,
25119
+ bytes: existing.size,
25120
+ chars: input.content.length,
25121
+ at,
25122
+ created: false
25123
+ };
25124
+ await fs.mkdir(this.sessionDir(sessionId), {
25125
+ recursive: true,
25126
+ mode: 448
25127
+ });
25128
+ await this.enforceSessionBudget(this.sessionDir(sessionId), bytes.length, target);
25129
+ const tmp = path.join(this.sessionDir(sessionId), `${digest}.${process.pid}.${Date.now()}${TMP_MARKER}`);
25130
+ await fs.writeFile(tmp, bytes, { mode: 384 });
25131
+ await fs.chmod(tmp, 384).catch(() => void 0);
25132
+ await fs.rename(tmp, target);
25133
+ await fs.chmod(target, 384).catch(() => void 0);
25134
+ return {
25135
+ refId,
25136
+ sessionId,
25137
+ digest,
25138
+ bytes: bytes.length,
25139
+ chars: input.content.length,
25140
+ at,
25141
+ created: true
25142
+ };
25143
+ }
25144
+ /**
25145
+ * 按 session 预算淘汰最旧的原文。
25146
+ *
25147
+ * 只按 mtime 从旧到新单个删除;删的是具体文件,路径来自 readdir,不含目录递归。
25148
+ * 淘汰永远不改任何历史 preview —— 那是调用方已经拿走的文本。
25149
+ */
25150
+ async enforceSessionBudget(dir, incoming, keep) {
25151
+ const budget = this.maxSessionBytes();
25152
+ if (incoming > budget) throw new CacheWarmupStoreError(`result is ${incoming} bytes, above the ${budget}-byte per-session limit`, "STORE_FULL");
25153
+ let entries;
25154
+ try {
25155
+ entries = await fs.readdir(dir, { withFileTypes: true });
25156
+ } catch {
25157
+ return;
25158
+ }
25159
+ const files = [];
25160
+ for (const entry of entries) {
25161
+ if (!entry.isFile() || !entry.name.endsWith(RESULT_SUFFIX)) continue;
25162
+ const file = path.join(dir, entry.name);
25163
+ if (file === keep) continue;
25164
+ const stats = await fs.stat(file).catch(() => void 0);
25165
+ if (stats === void 0) continue;
25166
+ files.push({
25167
+ file,
25168
+ size: stats.size,
25169
+ mtime: stats.mtimeMs
25170
+ });
25171
+ }
25172
+ let total = files.reduce((sum, file) => sum + file.size, 0);
25173
+ if (total + incoming <= budget) return;
25174
+ files.sort((left, right) => left.mtime - right.mtime);
25175
+ for (const file of files) {
25176
+ if (total + incoming <= budget) break;
25177
+ await fs.rm(file.file, { force: true }).catch(() => void 0);
25178
+ total -= file.size;
25179
+ }
25180
+ }
25181
+ /**
25182
+ * 读一页。
25183
+ *
25184
+ * 归属校验在碰文件系统之前完成:别人的引用不会告诉你它存在不存在。
25185
+ */
25186
+ async page(input) {
25187
+ const sessionId = assertSessionId(input.sessionId);
25188
+ const parsed = parseResultRefId(input.refId);
25189
+ if (parsed === void 0) throw new CacheWarmupStoreError("reference is not one this store issues", "REFERENCE_INVALID");
25190
+ if (parsed.sessionId !== sessionId) throw new CacheWarmupStoreError("reference does not belong to this session", "SESSION_MISMATCH");
25191
+ const file = this.fileFor(sessionId, parsed.digest);
25192
+ const stats = await fs.stat(file).catch(() => void 0);
25193
+ if (stats === void 0 || !stats.isFile()) throw new CacheWarmupStoreError("stored result is missing; it was released or evicted", "RESULT_NOT_FOUND");
25194
+ const ttlMs = this.options.ttlMs;
25195
+ if (ttlMs !== void 0 && ttlMs > 0 && this.now() - stats.mtimeMs > ttlMs) throw new CacheWarmupStoreError("stored result expired", "RESULT_EXPIRED");
25196
+ const buffer = await fs.readFile(file);
25197
+ const text = new TextDecoder("utf-8").decode(buffer);
25198
+ const limit = clampLimit(input.limit);
25199
+ const maxBytes = clampMaxBytes(input.maxBytes);
25200
+ const offset = input.offset ?? 0;
25201
+ if (input.fields !== void 0 || input.equals !== void 0) return pageStructured({
25202
+ sessionId,
25203
+ refId: input.refId,
25204
+ text,
25205
+ buffer,
25206
+ offset,
25207
+ limit,
25208
+ maxBytes,
25209
+ fields: input.fields,
25210
+ equals: input.equals
25211
+ });
25212
+ return pageText({
25213
+ sessionId,
25214
+ refId: input.refId,
25215
+ text,
25216
+ buffer,
25217
+ offset,
25218
+ limit,
25219
+ maxBytes
25220
+ });
25221
+ }
25222
+ /**
25223
+ * 释放一条引用:只删这一个具体文件,缺了就当已经释放(返回 false)。
25224
+ * 清理路径上不抛文件系统错误,免得在 finally 里把原始异常顶掉。
25225
+ */
25226
+ async release(input) {
25227
+ const sessionId = assertSessionId(input.sessionId);
25228
+ const parsed = parseResultRefId(input.refId);
25229
+ if (parsed === void 0) throw new CacheWarmupStoreError("reference is not one this store issues", "REFERENCE_INVALID");
25230
+ if (parsed.sessionId !== sessionId) throw new CacheWarmupStoreError("reference does not belong to this session", "SESSION_MISMATCH");
25231
+ const file = this.fileFor(sessionId, parsed.digest);
25232
+ const exists = await fs.stat(file).catch(() => void 0);
25233
+ if (exists === void 0 || !exists.isFile()) return false;
25234
+ try {
25235
+ await fs.rm(file, { force: true });
25236
+ return true;
25237
+ } catch {
25238
+ return false;
25239
+ }
25240
+ }
25241
+ };
25242
+ //#endregion
25243
+ //#region src/host/cache-warmup/usage.ts
25244
+ /**
25245
+ * 缓存用量登记表 —— 有界、只写不读消息、永不抛出。
25246
+ *
25247
+ * 为什么「永不抛出」是硬性契约:Kimi adapter 在 finally 路径里调用
25248
+ * `recordCacheWarmupUsage`。那个位置抛出的任何东西都会顶掉原始异常、把一次失败
25249
+ * 伪装成另一次失败,或者让重试/账号轮换逻辑读到半个状态。所以整个函数体被
25250
+ * 一层 catch 包住,坏输入、被污染的属性访问、内部容器异常一律降级成中性返回值。
25251
+ *
25252
+ * 有界:最多 {@link CACHE_WARMUP_DEFAULTS.maxUsageSessions} 个会话,
25253
+ * 每个会话最多 {@link CACHE_WARMUP_DEFAULTS.maxUsageReportsPerSession} 条报告。
25254
+ * 超出时淘汰最久未活动的会话 / 最旧的报告 —— 内存里只留「最近发生了什么」。
25255
+ *
25256
+ * @module dsh-chatgpt-subscription/host/cache-warmup/usage
25257
+ */
25258
+ /** 有界登记表:Map 的插入顺序同时是 LRU 顺序(命中即重新插入到队尾)。 */
25259
+ const registry = /* @__PURE__ */ new Map();
25260
+ /** 一个中性返回值,异常路径复用(不含任何容器操作)。 */
25261
+ function neutralState(sessionId) {
25262
+ return {
25263
+ sessionId,
25264
+ reports: 0,
25265
+ consecutiveHits: 0,
25266
+ hitRatio: 0
25267
+ };
25268
+ }
25269
+ /** 非负有限数;其他一律 0。 */
25270
+ function finiteCount(value) {
25271
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
25272
+ }
25273
+ /**
25274
+ * 一次请求的缓存命中率 = 命中缓存 token / (未命中 token + 命中 token)。
25275
+ *
25276
+ * 这里的两个字段是**互斥的两半**,不是整体与部分:Kimi/DSH 上报的
25277
+ * `inputTokens` 是这次真正送去计算的未命中部分,`cacheReadTokens` 是命中缓存、
25278
+ * 没有重算的部分。所以分母必须是两者之和。
25279
+ *
25280
+ * 用 `cached / inputTokens` 是错的,而且错得很隐蔽:只要命中数接近或超过未命中数,
25281
+ * 比值就会 ≥ 1 并被钳成 1,于是「连续两次 ≥ 0.97」的判据在真实流量的**第一次**请求上
25282
+ * 就成立,预热期形同不存在 —— 这正好是这套预算想防的那件事。
25283
+ *
25284
+ * 也不允许拿 `totalTokens` 当分母:那个字段的构成由上层决定(可能含输出 token、
25285
+ * 也可能已经包含缓存部分),用它会让命中率随无关字段漂移。
25286
+ *
25287
+ * @param usage - 至少含 inputTokens(未命中)与 cacheReadTokens(命中)的用量。
25288
+ * @returns 0-1;`input=0` 且 `cached>0` 时为 1;两者都为 0 或非法时为 0。
25289
+ */
25290
+ function cacheWarmupHitRatio(usage) {
25291
+ try {
25292
+ const input = finiteCount(usage.inputTokens);
25293
+ const cached = finiteCount(usage.cacheReadTokens);
25294
+ if (cached <= 0) return 0;
25295
+ const total = input + cached;
25296
+ if (total <= 0) return 0;
25297
+ return Math.min(1, cached / total);
25298
+ } catch {
25299
+ return 0;
25300
+ }
25301
+ }
25302
+ /**
25303
+ * 从最新一条往回数「连续高命中率」条数。
25304
+ *
25305
+ * 停止条件(任一):命中率低于阈值、该条带 driftCause、报告到头。
25306
+ * 「连续」是从最近往回数的,所以中间任何一次低命中或漂移都会把计数截断到 0。
25307
+ * @param reports - 按时间从旧到新排列的报告。
25308
+ * @returns 连续命中条数。
25309
+ */
25310
+ /** Kimi 对每个请求都会报告的生命周期状态;只有其余值才表示真实前缀漂移。 */
25311
+ const BENIGN_LIFECYCLE_CAUSES = /* @__PURE__ */ new Set(["stable", "first-request"]);
25312
+ function consecutiveWarmupHits(reports) {
25313
+ try {
25314
+ if (!Array.isArray(reports)) return 0;
25315
+ let hits = 0;
25316
+ for (let index = reports.length - 1; index >= 0; index -= 1) {
25317
+ const report = reports[index];
25318
+ if (report === void 0 || report === null) break;
25319
+ const drift = report.driftCause;
25320
+ if (typeof drift === "string" && drift.trim() !== "" && !BENIGN_LIFECYCLE_CAUSES.has(drift)) break;
25321
+ if (cacheWarmupHitRatio(report) < CACHE_WARMUP_DEFAULTS.hitRatioThreshold) break;
25322
+ hits += 1;
25323
+ }
25324
+ return hits;
25325
+ } catch {
25326
+ return 0;
25327
+ }
25328
+ }
25329
+ /** 把任意输入归一成一条用量报告;形状不对返回 undefined(绝不抛)。 */
25330
+ function normalizeUsage(input) {
25331
+ if (input === null || input === void 0 || typeof input !== "object") return void 0;
25332
+ const sessionId = input.sessionId;
25333
+ if (typeof sessionId !== "string" || sessionId.trim() === "") return void 0;
25334
+ const driftCause = typeof input.driftCause === "string" && input.driftCause.trim() !== "" ? input.driftCause : void 0;
25335
+ const at = typeof input.at === "number" && Number.isFinite(input.at) ? input.at : Date.now();
25336
+ const normalized = {
25337
+ sessionId,
25338
+ inputTokens: finiteCount(input.inputTokens),
25339
+ cacheReadTokens: finiteCount(input.cacheReadTokens),
25340
+ outputTokens: finiteCount(input.outputTokens),
25341
+ totalTokens: finiteCount(input.totalTokens),
25342
+ at
25343
+ };
25344
+ if (driftCause !== void 0) normalized.driftCause = driftCause;
25345
+ return normalized;
25346
+ }
25347
+ /** 由一份报告列表算出状态摘要。 */
25348
+ function stateOf(sessionId, reports) {
25349
+ const last = reports[reports.length - 1];
25350
+ return {
25351
+ sessionId,
25352
+ reports: reports.length,
25353
+ consecutiveHits: consecutiveWarmupHits(reports),
25354
+ hitRatio: last === void 0 ? 0 : cacheWarmupHitRatio(last),
25355
+ lastAt: last?.at
25356
+ };
25357
+ }
25358
+ /**
25359
+ * 登记一次用量报告。
25360
+ *
25361
+ * **total function**:任何输入、任何内部异常都不抛出,最差返回一份中性状态。
25362
+ * 有副作用(更新有界登记表),但不触碰消息、不发起请求、不改变请求参数。
25363
+ * @param input - 用量报告。
25364
+ * @returns 该会话登记之后的状态摘要。
25365
+ */
25366
+ function recordCacheWarmupUsage(input) {
25367
+ let sessionId = "";
25368
+ try {
25369
+ const usage = normalizeUsage(input);
25370
+ if (usage === void 0) return neutralState("");
25371
+ sessionId = usage.sessionId;
25372
+ const previous = registry.get(usage.sessionId);
25373
+ const kept = previous === void 0 ? [] : [...previous];
25374
+ kept.push(Object.freeze({ ...usage }));
25375
+ while (kept.length > CACHE_WARMUP_DEFAULTS.maxUsageReportsPerSession) kept.shift();
25376
+ if (previous !== void 0) registry.delete(usage.sessionId);
25377
+ registry.set(usage.sessionId, Object.freeze(kept));
25378
+ while (registry.size > CACHE_WARMUP_DEFAULTS.maxUsageSessions) {
25379
+ const oldest = registry.keys().next();
25380
+ if (oldest.done === true) break;
25381
+ registry.delete(oldest.value);
25382
+ }
25383
+ return stateOf(usage.sessionId, kept);
25384
+ } catch {
25385
+ try {
25386
+ if (sessionId !== "") registry.delete(sessionId);
25387
+ } catch {}
25388
+ return neutralState(sessionId);
25389
+ }
25390
+ }
25391
+ /**
25392
+ * 读一个会话的报告(从旧到新),不抛出。
25393
+ * @param sessionId - 会话 id。
25394
+ * @returns 报告副本;没有或读不到时为空数组。
25395
+ */
25396
+ function cacheWarmupUsageReports(sessionId) {
25397
+ try {
25398
+ if (typeof sessionId !== "string" || sessionId === "") return [];
25399
+ const reports = registry.get(sessionId);
25400
+ return reports === void 0 ? [] : [...reports];
25401
+ } catch {
25402
+ return [];
25403
+ }
25404
+ }
25405
+ /**
25406
+ * 列出所有有报告的会话,按最近活动倒序;不抛出。
25407
+ * @returns 会话摘要数组。
25408
+ */
25409
+ function cacheWarmupUsageSessions() {
25410
+ try {
25411
+ const summaries = [];
25412
+ for (const [sessionId, reports] of registry.entries()) {
25413
+ if (reports.length === 0) continue;
25414
+ const last = reports[reports.length - 1];
25415
+ summaries.push({
25416
+ sessionId,
25417
+ reports: reports.length,
25418
+ lastAt: last.at,
25419
+ hitRatio: cacheWarmupHitRatio(last)
25420
+ });
25421
+ }
25422
+ summaries.sort((left, right) => right.lastAt - left.lastAt);
25423
+ return summaries;
25424
+ } catch {
25425
+ return [];
25426
+ }
25427
+ }
25428
+ //#endregion
25429
+ //#region src/host/cache-warmup/budget.ts
25430
+ /**
25431
+ * 预热预算:预热期保守、连续命中后放大、漂移立刻收回。
25432
+ *
25433
+ * 控制器是**纯函数**:不发起请求、不改消息、不改登记表。它只回答一个问题 ——
25434
+ * 「这一次预览最多能占多少字符」。把决策与执行分开,才能保证预算收紧或放开
25435
+ * 都不会悄悄改写已经进入历史的文本(历史里的 preview 是常量)。
25436
+ *
25437
+ * @module dsh-chatgpt-subscription/host/cache-warmup/budget
25438
+ */
25439
+ /** 预热期的保守预算(构造一次,避免每次读模块常量)。 */
25440
+ const WARMUP_BUDGET = Object.freeze({
25441
+ maxChars: CACHE_WARMUP_DEFAULTS.warmupMaxChars,
25442
+ warmup: true,
25443
+ consecutiveHits: 0,
25444
+ hitRatio: 0
25445
+ });
25446
+ /**
25447
+ * 当前会话的预览预算。
25448
+ *
25449
+ * 规则(约束 4):预热期 `maxChars = 1200`(约 600-750 tokens);连续
25450
+ * `consecutiveHitsRequired`(2) 次命中率 ≥ 0.97 且期间没有 drift,才进入稳态
25451
+ * `maxChars = 8000`。任何一次 drift 都清零连续计数,于是下一条请求立刻回落到预热预算。
25452
+ *
25453
+ * **不抛出**:没有会话、没有报告、登记表异常,一律返回预热期的保守预算。
25454
+ * @param sessionId - 会话 id;不传则按最保守的预热预算处理。
25455
+ * @returns 本次投影可用的预算。
25456
+ */
25457
+ function cacheWarmupBudget(sessionId) {
25458
+ try {
25459
+ if (typeof sessionId !== "string" || sessionId.trim() === "") return { ...WARMUP_BUDGET };
25460
+ const reports = cacheWarmupUsageReports(sessionId);
25461
+ if (reports.length === 0) return { ...WARMUP_BUDGET };
25462
+ const consecutiveHits = consecutiveWarmupHits(reports);
25463
+ const last = reports[reports.length - 1];
25464
+ const hitRatio = last === void 0 ? 0 : cacheWarmupHitRatio(last);
25465
+ const warmup = consecutiveHits < CACHE_WARMUP_DEFAULTS.consecutiveHitsRequired;
25466
+ return {
25467
+ maxChars: warmup ? CACHE_WARMUP_DEFAULTS.warmupMaxChars : CACHE_WARMUP_DEFAULTS.steadyStateMaxChars,
25468
+ warmup,
25469
+ consecutiveHits,
25470
+ hitRatio
25471
+ };
25472
+ } catch {
25473
+ return { ...WARMUP_BUDGET };
25474
+ }
25475
+ }
25476
+ /**
25477
+ * 一份可读快照。**不抛出**,且完全无副作用(只读登记表)。
25478
+ * @param sessionId - 会话 id;不传则给全局汇总。
25479
+ * @returns 快照。
25480
+ */
25481
+ function cacheWarmupSnapshot(sessionId) {
25482
+ const fallback = {
25483
+ budget: { ...WARMUP_BUDGET },
25484
+ defaults: CACHE_WARMUP_DEFAULTS,
25485
+ usage: {
25486
+ reports: 0,
25487
+ hitRatio: 0,
25488
+ totals: {
25489
+ inputTokens: 0,
25490
+ cacheReadTokens: 0,
25491
+ outputTokens: 0,
25492
+ totalTokens: 0
25493
+ },
25494
+ driftCauses: []
25495
+ },
25496
+ sessions: 0
25497
+ };
25498
+ try {
25499
+ const budget = cacheWarmupBudget(sessionId);
25500
+ const reports = typeof sessionId === "string" && sessionId.trim() !== "" ? cacheWarmupUsageReports(sessionId) : [];
25501
+ const totals = {
25502
+ inputTokens: 0,
25503
+ cacheReadTokens: 0,
25504
+ outputTokens: 0,
25505
+ totalTokens: 0
25506
+ };
25507
+ const driftCauses = [];
25508
+ for (const report of reports) {
25509
+ totals.inputTokens += report.inputTokens;
25510
+ totals.cacheReadTokens += report.cacheReadTokens;
25511
+ totals.outputTokens += report.outputTokens;
25512
+ totals.totalTokens += report.totalTokens;
25513
+ const drift = report.driftCause;
25514
+ if (typeof drift === "string" && drift !== "" && !driftCauses.includes(drift)) driftCauses.push(drift);
25515
+ }
25516
+ const last = reports[reports.length - 1];
25517
+ const snapshot = {
25518
+ budget,
25519
+ defaults: CACHE_WARMUP_DEFAULTS,
25520
+ usage: {
25521
+ reports: reports.length,
25522
+ hitRatio: last === void 0 ? 0 : cacheWarmupHitRatio(last),
25523
+ totals,
25524
+ driftCauses
25525
+ },
25526
+ sessions: cacheWarmupUsageSessions().length
25527
+ };
25528
+ if (last !== void 0) snapshot.usage.lastAt = last.at;
25529
+ if (typeof sessionId === "string" && sessionId.trim() !== "") snapshot.sessionId = sessionId;
25530
+ return snapshot;
25531
+ } catch {
25532
+ return fallback;
25533
+ }
25534
+ }
25535
+ //#endregion
25536
+ //#region src/host/cache-warmup/projection.ts
25537
+ /**
25538
+ * 保守结构化投影:只有**可证明**的结构才换成预览,其余一律原样。
25539
+ *
25540
+ * 默认保守的四条线:
25541
+ *
25542
+ * - 小结果原样(不超过预算就不动),因为换来换去只会扰乱缓存前缀。
25543
+ * - 无法可靠解析的大文本原样;未知大文本原样;关键源码、接口/类型定义、子代理报告原样。
25544
+ * - 失败结果只允许换成「完整失败集」型预览(测试失败清单、shell 错误行),
25545
+ * 永远不会被压成一句计数 —— 计数看起来像结论,实际会把下一步的调试带偏。
25546
+ * - 预览必须自述完整度:带 ref、带省略字符数,读全文的路径写在预览里。
25547
+ *
25548
+ * 另一个不变量:本模块**不修改历史**。它返回一份新内容,调用方决定要不要写进新结果;
25549
+ * 已经进入历史的 preview 与其 refId 是常量,存储过期也不会回头改它。
25550
+ *
25551
+ * @module dsh-chatgpt-subscription/host/cache-warmup/projection
25552
+ */
25553
+ /** 子代理报告:一律原样,摘要会丢掉调用方真正要的判断依据。 */
25554
+ const TOOL_SUBAGENT = /(?:^|[-_/])(?:subagent|agent|task|delegate|ralph|teammate)(?:[-_/]|$)/i;
25555
+ /** 源码读取/写入类工具:一律原样。 */
25556
+ const TOOL_SOURCE = /(?:^|[-_/])(?:read|view|cat|open|edit|write|patch|apply_patch|notebook|document|diff)(?:[-_/]|$)/i;
25557
+ /** 一行文本的裁剪(超长行才裁剪,并留下省略号)。 */
25558
+ function clip(text, max) {
25559
+ return text.length <= max ? text : `${text.slice(0, max)}…`;
25560
+ }
25561
+ /**
25562
+ * 按预算装配预览正文。
25563
+ *
25564
+ * 必需段落要么整段进来,要么整体装不下就返回 undefined(调用方据此保持原文);
25565
+ * 可选段落逐段尝试,装不下就整段跳过。这样任何输出都不会出现「半截失败清单」。
25566
+ */
25567
+ function fitSections(sections, maxChars) {
25568
+ const lines = [];
25569
+ let used = 0;
25570
+ for (const section of sections) {
25571
+ if (section.optional === true) continue;
25572
+ for (const line of section.lines) {
25573
+ used += line.length + 1;
25574
+ lines.push(line);
25575
+ }
25576
+ }
25577
+ if (used > maxChars + 1) return void 0;
25578
+ for (const section of sections) {
25579
+ 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;
25582
+ for (const line of section.lines) lines.push(line);
25583
+ used += size;
25584
+ }
25585
+ return lines.join("\n");
25586
+ }
25587
+ /** 从测试输出里抽取「完整失败集」与汇总行;抽不全就整体放弃。 */
25588
+ function extractTestEvidence(text) {
25589
+ const summary = [];
25590
+ const failures = [];
25591
+ for (const raw of text.split("\n")) {
25592
+ const trimmed = raw.trim();
25593
+ if (trimmed === "") continue;
25594
+ if (/^(?:FAIL|FAILED|✗|✕|×)\s+\S/.test(trimmed) || /^not ok \d+ - /.test(trimmed) || /^●\s+\S/.test(trimmed) || /^--- FAIL:\s/.test(trimmed)) {
25595
+ failures.push(clip(trimmed, 240));
25596
+ continue;
25597
+ }
25598
+ if (/^Test Files\s+\d/.test(trimmed) || /^Tests\s+\d/.test(trimmed) || /^Test Suites:\s/.test(trimmed) || /^Snapshots:\s/.test(trimmed) || /^Time:\s/.test(trimmed) || /^={2,}.*\b\d+ (?:failed|passed|error)/.test(trimmed) || /^\d+ (?:failed|passed), \d+ (?:passed|failed)/.test(trimmed)) summary.push(clip(trimmed, 200));
25599
+ }
25600
+ if (summary.length === 0 && failures.length === 0) return void 0;
25601
+ if (summary.length === 0 && !/\b(?:test|tests|spec|suite|assert)\b/i.test(text.slice(0, 4e3))) return void 0;
25602
+ return {
25603
+ summary,
25604
+ failures
25605
+ };
25606
+ }
25607
+ /** 测试预览:失败集必须完整,汇总行紧随其后。 */
25608
+ function buildTestSections(evidence) {
25609
+ const sections = [{ lines: [`failures: ${evidence.failures.length} (complete set)`] }];
25610
+ if (evidence.failures.length > 0) sections.push({ lines: evidence.failures.map((line) => ` ${line}`) });
25611
+ if (evidence.summary.length > 0) sections.push({ lines: ["summary:", ...evidence.summary.map((line) => ` ${line}`)] });
25612
+ return sections;
25613
+ }
25614
+ /** 一行 shell 错误。用于「完整错误集」,所以匹配宁可宽一点。 */
25615
+ 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 结果的判定:无空白、像路径。 */
25617
+ function looksLikeGlobResult(text) {
25618
+ const lines = text.split("\n").filter((line) => line.trim() !== "");
25619
+ if (lines.length < 10) return false;
25620
+ let paths = 0;
25621
+ for (const line of lines) {
25622
+ const trimmed = line.trim();
25623
+ if (trimmed.length === 0 || trimmed.length > 400) continue;
25624
+ if (/\s/.test(trimmed)) continue;
25625
+ if (/^[-*+]$/.test(trimmed)) continue;
25626
+ paths += 1;
25627
+ }
25628
+ return paths / lines.length >= .9;
25629
+ }
25630
+ /** shell 输出判定:有退出码标记,或至少一行错误。 */
25631
+ function looksLikeShellOutput(text) {
25632
+ if (/(?:exit code|exited with code|process exited with code)[:=\s]+-?\d+/i.test(text)) return true;
25633
+ if (/\[exit code: -?\d+\]/i.test(text)) return true;
25634
+ if (/^Traceback \(most recent call last\):/m.test(text)) return true;
25635
+ const lines = text.split("\n");
25636
+ let errors = 0;
25637
+ 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;
25640
+ if (SHELL_ERROR_LINE.test(line)) errors += 1;
25641
+ if (errors >= 1) return true;
25642
+ }
25643
+ return false;
25644
+ }
25645
+ /** shell 预览:退出码 + 完整错误集(必需),首尾片段(可选)。 */
25646
+ function buildShellSections(text) {
25647
+ const lines = text.split("\n");
25648
+ const errors = [];
25649
+ for (const raw of lines) {
25650
+ const trimmed = raw.trim();
25651
+ if (trimmed === "") continue;
25652
+ if (/^\[exit code: -?\d+\]/i.test(trimmed)) continue;
25653
+ 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)}`);
25655
+ }
25656
+ if (errors.length > 300) return void 0;
25657
+ const exitMatch = /(?:exit code|exited with code|process exited with code)[:=\s]+(-?\d+)/i.exec(text) ?? /\[exit code: (-?\d+)\]/i.exec(text);
25658
+ const sections = [];
25659
+ if (exitMatch !== null) sections.push({ lines: [`exit code: ${exitMatch[1]}`] });
25660
+ sections.push({ lines: [`errors: ${errors.length} (complete set)`] });
25661
+ if (errors.length > 0) sections.push({ lines: errors });
25662
+ const nonEmpty = lines.map((line) => line.trimEnd()).filter((line) => line.trim() !== "");
25663
+ const head = nonEmpty.slice(0, 5).map((line) => ` ${clip(line.trim(), 160)}`);
25664
+ const tail = nonEmpty.slice(-5).map((line) => ` ${clip(line.trim(), 160)}`);
25665
+ if (head.length > 0) sections.push({
25666
+ lines: ["head:", ...head],
25667
+ optional: true
25668
+ });
25669
+ if (tail.length > 0) sections.push({
25670
+ lines: ["tail:", ...tail],
25671
+ optional: true
25672
+ });
25673
+ return sections;
25674
+ }
25675
+ /** 一行 grep 结果:`<path>:<line>[:<column>]:<text>`。 */
25676
+ const GREP_LINE = /^(.+?):(\d+):(?:\d+:)?(.*)$/;
25677
+ /** 抽取 grep 证据;命中线占比不到 80% 就当它不是 grep 输出。 */
25678
+ function extractGrepEvidence(text) {
25679
+ const lines = text.split("\n").filter((line) => line.trim() !== "");
25680
+ if (lines.length < 10) return void 0;
25681
+ const files = /* @__PURE__ */ new Map();
25682
+ let matched = 0;
25683
+ for (const line of lines) {
25684
+ const match = GREP_LINE.exec(line);
25685
+ if (match === null) continue;
25686
+ const file = match[1];
25687
+ if (!/[\\/.]/.test(file) || file.length > 400) continue;
25688
+ matched += 1;
25689
+ const entry = files.get(file);
25690
+ if (entry === void 0) files.set(file, {
25691
+ count: 1,
25692
+ first: match[3]
25693
+ });
25694
+ else entry.count += 1;
25695
+ }
25696
+ if (matched < 10 || matched / lines.length < .8) return void 0;
25697
+ return {
25698
+ files,
25699
+ total: matched
25700
+ };
25701
+ }
25702
+ /** grep 预览:命中总数与每文件计数(最多列 30 个文件,其余显式说明)。 */
25703
+ function buildGrepSections(evidence) {
25704
+ const ranked = [...evidence.files.entries()].sort((left, right) => right[1].count - left[1].count);
25705
+ const shown = ranked.slice(0, 30);
25706
+ const sections = [{ lines: [`matches: ${evidence.total} across ${ranked.length} files`] }, { lines: ["by file:", ...shown.map(([file, entry]) => ` ${clip(file, 120)}: ${entry.count}`)] }];
25707
+ if (ranked.length > shown.length) sections.push({ lines: [` … ${ranked.length - shown.length} more files (full list via ref)`] });
25708
+ sections.push({
25709
+ lines: ["first matches:", ...shown.slice(0, 8).map(([file, entry]) => ` ${clip(file, 80)}: ${clip(entry.first, 120)}`)],
25710
+ optional: true
25711
+ });
25712
+ return sections;
25713
+ }
25714
+ /** glob 预览:总数 + 扩展名分布(必需),样本路径(可选)。 */
25715
+ function buildGlobSections(text) {
25716
+ const paths = text.split("\n").map((line) => line.trim()).filter((line) => line !== "");
25717
+ const byExtension = /* @__PURE__ */ new Map();
25718
+ for (const entry of paths) {
25719
+ const dot = entry.lastIndexOf(".");
25720
+ const extension = dot > Math.max(entry.lastIndexOf("/"), entry.lastIndexOf("\\")) ? entry.slice(dot).toLowerCase() : "(none)";
25721
+ byExtension.set(extension, (byExtension.get(extension) ?? 0) + 1);
25722
+ }
25723
+ const top = [...byExtension.entries()].sort((left, right) => right[1] - left[1]).slice(0, 6);
25724
+ const sample = paths.slice(0, 10).map((entry) => ` ${clip(entry, 140)}`);
25725
+ if (paths.length > 10) sample.push(` … ${paths.length - 10} more`);
25726
+ return [
25727
+ { lines: [`paths: ${paths.length}`] },
25728
+ { lines: ["by extension:", ...top.map(([extension, count]) => ` ${extension}: ${count}`)] },
25729
+ {
25730
+ lines: ["sample:", ...sample],
25731
+ optional: true
25732
+ }
25733
+ ];
25734
+ }
25735
+ /** 结构化预览:行数与字段(必需),样本行(可选)。 */
25736
+ function buildStructuredSections(text) {
25737
+ const parsed = parseStructuredResultRows(text);
25738
+ if (parsed === void 0) return void 0;
25739
+ const sample = parsed.rows.slice(0, 3).map((row) => ` ${clip(JSON.stringify(row), 240)}`);
25740
+ return [{ lines: [`rows: ${parsed.rows.length}`, `fields: ${parsed.fields.join(", ")}`] }, {
25741
+ lines: ["sample:", ...sample],
25742
+ optional: true
25743
+ }];
25744
+ }
25745
+ /** 契约/类型定义:命中两行以上声明就当它是契约,保持原样。 */
25746
+ function looksLikeContract(text) {
25747
+ let declarations = 0;
25748
+ for (const line of text.split("\n").slice(0, 800)) {
25749
+ if (/^\s*(?:export\s+)?(?:declare\s+)?(?:interface|type)\s+[A-Za-z_$][\w$]*/.test(line)) declarations += 1;
25750
+ if (declarations >= 2) return true;
25751
+ }
25752
+ return false;
25753
+ }
25754
+ /** 源码判定:代码行占比过半(或含多处 markdown 代码围栏)。 */
25755
+ function looksLikeSourceCode(text) {
25756
+ let fences = 0;
25757
+ for (const line of text.split("\n").slice(0, 600)) if (line.trim().startsWith("```")) fences += 1;
25758
+ if (fences >= 4) return true;
25759
+ let code = 0;
25760
+ let nonEmpty = 0;
25761
+ for (const raw of text.split("\n").slice(0, 600)) {
25762
+ const trimmed = raw.trim();
25763
+ if (trimmed === "") continue;
25764
+ 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;
25766
+ }
25767
+ return nonEmpty >= 10 && code / nonEmpty >= .45;
25768
+ }
25769
+ /** 完整 JSON 文档或被截断的 JSON 对象:可能是批量源码/混合证据,不按裸日志猜测。 */
25770
+ function isJsonDocument(text) {
25771
+ const trimmed = text.trim();
25772
+ if (trimmed.length < 2) return false;
25773
+ const first = trimmed[0];
25774
+ const last = trimmed[trimmed.length - 1];
25775
+ if (first !== "{" && first !== "[") return false;
25776
+ if (first === "{" && last === "}" || first === "[" && last === "]") try {
25777
+ const value = JSON.parse(trimmed);
25778
+ return typeof value === "object" && value !== null;
25779
+ } catch {}
25780
+ if (first === "{") {
25781
+ let fields = 0;
25782
+ for (const line of trimmed.split("\n").slice(0, 24)) if (/^\s*"[^"]+"\s*:/.test(line)) fields += 1;
25783
+ return fields >= 2;
25784
+ }
25785
+ return false;
25786
+ }
25787
+ /**
25788
+ * 决定这一段文本该怎么处理。
25789
+ *
25790
+ * 顺序就是「可证明性」的顺序:子代理报告/源码工具/契约先被判成原样,
25791
+ * 然后是测试失败集、grep、结构化、glob、shell 这些有明确形状的,
25792
+ * 最后落到「无法解析 → 原样」。
25793
+ */
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"
25802
+ };
25803
+ if (looksLikeContract(text)) return {
25804
+ kind: "verbatim",
25805
+ strategy: "verbatim-contract"
25806
+ };
25807
+ if (isJsonDocument(text)) return {
25808
+ kind: "verbatim",
25809
+ strategy: "verbatim-unparsed"
25810
+ };
25811
+ if (looksLikeSourceCode(text)) return {
25812
+ kind: "verbatim",
25813
+ strategy: "verbatim-source-code"
25814
+ };
25815
+ const test = extractTestEvidence(text);
25816
+ if (test !== void 0) return {
25817
+ kind: "preview",
25818
+ strategy: "test-failure",
25819
+ sections: buildTestSections(test)
25820
+ };
25821
+ const grep = extractGrepEvidence(text);
25822
+ if (grep !== void 0) return {
25823
+ kind: "preview",
25824
+ strategy: "grep-results",
25825
+ sections: buildGrepSections(grep)
25826
+ };
25827
+ const structured = buildStructuredSections(text);
25828
+ if (structured !== void 0) return {
25829
+ kind: "preview",
25830
+ strategy: "structured-rows",
25831
+ sections: structured
25832
+ };
25833
+ if (looksLikeGlobResult(text)) return {
25834
+ kind: "preview",
25835
+ strategy: "glob-results",
25836
+ sections: buildGlobSections(text)
25837
+ };
25838
+ if (looksLikeShellOutput(text)) {
25839
+ const sections = buildShellSections(text);
25840
+ if (sections === void 0) return {
25841
+ kind: "verbatim",
25842
+ strategy: "verbatim-unparsed"
25843
+ };
25844
+ return {
25845
+ kind: "preview",
25846
+ strategy: "shell-output",
25847
+ sections
25848
+ };
25849
+ }
25850
+ return {
25851
+ kind: "verbatim",
25852
+ strategy: "verbatim-unparsed"
25853
+ };
25854
+ }
25855
+ /** 预览尾部:把「怎么读全文」写死在预览里,模型不用猜。 */
25856
+ function footerFor(refId) {
25857
+ return `[full result: ${refId} · page with offset/limit(default 64 lines)/maxBytes(default 16384 bytes)]`;
25858
+ }
25859
+ /**
25860
+ * 核心决策:预算 → 计划 → 装配 → 入库。
25861
+ *
25862
+ * 入库放在最后:预览装不进预算就不写文件,也不会留下一条没人引用的引用。
25863
+ * 入库失败一律降级为「保持原样」—— 没有 ref 的预览等于把原文丢了。
25864
+ */
25865
+ async function decide(args) {
25866
+ const { text, input, budget } = args;
25867
+ if (text.length <= budget.maxChars) return { reason: "verbatim-small" };
25868
+ const plan = planProjection(text, input.toolName);
25869
+ if (plan.kind === "verbatim") return { reason: plan.strategy };
25870
+ if (input.isError === true && plan.strategy !== "test-failure" && plan.strategy !== "shell-output") return { reason: "verbatim-unparsed" };
25871
+ const header = `[cache-warmup preview] strategy=${plan.strategy} input=${text.length}ch`;
25872
+ const footerBudget = footerFor(`ref://${input.sessionId}/${"0".repeat(64)}`).length;
25873
+ const bodyBudget = budget.maxChars - header.length - footerBudget - 2;
25874
+ if (bodyBudget <= 0) return { reason: "verbatim-budget" };
25875
+ const body = fitSections(plan.sections, bodyBudget);
25876
+ if (body === void 0) return { reason: "verbatim-budget" };
25877
+ const store = input.store;
25878
+ if (store === void 0) return { reason: "store-unavailable" };
25879
+ let reference;
25880
+ try {
25881
+ reference = await store.put({
25882
+ sessionId: input.sessionId,
25883
+ content: text,
25884
+ toolName: input.toolName,
25885
+ at: args.at
25886
+ });
25887
+ } catch {
25888
+ return { reason: "store-unavailable" };
25889
+ }
25890
+ const preview = `${header}\n${body}\n${footerFor(reference.refId)}`;
25891
+ if (preview.length > budget.maxChars) return { reason: "verbatim-budget" };
25892
+ return {
25893
+ preview,
25894
+ refId: reference.refId,
25895
+ strategy: plan.strategy
25896
+ };
25897
+ }
25898
+ /** 块数组里文本字符数的合计。 */
25899
+ function textCharsOf(blocks) {
25900
+ let total = 0;
25901
+ for (const block of blocks) if (typeof block.text === "string") total += block.text.length;
25902
+ return total;
25903
+ }
25904
+ /**
25905
+ * 投影一条工具结果。
25906
+ *
25907
+ * 纯函数式语义:不改入参、不写日志、不碰历史;返回新内容 + 度量。
25908
+ * 只有 `changed: true` 时才有 `refId`/`preview`。
25909
+ * @param input - 工具结果与预算。
25910
+ * @returns 投影结果。
25911
+ */
25912
+ async function projectToolResult(input) {
25913
+ const at = input.at ?? Date.now();
25914
+ const budget = input.budget ?? cacheWarmupBudget(input.sessionId);
25915
+ const base = {
25916
+ toolName: input.toolName,
25917
+ sessionId: input.sessionId,
25918
+ provider: input.provider,
25919
+ at
25920
+ };
25921
+ if (typeof input.content === "string") {
25922
+ const text = input.content;
25923
+ const decision = await decide({
25924
+ text,
25925
+ input,
25926
+ budget,
25927
+ at
25928
+ });
25929
+ if ("reason" in decision) return {
25930
+ ...base,
25931
+ content: input.content,
25932
+ changed: false,
25933
+ metrics: {
25934
+ inputChars: text.length,
25935
+ outputChars: text.length,
25936
+ omittedChars: 0,
25937
+ strategy: decision.reason
25938
+ }
25939
+ };
25940
+ return {
25941
+ ...base,
25942
+ content: decision.preview,
25943
+ changed: true,
25944
+ refId: decision.refId,
25945
+ preview: decision.preview,
25946
+ metrics: {
25947
+ inputChars: text.length,
25948
+ outputChars: decision.preview.length,
25949
+ omittedChars: Math.max(0, text.length - decision.preview.length),
25950
+ strategy: decision.strategy
25951
+ }
25952
+ };
25953
+ }
25954
+ const blocks = input.content;
25955
+ if (!(blocks.length === 1 && blocks[0]?.type === "text" && typeof blocks[0]?.text === "string")) {
25956
+ const chars = textCharsOf(blocks);
25957
+ const hasNonText = blocks.some((block) => block.type !== "text");
25958
+ return {
25959
+ ...base,
25960
+ content: input.content,
25961
+ changed: false,
25962
+ metrics: {
25963
+ inputChars: chars,
25964
+ outputChars: chars,
25965
+ omittedChars: 0,
25966
+ strategy: hasNonText ? "verbatim-non-text" : "verbatim-unparsed"
25967
+ }
25968
+ };
25969
+ }
25970
+ const text = blocks[0].text;
25971
+ const decision = await decide({
25972
+ text,
25973
+ input,
25974
+ budget,
25975
+ at
25976
+ });
25977
+ if ("reason" in decision) return {
25978
+ ...base,
25979
+ content: input.content,
25980
+ changed: false,
25981
+ metrics: {
25982
+ inputChars: text.length,
25983
+ outputChars: text.length,
25984
+ omittedChars: 0,
25985
+ strategy: decision.reason
25986
+ }
25987
+ };
25988
+ const rebuilt = {
25989
+ ...blocks[0],
25990
+ text: decision.preview
25991
+ };
25992
+ return {
25993
+ ...base,
25994
+ content: [rebuilt],
25995
+ changed: true,
25996
+ refId: decision.refId,
25997
+ preview: decision.preview,
25998
+ metrics: {
25999
+ inputChars: text.length,
26000
+ outputChars: decision.preview.length,
26001
+ omittedChars: Math.max(0, text.length - decision.preview.length),
26002
+ strategy: decision.strategy
26003
+ }
26004
+ };
26005
+ }
26006
+ //#endregion
24649
26007
  //#region src/host/common/request-images.ts
24650
26008
  function isRecord$41(value) {
24651
26009
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -31502,6 +32860,15 @@ function noteSessionCacheState(sessionId, state) {
31502
32860
  ...lastDrift === void 0 ? {} : { lastDrift },
31503
32861
  updatedAt: now
31504
32862
  });
32863
+ recordCacheWarmupUsage({
32864
+ sessionId,
32865
+ inputTokens: state.inputTokens,
32866
+ cacheReadTokens: state.cacheReadTokens,
32867
+ outputTokens: state.outputTokens,
32868
+ totalTokens: state.cacheReadTokens + state.inputTokens,
32869
+ at: now,
32870
+ ...lastDrift === void 0 ? {} : { driftCause: lastDrift }
32871
+ });
31505
32872
  }
31506
32873
  var KimiCodeAdapter = class extends LlmAdapter {
31507
32874
  store;
@@ -47075,7 +48442,7 @@ function sleep$2(ms, signal) {
47075
48442
  * so that spelling is accepted too — but only for the 127/8 block, never for a
47076
48443
  * v4-mapped public address.
47077
48444
  */
47078
- function isLoopbackAddress$1(address) {
48445
+ function isLoopbackAddress(address) {
47079
48446
  if (address === void 0) return false;
47080
48447
  if (address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1") return true;
47081
48448
  const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address);
@@ -47647,7 +49014,7 @@ const SUCCESS_PAGE = "Claude sign-in completed. You can close this window.";
47647
49014
  */
47648
49015
  function callbackHandler(request, response) {
47649
49016
  (async () => {
47650
- if (!isLoopbackAddress$1(request.socket.remoteAddress)) {
49017
+ if (!isLoopbackAddress(request.socket.remoteAddress)) {
47651
49018
  await writeHtml(response, 403, "This callback only answers on this machine.");
47652
49019
  return;
47653
49020
  }
@@ -53677,7 +55044,7 @@ function withTimeout$3(signal, ms) {
53677
55044
  return signal === void 0 ? timeout : AbortSignal.any([signal, timeout]);
53678
55045
  }
53679
55046
  /** 一句可以直接展示给用户的失败原因;正文只截前 200 字,且不含任何请求头。 */
53680
- function failure(detail) {
55047
+ function failure$1(detail) {
53681
55048
  return "GLM(ZCode 账号) " + detail;
53682
55049
  }
53683
55050
  /** 响应正文的一句话摘要(去换行、截断),用于把上游原因带给调用方。 */
@@ -53704,7 +55071,7 @@ function glmOpenAiProbeHeaders(codingPlanKey) {
53704
55071
  */
53705
55072
  function quotaRequestHeaders$1(codingPlanKey) {
53706
55073
  const key = codingPlanKey.trim();
53707
- if (key === "") throw new Error(failure("的配额接口需要一把 Coding Plan Key(裸值,不加 Bearer);空凭据不会变成一个能通过鉴权的请求。"));
55074
+ if (key === "") throw new Error(failure$1("的配额接口需要一把 Coding Plan Key(裸值,不加 Bearer);空凭据不会变成一个能通过鉴权的请求。"));
53708
55075
  return {
53709
55076
  "content-type": "application/json",
53710
55077
  accept: "application/json",
@@ -53786,13 +55153,13 @@ async function fetchCatalog(region, codingPlanKey, options = {}) {
53786
55153
  const text = await response.text().catch(() => "");
53787
55154
  if (!response.ok) {
53788
55155
  const detail = summarize(text);
53789
- throw new Error(failure("读取模型目录失败(HTTP " + response.status + "): " + url) + (detail === "" ? "" : ":" + detail));
55156
+ throw new Error(failure$1("读取模型目录失败(HTTP " + response.status + "): " + url) + (detail === "" ? "" : ":" + detail));
53790
55157
  }
53791
55158
  let payload;
53792
55159
  try {
53793
55160
  payload = JSON.parse(text);
53794
55161
  } catch {
53795
- throw new Error(failure("返回的模型目录不是可读的 JSON:" + url));
55162
+ throw new Error(failure$1("返回的模型目录不是可读的 JSON:" + url));
53796
55163
  }
53797
55164
  return parseCatalogListing(payload, region);
53798
55165
  }
@@ -54047,7 +55414,7 @@ async function fetchSubscriptionPlan(region, codingPlanKey, options = {}) {
54047
55414
  * 所以它同步抛出,与「上游读失败返回 `error`」那条路径分开。
54048
55415
  */
54049
55416
  async function fetchGlmQuota(region, codingPlanKey, options = {}) {
54050
- if (codingPlanKey.trim() === "") throw new Error(failure("的配额读取需要一把 Coding Plan Key(裸值,不加 Bearer)。"));
55417
+ if (codingPlanKey.trim() === "") throw new Error(failure$1("的配额读取需要一把 Coding Plan Key(裸值,不加 Bearer)。"));
54051
55418
  const accountId = options.accountId;
54052
55419
  const cached = quotaEntryFor(accountId);
54053
55420
  if (options.force !== true && cached !== null && Date.now() - cached.at < 12e4) return {
@@ -54073,7 +55440,7 @@ async function fetchGlmQuota(region, codingPlanKey, options = {}) {
54073
55440
  } catch (error) {
54074
55441
  return {
54075
55442
  snapshot: null,
54076
- error: failure("配额接口不可达:" + (error instanceof Error ? error.message : String(error))),
55443
+ error: failure$1("配额接口不可达:" + (error instanceof Error ? error.message : String(error))),
54077
55444
  fromCache: false
54078
55445
  };
54079
55446
  }
@@ -54082,7 +55449,7 @@ async function fetchGlmQuota(region, codingPlanKey, options = {}) {
54082
55449
  const detail = summarize(text);
54083
55450
  return {
54084
55451
  snapshot: null,
54085
- error: failure("配额查询失败(HTTP " + response.status + ")") + (detail === "" ? "" : ":" + detail),
55452
+ error: failure$1("配额查询失败(HTTP " + response.status + ")") + (detail === "" ? "" : ":" + detail),
54086
55453
  fromCache: false
54087
55454
  };
54088
55455
  }
@@ -54092,7 +55459,7 @@ async function fetchGlmQuota(region, codingPlanKey, options = {}) {
54092
55459
  } catch {
54093
55460
  return {
54094
55461
  snapshot: null,
54095
- error: failure("配额查询返回的不是 JSON。"),
55462
+ error: failure$1("配额查询返回的不是 JSON。"),
54096
55463
  fromCache: false
54097
55464
  };
54098
55465
  }
@@ -54105,7 +55472,7 @@ async function fetchGlmQuota(region, codingPlanKey, options = {}) {
54105
55472
  });
54106
55473
  if (snapshot === null) return {
54107
55474
  snapshot: null,
54108
- error: failure("上游没有返回可显示的额度窗口。"),
55475
+ error: failure$1("上游没有返回可显示的额度窗口。"),
54109
55476
  fromCache: false
54110
55477
  };
54111
55478
  return {
@@ -80937,746 +82304,489 @@ function releaseListener(handle) {
80937
82304
  }
80938
82305
  handle?.dispose?.();
80939
82306
  }
82307
+ //#endregion
82308
+ //#region src/host/cache-warmup/logging.ts
80940
82309
  /**
80941
- * 判断是否该催促。达到阈值即触发,注入之后调用方把计数清零。
80942
- * @param rounds - 自上次进展更新以来的连续轮次数。
80943
- * @returns 是否达到 {@link NUDGE_THRESHOLD}。
82310
+ * 把一条事件序列化成日志行。
82311
+ *
82312
+ * 形状写死在这里,而不是让调用方自己拼字符串:任何一次「顺手把 content 也带进
82313
+ * 去」的改动都只会发生在这个函数里,而它有测试盯着(断言日志里不含原文)。
82314
+ * 值为 `undefined` 的字段直接不出现在行里,保持每一行只描述它确实测到的指标。
82315
+ * @param event - 指标事件。
82316
+ * @returns 一行日志文本(带上本模块前缀)。
80944
82317
  */
80945
- function shouldNudge(rounds) {
80946
- return rounds >= 8;
82318
+ function formatCacheWarmupLog(event) {
82319
+ const record = {};
82320
+ for (const [key, value] of Object.entries(event)) if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") record[key] = value;
82321
+ return "[cache-warmup] " + JSON.stringify(record);
80947
82322
  }
80948
82323
  /**
80949
- * 把进展列表截断到环形缓冲上限,保留最新的一批。
80950
- * @param list - 任意长度的进展列表(不会被就地修改)。
80951
- * @returns 截断后的新数组。
82324
+ * 把一个异常压成**稳定的身份标签**,只用于宿主日志。
82325
+ *
82326
+ * 只取两样东西:`name`(构造器名;缺失或不是非空字符串时退成 `'Error'`)和
82327
+ * `code`(有就带上——`CacheWarmupStoreError` 与 Node 的系统错误都靠它区分)。
82328
+ *
82329
+ * **`message` 一律不进日志**,这是本函数存在的全部理由:异常消息里常常带着绝对
82330
+ * 路径、命令行片段、凭据,或者被读取结果的一段原文。一条日志就够把原文复制一份,
82331
+ * 或者把主机的目录结构写进部署方的日志管道。想知道"哪一类错误、来自哪一层",
82332
+ * name/code 就够了;想知道"具体哪一条",该去会话本身看,不该从日志里捞。
82333
+ * @param error - 捕获到的任意值。
82334
+ * @returns 形如 `Error`、`RangeError`、`CacheWarmupStoreError/SESSION_MISMATCH`
82335
+ * 的标签(`code` 是非空字符串时以 `/` 连接)。
82336
+ */
82337
+ function cacheWarmupErrorLabel(error) {
82338
+ if (typeof error !== "object" || error === null) return "Error";
82339
+ const record = error;
82340
+ const name = typeof record.name === "string" && record.name !== "" ? record.name : "Error";
82341
+ const code = typeof record.code === "string" && record.code !== "" ? record.code : void 0;
82342
+ return code === void 0 ? name : name + "/" + code;
82343
+ }
82344
+ /**
82345
+ * 发一条指标日志。
82346
+ *
82347
+ * logger 缺席时**安静跳过**:日志不是功能,装不上日志服务的宿主不该因此装不上
82348
+ * 这个模块。写入失败同样吞掉——一条日志把工具调用搞挂是最不划算的失败。
82349
+ * @param logger - 宿主 logger(可选)。
82350
+ * @param event - 指标事件。
82351
+ * @returns 写出的那一行(调用方/测试可以拿去断言);没写出去时 undefined。
80952
82352
  */
80953
- function capUpdates(list) {
80954
- return list.length <= 50 ? [...list] : list.slice(list.length - 50);
82353
+ function logCacheWarmup(logger, event) {
82354
+ const line = formatCacheWarmupLog(event);
82355
+ const info = logger?.info;
82356
+ if (typeof info !== "function") return void 0;
82357
+ try {
82358
+ info.call(logger, line);
82359
+ } catch {
82360
+ return;
82361
+ }
82362
+ return line;
82363
+ }
82364
+ //#endregion
82365
+ //#region src/host/cache-warmup/result-tool.ts
82366
+ /** 注册的工具名。冻结契约的一部分,不要改动。 */
82367
+ const CACHE_WARMUP_RESULT_TOOL = "cache_warmup_result";
82368
+ /** 是不是一个可用的非负整数。 */
82369
+ function isCount(value) {
82370
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
82371
+ }
82372
+ /** 读一个未知值为有限数字(`null` 不算:契约用 `null` 表示"没有下一页")。 */
82373
+ function numberOf$1(value) {
82374
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
82375
+ }
82376
+ /** 读一个未知值为布尔。 */
82377
+ function booleanOf$1(value) {
82378
+ return typeof value === "boolean" ? value : void 0;
82379
+ }
82380
+ /** 读一个未知值为非空字符串。 */
82381
+ function stringOf$1(value) {
82382
+ return typeof value === "string" && value !== "" ? value : void 0;
80955
82383
  }
80956
- /** 进程内自增序号,只用于拼出稳定 id;不含任何运行时依赖。 */
80957
- let sequence = 0;
80958
82384
  /**
80959
- * 生成一条进展的稳定 id。
80960
- * @param at - 产生时刻(Unix 毫秒)。
80961
- * @returns 形如 `pu-<时间戳>-<序号>` 的字符串。
82385
+ * 从存储交回来的 `omitted` 里读出省略字符数。
82386
+ *
82387
+ * 契约给的是一个结构化对象(`{ reason, chars, lines, rows, bytes }`,`reason: 'none'`
82388
+ * 表示这一页就是全部);这里同时容忍一个裸数字,因为"省略了多少字符"是唯一
82389
+ * 模型真正需要的那个数,而形状变化不该让整页读取失败。
82390
+ * @param value - 存储返回的 `omitted`。
82391
+ * @returns 省略的字符数;读不到时 undefined。
80962
82392
  */
80963
- function nextUpdateId(at) {
80964
- sequence += 1;
80965
- return `pu-${at}-${sequence}`;
82393
+ function omittedCharsOf(value) {
82394
+ const direct = numberOf$1(value);
82395
+ if (direct !== void 0) return direct;
82396
+ if (typeof value !== "object" || value === null) return void 0;
82397
+ return numberOf$1(value.chars);
80966
82398
  }
80967
82399
  /**
80968
- * 创建一个空的进展更新 store。
80969
- * @returns 新的会话维度 store。
82400
+ * 造一个失败值。
82401
+ * @param action - 回显的动作。
82402
+ * @param refId - 被拒绝的引用。
82403
+ * @param sessionId - 当前会话(拿不到时为空串)。
82404
+ * @param code - 稳定错误码。
82405
+ * @param message - 一句话说明。
82406
+ * @returns 规范值。
80970
82407
  */
80971
- function createProgressUpdateStore() {
80972
- let current = {
80973
- updates: [],
80974
- roundsSinceUpdate: 0,
80975
- nudges: 0
82408
+ function failure(action, refId, sessionId, code, message) {
82409
+ return {
82410
+ ok: false,
82411
+ action,
82412
+ refId,
82413
+ sessionId,
82414
+ error: {
82415
+ code,
82416
+ message
82417
+ }
80976
82418
  };
82419
+ }
82420
+ /**
82421
+ * 把未知异常压成一句不含原文的话。
82422
+ *
82423
+ * 异常消息里可能带着被读取内容的片段(解析错误最常见),所以只取消息的第一行,
82424
+ * 长度也截断——工具输出不该成为原文的第二条出口。
82425
+ * @param error - 捕获到的值。
82426
+ * @returns 一句话描述。
82427
+ */
82428
+ function describe(error) {
82429
+ const firstLine = (error instanceof Error ? error.message : String(error)).split("\n", 1)[0] ?? "";
82430
+ return firstLine.length > 160 ? firstLine.slice(0, 157) + "..." : firstLine;
82431
+ }
82432
+ /**
82433
+ * 取结构化错误码:存储自己的码优先,否则用本工具给的兜底码。
82434
+ * @param error - 捕获到的值。
82435
+ * @param fallback - 兜底码。
82436
+ * @returns 错误码。
82437
+ */
82438
+ function errorCodeOf(error, fallback) {
82439
+ const code = error?.code;
82440
+ return typeof code === "string" && code !== "" ? code : fallback;
82441
+ }
82442
+ /**
82443
+ * 把共享存储交回来的页读成规范值。
82444
+ *
82445
+ * 字段一律按「读得到就用、读不到就不出现」处理:核心侧多给字段不会让这里报错,
82446
+ * 少给字段也不会伪造一个 `complete:true`——「不确定是否读完」必须表现成
82447
+ * `complete:false`,把 partial 伪装成完整是这条路线上最贵的一种错。
82448
+ * @param raw - 存储的返回值(`ResultPage`,按共享约定结构化读取)。
82449
+ * @param refId - 引用。
82450
+ * @param sessionId - 会话。
82451
+ * @returns 规范值。
82452
+ */
82453
+ function pageValue(raw, refId, sessionId) {
82454
+ const page = typeof raw === "object" && raw !== null ? raw : {};
82455
+ const text = stringOf$1(page.content) ?? stringOf$1(page.text);
82456
+ const complete = booleanOf$1(page.complete) === true;
82457
+ const offset = numberOf$1(page.offset);
82458
+ const nextOffset = complete ? void 0 : numberOf$1(page.nextOffset);
82459
+ const omitted = omittedCharsOf(page.omitted);
82460
+ const totalChars = numberOf$1(page.totalChars);
80977
82461
  return {
80978
- state: () => current,
80979
- push: (update) => {
80980
- current = {
80981
- ...current,
80982
- updates: capUpdates([...current.updates, update])
80983
- };
80984
- return current;
80985
- },
80986
- bumpRound: () => {
80987
- current = {
80988
- ...current,
80989
- roundsSinceUpdate: current.roundsSinceUpdate + 1
80990
- };
80991
- return current.roundsSinceUpdate;
80992
- },
80993
- markUpdated: () => {
80994
- current = {
80995
- ...current,
80996
- roundsSinceUpdate: 0
80997
- };
80998
- return current;
80999
- },
81000
- markNudged: () => {
81001
- current = {
81002
- ...current,
81003
- roundsSinceUpdate: 0,
81004
- nudges: current.nudges + 1
81005
- };
81006
- return current;
81007
- },
81008
- reset: () => {
81009
- current = {
81010
- updates: [],
81011
- roundsSinceUpdate: 0,
81012
- nudges: 0
81013
- };
81014
- return current;
82462
+ ok: true,
82463
+ action: "page",
82464
+ refId,
82465
+ sessionId,
82466
+ complete,
82467
+ ...offset === void 0 ? {} : { offset },
82468
+ ...nextOffset === void 0 ? {} : { nextOffset },
82469
+ ...omitted === void 0 ? {} : { omitted },
82470
+ ...totalChars === void 0 ? {} : { totalChars },
82471
+ ...text === void 0 ? {} : {
82472
+ text,
82473
+ chars: text.length
81015
82474
  }
81016
82475
  };
81017
82476
  }
81018
- //#endregion
81019
- //#region src/host/progress-updates/tool.ts
81020
- /** 注册的工具名。冻结契约的一部分,不要改动。 */
81021
- const NOTIFY_USER_TOOL = "progress_update";
81022
- /** 工具关闭时返回给模型的文本(canonical 输出的 render,而不是抛错)。 */
81023
- const DISABLED_OUTPUT_TEXT = "Progress updates are disabled; the update was not displayed.";
81024
- /** 工具描述的最终全文。 */
81025
- const PROGRESS_UPDATE_DESCRIPTION = [
81026
- "Report progress to the user without ending your turn.",
81027
- "Two updates are part of the job, not optional narration: send one BEFORE your first tool call of a multi-step task (the plan), and one BEFORE your final answer (what was done and what was verified).",
81028
- "Use it during the rest of the work so the user can follow along and correct course early:",
81029
- "state your plan near the beginning of the task (what you will do, and in what order);",
81030
- "report a meaningful conclusion the moment you have it (a key fact, the root cause, a verified assumption) rather than narrating every step;",
81031
- "give a stage judgement when one phase ends and the next begins (where things stand, what happens next);",
81032
- "explain what you are waiting for before a long wait such as a test run, an install, or a build;",
81033
- "and say what blocks you — and what you will try next, or what you need from the user — when you are stuck.",
81034
- "Each update has one short title line (plain text, no Markdown, no trailing period) with the details in the message body.",
81035
- "Do not repeat a status that has not changed.",
81036
- "When you run as a subagent, describe only your own subtask: finishing your subtask is not the same as finishing the overall task."
81037
- ].join(" ");
81038
- /**
81039
- * 构造 progress_update 工具。
81040
- * @param deps - 依赖:当前开关与进展接收端。
82477
+ /**
82478
+ * 造 `cache_warmup_result` 工具。
82479
+ * @param deps - 依赖:线路校验、存储读取、指标日志。
81041
82480
  * @returns 可注册的工具定义。
81042
82481
  */
81043
- function createProgressUpdateTool(deps) {
82482
+ function createCacheWarmupResultTool(deps) {
82483
+ /** 发一条日志;安装方没接线时什么也不做。 */
82484
+ const log = (event) => {
82485
+ try {
82486
+ deps.log?.(event);
82487
+ } catch {}
82488
+ };
81044
82489
  return defineTool({
81045
- name: NOTIFY_USER_TOOL,
81046
- description: PROGRESS_UPDATE_DESCRIPTION,
82490
+ name: CACHE_WARMUP_RESULT_TOOL,
82491
+ description: [
82492
+ "Read back a result that was replaced by a preview, using the `ref://<session>/<sha256>` handle written in that preview.",
82493
+ "Use `action: \"page\"` to read a slice: pass `offset`, and optionally `limit`, `maxBytes`, `fields` and `equals`.",
82494
+ "Every page reports `complete`, `nextOffset`, `omitted` and `totalChars`, so you can see whether more remains instead of assuming a slice is the whole thing.",
82495
+ "Use `action: \"release\"` when the stored copy is no longer needed."
82496
+ ].join(" "),
81047
82497
  parameters: {
81048
- title: {
82498
+ action: {
81049
82499
  type: "string",
81050
- description: "One line, about 60 characters at most, in the user's language: plain text, no Markdown, no trailing period."
82500
+ required: true,
82501
+ description: "page reads a slice of the stored result; release drops the stored copy.",
82502
+ enum: ["page", "release"]
81051
82503
  },
81052
- message: {
82504
+ refId: {
81053
82505
  type: "string",
81054
82506
  required: true,
81055
- description: "The details, about 1000 characters at most, in light Markdown: one paragraph of prose plus a few bullets. Tables are supported: when several items share fields, use a GFM pipe table (header row plus a | --- | separator row) instead of one item per line."
82507
+ description: "The `ref://<session>/<sha256>` handle exactly as written in the preview."
82508
+ },
82509
+ offset: {
82510
+ type: "integer",
82511
+ description: "Where to start: a UTF-8 byte offset for text results, a row index for structured ones."
82512
+ },
82513
+ limit: {
82514
+ type: "integer",
82515
+ description: "Maximum number of rows for a structured result."
82516
+ },
82517
+ maxBytes: {
82518
+ type: "integer",
82519
+ description: "Byte budget for this page; the store returns less when the budget is smaller."
82520
+ },
82521
+ fields: {
82522
+ type: "array",
82523
+ items: { type: "string" },
82524
+ description: "Restrict a structured result to these fields."
82525
+ },
82526
+ equals: {
82527
+ type: "object",
82528
+ additionalProperties: true,
82529
+ description: "Keep only structured rows whose named field equals the given string."
81056
82530
  }
81057
82531
  },
81058
82532
  output: {
81059
82533
  schema: {
81060
82534
  type: "object",
81061
82535
  additionalProperties: false,
81062
- properties: { displayed: {
81063
- type: "boolean",
81064
- description: "Whether the update was actually shown to the user."
81065
- } }
82536
+ properties: {
82537
+ ok: {
82538
+ type: "boolean",
82539
+ description: "Whether the call succeeded."
82540
+ },
82541
+ action: {
82542
+ type: "string",
82543
+ enum: ["page", "release"],
82544
+ description: "The action that ran."
82545
+ },
82546
+ refId: {
82547
+ type: "string",
82548
+ description: "The handle this call addressed."
82549
+ },
82550
+ sessionId: {
82551
+ type: "string",
82552
+ description: "The session that owns the handle."
82553
+ },
82554
+ complete: {
82555
+ type: "boolean",
82556
+ description: "Whether this page reached the end of the stored result."
82557
+ },
82558
+ offset: {
82559
+ type: "number",
82560
+ description: "Where this page actually started."
82561
+ },
82562
+ nextOffset: {
82563
+ type: "number",
82564
+ description: "Where the next page should start."
82565
+ },
82566
+ omitted: {
82567
+ type: "number",
82568
+ description: "Characters still not returned after this page."
82569
+ },
82570
+ chars: {
82571
+ type: "number",
82572
+ description: "Characters in this page."
82573
+ },
82574
+ totalChars: {
82575
+ type: "number",
82576
+ description: "Characters in the whole stored result."
82577
+ },
82578
+ released: {
82579
+ type: "boolean",
82580
+ description: "Whether a stored copy was actually released."
82581
+ },
82582
+ text: {
82583
+ type: "string",
82584
+ description: "This page of the stored result."
82585
+ },
82586
+ error: {
82587
+ type: "object",
82588
+ additionalProperties: false,
82589
+ properties: {
82590
+ code: {
82591
+ type: "string",
82592
+ description: "Stable error code."
82593
+ },
82594
+ message: {
82595
+ type: "string",
82596
+ description: "One-line explanation."
82597
+ }
82598
+ }
82599
+ }
82600
+ }
81066
82601
  },
81067
82602
  render(_args, value) {
82603
+ if (value.ok !== true) {
82604
+ const code = value.error?.code ?? "FAILED";
82605
+ const message = value.error?.message ?? "the request was refused";
82606
+ return [{
82607
+ type: "text",
82608
+ text: "cache_warmup_result " + value.action + " refused (" + code + "): " + message
82609
+ }];
82610
+ }
82611
+ if (value.action === "release") return [{
82612
+ type: "text",
82613
+ text: value.released === true ? "Released " + value.refId + ". The preview in your history is unchanged; it can no longer be read back." : "Nothing stored for " + value.refId + "; nothing to release."
82614
+ }];
82615
+ const at = value.offset ?? 0;
82616
+ const span = typeof value.chars === "number" ? value.chars + " chars" : "no content";
82617
+ const total = typeof value.totalChars === "number" ? " of " + value.totalChars : "";
82618
+ const tail = value.complete === true ? "complete" : "more remains" + (typeof value.nextOffset === "number" ? ", continue at offset " + value.nextOffset : "") + (typeof value.omitted === "number" ? ", " + value.omitted + " chars omitted" : "");
82619
+ const body = typeof value.text === "string" && value.text !== "" ? value.text : "(empty page)";
81068
82620
  return [{
81069
82621
  type: "text",
81070
- text: value.displayed ? "Progress update displayed." : DISABLED_OUTPUT_TEXT
82622
+ text: value.refId + " from offset " + at + " — " + span + total + ", " + tail + "\n\n" + body
81071
82623
  }];
81072
82624
  }
81073
82625
  },
81074
82626
  async execute(args, exec) {
81075
- if (!deps.isEnabled()) return { displayed: false };
81076
- const sink = deps.sink?.();
81077
- if (sink === void 0) return { displayed: false };
81078
- const at = Date.now();
81079
- const source = deps.sourceOf?.(exec);
81080
- const update = {
81081
- id: nextUpdateId(at),
81082
- message: args.message,
81083
- at,
81084
- ...args.title === void 0 || args.title.trim() === "" ? {} : { title: args.title.trim() },
81085
- ...source === void 0 ? {} : { source }
82627
+ const route = deps.routeOf(exec);
82628
+ const refId = args.refId;
82629
+ const action = args.action;
82630
+ if (route.provider !== deps.provider) return failure(action, refId, route.sessionId ?? "", "WRONG_PROVIDER", "ref handles belong to the " + deps.provider + " line; this session is routed to \"" + (route.provider ?? "unknown") + "\"");
82631
+ const sessionId = route.sessionId;
82632
+ if (sessionId === void 0) return failure(action, refId, "", "NO_SESSION", "the current session could not be resolved, so the handle cannot be checked");
82633
+ const store = deps.store();
82634
+ if (store === void 0) return failure(action, refId, sessionId, "NO_STORE", "the result store is unavailable on this host");
82635
+ if (action === "release") {
82636
+ let released = false;
82637
+ try {
82638
+ const raw = await store.release({
82639
+ sessionId,
82640
+ refId
82641
+ });
82642
+ released = booleanOf$1(raw) ?? booleanOf$1(raw?.released) ?? false;
82643
+ } catch (error) {
82644
+ return failure(action, refId, sessionId, errorCodeOf(error, "STORE_FAILED"), "releasing the stored copy failed: " + describe(error));
82645
+ }
82646
+ log({
82647
+ event: "ref-release",
82648
+ session: sessionId,
82649
+ refId,
82650
+ released
82651
+ });
82652
+ return {
82653
+ ok: true,
82654
+ action,
82655
+ refId,
82656
+ sessionId,
82657
+ released
82658
+ };
82659
+ }
82660
+ for (const [name, value] of [
82661
+ ["offset", args.offset],
82662
+ ["limit", args.limit],
82663
+ ["maxBytes", args.maxBytes]
82664
+ ]) if (value !== void 0 && !isCount(value)) return failure(action, refId, sessionId, "BAD_ARGUMENT", name + " must be a non-negative integer");
82665
+ const request = {
82666
+ sessionId,
82667
+ refId
81086
82668
  };
81087
- return { displayed: sink.push(update) };
81088
- }
81089
- });
81090
- }
81091
- //#endregion
81092
- //#region src/host/progress-updates/nudge.ts
81093
- /** 注入到模型上下文里的催促正文。 */
81094
- const NUDGE_TEXT = [
81095
- "You have made a run of tool calls without reporting progress to the user.",
81096
- "If you are still in the middle of multi-step work, send one progress update now with the progress_update tool:",
81097
- "one short title line and the details in the body — what you have established so far, where things stand, and what you are doing next.",
81098
- "Skip it when there is genuinely nothing new to report."
81099
- ].join(" ");
81100
- /** 催促上下文在消息来源上的 kind(供客户端与日志区分这类注入)。 */
81101
- const NUDGE_SOURCE_KIND = "progress-updates-nudge";
81102
- /**
81103
- * 造一条催促上下文。
81104
- * @param sequence - 本会话第几次催促,写进 notice 摘要便于日志辨认。
81105
- * @returns 可直接放进 `additionalContexts` 的用户角色消息。
81106
- */
81107
- function createNudgeContext(sequence) {
81108
- return createUserMessage({
81109
- content: [{
81110
- type: "text",
81111
- text: NUDGE_TEXT
81112
- }],
81113
- source: {
81114
- kind: NUDGE_SOURCE_KIND,
81115
- form: "notice",
81116
- summary: `progress update nudge #${sequence}`
82669
+ if (args.offset !== void 0) request.offset = args.offset;
82670
+ if (args.limit !== void 0) request.limit = args.limit;
82671
+ if (args.maxBytes !== void 0) request.maxBytes = args.maxBytes;
82672
+ if (args.fields !== void 0) request.fields = args.fields;
82673
+ if (args.equals !== void 0) request.equals = args.equals;
82674
+ let raw;
82675
+ try {
82676
+ raw = await store.page(request);
82677
+ } catch (error) {
82678
+ return failure(action, refId, sessionId, errorCodeOf(error, "STORE_FAILED"), "reading the stored copy failed: " + describe(error));
82679
+ }
82680
+ const value = pageValue(raw, refId, sessionId);
82681
+ log({
82682
+ event: "ref-page",
82683
+ session: sessionId,
82684
+ refId,
82685
+ offset: value.offset ?? args.offset ?? 0,
82686
+ returnedChars: value.chars ?? 0,
82687
+ complete: value.complete === true,
82688
+ ...value.nextOffset === void 0 ? {} : { nextOffset: value.nextOffset }
82689
+ });
82690
+ return value;
81117
82691
  }
81118
82692
  });
81119
82693
  }
81120
- /**
81121
- * 找出一次工具调用所属的**计数会话**:子会话归并到最近的顶层祖先,整棵委派
81122
- * 树共用一个计数器。
81123
- * @param agent - 事件里的调用方 agent。
81124
- * @param sessions - 会话查找表,缺省时退化为只认调用方自己的会话。
81125
- * @returns 计数用的会话 id,无法判定时返回 undefined。
81126
- */
81127
- function counterSessionIdOf(agent, sessions) {
81128
- const own = stringOf$1(agent?.session?.header?.id) ?? stringOf$1(agent?.id);
81129
- if (own === void 0) return void 0;
81130
- if (sessions === void 0) return own;
81131
- let current = own;
81132
- let header = agent?.session?.header;
81133
- for (let depth = 0; depth < 16 && current !== void 0; depth += 1) {
81134
- const resolved = header ?? sessions.get(current)?.header;
81135
- if (resolved === void 0) return current;
81136
- const parentId = stringOf$1(resolved.parentSession);
81137
- if (parentId === void 0) return current;
81138
- const parent = sessions.get(parentId);
81139
- if (parent === void 0) return current;
81140
- header = parent.header;
81141
- current = parentId;
81142
- }
81143
- return current;
81144
- }
81145
- /**
81146
- * 读一次工具调用所属的「步」标识,用于把同一个模型步里的多条工具结果合并成一轮。
81147
- *
81148
- * 拿不到 turnBoundary(没有 agent-loop,或宿主不是真实会话)时返回 undefined,
81149
- * 调用方退化为按工具结果计数——宁可多计,也不要漏掉催促。
81150
- * @param agent - 事件里的调用方 agent。
81151
- * @param projections - 会话投影服务,可选。
81152
- * @returns 该步的唯一键,或 undefined。
81153
- */
81154
- function stepKeyOf(agent, projections) {
81155
- if (projections === void 0) return void 0;
81156
- const session = agent?.session;
81157
- if (session === void 0) return void 0;
81158
- try {
81159
- const seq = projections.stateOf(session, "turnBoundary")?.lastStepStartSeq;
81160
- return typeof seq === "number" ? `step:${seq}` : void 0;
81161
- } catch {
81162
- return;
81163
- }
81164
- }
81165
- /**
81166
- * 读一个未知字段为字符串。
81167
- * @param value - 候选值。
81168
- * @returns 字符串,或任何其它类型的 undefined。
81169
- */
81170
- function stringOf$1(value) {
81171
- return typeof value === "string" && value !== "" ? value : void 0;
81172
- }
81173
82694
  //#endregion
81174
- //#region src/host/progress-updates/prompt.ts
82695
+ //#region src/host/cache-warmup/install.ts
81175
82696
  /**
81176
- * 系统提示词引导:让模型知道「多步任务里要主动汇报进展」这件事该怎么用。
81177
- *
81178
- * 文本用函数形式提供(而不是静态字符串):开关关闭时返回空串,装配期就地
81179
- * 抹掉引导,不需要重启会话、也不需要重挂插件行。
81180
- *
81181
- * ## 为什么是英文
82697
+ * 本模块服务的线路。
81182
82698
  *
81183
- * 提示词是写给模型看的,不是写给用户看的:同一段要求在英文下最短、歧义最少,
81184
- * 也和工具描述(`tool.ts` 的 `PROGRESS_UPDATE_DESCRIPTION`,本来就是英文)保持
81185
- * 同一套措辞。用户看到的语言由 {@link progressLanguageDirective} 单独钉住——
81186
- * 「指令用英文」与「输出用中文」是两件事,不要混在一起。
81187
- *
81188
- * @module dsh-chatgpt-subscription/host/progress-updates/prompt
82699
+ * 结果投影的正确性依赖这条线路的缓存语义(内容前缀命中),别的线路上把结果换成
82700
+ * 预览只有坏处没有好处,所以这里写死一个 provider 名而不是做成开关。
81189
82701
  */
82702
+ const CACHE_WARMUP_PROVIDER = "kimi-code";
82703
+ /** 提示词分节名,全局唯一(重复注册会抛错)。 */
82704
+ const CACHE_WARMUP_SECTION_NAME = "plugin:cache-warmup";
81190
82705
  /**
81191
- * 本模块提示词分节的名称,全局唯一(重复注册会抛错)。
82706
+ * 一段**恒定**的引导词:告诉模型预览是什么、怎么补读。
81192
82707
  *
81193
- * 保留原插件的 `plugin:progress-updates` 拼写:会话里已有的分节名不能因为搬家
81194
- * 而变成第二个分节。
81195
- */
81196
- const PROGRESS_SECTION_NAME = "plugin:progress-updates";
81197
- /** 允许的取值,供设置解析做白名单校验(配置里写错就等于没写)。 */
81198
- const PROGRESS_UPDATE_LANGUAGES = [
81199
- "auto",
81200
- "zh",
81201
- "en"
81202
- ];
81203
- /**
81204
- * 引导词正文。写法对齐 Kimi 的「进展更新」:先说清多步任务早期的思路,再报
81205
- * 有意义的结论、阶段判断、长等待与阻塞;每条给一行标题,细节放正文;不重复
81206
- * 没有变化的状态。开头与结尾两条被提成硬要求——没有它们,面板在任务的两端
81207
- * 都是空的。
82708
+ * 文本与开关无关,这是刻意的——系统提示是缓存前缀的第一段,会话中途改一个字就
82709
+ * 让整段前缀失效、整段重新计费。开关的语义只交给 post-execute 与工具。
81208
82710
  */
81209
- const PROGRESS_GUIDANCE = [
81210
- "## Progress updates",
81211
- "",
81212
- "During multi-step work you can call `progress_update` to report progress without ending your turn and without spending a question: report, then keep working.",
81213
- "",
81214
- "**Two updates are mandatory — missing either one means the job is not finished:**",
82711
+ const CACHE_WARMUP_GUIDANCE = [
82712
+ "## Result previews",
81215
82713
  "",
81216
- "- **Opening**: before the first tool call of the task, send one update stating the plan (which steps, what you touch first). The user must be able to redirect you before you disappear into execution.",
81217
- "- **Closing**: after the work is done and before your final answer, send one update stating the result (what changed, what was verified, what is left). The panel and the conversation both close on this line.",
82714
+ "On large `run_code` results this line may replace the output with a preview: a short description plus one or more `ref://<session>/<sha256>` handles.",
81218
82715
  "",
81219
- "Beyond those two, these moments are worth a report:",
81220
- "- **Early planning**: the opening update covers it; report again only when the plan really changes (different approach, smaller scope, a broken premise).",
81221
- "- **A meaningful conclusion**: a key fact, a root cause, a verified assumption — report the conclusion itself, not \"I just read a file\".",
81222
- "- **A stage judgement**: one phase ends and the next begins — one line on where things stand and what happens next.",
81223
- "- **Before a long wait**: a test run, an install, a build — say what you are waiting for and roughly how long.",
81224
- "- **When blocked**: missing information, missing permission, an external failure — say where you are stuck, what you will try next, or what you need from the user.",
81225
- "",
81226
- "How to write one:",
81227
- "- One short title line (plain text, no Markdown, no trailing period); the details go in the message body.",
81228
- "- The body uses light Markdown: one paragraph of prose plus a few bullets. **When several rows share fields, use a GFM pipe table** (header row plus a `| --- |` separator row) instead of one item per line.",
81229
- "- Do not repeat a status that has not changed: if the last update already said it is running, it needs no second report.",
81230
- "- A progress update is for the user, not for the log: write it in human words, do not restate tool arguments.",
81231
- "",
81232
- "When you run as a subagent: describe only your own subtask progress. Finishing your subtask is not the same as finishing the overall task — do not report the overall progress as done."
82716
+ "- A preview is **not** the full result. It states `complete`, `omitted` and `nextOffset` explicitly; treat an incomplete preview as incomplete data, not as the whole output.",
82717
+ "- To read the original, call `cache_warmup_result` with `action: \"page\"` and that `refId`, then follow `nextOffset` until `complete` is true.",
82718
+ "- `action: \"release\"` drops the stored copy. The preview already in the conversation stays identical; it simply can no longer be read back.",
82719
+ "- Source code, type and contract definitions, mixed evidence and anything that could not be parsed reliably are never replaced: they arrive in full."
81233
82720
  ].join("\n");
81234
82721
  /**
81235
- * 每个语言档位对应的一句指令。
81236
- *
81237
- * 只约束输出的标题与正文,不约束引导词本身;三条都必须自洽——`auto` 不许在
81238
- * 用户说中文时滑回英文,`zh`/`en` 则明确压过对话语言。
82722
+ * 静态引导词的文本提供者。
82723
+ * @returns 一个永远返回同一段文本的函数(系统提示的稳定前缀)。
81239
82724
  */
81240
- const PROGRESS_LANGUAGE_DIRECTIVES = {
81241
- auto: "Write the title and the message of every progress update in the language the user is writing in, whatever it is. Follow the conversation; do not fall back to English unless the user does.",
81242
- zh: "Write the title and the message of every progress update in Simplified Chinese, even when the conversation is in another language.",
81243
- en: "Write the title and the message of every progress update in English, even when the conversation is in another language."
81244
- };
81245
- /**
81246
- * 取一个语言档位的指令文本;未知取值按 `auto` 处理。
81247
- * @param language - 配置里读到的语言档位。
81248
- * @returns 追加到引导词末尾的一句指令。
81249
- */
81250
- function progressLanguageDirective(language) {
81251
- return PROGRESS_LANGUAGE_DIRECTIVES[language] ?? PROGRESS_LANGUAGE_DIRECTIVES.auto;
82725
+ function cacheWarmupSectionText() {
82726
+ return () => CACHE_WARMUP_GUIDANCE;
81252
82727
  }
81253
82728
  /**
81254
- * 生成提示词分节的文本提供者。
81255
- *
81256
- * 文本与开关无关,这是刻意的:系统提示是缓存前缀的第一段,会话中途改一个字
81257
- * 就会让整段前缀失效、整段重新计费(Kimi 的缓存是内容前缀匹配)。开关的语义
81258
- * 因此只留给工具自己——关闭时 `execute` 直接返回 `displayed:false`。
81259
- * @param language - 读取当前输出语言的函数;缺省按 `auto` 处理。
81260
- * @returns 引导词加语言指令。
81261
- */
81262
- function progressSectionText(language = () => "auto") {
81263
- return () => PROGRESS_GUIDANCE + "\n\n" + progressLanguageDirective(language());
81264
- }
81265
- //#endregion
81266
- //#region src/host/progress-updates/projection.ts
81267
- /**
81268
- * 「进展更新」的会话投影:把日志里的 progress_update 调用折叠成一条条进展。
81269
- *
81270
- * ## 为什么要有它
81271
- *
81272
- * 进展更新原来只活在进程内的一个 Map 里(`store.ts` 的环形缓冲):重开对话、
81273
- * 刷新页面、宿主重启之后那份状态就没了,用户看到的是「进展面板空了」。而数据其实
81274
- * 一直没丢 —— `progress_update` 的每次调用本身就是一条 `tool/call` 会话事件,
81275
- * 早已被 DSH 持久化。所以这里把**日志**当成唯一真源:事件提交时折叠一次,读的
81276
- * 时候就是一份可以直接回放的历史。
82729
+ * 读一次工具调用所属的线路。
81277
82730
  *
81278
- * ## 为什么不 import 投影包
81279
- *
81280
- * `@deepseek-ai/dsh-session-projection` **不在** peerDependencies 里:本插件要
81281
- * 同时支持 0.1.2-alpha.5 ~ 0.2.1-rc.2,那一串宿主里有的根本没有这个包,运行时
81282
- * import 会让整个插件在那代宿主上装载失败。所以这里按**结构化类型**自己声明
81283
- * 契约(与 `nudge.ts` 的 `NudgeSessionLookup` / `TurnBoundaryLookup` 同一套
81284
- * 做法),运行时只经 `ctx.get('sessionProjections')` 拿服务。
81285
- *
81286
- * ## 折叠是日志重放路径,任何情况都不许抛错
81287
- *
81288
- * `apply` 在每条提交事件上跑一次。这里抛出一次,毁掉的不只是本单元:重放链路
81289
- * 会当场断掉,会话可能连读都读不出来。所以对事件形状一律**不信任** —— 不是
81290
- * tool/call、不是 progress_update、arguments 不是合法 JSON、message 不是字符串、
81291
- * seq/time 不是数字 —— 全部原样返回**同一个 state 引用**(`Object.is` 相等,
81292
- * 下游零工作)。
81293
- *
81294
- * ## id 为什么用 seq,还要带上会话 id
81295
- *
81296
- * 客户端(`src/client/progress-updates/host-source.ts` 的 `sameList`)按 id 比较
81297
- * 两份快照,面板列表也拿 id 当 React key。时间戳会随重放时的时钟漂移,seq 不会:
81298
- * 同一段日志重放两次必须得到**逐字相同**的 id。会话 id 后缀解决的是另一半问题 ——
81299
- * 读值要合并整棵委派树(见 `index.ts` 的 `projectionUpdatesOf`),而 seq 在
81300
- * 每个会话里都从 0 开始,只带 seq 的话根会话与子会话的第一条进展会撞 id。
81301
- *
81302
- * @module dsh-chatgpt-subscription/host/progress-updates/projection
81303
- */
81304
- /** 投影键:宿主读状态、客户端取 wire 值都用这个名字。 */
81305
- const PROGRESS_UPDATES_PROJECTION_KEY = "progressUpdates";
81306
- /** 单条进展的 schema:与 `ProgressUpdate` 逐字同形。 */
81307
- const UPDATE_SCHEMA = z$1.object({
81308
- id: z$1.string(),
81309
- title: z$1.string().optional(),
81310
- message: z$1.string(),
81311
- at: z$1.number(),
81312
- source: z$1.string().optional()
81313
- });
81314
- /** 折叠状态的 schema(宿主状态)。 */
81315
- const progressUpdatesStateSchema = z$1.object({
81316
- updates: z$1.array(UPDATE_SCHEMA),
81317
- source: z$1.string().optional()
81318
- });
81319
- /** wire 值的 schema(客户端看到的数组)。 */
81320
- const progressUpdatesViewSchema = z$1.array(UPDATE_SCHEMA);
81321
- /**
81322
- * 造一条进展的稳定 id。
81323
- * @param seq - 事件在会话日志里的序号(重放稳定)。
81324
- * @param source - 所属会话 id;拿不到时省略后缀。
81325
- * @returns 形如 `pu-<seq>-<sessionId>` 的字符串。
81326
- */
81327
- function updateIdOf(seq, source) {
81328
- return source === void 0 ? "pu-" + seq : "pu-" + seq + "-" + source;
81329
- }
81330
- /**
81331
- * 读一个未知值为非空字符串。
81332
- * @param value - 候选值。
81333
- * @returns 非空字符串,其它类型的 undefined。
81334
- */
81335
- function stringOf(value) {
81336
- return typeof value === "string" && value !== "" ? value : void 0;
81337
- }
81338
- /**
81339
- * 把 `tool/call` 里的 `arguments` 原文解析成进展内容。
81340
- *
81341
- * **任何一点不对就返回 undefined**(而不是抛错,也不是造一条半截进展):这是重放
81342
- * 路径,一条形状坏掉的历史事件只该被跳过。
81343
- * @param raw - 工具入参 JSON 原文。
81344
- * @returns 正文与(去空白后的)可选标题。
82731
+ * provider 先问会话的 request header(那是本轮请求真正走的那条线),再退回
82732
+ * agent 自己的 options。两处都没有就返回空对象——调用方据此放手,而不是猜一个。
82733
+ * @param agent - 事件里的调用方 agent。
82734
+ * @returns 线路信息;读不到的字段省略。
81345
82735
  */
81346
- function parseArguments(raw) {
81347
- if (typeof raw !== "string") return void 0;
81348
- let value;
82736
+ function cacheWarmupRouteOf(agent) {
82737
+ const record = typeof agent === "object" && agent !== null ? agent : void 0;
82738
+ const session = record?.session;
82739
+ let provider;
81349
82740
  try {
81350
- value = JSON.parse(raw);
82741
+ provider = stringOf(session?.requestHeader?.()?.config?.provider);
81351
82742
  } catch {
81352
- return;
82743
+ provider = void 0;
81353
82744
  }
81354
- if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
81355
- const record = value;
81356
- if (typeof record.message !== "string") return void 0;
81357
- const title = typeof record.title === "string" ? record.title.trim() : "";
81358
- return title === "" ? { message: record.message } : {
81359
- message: record.message,
81360
- title
81361
- };
81362
- }
81363
- /**
81364
- * 把 PTC 子调用里的 `arguments`(**已解析的对象**)读成进展内容。
81365
- *
81366
- * 与 {@link parseArguments} 只差一层:那条路径拿到的是模型吐出的 JSON 原文,
81367
- * 这条拿到的是宿主已经归一化过的对象(见 `dsh-tools` 的 `PtcDispatchEventData`)。
81368
- * 同样是"任何一点不对就返回 undefined",同样不抛错。
81369
- * @param raw - 已归一化的入参对象。
81370
- * @returns 正文与(去空白后的)可选标题。
81371
- */
81372
- function parseDispatchedArguments(raw) {
81373
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
81374
- const record = raw;
81375
- if (typeof record.message !== "string") return void 0;
81376
- const title = typeof record.title === "string" ? record.title.trim() : "";
81377
- return title === "" ? { message: record.message } : {
81378
- message: record.message,
81379
- title
81380
- };
81381
- }
81382
- /**
81383
- * 造一个进展折叠单元。
81384
- *
81385
- * 每次登记都要一份**新的**定义对象:它是纯函数集合,没有跨会话的状态,但把
81386
- * `init` 闭包里的会话头漏进来就会把不同会话串成一份。
81387
- * @returns 可交给 `sessionProjections.register` 的定义。
81388
- */
81389
- function progressUpdatesProjection() {
82745
+ if (provider === void 0) provider = stringOf(record?.options?.provider);
82746
+ const sessionId = stringOf(session?.header?.id) ?? stringOf(record?.id);
81390
82747
  return {
81391
- key: PROGRESS_UPDATES_PROJECTION_KEY,
81392
- stateVersion: 2,
81393
- stateSchema: progressUpdatesStateSchema,
81394
- init(header) {
81395
- const source = stringOf(header?.id);
81396
- return source === void 0 ? { updates: [] } : {
81397
- updates: [],
81398
- source
81399
- };
81400
- },
81401
- apply(state, event) {
81402
- const isNative = event?.type === "tool/call";
81403
- const isDispatch = event?.type === "tool/ptc-dispatch";
81404
- if (!isNative && !isDispatch) return state;
81405
- const data = event.data;
81406
- if (typeof data !== "object" || data === null) return state;
81407
- if (data.name !== "progress_update") return state;
81408
- const body = isNative ? parseArguments(data.arguments) : parseDispatchedArguments(data.arguments);
81409
- if (body === void 0) return state;
81410
- const seq = event.seq;
81411
- if (typeof seq !== "number" || !Number.isFinite(seq)) return state;
81412
- const at = event.time;
81413
- if (typeof at !== "number" || !Number.isFinite(at)) return state;
81414
- const update = {
81415
- id: updateIdOf(seq, state.source),
81416
- message: body.message,
81417
- at,
81418
- ...body.title === void 0 ? {} : { title: body.title },
81419
- ...state.source === void 0 ? {} : { source: state.source }
81420
- };
81421
- return {
81422
- ...state,
81423
- updates: capUpdates([...state.updates, update])
81424
- };
81425
- },
81426
- wire: {
81427
- viewSchema: progressUpdatesViewSchema,
81428
- view: (state) => state.updates
81429
- }
82748
+ ...provider === void 0 ? {} : { provider },
82749
+ ...sessionId === void 0 ? {} : { sessionId }
81430
82750
  };
81431
82751
  }
81432
82752
  /**
81433
- * 读一个会话上折叠出来的进展。
81434
- *
81435
- * 两条读取路径,都按**不可用就当没读到**处理(返回 undefined,调用方回退到内存
81436
- * 实现):先问 `stateOf`(宿主状态,最直接),没有再问 `snapshot`(wire 切面,
81437
- * 老一点的投影服务上只有它)。读出来的值一律过 schema,形状不对就当投影不可用 ——
81438
- * 宁可用进程内那份,也不要把一份坏数据渲染成历史。
81439
- * @param projections - 会话投影服务。
81440
- * @param session - 会话对象。
81441
- * @returns 该会话的进展;投影不可用时 undefined。
81442
- */
81443
- function readProjectedUpdates(projections, session) {
81444
- const stateOf = projections.stateOf;
81445
- if (typeof stateOf === "function") {
81446
- let state;
81447
- try {
81448
- state = stateOf.call(projections, session, PROGRESS_UPDATES_PROJECTION_KEY);
81449
- } catch {
81450
- return;
81451
- }
81452
- if (state === void 0) return void 0;
81453
- const parsed = progressUpdatesStateSchema.safeParse(state);
81454
- return parsed.success ? parsed.data.updates : void 0;
81455
- }
81456
- const snapshot = projections.snapshot;
81457
- if (typeof snapshot !== "function") return void 0;
81458
- let values;
81459
- try {
81460
- values = snapshot.call(projections, session, [PROGRESS_UPDATES_PROJECTION_KEY])?.values;
81461
- } catch {
81462
- return;
81463
- }
81464
- if (values === void 0) return void 0;
81465
- const parsed = progressUpdatesViewSchema.safeParse(values[PROGRESS_UPDATES_PROJECTION_KEY]);
81466
- return parsed.success ? parsed.data : void 0;
81467
- }
81468
- /**
81469
- * 把多个会话的进展合并成一份。
81470
- *
81471
- * 按 `at` 升序排(同一毫秒时按输入顺序,JS 的排序是稳定的:传入方把"被查询的
81472
- * 会话自己"放在最前面),再按 {@link MAX_UPDATES} 截断保留最新的。整棵委派树共用
81473
- * 一份进展是今天的语义,所以合并后的上限与单会话一致,不随子会话数量膨胀。
81474
- * @param lists - 各会话各自的进展(每条已各自截断过)。
81475
- * @returns 合并后的进展,最多 {@link MAX_UPDATES} 条。
81476
- */
81477
- function mergeProjectedUpdates(lists) {
81478
- const merged = [].concat(...lists.map((list) => [...list]));
81479
- merged.sort((left, right) => left.at - right.at);
81480
- return capUpdates(merged);
81481
- }
81482
- //#endregion
81483
- //#region src/host/progress-updates/routes.ts
81484
- /** 进展读取路由的完整路径。 */
81485
- const PROGRESS_UPDATES_PATH = "/api/dsh-progress-updates/updates";
81486
- /** 路由默认的 JSON 响应头。 */
81487
- const JSON_HEADERS = {
81488
- "content-type": "application/json; charset=utf-8",
81489
- "referrer-policy": "no-referrer"
81490
- };
81491
- /**
81492
- * 断言本次请求来自本机。
82753
+ * 预热窗口结束时,本次算「稳住了」还是「掉了」。
81493
82754
  *
81494
- * 进展内容会包含用户在会话里做的事,属于个人数据,所以只有 loopback(桌面端
81495
- * 自己)能读:socket 地址是权威判断,Host 头必须也是 loopback 权威(挡住 DNS
81496
- * 重绑定),`X-Forwarded-For` 永远不信任。
81497
- * @param request - 进来的 HTTP 请求。
81498
- * @returns 是否放行。
82755
+ * 阈值取核心契约里的 `consecutiveHitsRequired`(连续高命中率报告条数),而不是在
82756
+ * 这里另编一个命中率——判定标准只有核心一份,日志只如实记录它当时的结论。
82757
+ * @param consecutiveHits - 退出时的连续命中条数;读不到时 undefined。
82758
+ * @returns 稳定的退出原因。
81499
82759
  */
81500
- function isLoopbackRequest(request) {
81501
- if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
81502
- const host = request.headers.host;
81503
- if (typeof host !== "string" || host === "") return false;
81504
- let hostname;
81505
- try {
81506
- hostname = new URL("http://" + host).hostname.toLowerCase();
81507
- } catch {
81508
- return false;
81509
- }
81510
- if (!isLoopbackHostname(hostname)) return false;
81511
- return request.headers["sec-fetch-site"] !== "cross-site";
81512
- }
81513
- /** socket 远端地址是否落在 loopback 范围(127/8、::1、IPv4-mapped)。 */
81514
- function isLoopbackAddress(address) {
81515
- if (address === void 0) return false;
81516
- const normalized = address.toLowerCase();
81517
- if (normalized === "::1") return true;
81518
- if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
81519
- return isIPv4Loopback(normalized);
81520
- }
81521
- /** IPv4 127/8 判定(四段十进制,首段为 127)。 */
81522
- function isIPv4Loopback(v4) {
81523
- const parts = v4.split(".");
81524
- return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
81525
- }
81526
- /** 主机名是否指向 loopback 权威(localhost、[::1]、127/8)。 */
81527
- function isLoopbackHostname(hostname) {
81528
- if (hostname === "localhost" || hostname === "[::1]") return true;
81529
- return isIPv4Loopback(hostname);
81530
- }
81531
- /**
81532
- * 写一个 JSON 响应。
81533
- * @param res - 响应对象。
81534
- * @param status - HTTP 状态码。
81535
- * @param body - 要序列化的值。
81536
- * @param headers - 追加或覆盖的头。
81537
- */
81538
- function writeJson(res, status, body, headers = {}) {
81539
- res.writeHead(status, {
81540
- ...JSON_HEADERS,
81541
- ...headers
81542
- });
81543
- res.end(JSON.stringify(body));
81544
- }
81545
- /**
81546
- * 进展读取路由:`GET /api/dsh-progress-updates/updates?session=<id>`。
81547
- *
81548
- * 返回 `{ updates: ProgressUpdate[] }`。响应带 no-store:这个列表随 agent 干活
81549
- * 实时变化,缓存住就等于展示过期进展。
81550
- * @param reader - 进展读取面。
81551
- * @returns 可注册的 route。
81552
- */
81553
- function makeProgressUpdatesRoute(reader) {
81554
- return {
81555
- kind: "exact",
81556
- path: PROGRESS_UPDATES_PATH,
81557
- handler: (req, res) => {
81558
- if (!isLoopbackRequest(req)) {
81559
- writeJson(res, 403, {
81560
- ok: false,
81561
- error: "forbidden: loopback-only"
81562
- });
81563
- return;
81564
- }
81565
- const session = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("session") ?? "";
81566
- writeJson(res, 200, { updates: reader.updatesOf(session) }, { "cache-control": "no-store" });
81567
- }
81568
- };
81569
- }
81570
- //#endregion
81571
- //#region src/host/progress-updates/index.ts
81572
- /**
81573
- * 读取一个配置字段,沿着 volatile 活引用取当前值。
81574
- * @param field - 字段本身。
81575
- * @returns 当前值,或字段缺失时的 undefined。
81576
- */
81577
- function readProgressConfigField(field) {
81578
- if (field === void 0 || field === null) return void 0;
81579
- if (typeof field === "object" && typeof field.get === "function") return field.get();
81580
- return field;
82760
+ function exitReasonOf(consecutiveHits) {
82761
+ return consecutiveHits !== void 0 && consecutiveHits >= CACHE_WARMUP_DEFAULTS.consecutiveHitsRequired ? "settled" : "dropped";
81581
82762
  }
82763
+ /** 单实例登记表,走一个 global symbol(与进展更新模块共用一个表)。 */
82764
+ const MOUNTED = Symbol.for("dsh-chatgpt-subscription.mounted-modules");
82765
+ /** 本模块在登记表里的名字。 */
82766
+ const MODULE_ID = "cache-warmup";
81582
82767
  /**
81583
- * 解析一次读取的有效设置:每次调用都重读活引用,因此设置保存后立刻生效。
81584
- * @param config - 主入口收到的插件配置(活引用或普通值)。
81585
- * @returns 生效的开关与催促阈值。
82768
+ * 读一个未知值为非空字符串。
82769
+ * @param value - 候选值。
82770
+ * @returns 非空字符串,其它类型的 undefined。
81586
82771
  */
81587
- function resolveProgressUpdatesSettings(config) {
81588
- const enabled = readProgressConfigField(config?.progressUpdates);
81589
- const threshold = readProgressConfigField(config?.progressUpdatesNudgeThreshold);
81590
- const language = readProgressConfigField(config?.progressUpdatesLanguage);
81591
- return {
81592
- enabled: enabled !== false,
81593
- nudgeThreshold: typeof threshold === "number" && Number.isSafeInteger(threshold) && threshold > 0 ? threshold : 8,
81594
- language: language !== void 0 && PROGRESS_UPDATE_LANGUAGES.includes(language) ? language : "auto"
81595
- };
82772
+ function stringOf(value) {
82773
+ return typeof value === "string" && value !== "" ? value : void 0;
81596
82774
  }
81597
- /** 血缘回溯的深度上限:与 `nudge.ts` 的 `counterSessionIdOf` 同一个数。 */
81598
- const LINEAGE_DEPTH_LIMIT = 16;
81599
82775
  /**
81600
- * 找出一个会话的全部子孙(广度优先,只看 `sessions.list()` 里活跃的那些)。
81601
- *
81602
- * 重开对话之后历史子孙并不在 `list()` 里 —— 那时它们各自的进展本来就取不到,
81603
- * 这里能做的只是把**当前活跃**的那几支合进来,剩下的等它们 attach。
81604
- * @param rootId - 祖先会话 id。
81605
- * @param sessions - 会话服务。
81606
- * @returns 子孙会话(不含根自己),按发现顺序。
82776
+ * 读一个未知值为有限数字。
82777
+ * @param value - 候选值。
82778
+ * @returns 有限数字,其它类型的 undefined。
81607
82779
  */
81608
- function descendantSessionsOf(rootId, sessions) {
81609
- const list = sessions.list;
81610
- if (typeof list !== "function") return [];
81611
- let all;
81612
- try {
81613
- all = list.call(sessions);
81614
- } catch {
81615
- return [];
81616
- }
81617
- if (!Array.isArray(all) || all.length === 0) return [];
81618
- /** 父会话 id → 直接子会话。 */
81619
- const childrenOf = /* @__PURE__ */ new Map();
81620
- for (const session of all) {
81621
- const parent = typeof session?.header?.parentSession === "string" ? session.header.parentSession : void 0;
81622
- if (parent === void 0) continue;
81623
- const bucket = childrenOf.get(parent);
81624
- if (bucket === void 0) childrenOf.set(parent, [session]);
81625
- else bucket.push(session);
81626
- }
81627
- const found = [];
81628
- const seen = /* @__PURE__ */ new Set([rootId]);
81629
- let frontier = [rootId];
81630
- for (let depth = 0; depth < LINEAGE_DEPTH_LIMIT && frontier.length > 0; depth += 1) {
81631
- const next = [];
81632
- for (const parentId of frontier) for (const child of childrenOf.get(parentId) ?? []) {
81633
- const childId = typeof child.header?.id === "string" ? child.header.id : void 0;
81634
- if (childId !== void 0) {
81635
- if (seen.has(childId)) continue;
81636
- seen.add(childId);
81637
- next.push(childId);
81638
- }
81639
- found.push(child);
81640
- }
81641
- frontier = next;
81642
- }
81643
- return found;
82780
+ function numberOf(value) {
82781
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
81644
82782
  }
81645
82783
  /**
81646
- * 已挂载的模块登记表,走一个 global symbol:同一个包的两份模块实例(npm 拷贝与
81647
- * 仓库 link)仍然共享一次判定,而重复注册同名工具或同名提示词分节会让宿主启动
81648
- * 失败。
82784
+ * 读一个未知值为布尔。
82785
+ * @param value - 候选值。
82786
+ * @returns 布尔,其它类型的 undefined。
81649
82787
  */
81650
- const MOUNTED = Symbol.for("dsh-chatgpt-subscription.mounted-modules");
81651
- /** 本模块在登记表里的名字。 */
81652
- const MODULE_ID = "progress-updates";
81653
- /**
81654
- * 把一条催促挂到一个 post-execute 决策上,同时保住该决策原有的附加上下文。
81655
- * @param decision - `next()` 交回来的决策。
81656
- * @param context - 要追加的催促上下文。
81657
- * @returns 追加后的决策(形状不变)。
81658
- */
81659
- function withNudge(decision, context) {
81660
- const additionalContexts = [...decision.additionalContexts ?? [], context];
81661
- if (decision.kind === "block") return {
81662
- kind: "block",
81663
- feedback: decision.feedback,
81664
- additionalContexts
81665
- };
81666
- if (decision.value !== void 0) return {
81667
- kind: "accept",
81668
- value: decision.value,
81669
- additionalContexts
81670
- };
81671
- if (decision.content !== void 0) return {
81672
- kind: "accept",
81673
- content: decision.content,
81674
- additionalContexts
81675
- };
81676
- return {
81677
- kind: "accept",
81678
- additionalContexts
81679
- };
82788
+ function booleanOf(value) {
82789
+ return typeof value === "boolean" ? value : void 0;
81680
82790
  }
81681
82791
  /**
81682
82792
  * 单实例守卫:本模块已挂载时放弃本次安装。
@@ -81693,199 +82803,252 @@ function claimMount(ctx) {
81693
82803
  const release = () => {
81694
82804
  mounted.delete(MODULE_ID);
81695
82805
  };
81696
- if (typeof ctx.effect === "function") ctx.effect(() => release, "dsh-chatgpt-subscription: progress updates mount");
82806
+ if (typeof ctx.effect === "function") ctx.effect(() => release, "dsh-chatgpt-subscription: cache warmup mount");
81697
82807
  return release;
81698
82808
  }
81699
82809
  /**
81700
- * 挂载「进展更新」的宿主半边。
82810
+ * 读一次会话级指标快照。
82811
+ *
82812
+ * 形状按共享约定**结构化**推断,任何字段缺失都不影响日志;读不出来就返回
82813
+ * undefined,让调用方退回预算对象上的那一份。
82814
+ * @param sessionId - 会话标识。
82815
+ * @returns 快照,或读不到时的 undefined。
82816
+ */
82817
+ function readCacheWarmupSnapshot(sessionId) {
82818
+ try {
82819
+ const raw = cacheWarmupSnapshot(sessionId);
82820
+ return typeof raw === "object" && raw !== null ? raw : void 0;
82821
+ } catch {
82822
+ return;
82823
+ }
82824
+ }
82825
+ /**
82826
+ * 挂载「缓存预热」的宿主半边。
81701
82827
  * @param ctx - 宿主插件上下文。
81702
- * @param options - 挂载选项(设置读取器)。
82828
+ * @param options - 挂载选项(设置读取器、可选的存储工厂)。
81703
82829
  * @returns 卸载函数;重复挂载时返回一个空操作。
81704
82830
  */
81705
- function installProgressUpdates(ctx, options) {
82831
+ function installCacheWarmup(ctx, options) {
81706
82832
  const release = claimMount(ctx);
81707
82833
  if (release === void 0) return () => void 0;
81708
82834
  /** 每次调用重新读服务:宿主树可能在本模块挂载之后才 settle,读一次存下来会漏。 */
81709
82835
  const serviceOf = (name) => {
81710
82836
  const read = ctx.get;
81711
- return typeof read === "function" ? read.call(ctx, name) : void 0;
82837
+ if (typeof read !== "function") return void 0;
82838
+ try {
82839
+ return read.call(ctx, name);
82840
+ } catch {
82841
+ return;
82842
+ }
81712
82843
  };
81713
82844
  /** 登记一个副作用;上下文没有 effect 时立即执行(测试用假上下文)。 */
81714
82845
  const track = (execute, label) => {
81715
82846
  if (typeof ctx.effect === "function") ctx.effect(execute, label);
81716
82847
  else execute();
81717
82848
  };
81718
- /** 会话查找表:够把子会话回溯到顶层祖先即可。 */
81719
- const sessionsOf = () => serviceOf("sessions");
81720
- /** 会话投影服务:只用来读 turnBoundary,把同一个模型步里的多条工具结果合并成一轮。 */
81721
- const projectionsOf = () => serviceOf("sessionProjections");
82849
+ /** 日志出口(每次现读,logger 可能晚于本模块挂载)。 */
82850
+ const loggerOf = () => ctx.logger ?? serviceOf("logger");
82851
+ /** 发一条指标日志。 */
82852
+ const log = (event) => {
82853
+ logCacheWarmup(loggerOf(), event);
82854
+ };
82855
+ /** 惰性构造出来的存储(注入优先)。 */
82856
+ let ownedStore;
81722
82857
  /**
81723
- * 会话投影服务:登记/读取进展折叠。
82858
+ * 取结果引用存储。
81724
82859
  *
81725
- * 与 {@link projectionsOf} 是同一个服务的两份结构化视图(`nudge` 那边只读
81726
- * `turnBoundary`,这里读写进展键),分开写是为了让各自的类型面只声明自己真正
81727
- * 用到的成员。服务缺席(老宿主、没装 agent-loop)时是 undefined。
81728
- */
81729
- const sessionProjectionsOf = () => serviceOf("sessionProjections");
81730
- /**
81731
- * 登记进展折叠单元。服务缺席时**安静跳过**:这是可选服务,缺了只是回到内存
81732
- * 实现(今天的行为),不是一次安装失败。
82860
+ * 构造失败只意味着「这台主机上没有引用存储」:此时投影不生效(原样返回),
82861
+ * `cache_warmup_result` 返回结构化错误。都不是一次安装失败。
82862
+ * @returns 存储,或拿不到时的 undefined。
81733
82863
  */
81734
- track(() => {
81735
- const projections = sessionProjectionsOf();
81736
- if (typeof projections?.register !== "function") return () => void 0;
81737
- try {
81738
- return projections.register(progressUpdatesProjection());
82864
+ const storeOf = () => {
82865
+ if (options.store !== void 0) try {
82866
+ return options.store();
81739
82867
  } catch (error) {
81740
- serviceOf("logger")?.warn("[dsh-chatgpt-subscription] progress updates projection not registered: " + String(error));
81741
- return () => void 0;
81742
- }
81743
- }, "dsh-chatgpt-subscription: progress update projection");
81744
- /** 会话维度状态;键是计数会话 id(子会话已归并到顶层祖先)。 */
81745
- const stores = /* @__PURE__ */ new Map();
81746
- /** 已经计过数的「步」:同一个模型步里的后续工具结果不再重复计数。 */
81747
- const countedSteps = /* @__PURE__ */ new Map();
81748
- /**
81749
- * 取一个计数会话的 store,按需创建。
81750
- * @param key - 计数会话 id。
81751
- * @returns 该会话的 store。
81752
- */
81753
- const storeOf = (key) => {
81754
- let store = stores.get(key);
81755
- if (store === void 0) {
81756
- store = createProgressUpdateStore();
81757
- stores.set(key, store);
82868
+ loggerOf()?.warn?.("[dsh-chatgpt-subscription] cache warmup store injection failed: " + cacheWarmupErrorLabel(error));
82869
+ return;
81758
82870
  }
81759
- return store;
81760
- };
81761
- /**
81762
- * 一次工具调用归属的计数会话 id。
81763
- * @param exec - 工具执行上下文(或任意带 agent 的对象)。
81764
- * @returns 计数会话 id;判定不出来时归入 `session` 这一桶。
81765
- */
81766
- const counterKeyOf = (exec) => counterSessionIdOf(exec?.agent, sessionsOf()) ?? "session";
81767
- /**
81768
- * 从会话日志折叠出来的进展 —— 面板的**首选**取数路径。
81769
- *
81770
- * 读三样东西,任缺其一就返回 undefined,让调用方回退到内存实现:
81771
- * 会话服务、投影服务,以及"这个 id 对应的会话对象"。最后一件事在重开对话之后
81772
- * 并不必然成立:`sessions` 里只有**活跃**会话,历史会话要等宿主 attach 上来
81773
- * 才读得到 —— 那之前面板显示的是内存里那一份(通常为空),attach 之后同一个
81774
- * 轮询请求就能读到完整历史。
81775
- *
81776
- * 合并范围是**整棵委派树**:今天的语义是一个子会话的进展归并到顶层祖先的桶里,
81777
- * 折叠之后靠 `header.parentSession` 把子孙找回来(与 `nudge.ts` 的 lineage
81778
- * 走法同一套,深度上限 16 是防一条损坏的日志把这里变成死循环),按 `at` 升序
81779
- * 排 —— 子会话汇报的进展因此落在它真正发生的那个时间点上,而不是全排在最后。
81780
- * @param sessionId - 浏览器问的会话 id。
81781
- * @returns 合并后的进展;这条路径不可用时 undefined。
81782
- */
81783
- const projectionUpdatesOf = (sessionId) => {
81784
- if (sessionId === "") return void 0;
81785
- const sessions = sessionsOf();
81786
- if (sessions === void 0) return void 0;
81787
- const projections = sessionProjectionsOf();
81788
- if (projections === void 0) return void 0;
81789
- let own;
82871
+ if (ownedStore !== void 0) return ownedStore;
81790
82872
  try {
81791
- own = sessions.get?.(sessionId);
81792
- } catch {
82873
+ ownedStore = new FileResultReferenceStore();
82874
+ } catch (error) {
82875
+ loggerOf()?.warn?.("[dsh-chatgpt-subscription] cache warmup store unavailable: " + cacheWarmupErrorLabel(error));
81793
82876
  return;
81794
82877
  }
81795
- if (own === void 0) return void 0;
81796
- const updates = readProjectedUpdates(projections, own);
81797
- if (updates === void 0) return void 0;
81798
- const lists = [updates];
81799
- for (const child of descendantSessionsOf(sessionId, sessions)) {
81800
- const childUpdates = readProjectedUpdates(projections, child);
81801
- if (childUpdates !== void 0) lists.push(childUpdates);
81802
- }
81803
- return lists.length === 1 ? updates : mergeProjectedUpdates(lists);
82878
+ return ownedStore;
81804
82879
  };
81805
- /**
81806
- * 浏览器半边的读取面:给定会话 id 就交出那个会话的进展快照。
81807
- *
81808
- * **先投影、后内存**:投影是从已持久化的会话日志折出来的,重开对话、刷新页面、
81809
- * 宿主重启之后都还在;内存 `stores` 只在投影这条路不可用时兜底(老宿主没有
81810
- * 投影服务、会话还没 attach、或者压根没装会话服务)。
81811
- *
81812
- * 请求不带 session(或带一个本进程没见过的 id)时,回退到那个"合成会话桶":
81813
- * 这是单会话部署里的常态,也是插件第一次被调用、祖先血缘还没建立时的落点。
81814
- */
81815
- const reader = { updatesOf: (sessionId) => {
81816
- const projected = projectionUpdatesOf(sessionId);
81817
- if (projected !== void 0) return projected;
81818
- const direct = stores.get(sessionId);
81819
- if (direct !== void 0) return direct.state().updates;
81820
- if (sessionId !== "") {
81821
- const merged = counterSessionIdOf({ id: sessionId }, sessionsOf());
81822
- const parent = merged === void 0 ? void 0 : stores.get(merged);
81823
- if (parent !== void 0) return parent.state().updates;
81824
- return [];
81825
- }
81826
- const fallback = stores.get("session");
81827
- return fallback === void 0 ? [] : fallback.state().updates;
81828
- } };
81829
82880
  track(() => {
81830
82881
  const tools = serviceOf("tools");
81831
- if (tools === void 0) {
81832
- serviceOf("logger")?.warn("[dsh-chatgpt-subscription] progress updates tool skipped: no tools service");
81833
- return () => void 0;
81834
- }
81835
- const tool = createProgressUpdateTool({
81836
- isEnabled: () => options.settings().enabled,
81837
- sink: () => ({ push: (update) => {
81838
- if (!options.settings().enabled) return false;
81839
- storeOf(update.source ?? "session").push(update);
81840
- return true;
81841
- } }),
81842
- sourceOf: (exec) => counterKeyOf(exec)
81843
- });
81844
- return tools.register(tool);
81845
- }, "dsh-chatgpt-subscription: progress update tool");
82882
+ if (tools === void 0) return () => void 0;
82883
+ return tools.register(createCacheWarmupResultTool({
82884
+ provider: CACHE_WARMUP_PROVIDER,
82885
+ routeOf: (exec) => cacheWarmupRouteOf(exec?.agent),
82886
+ store: storeOf,
82887
+ log
82888
+ }));
82889
+ }, "dsh-chatgpt-subscription: cache warmup result tool");
81846
82890
  track(() => {
81847
82891
  const systemPrompt = serviceOf("systemPrompt");
81848
82892
  if (systemPrompt === void 0) return () => void 0;
81849
82893
  return systemPrompt.section({
81850
- name: PROGRESS_SECTION_NAME,
81851
- order: 220,
81852
- text: progressSectionText(() => options.settings().language)
82894
+ name: CACHE_WARMUP_SECTION_NAME,
82895
+ order: 225,
82896
+ text: cacheWarmupSectionText()
81853
82897
  });
81854
- }, "dsh-chatgpt-subscription: progress update prompt section");
81855
- track(() => {
81856
- const webServer = serviceOf("webServer");
81857
- if (webServer === void 0) return () => void 0;
81858
- return webServer.register(makeProgressUpdatesRoute(reader));
81859
- }, "dsh-chatgpt-subscription: progress update route");
81860
- const disposeListener = ctx.on?.("tools/post-execute", (async (exec, _result, next) => {
82898
+ }, "dsh-chatgpt-subscription: cache warmup prompt section");
82899
+ /** 上一轮看到的预热状态,用来在窗口结束时发一条 `warmup-exit`。 */
82900
+ const warmupState = /* @__PURE__ */ new Map();
82901
+ /**
82902
+ * 记账并(必要时)发一条 `warmup-exit`。
82903
+ * @param sessionId - 会话标识。
82904
+ * @param budget - 本轮读到的预算对象。
82905
+ */
82906
+ const trackWarmup = (sessionId, budget) => {
82907
+ const snapshot = readCacheWarmupSnapshot(sessionId);
82908
+ const warmup = booleanOf(snapshot?.warmup) ?? booleanOf(budget.warmup);
82909
+ const consecutiveHits = numberOf(snapshot?.consecutiveHits) ?? numberOf(budget.consecutiveHits);
82910
+ const hitRatio = numberOf(snapshot?.hitRatio) ?? numberOf(budget.hitRatio);
82911
+ if (warmupState.get(sessionId) === true && warmup === false) {
82912
+ const reason = exitReasonOf(consecutiveHits);
82913
+ log({
82914
+ event: "warmup-exit",
82915
+ session: sessionId,
82916
+ reason,
82917
+ ...consecutiveHits === void 0 ? {} : { consecutiveHits },
82918
+ ...hitRatio === void 0 ? {} : { hitRatio }
82919
+ });
82920
+ }
82921
+ if (warmup !== void 0) warmupState.set(sessionId, warmup);
82922
+ return {
82923
+ ...warmup === void 0 ? {} : { warmup },
82924
+ ...consecutiveHits === void 0 ? {} : { consecutiveHits },
82925
+ ...hitRatio === void 0 ? {} : { hitRatio }
82926
+ };
82927
+ };
82928
+ /**
82929
+ * 一次工具结果的投影。
82930
+ *
82931
+ * 决策取舍写在代码里,因为它全是「什么时候**不动**」:
82932
+ * - 不是 `accept`(`block` 的 feedback 是别人的东西);
82933
+ * - 已经带 `value`(执行本地的 canonical 值,replay 与结构化消费方读它,
82934
+ * 替换掉等于把一份 JSON 换成字符串);
82935
+ * - 工具名不是 `run_code`、线路不是本线、会话拿不到;
82936
+ * - 没有存储、内容为空。
82937
+ * 上面任何一条成立就原样返回同一个决策对象。
82938
+ * @param exec - 工具执行上下文。
82939
+ * @param result - 归一化后的执行结果。
82940
+ * @param decision - `next()` 交回来的决策。
82941
+ * @returns 投影后的决策(形状不变),或原决策。
82942
+ */
82943
+ const projectDecision = async (exec, result, decision) => {
82944
+ if (decision.kind !== "accept") return decision;
82945
+ if (decision.value !== void 0) return decision;
82946
+ if (exec.name !== "run_code") return decision;
82947
+ if (!options.settings().enabled) return decision;
82948
+ const route = cacheWarmupRouteOf(exec.agent);
82949
+ if (route.provider !== "kimi-code") return decision;
82950
+ const sessionId = route.sessionId;
82951
+ if (sessionId === void 0) return decision;
82952
+ const store = storeOf();
82953
+ if (store === void 0) return decision;
82954
+ const blocks = decision.content ?? result.content;
82955
+ if (blocks.length === 0) return decision;
82956
+ const content = blocks;
82957
+ const budget = cacheWarmupBudget(sessionId);
82958
+ const projected = await projectToolResult({
82959
+ toolName: exec.name,
82960
+ provider: route.provider,
82961
+ sessionId,
82962
+ content,
82963
+ isError: result.isError === true,
82964
+ budget,
82965
+ store
82966
+ }).catch((error) => {
82967
+ loggerOf()?.warn?.("[dsh-chatgpt-subscription] cache warmup projection skipped: " + cacheWarmupErrorLabel(error));
82968
+ });
82969
+ if (projected === void 0) return decision;
82970
+ const metrics = trackWarmup(sessionId, budget);
82971
+ log({
82972
+ event: "projection",
82973
+ session: sessionId,
82974
+ strategy: String(projected.metrics.strategy),
82975
+ changed: projected.changed === true,
82976
+ inputChars: projected.metrics.inputChars,
82977
+ outputChars: projected.metrics.outputChars,
82978
+ omittedChars: projected.metrics.omittedChars,
82979
+ ...metrics.warmup === void 0 ? {} : { warmup: metrics.warmup }
82980
+ });
82981
+ if (projected.changed !== true) return decision;
82982
+ return {
82983
+ kind: "accept",
82984
+ content: blocksOf(projected.content),
82985
+ ...decision.additionalContexts === void 0 ? {} : { additionalContexts: decision.additionalContexts }
82986
+ };
82987
+ };
82988
+ const disposeListener = ctx.on?.("tools/post-execute", (async (exec, result, next) => {
81861
82989
  const decision = await next();
81862
- const current = options.settings();
81863
- if (!current.enabled) return decision;
81864
- const key = counterKeyOf(exec);
81865
- const store = storeOf(key);
81866
- if (exec.name === "progress_update") {
81867
- store.markUpdated();
81868
- const reportedStep = stepKeyOf(exec.agent, projectionsOf());
81869
- if (reportedStep !== void 0) countedSteps.set(key, reportedStep);
81870
- return decision;
81871
- }
81872
- const step = stepKeyOf(exec.agent, projectionsOf());
81873
- if (step !== void 0) {
81874
- if (countedSteps.get(key) === step) return decision;
81875
- countedSteps.set(key, step);
81876
- }
81877
- const rounds = store.bumpRound();
81878
- if (!(current.nudgeThreshold === 8 ? shouldNudge(rounds) : rounds >= current.nudgeThreshold)) return decision;
81879
- const nudges = store.markNudged().nudges;
81880
- return withNudge(decision, createNudgeContext(nudges));
82990
+ return projectDecision(exec, result, decision);
81881
82991
  }));
81882
82992
  return () => {
81883
82993
  disposeListener?.();
81884
- stores.clear();
81885
- countedSteps.clear();
82994
+ warmupState.clear();
81886
82995
  release();
81887
82996
  };
81888
82997
  }
82998
+ /**
82999
+ * 把投影结果的内容转回宿主的内容块数组。
83000
+ *
83001
+ * 核心的 `ProjectionResult.content` 允许纯文本(预览就是一段文本),也允许块数组
83002
+ * (只替换了其中一个文本块,非文本块原样保留)。宿主的 `PostToolDecision.content`
83003
+ * 只认块数组,所以纯文本在这里包成一个 text 块——包一层,不改字。
83004
+ * @param content - 核心交回来的内容。
83005
+ * @returns 宿主的块数组。
83006
+ */
83007
+ function blocksOf(content) {
83008
+ if (typeof content === "string") return [{
83009
+ type: "text",
83010
+ text: content
83011
+ }];
83012
+ return content;
83013
+ }
83014
+ /**
83015
+ * 本文件刻意**不保留**任何"把异常压成一句话"的辅助函数。
83016
+ *
83017
+ * 这里曾经有一个 `describe(error)`,把 `error.message` 截断后拼进宿主日志。它看上去
83018
+ * 无害(只取第一行、还截了长度),实际上是一条原文出口:异常消息里常常带着绝对
83019
+ * 路径、命令行片段、凭据,或者被读取结果的一段正文。三处降级日志现在统一走
83020
+ * {@link cacheWarmupErrorLabel}——只输出稳定的 name/code。
83021
+ */
83022
+ //#endregion
83023
+ //#region src/host/cache-warmup/settings.ts
83024
+ /**
83025
+ * 设置卡上这一行的说明文字。
83026
+ *
83027
+ * schema(`src/index.ts` 的 `Config`)与设置卡共用同一份文案:两处各写一份,
83028
+ * 迟早变成「卡片说只影响新结果、schema 说立刻生效」这种自相矛盾。
83029
+ */
83030
+ const CACHE_WARMUP_SETTING_DESCRIPTION = "启用缓存预热的结果投影:Kimi 线路上可以可靠结构化提取的超大工具结果会被替换成带 ref:// 引用的预览,需要原文时由 cache_warmup_result 精确补读。关闭只影响之后产生的新结果,已进入历史的内容不变。";
83031
+ /**
83032
+ * 读取一个配置字段,沿着 volatile 活引用取当前值。
83033
+ * @param field - 字段本身。
83034
+ * @returns 当前值,或字段缺失时的 undefined。
83035
+ */
83036
+ function readCacheWarmupConfigField(field) {
83037
+ if (field === void 0 || field === null) return void 0;
83038
+ if (typeof field === "object" && typeof field.get === "function") return field.get();
83039
+ return field;
83040
+ }
83041
+ /**
83042
+ * 解析一次读取的有效设置。
83043
+ *
83044
+ * 每次调用都重读活引用,因此设置保存后立刻生效(对**之后**的新结果生效;历史
83045
+ * 结果不重新投影,见模块说明)。
83046
+ * @param config - 主入口收到的插件配置(活引用或普通值)。
83047
+ * @returns 生效的开关。
83048
+ */
83049
+ function resolveCacheWarmupSettings(config) {
83050
+ return { enabled: readCacheWarmupConfigField(config?.cacheWarmup) !== false };
83051
+ }
81889
83052
  //#endregion
81890
83053
  //#region src/index.ts
81891
83054
  const Config = z.object({
@@ -81903,15 +83066,16 @@ const Config = z.object({
81903
83066
  subagentModelScope: z.union([z.const("session"), z.const("preference")]).default("session"),
81904
83067
  reasoningCollapseGuard: z.union([z.object({}), z.const(false)]).default(false),
81905
83068
  noAnswerResume: z.union([z.object({}), z.const(false)]).default({}),
81906
- codexStreamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).step(1).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS$2),
81907
- poolStorage: z.union([z.const("keyring"), z.const("credentials")]).default("keyring").description("号池存储后端:keyring(默认,桌面平台的系统密钥环)或 credentials(DSH 凭据库,服务器推荐)。"),
81908
83069
  progressUpdates: z.boolean().default(true).volatile().description("启用进展更新:允许 agent 用 progress_update 工具主动汇报多步任务的进展,并在侧栏展示。"),
81909
83070
  progressUpdatesNudgeThreshold: z.number().step(1).min(1).max(50).default(8).volatile().description("催促阈值:连续这么多轮带工具调用却没有进展更新时,提醒 agent 发一条进展。"),
81910
83071
  progressUpdatesLanguage: z.union([
81911
83072
  z.const("auto"),
81912
83073
  z.const("zh"),
81913
83074
  z.const("en")
81914
- ]).default("auto").volatile().description("进展更新的输出语言:auto 跟随对话语言,zh 中文,en 英文。")
83075
+ ]).default("auto").volatile().description("进展更新的输出语言:auto 跟随对话语言,zh 中文,en 英文。"),
83076
+ cacheWarmup: z.boolean().default(true).volatile().description(CACHE_WARMUP_SETTING_DESCRIPTION),
83077
+ codexStreamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).step(1).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS$2),
83078
+ poolStorage: z.union([z.const("keyring"), z.const("credentials")]).default("keyring").description("号池存储后端:keyring(默认,桌面平台的系统密钥环)或 credentials(DSH 凭据库,服务器推荐)。")
81915
83079
  });
81916
83080
  const inject = [
81917
83081
  "webServer",
@@ -81932,6 +83096,7 @@ function apply(ctx, pluginConfig = {}) {
81932
83096
  logger: ctx.logger
81933
83097
  });
81934
83098
  if (pluginConfig.syncAgentPresets !== false) ctx.effect(() => installBundledPresets(ctx, installDispatchPreset(ctx)), "dsh-chatgpt-subscription: agent preset sync");
83099
+ attachProgressSettings(() => resolveProgressUpdatesSettings(pluginConfig));
81935
83100
  const store = createPlatformTokenStore();
81936
83101
  const preferences = registerPreferenceStore(ctx.settings);
81937
83102
  preferences.hydrate().catch(() => void 0);
@@ -82026,6 +83191,7 @@ function apply(ctx, pluginConfig = {}) {
82026
83191
  return dispose;
82027
83192
  }, "dsh-chatgpt-subscription: no-answer resume");
82028
83193
  }
83194
+ ctx.effect(() => installCacheWarmup(ctx, { settings: () => resolveCacheWarmupSettings(pluginConfig) }), "dsh-chatgpt-subscription: cache warmup");
82029
83195
  const probeEnvFile = relayProbeEnvFile();
82030
83196
  if (relayProbeEnabled(process.env, { envFile: probeEnvFile })) {
82031
83197
  const probePath = relayProbeLogPath(process.env, { envFile: probeEnvFile });
@@ -82515,7 +83681,6 @@ function apply(ctx, pluginConfig = {}) {
82515
83681
  fetchFn: proxyFetch,
82516
83682
  pool: minimaxCodeAccountPool
82517
83683
  })) : () => void 0;
82518
- const disposeProgressUpdates = installProgressUpdates(ctx, { settings: () => resolveProgressUpdatesSettings(pluginConfig) });
82519
83684
  ctx.inject(["web"], (ctx) => {
82520
83685
  ctx.web.registerSearchProvider(createCodexSearchProvider(oauth, { fetchFn: proxyFetch }));
82521
83686
  ctx.web.registerFetchProvider(createCodexFetchProvider({
@@ -82542,7 +83707,6 @@ function apply(ctx, pluginConfig = {}) {
82542
83707
  disposeVideoTool();
82543
83708
  disposeMinimaxUnderstandVideoTool();
82544
83709
  disposeMinimaxGenerateVideoTool();
82545
- disposeProgressUpdates();
82546
83710
  disposeAdapter();
82547
83711
  disposeRoutes();
82548
83712
  disposeHubOverview();