@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.
- package/CHANGELOG.md +29 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +94 -8
- package/lib/client.js.map +1 -1
- package/lib/entry-progress-updates-CkGJr_fe.js +1004 -0
- package/lib/index.js +2011 -847
- package/lib/progress-updates.js +2 -0
- package/lib/types/client/progress-updates/host-source.d.ts +16 -0
- package/lib/types/client/progress-updates/host-source.d.ts.map +1 -1
- package/lib/types/client/progress-updates/index.d.ts.map +1 -1
- package/lib/types/client/progress-updates/settings-binding.d.ts +5 -0
- package/lib/types/client/progress-updates/settings-binding.d.ts.map +1 -1
- package/lib/types/compat.d.ts +1 -1
- package/lib/types/entry-progress-updates.d.ts +43 -0
- package/lib/types/entry-progress-updates.d.ts.map +1 -0
- package/lib/types/host/cache-warmup/budget.d.ts +64 -0
- package/lib/types/host/cache-warmup/budget.d.ts.map +1 -0
- package/lib/types/host/cache-warmup/contracts.d.ts +267 -0
- package/lib/types/host/cache-warmup/contracts.d.ts.map +1 -0
- package/lib/types/host/cache-warmup/index.d.ts +21 -0
- package/lib/types/host/cache-warmup/index.d.ts.map +1 -0
- package/lib/types/host/cache-warmup/install.d.ts +130 -0
- package/lib/types/host/cache-warmup/install.d.ts.map +1 -0
- package/lib/types/host/cache-warmup/logging.d.ts +120 -0
- package/lib/types/host/cache-warmup/logging.d.ts.map +1 -0
- package/lib/types/host/cache-warmup/projection.d.ts +29 -0
- package/lib/types/host/cache-warmup/projection.d.ts.map +1 -0
- package/lib/types/host/cache-warmup/result-tool.d.ts +87 -0
- package/lib/types/host/cache-warmup/result-tool.d.ts.map +1 -0
- package/lib/types/host/cache-warmup/settings.d.ts +73 -0
- package/lib/types/host/cache-warmup/settings.d.ts.map +1 -0
- package/lib/types/host/cache-warmup/store.d.ts +108 -0
- package/lib/types/host/cache-warmup/store.d.ts.map +1 -0
- package/lib/types/host/cache-warmup/usage.d.ts +86 -0
- package/lib/types/host/cache-warmup/usage.d.ts.map +1 -0
- package/lib/types/host/kimi-code/adapter.d.ts.map +1 -1
- package/lib/types/host/proxy-manager.d.ts +6 -0
- package/lib/types/host/proxy-manager.d.ts.map +1 -1
- package/lib/types/index.d.ts +32 -25
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/shared/model-catalog.d.ts +12 -1
- package/lib/types/shared/model-catalog.d.ts.map +1 -1
- package/locale/progress-updates/en.json +6 -0
- package/locale/progress-updates/zh.json +6 -0
- package/package.json +13 -3
- 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,
|
|
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.
|
|
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$
|
|
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$
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
80943
|
-
*
|
|
82310
|
+
* 把一条事件序列化成日志行。
|
|
82311
|
+
*
|
|
82312
|
+
* 形状写死在这里,而不是让调用方自己拼字符串:任何一次「顺手把 content 也带进
|
|
82313
|
+
* 去」的改动都只会发生在这个函数里,而它有测试盯着(断言日志里不含原文)。
|
|
82314
|
+
* 值为 `undefined` 的字段直接不出现在行里,保持每一行只描述它确实测到的指标。
|
|
82315
|
+
* @param event - 指标事件。
|
|
82316
|
+
* @returns 一行日志文本(带上本模块前缀)。
|
|
80944
82317
|
*/
|
|
80945
|
-
function
|
|
80946
|
-
|
|
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
|
-
*
|
|
80951
|
-
*
|
|
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
|
|
80954
|
-
|
|
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
|
-
*
|
|
80960
|
-
*
|
|
80961
|
-
*
|
|
82385
|
+
* 从存储交回来的 `omitted` 里读出省略字符数。
|
|
82386
|
+
*
|
|
82387
|
+
* 契约给的是一个结构化对象(`{ reason, chars, lines, rows, bytes }`,`reason: 'none'`
|
|
82388
|
+
* 表示这一页就是全部);这里同时容忍一个裸数字,因为"省略了多少字符"是唯一
|
|
82389
|
+
* 模型真正需要的那个数,而形状变化不该让整页读取失败。
|
|
82390
|
+
* @param value - 存储返回的 `omitted`。
|
|
82391
|
+
* @returns 省略的字符数;读不到时 undefined。
|
|
80962
82392
|
*/
|
|
80963
|
-
function
|
|
80964
|
-
|
|
80965
|
-
return
|
|
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
|
-
*
|
|
80969
|
-
* @
|
|
82400
|
+
* 造一个失败值。
|
|
82401
|
+
* @param action - 回显的动作。
|
|
82402
|
+
* @param refId - 被拒绝的引用。
|
|
82403
|
+
* @param sessionId - 当前会话(拿不到时为空串)。
|
|
82404
|
+
* @param code - 稳定错误码。
|
|
82405
|
+
* @param message - 一句话说明。
|
|
82406
|
+
* @returns 规范值。
|
|
80970
82407
|
*/
|
|
80971
|
-
function
|
|
80972
|
-
|
|
80973
|
-
|
|
80974
|
-
|
|
80975
|
-
|
|
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
|
-
|
|
80979
|
-
|
|
80980
|
-
|
|
80981
|
-
|
|
80982
|
-
|
|
80983
|
-
|
|
80984
|
-
|
|
80985
|
-
},
|
|
80986
|
-
|
|
80987
|
-
|
|
80988
|
-
|
|
80989
|
-
|
|
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
|
-
|
|
81019
|
-
|
|
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
|
|
82482
|
+
function createCacheWarmupResultTool(deps) {
|
|
82483
|
+
/** 发一条日志;安装方没接线时什么也不做。 */
|
|
82484
|
+
const log = (event) => {
|
|
82485
|
+
try {
|
|
82486
|
+
deps.log?.(event);
|
|
82487
|
+
} catch {}
|
|
82488
|
+
};
|
|
81044
82489
|
return defineTool({
|
|
81045
|
-
name:
|
|
81046
|
-
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
|
-
|
|
82498
|
+
action: {
|
|
81049
82499
|
type: "string",
|
|
81050
|
-
|
|
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
|
-
|
|
82504
|
+
refId: {
|
|
81053
82505
|
type: "string",
|
|
81054
82506
|
required: true,
|
|
81055
|
-
description: "The
|
|
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: {
|
|
81063
|
-
|
|
81064
|
-
|
|
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.
|
|
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
|
-
|
|
81076
|
-
const
|
|
81077
|
-
|
|
81078
|
-
|
|
81079
|
-
const
|
|
81080
|
-
|
|
81081
|
-
|
|
81082
|
-
|
|
81083
|
-
|
|
81084
|
-
|
|
81085
|
-
|
|
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
|
-
|
|
81088
|
-
|
|
81089
|
-
|
|
81090
|
-
|
|
81091
|
-
|
|
81092
|
-
|
|
81093
|
-
|
|
81094
|
-
|
|
81095
|
-
|
|
81096
|
-
|
|
81097
|
-
|
|
81098
|
-
|
|
81099
|
-
|
|
81100
|
-
|
|
81101
|
-
|
|
81102
|
-
|
|
81103
|
-
|
|
81104
|
-
|
|
81105
|
-
|
|
81106
|
-
|
|
81107
|
-
|
|
81108
|
-
|
|
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/
|
|
82695
|
+
//#region src/host/cache-warmup/install.ts
|
|
81175
82696
|
/**
|
|
81176
|
-
*
|
|
81177
|
-
*
|
|
81178
|
-
* 文本用函数形式提供(而不是静态字符串):开关关闭时返回空串,装配期就地
|
|
81179
|
-
* 抹掉引导,不需要重启会话、也不需要重挂插件行。
|
|
81180
|
-
*
|
|
81181
|
-
* ## 为什么是英文
|
|
82697
|
+
* 本模块服务的线路。
|
|
81182
82698
|
*
|
|
81183
|
-
*
|
|
81184
|
-
*
|
|
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
|
-
*
|
|
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
|
|
81210
|
-
"##
|
|
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
|
-
"
|
|
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
|
-
"
|
|
81220
|
-
"-
|
|
81221
|
-
"-
|
|
81222
|
-
"-
|
|
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
|
-
|
|
81241
|
-
|
|
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
|
-
*
|
|
81279
|
-
*
|
|
81280
|
-
*
|
|
81281
|
-
*
|
|
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
|
|
81347
|
-
|
|
81348
|
-
|
|
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
|
-
|
|
82741
|
+
provider = stringOf(session?.requestHeader?.()?.config?.provider);
|
|
81351
82742
|
} catch {
|
|
81352
|
-
|
|
82743
|
+
provider = void 0;
|
|
81353
82744
|
}
|
|
81354
|
-
if (
|
|
81355
|
-
const
|
|
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
|
-
|
|
81392
|
-
|
|
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
|
-
*
|
|
81495
|
-
*
|
|
81496
|
-
*
|
|
81497
|
-
* @
|
|
81498
|
-
* @returns 是否放行。
|
|
82755
|
+
* 阈值取核心契约里的 `consecutiveHitsRequired`(连续高命中率报告条数),而不是在
|
|
82756
|
+
* 这里另编一个命中率——判定标准只有核心一份,日志只如实记录它当时的结论。
|
|
82757
|
+
* @param consecutiveHits - 退出时的连续命中条数;读不到时 undefined。
|
|
82758
|
+
* @returns 稳定的退出原因。
|
|
81499
82759
|
*/
|
|
81500
|
-
function
|
|
81501
|
-
|
|
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
|
|
81585
|
-
* @returns
|
|
82768
|
+
* 读一个未知值为非空字符串。
|
|
82769
|
+
* @param value - 候选值。
|
|
82770
|
+
* @returns 非空字符串,其它类型的 undefined。
|
|
81586
82771
|
*/
|
|
81587
|
-
function
|
|
81588
|
-
|
|
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
|
-
*
|
|
81601
|
-
*
|
|
81602
|
-
*
|
|
81603
|
-
* 这里能做的只是把**当前活跃**的那几支合进来,剩下的等它们 attach。
|
|
81604
|
-
* @param rootId - 祖先会话 id。
|
|
81605
|
-
* @param sessions - 会话服务。
|
|
81606
|
-
* @returns 子孙会话(不含根自己),按发现顺序。
|
|
82776
|
+
* 读一个未知值为有限数字。
|
|
82777
|
+
* @param value - 候选值。
|
|
82778
|
+
* @returns 有限数字,其它类型的 undefined。
|
|
81607
82779
|
*/
|
|
81608
|
-
function
|
|
81609
|
-
|
|
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
|
-
*
|
|
81647
|
-
*
|
|
81648
|
-
*
|
|
82784
|
+
* 读一个未知值为布尔。
|
|
82785
|
+
* @param value - 候选值。
|
|
82786
|
+
* @returns 布尔,其它类型的 undefined。
|
|
81649
82787
|
*/
|
|
81650
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
81720
|
-
/**
|
|
81721
|
-
const
|
|
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
|
-
*
|
|
81726
|
-
* `
|
|
81727
|
-
*
|
|
81728
|
-
*/
|
|
81729
|
-
const sessionProjectionsOf = () => serviceOf("sessionProjections");
|
|
81730
|
-
/**
|
|
81731
|
-
* 登记进展折叠单元。服务缺席时**安静跳过**:这是可选服务,缺了只是回到内存
|
|
81732
|
-
* 实现(今天的行为),不是一次安装失败。
|
|
82860
|
+
* 构造失败只意味着「这台主机上没有引用存储」:此时投影不生效(原样返回),
|
|
82861
|
+
* `cache_warmup_result` 返回结构化错误。都不是一次安装失败。
|
|
82862
|
+
* @returns 存储,或拿不到时的 undefined。
|
|
81733
82863
|
*/
|
|
81734
|
-
|
|
81735
|
-
|
|
81736
|
-
|
|
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
|
-
|
|
81741
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81833
|
-
|
|
81834
|
-
|
|
81835
|
-
|
|
81836
|
-
|
|
81837
|
-
|
|
81838
|
-
|
|
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:
|
|
81851
|
-
order:
|
|
81852
|
-
text:
|
|
82894
|
+
name: CACHE_WARMUP_SECTION_NAME,
|
|
82895
|
+
order: 225,
|
|
82896
|
+
text: cacheWarmupSectionText()
|
|
81853
82897
|
});
|
|
81854
|
-
}, "dsh-chatgpt-subscription:
|
|
81855
|
-
|
|
81856
|
-
|
|
81857
|
-
|
|
81858
|
-
|
|
81859
|
-
|
|
81860
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|