@eddyskywalker/dsh-chatgpt-subscription 0.44.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/lib/client.js +32 -0
- package/lib/client.js.map +1 -1
- package/lib/index.js +2888 -93
- package/lib/types/client/kimi-code/KimiCodeSection.d.ts.map +1 -1
- package/lib/types/client/kimi-code/locales.d.ts +11 -2
- package/lib/types/client/kimi-code/locales.d.ts.map +1 -1
- package/lib/types/client/locales-ru/kimi-code.d.ts.map +1 -1
- package/lib/types/compat.d.ts +1 -1
- package/lib/types/host/cache-warmup/budget.d.ts +79 -0
- package/lib/types/host/cache-warmup/budget.d.ts.map +1 -0
- package/lib/types/host/cache-warmup/contracts.d.ts +280 -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 +138 -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 +96 -0
- package/lib/types/host/cache-warmup/usage.d.ts.map +1 -0
- package/lib/types/host/cline-pass/adapter.d.ts.map +1 -1
- package/lib/types/host/cline-pass/mapper.d.ts +10 -0
- package/lib/types/host/cline-pass/mapper.d.ts.map +1 -1
- package/lib/types/host/kimi-code/adapter.d.ts.map +1 -1
- package/lib/types/host/kimi-code/routes.d.ts.map +1 -1
- package/lib/types/host/kimi-code/session-cache-state.d.ts +12 -0
- package/lib/types/host/kimi-code/session-cache-state.d.ts.map +1 -1
- package/lib/types/host/opendesign/adapter.d.ts +15 -0
- package/lib/types/host/opendesign/adapter.d.ts.map +1 -1
- package/lib/types/host/opendesign/mapper.d.ts +50 -3
- package/lib/types/host/opendesign/mapper.d.ts.map +1 -1
- package/lib/types/index.d.ts +11 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/shared/kimi-code-contracts.d.ts +13 -0
- package/lib/types/shared/kimi-code-contracts.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/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -141,7 +141,7 @@ const ROUTE_PREFIX$4 = "/api/dsh-chatgpt-subscription";
|
|
|
141
141
|
* `test/responses-client.test.ts` asserts the two agree, so a release that
|
|
142
142
|
* forgets this constant fails the suite instead of shipping quietly.
|
|
143
143
|
*/
|
|
144
|
-
const PLUGIN_VERSION = "0.
|
|
144
|
+
const PLUGIN_VERSION = "0.46.0";
|
|
145
145
|
const CODEX_CHATGPT_PROVIDER_ID = "codex-chatgpt";
|
|
146
146
|
const CODEX_API_BASE = "https://chatgpt.com/backend-api/codex";
|
|
147
147
|
const CODEX_RESPONSES_URL = `${CODEX_API_BASE}/responses`;
|
|
@@ -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",
|
|
@@ -24670,6 +24683,1682 @@ var OllamaAccountPool = class extends AccountPoolCore {
|
|
|
24670
24683
|
}
|
|
24671
24684
|
};
|
|
24672
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
|
+
* 稳态第二轨(短会话分母效应兜底):已命中的缓存前缀至少达到该 token 规模,
|
|
24715
|
+
* 且相比上一条报告一字未掉(`cacheReadTokens >= prev.cacheReadTokens`),
|
|
24716
|
+
* 同时本轮新增未命中部分不超过 `retainedPrefixMaxFreshTokens` 时,同样计为一次有效命中。
|
|
24717
|
+
*/
|
|
24718
|
+
retainedPrefixMinCachedTokens: 1024,
|
|
24719
|
+
/** 稳态第二轨允许的单轮最大新增未命中 token 数(与 Kimi 256-token 缓存块对齐)。 */
|
|
24720
|
+
retainedPrefixMaxFreshTokens: 256,
|
|
24721
|
+
/**
|
|
24722
|
+
* 缓存存活窗口(毫秒,25 分钟,与 Kimi 实测全热上限一致)。
|
|
24723
|
+
* 距离最近一次报告超过该时长时,预算计算自动预判缓存已冷、退回预热期保守预算。
|
|
24724
|
+
*/
|
|
24725
|
+
cacheTtlMs: 1500 * 1e3,
|
|
24726
|
+
/** 单结果原文上限(字节)。 */
|
|
24727
|
+
maxResultBytes: 8 * 1024 * 1024,
|
|
24728
|
+
/** 每 session 原文总量上限(字节)。 */
|
|
24729
|
+
maxSessionBytes: 64 * 1024 * 1024,
|
|
24730
|
+
/** 分页 limit 默认值(行)。 */
|
|
24731
|
+
pageLimitDefault: 64,
|
|
24732
|
+
/** 分页 limit 上限(行)。 */
|
|
24733
|
+
pageLimitMax: 512,
|
|
24734
|
+
/** 分页 maxBytes 默认值(字节)。 */
|
|
24735
|
+
pageMaxBytesDefault: 16 * 1024,
|
|
24736
|
+
/** 分页 maxBytes 上限(字节)。 */
|
|
24737
|
+
pageMaxBytesMax: 64 * 1024,
|
|
24738
|
+
/** usage registry 的 session 数上限。 */
|
|
24739
|
+
maxUsageSessions: 256,
|
|
24740
|
+
/** 每 session 保留的用量报告条数上限。 */
|
|
24741
|
+
maxUsageReportsPerSession: 32
|
|
24742
|
+
};
|
|
24743
|
+
/**
|
|
24744
|
+
* 存储层错误。信息里只描述**请求本身**的问题,绝不回显其他 session 的内容或存在性。
|
|
24745
|
+
*/
|
|
24746
|
+
var CacheWarmupStoreError = class extends Error {
|
|
24747
|
+
code;
|
|
24748
|
+
constructor(message, code) {
|
|
24749
|
+
super(message);
|
|
24750
|
+
this.code = code;
|
|
24751
|
+
this.name = "CacheWarmupStoreError";
|
|
24752
|
+
}
|
|
24753
|
+
};
|
|
24754
|
+
/** 一段文本被拆开的一列。 */
|
|
24755
|
+
function splitDelimitedLine(line, delimiter) {
|
|
24756
|
+
const cells = [];
|
|
24757
|
+
let current = "";
|
|
24758
|
+
let quoted = false;
|
|
24759
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
24760
|
+
const char = line[index];
|
|
24761
|
+
if (char === "\"") {
|
|
24762
|
+
if (quoted && line[index + 1] === "\"") {
|
|
24763
|
+
current += "\"";
|
|
24764
|
+
index += 1;
|
|
24765
|
+
} else quoted = !quoted;
|
|
24766
|
+
continue;
|
|
24767
|
+
}
|
|
24768
|
+
if (!quoted && char === delimiter) {
|
|
24769
|
+
cells.push(current);
|
|
24770
|
+
current = "";
|
|
24771
|
+
continue;
|
|
24772
|
+
}
|
|
24773
|
+
current += char;
|
|
24774
|
+
}
|
|
24775
|
+
cells.push(current);
|
|
24776
|
+
return cells;
|
|
24777
|
+
}
|
|
24778
|
+
/** 把任意 JSON 值压成单元格文本;对象/数组保持可读的 JSON。 */
|
|
24779
|
+
function cellOf(value) {
|
|
24780
|
+
if (value === null || value === void 0) return "";
|
|
24781
|
+
if (typeof value === "string") return value;
|
|
24782
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
24783
|
+
try {
|
|
24784
|
+
return JSON.stringify(value) ?? "";
|
|
24785
|
+
} catch {
|
|
24786
|
+
return "";
|
|
24787
|
+
}
|
|
24788
|
+
}
|
|
24789
|
+
/** JSON Lines:每一行都是一个结构一致的表格式对象,且至少 5 行(防止 2~3 个异构 tool 结果对象被当成一张表压掉)。 */
|
|
24790
|
+
function parseJsonLines(lines) {
|
|
24791
|
+
if (lines.length < 5) return void 0;
|
|
24792
|
+
const fields = [];
|
|
24793
|
+
const seen = /* @__PURE__ */ new Set();
|
|
24794
|
+
const rows = [];
|
|
24795
|
+
let firstKeySet;
|
|
24796
|
+
for (const line of lines) {
|
|
24797
|
+
let parsed;
|
|
24798
|
+
try {
|
|
24799
|
+
parsed = JSON.parse(line);
|
|
24800
|
+
} catch {
|
|
24801
|
+
return;
|
|
24802
|
+
}
|
|
24803
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
|
|
24804
|
+
const record = parsed;
|
|
24805
|
+
const keys = Object.keys(record);
|
|
24806
|
+
if (keys.length === 0) return void 0;
|
|
24807
|
+
if (firstKeySet === void 0) firstKeySet = new Set(keys);
|
|
24808
|
+
else if (!keys.some((k) => firstKeySet.has(k))) return void 0;
|
|
24809
|
+
const row = {};
|
|
24810
|
+
for (const [key, value] of Object.entries(record)) {
|
|
24811
|
+
if (!seen.has(key)) {
|
|
24812
|
+
seen.add(key);
|
|
24813
|
+
fields.push(key);
|
|
24814
|
+
}
|
|
24815
|
+
row[key] = cellOf(value);
|
|
24816
|
+
}
|
|
24817
|
+
rows.push(row);
|
|
24818
|
+
}
|
|
24819
|
+
if (fields.length === 0) return void 0;
|
|
24820
|
+
return {
|
|
24821
|
+
fields,
|
|
24822
|
+
rows
|
|
24823
|
+
};
|
|
24824
|
+
}
|
|
24825
|
+
/** 分隔符表格:首行合法表头(标识符列名,非 grep 路径行号或代码行)+ 至少两行同列数的数据。 */
|
|
24826
|
+
function parseDelimitedRows(lines) {
|
|
24827
|
+
if (lines.length < 3) return void 0;
|
|
24828
|
+
const header = lines[0];
|
|
24829
|
+
const delimiter = header.includes(" ") ? " " : header.includes(",") ? "," : void 0;
|
|
24830
|
+
if (delimiter === void 0) return void 0;
|
|
24831
|
+
const fields = splitDelimitedLine(header, delimiter).map((cell) => cell.trim());
|
|
24832
|
+
if (fields.length < 2 || fields.some((field) => field === "")) return void 0;
|
|
24833
|
+
if (fields.some((field) => field.length > 64 || /[:;{}()[\]\\/*'"`]/.test(field) || /^\d+$/.test(field))) return;
|
|
24834
|
+
if (new Set(fields).size !== fields.length) return void 0;
|
|
24835
|
+
const rows = [];
|
|
24836
|
+
for (const line of lines.slice(1)) {
|
|
24837
|
+
const cells = splitDelimitedLine(line, delimiter);
|
|
24838
|
+
if (cells.length !== fields.length) return void 0;
|
|
24839
|
+
const row = {};
|
|
24840
|
+
fields.forEach((field, index) => {
|
|
24841
|
+
row[field] = cells[index];
|
|
24842
|
+
});
|
|
24843
|
+
rows.push(row);
|
|
24844
|
+
}
|
|
24845
|
+
return rows.length === 0 ? void 0 : {
|
|
24846
|
+
fields,
|
|
24847
|
+
rows
|
|
24848
|
+
};
|
|
24849
|
+
}
|
|
24850
|
+
/**
|
|
24851
|
+
* 把一段文本可靠地解析成结构化行;解析不可靠就返回 undefined。
|
|
24852
|
+
*
|
|
24853
|
+
* 判定刻意保守:宁可返回 undefined 让调用方保持原文,也不要猜出一张表,
|
|
24854
|
+
* 因为下游会把「猜出来的表」当成完整事实。
|
|
24855
|
+
*/
|
|
24856
|
+
function parseStructuredResultRows(text) {
|
|
24857
|
+
const lines = text.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim() !== "");
|
|
24858
|
+
if (lines.length < 2) return void 0;
|
|
24859
|
+
return parseJsonLines(lines) ?? parseDelimitedRows(lines);
|
|
24860
|
+
}
|
|
24861
|
+
//#endregion
|
|
24862
|
+
//#region src/host/cache-warmup/store.ts
|
|
24863
|
+
/**
|
|
24864
|
+
* 结果引用存储:内容寻址、原子写入、按字节安全分页。
|
|
24865
|
+
*
|
|
24866
|
+
* 三条不变量:
|
|
24867
|
+
*
|
|
24868
|
+
* 1. **原文逐字保存**。文件名是原文 UTF-8 字节的 sha256,所以同一份内容重复入库是幂等的,
|
|
24869
|
+
* 也不可能有一条引用悄悄指向别的字节。
|
|
24870
|
+
* 2. **历史 preview 与存储生命周期无关**。过期/淘汰/释放只影响「还能不能读到原文」,
|
|
24871
|
+
* 已经写进历史的那段预览文本是常量 —— 本模块从不接触历史。
|
|
24872
|
+
* 3. **只删具体文件**。淘汰按 mtime 逐个 `rm` 文件,绝不递归删除任何目录。
|
|
24873
|
+
*
|
|
24874
|
+
* @module dsh-chatgpt-subscription/host/cache-warmup/store
|
|
24875
|
+
*/
|
|
24876
|
+
/** 会话 id 的合法形状:单个路径段,不允许分隔符与 `..`。 */
|
|
24877
|
+
const SESSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
24878
|
+
/** `ref://<session>/<sha256>`。 */
|
|
24879
|
+
const REFERENCE_PATTERN = /^ref:\/\/([A-Za-z0-9][A-Za-z0-9._-]{0,127})\/([0-9a-f]{64})$/;
|
|
24880
|
+
/** 引用前缀。 */
|
|
24881
|
+
const RESULT_REFERENCE_PREFIX = "ref://";
|
|
24882
|
+
/** 原文文件后缀。 */
|
|
24883
|
+
const RESULT_SUFFIX = ".txt";
|
|
24884
|
+
/** 临时文件标记(原子写入的中转,永不被分页读到)。 */
|
|
24885
|
+
const TMP_MARKER = ".tmp.";
|
|
24886
|
+
/**
|
|
24887
|
+
* 拼一条引用。digest 必须是 64 位小写十六进制,否则分页时会拒绝解析。
|
|
24888
|
+
* @param sessionId - 会话 id。
|
|
24889
|
+
* @param digest - 原文 sha256。
|
|
24890
|
+
* @returns `ref://<session>/<digest>`。
|
|
24891
|
+
*/
|
|
24892
|
+
function formatResultRefId(sessionId, digest) {
|
|
24893
|
+
return `${RESULT_REFERENCE_PREFIX}${sessionId}/${digest}`;
|
|
24894
|
+
}
|
|
24895
|
+
/**
|
|
24896
|
+
* 解析一条引用。
|
|
24897
|
+
* @param refId - 待解析的引用。
|
|
24898
|
+
* @returns 会话与 digest;形状不合法时 undefined。
|
|
24899
|
+
*/
|
|
24900
|
+
function parseResultRefId(refId) {
|
|
24901
|
+
if (typeof refId !== "string") return void 0;
|
|
24902
|
+
const match = REFERENCE_PATTERN.exec(refId);
|
|
24903
|
+
if (match === null) return void 0;
|
|
24904
|
+
return {
|
|
24905
|
+
sessionId: match[1],
|
|
24906
|
+
digest: match[2]
|
|
24907
|
+
};
|
|
24908
|
+
}
|
|
24909
|
+
/**
|
|
24910
|
+
* 默认根目录。
|
|
24911
|
+
*
|
|
24912
|
+
* 每次调用都重新解析 `DSH_HOME`,所以构造之后再改环境变量(测试、多 profile)
|
|
24913
|
+
* 仍然生效;这也是零参构造能安全地只拿一个惰性默认值的原因。
|
|
24914
|
+
* @returns 默认根目录的绝对路径。
|
|
24915
|
+
*/
|
|
24916
|
+
function defaultResultStoreRootDir() {
|
|
24917
|
+
return path.join(dshHomeDir(), "storages", "cache-warmup");
|
|
24918
|
+
}
|
|
24919
|
+
/** 校验会话 id 形状,非法直接拒绝(路径穿越在这里被挡住)。 */
|
|
24920
|
+
function assertSessionId(sessionId) {
|
|
24921
|
+
if (typeof sessionId !== "string" || !SESSION_PATTERN.test(sessionId)) throw new CacheWarmupStoreError("session id is not a single safe path segment", "SESSION_INVALID");
|
|
24922
|
+
return sessionId;
|
|
24923
|
+
}
|
|
24924
|
+
/** 把 limit 收进 [1, 512]。 */
|
|
24925
|
+
function clampLimit(limit) {
|
|
24926
|
+
if (limit === void 0 || !Number.isFinite(limit)) return CACHE_WARMUP_DEFAULTS.pageLimitDefault;
|
|
24927
|
+
const rounded = Math.floor(limit);
|
|
24928
|
+
if (rounded < 1) return 1;
|
|
24929
|
+
return Math.min(rounded, CACHE_WARMUP_DEFAULTS.pageLimitMax);
|
|
24930
|
+
}
|
|
24931
|
+
/** 把 maxBytes 收进 [1, 65536]。 */
|
|
24932
|
+
function clampMaxBytes(maxBytes) {
|
|
24933
|
+
if (maxBytes === void 0 || !Number.isFinite(maxBytes)) return CACHE_WARMUP_DEFAULTS.pageMaxBytesDefault;
|
|
24934
|
+
const rounded = Math.floor(maxBytes);
|
|
24935
|
+
if (rounded < 1) return 1;
|
|
24936
|
+
return Math.min(rounded, CACHE_WARMUP_DEFAULTS.pageMaxBytesMax);
|
|
24937
|
+
}
|
|
24938
|
+
/**
|
|
24939
|
+
* 判断一段文本是否为不含真实换行(或几乎全在一行内)的 JSON 转义文本。
|
|
24940
|
+
*
|
|
24941
|
+
* 当 `run_code` 返回 `JSON.stringify({ stdout: "a\nb\nc..." })` 时,原文逐字保存在
|
|
24942
|
+
* 存储里(不改任何字节),但整份文本里没有真 `\n`,只有字面量两字符序列 `\n`。
|
|
24943
|
+
* 若只数真 `\n`,`limit: 64` 行会退化成一次吐完 `maxBytes`。识别出这种形态后,
|
|
24944
|
+
* `pageText` 和 `countLines` 把未转义的 `\n`(即前面有奇数个 `\` 的 `n`)也算作逻辑换行,
|
|
24945
|
+
* 既 100% 保持「翻页拼起来逐字节等于原文」,又让 `limit` 行上限对单行 JSON 同样生效。
|
|
24946
|
+
*/
|
|
24947
|
+
function usesEscapedNewlines(text) {
|
|
24948
|
+
if (text.length < 4) return false;
|
|
24949
|
+
let realNewlines = 0;
|
|
24950
|
+
for (let i = 0; i < text.length; i += 1) if (text.charCodeAt(i) === 10) {
|
|
24951
|
+
realNewlines += 1;
|
|
24952
|
+
if (realNewlines > 2) return false;
|
|
24953
|
+
}
|
|
24954
|
+
return text.includes("\\n");
|
|
24955
|
+
}
|
|
24956
|
+
/** 判断 `text[index]`(已知为 `'n'`)是否构成一个 JSON 转义换行 `\n`(前面紧邻奇数个 `\`)。 */
|
|
24957
|
+
function isEscapedNewlineAt(text, index) {
|
|
24958
|
+
let backslashes = 0;
|
|
24959
|
+
for (let cursor = index - 1; cursor >= 0 && text.charCodeAt(cursor) === 92; cursor -= 1) backslashes += 1;
|
|
24960
|
+
return (backslashes & 1) === 1;
|
|
24961
|
+
}
|
|
24962
|
+
/** 一段文本的行数(末尾没有换行也算一行;支持单行 JSON 内的转义 `\n`)。 */
|
|
24963
|
+
function countLines(text, escapedMode = usesEscapedNewlines(text)) {
|
|
24964
|
+
if (text === "") return 0;
|
|
24965
|
+
let lines = 0;
|
|
24966
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
24967
|
+
const code = text.charCodeAt(index);
|
|
24968
|
+
if (code === 10) lines += 1;
|
|
24969
|
+
else if (escapedMode && code === 110 && isEscapedNewlineAt(text, index)) lines += 1;
|
|
24970
|
+
}
|
|
24971
|
+
return text.endsWith("\n") || escapedMode && text.endsWith("n") && isEscapedNewlineAt(text, text.length - 1) ? lines : lines + 1;
|
|
24972
|
+
}
|
|
24973
|
+
/**
|
|
24974
|
+
* 按 UTF-8 字节安全分页。
|
|
24975
|
+
*
|
|
24976
|
+
* `offset` 是字节偏移,必须落在字符边界上(上一页的 `nextOffset` 天然满足);
|
|
24977
|
+
* 每页最多 `limit` 行且不超过 `maxBytes` 字节,任何一页都不会把一个字符切成两半。
|
|
24978
|
+
* 两个上限谁先到就在谁那里收手,`nextOffset` 精确指回同一位置,所以
|
|
24979
|
+
* 「两次翻页拼起来等于原文」是构造出来的性质,不是巧合。
|
|
24980
|
+
*/
|
|
24981
|
+
function pageText(args) {
|
|
24982
|
+
const { sessionId, refId, text, buffer, offset, limit, maxBytes } = args;
|
|
24983
|
+
const totalBytes = buffer.length;
|
|
24984
|
+
if (!Number.isInteger(offset) || offset < 0) throw new CacheWarmupStoreError("offset must be a non-negative integer byte offset", "OFFSET_OUT_OF_RANGE");
|
|
24985
|
+
if (offset > totalBytes) throw new CacheWarmupStoreError(`offset ${offset} is past the ${totalBytes}-byte result`, "OFFSET_OUT_OF_RANGE");
|
|
24986
|
+
if (offset < totalBytes && (buffer[offset] & 192) === 128) throw new CacheWarmupStoreError("offset is not on a UTF-8 character boundary", "OFFSET_INVALID");
|
|
24987
|
+
const escapedMode = usesEscapedNewlines(text);
|
|
24988
|
+
const rest = offset === 0 ? text : new TextDecoder("utf-8").decode(buffer.subarray(offset));
|
|
24989
|
+
let content = "";
|
|
24990
|
+
let contentBytes = 0;
|
|
24991
|
+
let lines = 0;
|
|
24992
|
+
let stoppedByBytes = false;
|
|
24993
|
+
let stoppedByLines = false;
|
|
24994
|
+
for (const char of rest) {
|
|
24995
|
+
const charBytes = Buffer.byteLength(char, "utf8");
|
|
24996
|
+
if (lines >= limit) {
|
|
24997
|
+
stoppedByLines = true;
|
|
24998
|
+
break;
|
|
24999
|
+
}
|
|
25000
|
+
if (contentBytes + charBytes > maxBytes) {
|
|
25001
|
+
if (content === "") {
|
|
25002
|
+
content = char;
|
|
25003
|
+
contentBytes = charBytes;
|
|
25004
|
+
}
|
|
25005
|
+
stoppedByBytes = true;
|
|
25006
|
+
break;
|
|
25007
|
+
}
|
|
25008
|
+
content += char;
|
|
25009
|
+
contentBytes += charBytes;
|
|
25010
|
+
if (char === "\n") lines += 1;
|
|
25011
|
+
else if (escapedMode && char === "n" && isEscapedNewlineAt(content, content.length - 1)) lines += 1;
|
|
25012
|
+
}
|
|
25013
|
+
const nextOffset = offset + Buffer.byteLength(content, "utf8");
|
|
25014
|
+
const complete = nextOffset >= totalBytes;
|
|
25015
|
+
const remaining = rest.slice(content.length);
|
|
25016
|
+
return {
|
|
25017
|
+
sessionId,
|
|
25018
|
+
refId,
|
|
25019
|
+
mode: "text",
|
|
25020
|
+
content,
|
|
25021
|
+
offset,
|
|
25022
|
+
nextOffset: complete ? null : nextOffset,
|
|
25023
|
+
complete,
|
|
25024
|
+
omitted: {
|
|
25025
|
+
reason: complete ? "none" : stoppedByBytes ? "bytes" : stoppedByLines ? "lines" : "bytes",
|
|
25026
|
+
chars: remaining.length,
|
|
25027
|
+
lines: countLines(remaining, escapedMode),
|
|
25028
|
+
rows: 0,
|
|
25029
|
+
bytes: Buffer.byteLength(remaining, "utf8")
|
|
25030
|
+
},
|
|
25031
|
+
totalChars: text.length,
|
|
25032
|
+
totalBytes,
|
|
25033
|
+
limit,
|
|
25034
|
+
maxBytes
|
|
25035
|
+
};
|
|
25036
|
+
}
|
|
25037
|
+
/**
|
|
25038
|
+
* 结构化行分页:先可靠解析,再按 `fields`/`equals` 过滤,最后按行取一页。
|
|
25039
|
+
*
|
|
25040
|
+
* 解析不可信就抛 `NOT_STRUCTURED`,而不是退回文本模式 —— 调用方要的是「按字段取」,
|
|
25041
|
+
* 悄悄给回一段原文等于让它以为字段过滤生效了。
|
|
25042
|
+
*/
|
|
25043
|
+
function pageStructured(args) {
|
|
25044
|
+
const { sessionId, refId, text, buffer, offset, limit, maxBytes } = args;
|
|
25045
|
+
const parsed = parseStructuredResultRows(text);
|
|
25046
|
+
if (parsed === void 0) throw new CacheWarmupStoreError("stored result is not reliably structured rows", "NOT_STRUCTURED");
|
|
25047
|
+
const requested = args.fields === void 0 ? [...parsed.fields] : [...args.fields];
|
|
25048
|
+
for (const field of requested) if (!parsed.fields.includes(field)) throw new CacheWarmupStoreError(`unknown field "${field}"`, "FIELD_UNKNOWN");
|
|
25049
|
+
const equals = args.equals ?? {};
|
|
25050
|
+
for (const key of Object.keys(equals)) if (!parsed.fields.includes(key)) throw new CacheWarmupStoreError(`unknown field "${key}"`, "FIELD_UNKNOWN");
|
|
25051
|
+
const matched = parsed.rows.filter((row) => Object.entries(equals).every(([key, value]) => (row[key] ?? "") === value));
|
|
25052
|
+
if (!Number.isInteger(offset) || offset < 0) throw new CacheWarmupStoreError("offset must be a non-negative integer row index", "OFFSET_OUT_OF_RANGE");
|
|
25053
|
+
if (offset > matched.length) throw new CacheWarmupStoreError(`offset ${offset} is past the ${matched.length}-row result`, "OFFSET_OUT_OF_RANGE");
|
|
25054
|
+
const project = (row) => {
|
|
25055
|
+
const out = {};
|
|
25056
|
+
for (const field of requested) out[field] = row[field] ?? "";
|
|
25057
|
+
return out;
|
|
25058
|
+
};
|
|
25059
|
+
const serialized = (row) => JSON.stringify(project(row));
|
|
25060
|
+
const selected = [];
|
|
25061
|
+
let bytes = 0;
|
|
25062
|
+
let index = offset;
|
|
25063
|
+
let hitByteCap = false;
|
|
25064
|
+
while (index < matched.length && selected.length < limit) {
|
|
25065
|
+
const line = serialized(matched[index]);
|
|
25066
|
+
const lineBytes = Buffer.byteLength(line, "utf8") + (selected.length === 0 ? 0 : 1);
|
|
25067
|
+
if (bytes + lineBytes > maxBytes && selected.length > 0) {
|
|
25068
|
+
hitByteCap = true;
|
|
25069
|
+
break;
|
|
25070
|
+
}
|
|
25071
|
+
if (lineBytes > maxBytes) hitByteCap = true;
|
|
25072
|
+
selected.push(line);
|
|
25073
|
+
bytes += lineBytes;
|
|
25074
|
+
index += 1;
|
|
25075
|
+
}
|
|
25076
|
+
const remainingRows = matched.slice(index);
|
|
25077
|
+
const remainingText = remainingRows.map(serialized).join("\n");
|
|
25078
|
+
const complete = index >= matched.length;
|
|
25079
|
+
return {
|
|
25080
|
+
sessionId,
|
|
25081
|
+
refId,
|
|
25082
|
+
mode: "structured",
|
|
25083
|
+
content: selected.join("\n"),
|
|
25084
|
+
offset,
|
|
25085
|
+
nextOffset: complete ? null : index,
|
|
25086
|
+
complete,
|
|
25087
|
+
omitted: {
|
|
25088
|
+
reason: complete ? "none" : hitByteCap ? "bytes" : "rows",
|
|
25089
|
+
chars: remainingText.length,
|
|
25090
|
+
lines: remainingRows.length,
|
|
25091
|
+
rows: remainingRows.length,
|
|
25092
|
+
bytes: Buffer.byteLength(remainingText, "utf8")
|
|
25093
|
+
},
|
|
25094
|
+
totalChars: text.length,
|
|
25095
|
+
totalBytes: buffer.length,
|
|
25096
|
+
limit,
|
|
25097
|
+
maxBytes,
|
|
25098
|
+
fields: requested,
|
|
25099
|
+
totalRows: parsed.rows.length,
|
|
25100
|
+
matchedRows: matched.length
|
|
25101
|
+
};
|
|
25102
|
+
}
|
|
25103
|
+
/**
|
|
25104
|
+
* 文件系统结果存储。
|
|
25105
|
+
*
|
|
25106
|
+
* 目录布局:`<rootDir>/<sessionId>/<sha256>.txt`。会话 id 已经是单个安全路径段,
|
|
25107
|
+
* 所以「另一个 session 的引用」在路径上就构造不出来;即便如此 page 仍然先做归属校验,
|
|
25108
|
+
* 免得将来换布局时丢掉这道闸。
|
|
25109
|
+
*/
|
|
25110
|
+
var FileResultReferenceStore = class {
|
|
25111
|
+
options;
|
|
25112
|
+
resolvedRootDir;
|
|
25113
|
+
/** @param options - 根目录与上限;全部可选。 */
|
|
25114
|
+
constructor(options = {}) {
|
|
25115
|
+
this.options = { ...options };
|
|
25116
|
+
}
|
|
25117
|
+
/** 当前生效的根目录。 */
|
|
25118
|
+
rootDir() {
|
|
25119
|
+
const configured = this.options.rootDir;
|
|
25120
|
+
if (configured !== void 0 && configured.trim() !== "") return path.resolve(configured);
|
|
25121
|
+
if (this.resolvedRootDir === void 0) this.resolvedRootDir = path.resolve(defaultResultStoreRootDir());
|
|
25122
|
+
return this.resolvedRootDir;
|
|
25123
|
+
}
|
|
25124
|
+
/** 单结果字节上限。 */
|
|
25125
|
+
maxResultBytes() {
|
|
25126
|
+
const value = this.options.maxResultBytes;
|
|
25127
|
+
return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : CACHE_WARMUP_DEFAULTS.maxResultBytes;
|
|
25128
|
+
}
|
|
25129
|
+
/** 每 session 字节总量上限。 */
|
|
25130
|
+
maxSessionBytes() {
|
|
25131
|
+
const value = this.options.maxSessionBytes;
|
|
25132
|
+
return value !== void 0 && Number.isFinite(value) && value > 0 ? Math.floor(value) : CACHE_WARMUP_DEFAULTS.maxSessionBytes;
|
|
25133
|
+
}
|
|
25134
|
+
/** 当前时钟。 */
|
|
25135
|
+
now() {
|
|
25136
|
+
return this.options.now === void 0 ? Date.now() : this.options.now();
|
|
25137
|
+
}
|
|
25138
|
+
/** 一个会话的目录。 */
|
|
25139
|
+
sessionDir(sessionId) {
|
|
25140
|
+
return path.join(this.rootDir(), sessionId);
|
|
25141
|
+
}
|
|
25142
|
+
/** 一个会话里一个 digest 的文件路径。 */
|
|
25143
|
+
fileFor(sessionId, digest) {
|
|
25144
|
+
return path.join(this.sessionDir(sessionId), digest + RESULT_SUFFIX);
|
|
25145
|
+
}
|
|
25146
|
+
/**
|
|
25147
|
+
* 保存原文。
|
|
25148
|
+
*
|
|
25149
|
+
* 幂等:文件已存在就直接返回同一个 refId,不重写、不改 mtime。
|
|
25150
|
+
* 原子:先写同目录的临时文件再 rename,读到一半的引用不可能存在。
|
|
25151
|
+
* 权限:写 0600(Windows 上是尽力而为,测试按平台跳过权限断言)。
|
|
25152
|
+
*/
|
|
25153
|
+
async put(input) {
|
|
25154
|
+
const sessionId = assertSessionId(input.sessionId);
|
|
25155
|
+
if (typeof input.content !== "string") throw new CacheWarmupStoreError("content must be a string", "CONTENT_INVALID");
|
|
25156
|
+
const bytes = Buffer.from(input.content, "utf8");
|
|
25157
|
+
const maxResultBytes = this.maxResultBytes();
|
|
25158
|
+
if (bytes.length > maxResultBytes) throw new CacheWarmupStoreError(`result is ${bytes.length} bytes, above the ${maxResultBytes}-byte single-result limit`, "RESULT_TOO_LARGE");
|
|
25159
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
25160
|
+
const refId = formatResultRefId(sessionId, digest);
|
|
25161
|
+
const at = input.at ?? this.now();
|
|
25162
|
+
const target = this.fileFor(sessionId, digest);
|
|
25163
|
+
const existing = await fs.stat(target).catch(() => void 0);
|
|
25164
|
+
if (existing !== void 0 && existing.isFile()) return {
|
|
25165
|
+
refId,
|
|
25166
|
+
sessionId,
|
|
25167
|
+
digest,
|
|
25168
|
+
bytes: existing.size,
|
|
25169
|
+
chars: input.content.length,
|
|
25170
|
+
at,
|
|
25171
|
+
created: false
|
|
25172
|
+
};
|
|
25173
|
+
await fs.mkdir(this.sessionDir(sessionId), {
|
|
25174
|
+
recursive: true,
|
|
25175
|
+
mode: 448
|
|
25176
|
+
});
|
|
25177
|
+
await this.enforceSessionBudget(this.sessionDir(sessionId), bytes.length, target);
|
|
25178
|
+
const tmp = path.join(this.sessionDir(sessionId), `${digest}.${process.pid}.${Date.now()}${TMP_MARKER}`);
|
|
25179
|
+
await fs.writeFile(tmp, bytes, { mode: 384 });
|
|
25180
|
+
await fs.chmod(tmp, 384).catch(() => void 0);
|
|
25181
|
+
await fs.rename(tmp, target);
|
|
25182
|
+
await fs.chmod(target, 384).catch(() => void 0);
|
|
25183
|
+
return {
|
|
25184
|
+
refId,
|
|
25185
|
+
sessionId,
|
|
25186
|
+
digest,
|
|
25187
|
+
bytes: bytes.length,
|
|
25188
|
+
chars: input.content.length,
|
|
25189
|
+
at,
|
|
25190
|
+
created: true
|
|
25191
|
+
};
|
|
25192
|
+
}
|
|
25193
|
+
/**
|
|
25194
|
+
* 按 session 预算淘汰最旧的原文。
|
|
25195
|
+
*
|
|
25196
|
+
* 只按 mtime 从旧到新单个删除;删的是具体文件,路径来自 readdir,不含目录递归。
|
|
25197
|
+
* 淘汰永远不改任何历史 preview —— 那是调用方已经拿走的文本。
|
|
25198
|
+
*/
|
|
25199
|
+
async enforceSessionBudget(dir, incoming, keep) {
|
|
25200
|
+
const budget = this.maxSessionBytes();
|
|
25201
|
+
if (incoming > budget) throw new CacheWarmupStoreError(`result is ${incoming} bytes, above the ${budget}-byte per-session limit`, "STORE_FULL");
|
|
25202
|
+
let entries;
|
|
25203
|
+
try {
|
|
25204
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
25205
|
+
} catch {
|
|
25206
|
+
return;
|
|
25207
|
+
}
|
|
25208
|
+
const files = [];
|
|
25209
|
+
for (const entry of entries) {
|
|
25210
|
+
if (!entry.isFile() || !entry.name.endsWith(RESULT_SUFFIX)) continue;
|
|
25211
|
+
const file = path.join(dir, entry.name);
|
|
25212
|
+
if (file === keep) continue;
|
|
25213
|
+
const stats = await fs.stat(file).catch(() => void 0);
|
|
25214
|
+
if (stats === void 0) continue;
|
|
25215
|
+
files.push({
|
|
25216
|
+
file,
|
|
25217
|
+
size: stats.size,
|
|
25218
|
+
mtime: stats.mtimeMs
|
|
25219
|
+
});
|
|
25220
|
+
}
|
|
25221
|
+
let total = files.reduce((sum, file) => sum + file.size, 0);
|
|
25222
|
+
if (total + incoming <= budget) return;
|
|
25223
|
+
files.sort((left, right) => left.mtime - right.mtime);
|
|
25224
|
+
for (const file of files) {
|
|
25225
|
+
if (total + incoming <= budget) break;
|
|
25226
|
+
await fs.rm(file.file, { force: true }).catch(() => void 0);
|
|
25227
|
+
total -= file.size;
|
|
25228
|
+
}
|
|
25229
|
+
}
|
|
25230
|
+
/**
|
|
25231
|
+
* 读一页。
|
|
25232
|
+
*
|
|
25233
|
+
* 归属校验在碰文件系统之前完成:别人的引用不会告诉你它存在不存在。
|
|
25234
|
+
*/
|
|
25235
|
+
async page(input) {
|
|
25236
|
+
const sessionId = assertSessionId(input.sessionId);
|
|
25237
|
+
const parsed = parseResultRefId(input.refId);
|
|
25238
|
+
if (parsed === void 0) throw new CacheWarmupStoreError("reference is not one this store issues", "REFERENCE_INVALID");
|
|
25239
|
+
if (parsed.sessionId !== sessionId) throw new CacheWarmupStoreError("reference does not belong to this session", "SESSION_MISMATCH");
|
|
25240
|
+
const file = this.fileFor(sessionId, parsed.digest);
|
|
25241
|
+
const stats = await fs.stat(file).catch(() => void 0);
|
|
25242
|
+
if (stats === void 0 || !stats.isFile()) throw new CacheWarmupStoreError("stored result is missing; it was released or evicted", "RESULT_NOT_FOUND");
|
|
25243
|
+
const ttlMs = this.options.ttlMs;
|
|
25244
|
+
if (ttlMs !== void 0 && ttlMs > 0 && this.now() - stats.mtimeMs > ttlMs) throw new CacheWarmupStoreError("stored result expired", "RESULT_EXPIRED");
|
|
25245
|
+
const buffer = await fs.readFile(file);
|
|
25246
|
+
const text = new TextDecoder("utf-8").decode(buffer);
|
|
25247
|
+
const limit = clampLimit(input.limit);
|
|
25248
|
+
const maxBytes = clampMaxBytes(input.maxBytes);
|
|
25249
|
+
const offset = input.offset ?? 0;
|
|
25250
|
+
if (input.fields !== void 0 || input.equals !== void 0) return pageStructured({
|
|
25251
|
+
sessionId,
|
|
25252
|
+
refId: input.refId,
|
|
25253
|
+
text,
|
|
25254
|
+
buffer,
|
|
25255
|
+
offset,
|
|
25256
|
+
limit,
|
|
25257
|
+
maxBytes,
|
|
25258
|
+
fields: input.fields,
|
|
25259
|
+
equals: input.equals
|
|
25260
|
+
});
|
|
25261
|
+
return pageText({
|
|
25262
|
+
sessionId,
|
|
25263
|
+
refId: input.refId,
|
|
25264
|
+
text,
|
|
25265
|
+
buffer,
|
|
25266
|
+
offset,
|
|
25267
|
+
limit,
|
|
25268
|
+
maxBytes
|
|
25269
|
+
});
|
|
25270
|
+
}
|
|
25271
|
+
/**
|
|
25272
|
+
* 释放一条引用:只删这一个具体文件,缺了就当已经释放(返回 false)。
|
|
25273
|
+
* 清理路径上不抛文件系统错误,免得在 finally 里把原始异常顶掉。
|
|
25274
|
+
*/
|
|
25275
|
+
async release(input) {
|
|
25276
|
+
const sessionId = assertSessionId(input.sessionId);
|
|
25277
|
+
const parsed = parseResultRefId(input.refId);
|
|
25278
|
+
if (parsed === void 0) throw new CacheWarmupStoreError("reference is not one this store issues", "REFERENCE_INVALID");
|
|
25279
|
+
if (parsed.sessionId !== sessionId) throw new CacheWarmupStoreError("reference does not belong to this session", "SESSION_MISMATCH");
|
|
25280
|
+
const file = this.fileFor(sessionId, parsed.digest);
|
|
25281
|
+
const exists = await fs.stat(file).catch(() => void 0);
|
|
25282
|
+
if (exists === void 0 || !exists.isFile()) return false;
|
|
25283
|
+
try {
|
|
25284
|
+
await fs.rm(file, { force: true });
|
|
25285
|
+
return true;
|
|
25286
|
+
} catch {
|
|
25287
|
+
return false;
|
|
25288
|
+
}
|
|
25289
|
+
}
|
|
25290
|
+
};
|
|
25291
|
+
//#endregion
|
|
25292
|
+
//#region src/host/cache-warmup/usage.ts
|
|
25293
|
+
/**
|
|
25294
|
+
* 缓存用量登记表 —— 有界、只写不读消息、永不抛出。
|
|
25295
|
+
*
|
|
25296
|
+
* 为什么「永不抛出」是硬性契约:Kimi adapter 在 finally 路径里调用
|
|
25297
|
+
* `recordCacheWarmupUsage`。那个位置抛出的任何东西都会顶掉原始异常、把一次失败
|
|
25298
|
+
* 伪装成另一次失败,或者让重试/账号轮换逻辑读到半个状态。所以整个函数体被
|
|
25299
|
+
* 一层 catch 包住,坏输入、被污染的属性访问、内部容器异常一律降级成中性返回值。
|
|
25300
|
+
*
|
|
25301
|
+
* 有界:最多 {@link CACHE_WARMUP_DEFAULTS.maxUsageSessions} 个会话,
|
|
25302
|
+
* 每个会话最多 {@link CACHE_WARMUP_DEFAULTS.maxUsageReportsPerSession} 条报告。
|
|
25303
|
+
* 超出时淘汰最久未活动的会话 / 最旧的报告 —— 内存里只留「最近发生了什么」。
|
|
25304
|
+
*
|
|
25305
|
+
* @module dsh-chatgpt-subscription/host/cache-warmup/usage
|
|
25306
|
+
*/
|
|
25307
|
+
/** 有界登记表:Map 的插入顺序同时是 LRU 顺序(命中即重新插入到队尾)。 */
|
|
25308
|
+
const registry = /* @__PURE__ */ new Map();
|
|
25309
|
+
/** 一个中性返回值,异常路径复用(不含任何容器操作)。 */
|
|
25310
|
+
function neutralState(sessionId) {
|
|
25311
|
+
return {
|
|
25312
|
+
sessionId,
|
|
25313
|
+
reports: 0,
|
|
25314
|
+
consecutiveHits: 0,
|
|
25315
|
+
hitRatio: 0
|
|
25316
|
+
};
|
|
25317
|
+
}
|
|
25318
|
+
/** 非负有限数;其他一律 0。 */
|
|
25319
|
+
function finiteCount(value) {
|
|
25320
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
25321
|
+
}
|
|
25322
|
+
/**
|
|
25323
|
+
* 一次请求的缓存命中率 = 命中缓存 token / (未命中 token + 命中 token)。
|
|
25324
|
+
*
|
|
25325
|
+
* 这里的两个字段是**互斥的两半**,不是整体与部分:Kimi/DSH 上报的
|
|
25326
|
+
* `inputTokens` 是这次真正送去计算的未命中部分,`cacheReadTokens` 是命中缓存、
|
|
25327
|
+
* 没有重算的部分。所以分母必须是两者之和。
|
|
25328
|
+
*
|
|
25329
|
+
* 用 `cached / inputTokens` 是错的,而且错得很隐蔽:只要命中数接近或超过未命中数,
|
|
25330
|
+
* 比值就会 ≥ 1 并被钳成 1,于是「连续两次 ≥ 0.97」的判据在真实流量的**第一次**请求上
|
|
25331
|
+
* 就成立,预热期形同不存在 —— 这正好是这套预算想防的那件事。
|
|
25332
|
+
*
|
|
25333
|
+
* 也不允许拿 `totalTokens` 当分母:那个字段的构成由上层决定(可能含输出 token、
|
|
25334
|
+
* 也可能已经包含缓存部分),用它会让命中率随无关字段漂移。
|
|
25335
|
+
*
|
|
25336
|
+
* @param usage - 至少含 inputTokens(未命中)与 cacheReadTokens(命中)的用量。
|
|
25337
|
+
* @returns 0-1;`input=0` 且 `cached>0` 时为 1;两者都为 0 或非法时为 0。
|
|
25338
|
+
*/
|
|
25339
|
+
function cacheWarmupHitRatio(usage) {
|
|
25340
|
+
try {
|
|
25341
|
+
const input = finiteCount(usage.inputTokens);
|
|
25342
|
+
const cached = finiteCount(usage.cacheReadTokens);
|
|
25343
|
+
if (cached <= 0) return 0;
|
|
25344
|
+
const total = input + cached;
|
|
25345
|
+
if (total <= 0) return 0;
|
|
25346
|
+
return Math.min(1, cached / total);
|
|
25347
|
+
} catch {
|
|
25348
|
+
return 0;
|
|
25349
|
+
}
|
|
25350
|
+
}
|
|
25351
|
+
/**
|
|
25352
|
+
* 从最新一条往回数「连续高命中率」条数。
|
|
25353
|
+
*
|
|
25354
|
+
* 停止条件(任一):命中率低于阈值、该条带 driftCause、报告到头。
|
|
25355
|
+
* 「连续」是从最近往回数的,所以中间任何一次低命中或漂移都会把计数截断到 0。
|
|
25356
|
+
* @param reports - 按时间从旧到新排列的报告。
|
|
25357
|
+
* @returns 连续命中条数。
|
|
25358
|
+
*/
|
|
25359
|
+
/** Kimi 对每个请求都会报告的生命周期状态;只有其余值才表示真实前缀漂移。 */
|
|
25360
|
+
const BENIGN_LIFECYCLE_CAUSES = /* @__PURE__ */ new Set(["stable", "first-request"]);
|
|
25361
|
+
/**
|
|
25362
|
+
* 判断一条报告是否算作「预热命中」(双轨判据):
|
|
25363
|
+
*
|
|
25364
|
+
* 1. **第一轨(比例轨)**:`hitRatio >= 0.97`;
|
|
25365
|
+
* 2. **第二轨(前缀完整延续轨)**:解决短会话的分母效应(如 `1792 cached + 84 input = 95.52%`)。
|
|
25366
|
+
* 当本轮 `cacheReadTokens >= 1024`(已形成实质缓存前缀)、存在紧邻的上一条无真实漂移报告、
|
|
25367
|
+
* 本轮命中缓存没有比上一轮缩水(`cacheReadTokens >= previous.cacheReadTokens`),
|
|
25368
|
+
* 且本轮新增未命中部分 `inputTokens <= 256`(一个缓存块的正常尾巴)时,同样视为有效命中。
|
|
25369
|
+
*/
|
|
25370
|
+
function isWarmupHitReport(report, previous) {
|
|
25371
|
+
if (cacheWarmupHitRatio(report) >= CACHE_WARMUP_DEFAULTS.hitRatioThreshold) return true;
|
|
25372
|
+
if (previous === void 0 || previous === null) return false;
|
|
25373
|
+
const prevDrift = previous.driftCause;
|
|
25374
|
+
if (typeof prevDrift === "string" && prevDrift.trim() !== "" && !BENIGN_LIFECYCLE_CAUSES.has(prevDrift)) return false;
|
|
25375
|
+
const cached = finiteCount(report.cacheReadTokens);
|
|
25376
|
+
const prevCached = finiteCount(previous.cacheReadTokens);
|
|
25377
|
+
const fresh = finiteCount(report.inputTokens);
|
|
25378
|
+
if (cached < CACHE_WARMUP_DEFAULTS.retainedPrefixMinCachedTokens) return false;
|
|
25379
|
+
if (prevCached < CACHE_WARMUP_DEFAULTS.retainedPrefixMinCachedTokens) return false;
|
|
25380
|
+
if (cached < prevCached) return false;
|
|
25381
|
+
return fresh <= CACHE_WARMUP_DEFAULTS.retainedPrefixMaxFreshTokens;
|
|
25382
|
+
}
|
|
25383
|
+
function consecutiveWarmupHits(reports) {
|
|
25384
|
+
try {
|
|
25385
|
+
if (!Array.isArray(reports)) return 0;
|
|
25386
|
+
let hits = 0;
|
|
25387
|
+
for (let index = reports.length - 1; index >= 0; index -= 1) {
|
|
25388
|
+
const report = reports[index];
|
|
25389
|
+
if (report === void 0 || report === null) break;
|
|
25390
|
+
const drift = report.driftCause;
|
|
25391
|
+
if (typeof drift === "string" && drift.trim() !== "" && !BENIGN_LIFECYCLE_CAUSES.has(drift)) break;
|
|
25392
|
+
if (!isWarmupHitReport(report, index > 0 ? reports[index - 1] : void 0)) break;
|
|
25393
|
+
hits += 1;
|
|
25394
|
+
}
|
|
25395
|
+
return hits;
|
|
25396
|
+
} catch {
|
|
25397
|
+
return 0;
|
|
25398
|
+
}
|
|
25399
|
+
}
|
|
25400
|
+
/** 把任意输入归一成一条用量报告;形状不对返回 undefined(绝不抛)。 */
|
|
25401
|
+
function normalizeUsage(input) {
|
|
25402
|
+
if (input === null || input === void 0 || typeof input !== "object") return void 0;
|
|
25403
|
+
const sessionId = input.sessionId;
|
|
25404
|
+
if (typeof sessionId !== "string" || sessionId.trim() === "") return void 0;
|
|
25405
|
+
const driftCause = typeof input.driftCause === "string" && input.driftCause.trim() !== "" ? input.driftCause : void 0;
|
|
25406
|
+
const at = typeof input.at === "number" && Number.isFinite(input.at) ? input.at : Date.now();
|
|
25407
|
+
const normalized = {
|
|
25408
|
+
sessionId,
|
|
25409
|
+
inputTokens: finiteCount(input.inputTokens),
|
|
25410
|
+
cacheReadTokens: finiteCount(input.cacheReadTokens),
|
|
25411
|
+
outputTokens: finiteCount(input.outputTokens),
|
|
25412
|
+
totalTokens: finiteCount(input.totalTokens),
|
|
25413
|
+
at
|
|
25414
|
+
};
|
|
25415
|
+
if (driftCause !== void 0) normalized.driftCause = driftCause;
|
|
25416
|
+
return normalized;
|
|
25417
|
+
}
|
|
25418
|
+
/** 由一份报告列表算出状态摘要。 */
|
|
25419
|
+
function stateOf(sessionId, reports) {
|
|
25420
|
+
const last = reports[reports.length - 1];
|
|
25421
|
+
return {
|
|
25422
|
+
sessionId,
|
|
25423
|
+
reports: reports.length,
|
|
25424
|
+
consecutiveHits: consecutiveWarmupHits(reports),
|
|
25425
|
+
hitRatio: last === void 0 ? 0 : cacheWarmupHitRatio(last),
|
|
25426
|
+
lastAt: last?.at
|
|
25427
|
+
};
|
|
25428
|
+
}
|
|
25429
|
+
/**
|
|
25430
|
+
* 登记一次用量报告。
|
|
25431
|
+
*
|
|
25432
|
+
* **total function**:任何输入、任何内部异常都不抛出,最差返回一份中性状态。
|
|
25433
|
+
* 有副作用(更新有界登记表),但不触碰消息、不发起请求、不改变请求参数。
|
|
25434
|
+
* @param input - 用量报告。
|
|
25435
|
+
* @returns 该会话登记之后的状态摘要。
|
|
25436
|
+
*/
|
|
25437
|
+
function recordCacheWarmupUsage(input) {
|
|
25438
|
+
let sessionId = "";
|
|
25439
|
+
try {
|
|
25440
|
+
const usage = normalizeUsage(input);
|
|
25441
|
+
if (usage === void 0) return neutralState("");
|
|
25442
|
+
sessionId = usage.sessionId;
|
|
25443
|
+
const previous = registry.get(usage.sessionId);
|
|
25444
|
+
const kept = previous === void 0 ? [] : [...previous];
|
|
25445
|
+
kept.push(Object.freeze({ ...usage }));
|
|
25446
|
+
while (kept.length > CACHE_WARMUP_DEFAULTS.maxUsageReportsPerSession) kept.shift();
|
|
25447
|
+
if (previous !== void 0) registry.delete(usage.sessionId);
|
|
25448
|
+
registry.set(usage.sessionId, Object.freeze(kept));
|
|
25449
|
+
while (registry.size > CACHE_WARMUP_DEFAULTS.maxUsageSessions) {
|
|
25450
|
+
const oldest = registry.keys().next();
|
|
25451
|
+
if (oldest.done === true) break;
|
|
25452
|
+
registry.delete(oldest.value);
|
|
25453
|
+
}
|
|
25454
|
+
return stateOf(usage.sessionId, kept);
|
|
25455
|
+
} catch {
|
|
25456
|
+
try {
|
|
25457
|
+
if (sessionId !== "") registry.delete(sessionId);
|
|
25458
|
+
} catch {}
|
|
25459
|
+
return neutralState(sessionId);
|
|
25460
|
+
}
|
|
25461
|
+
}
|
|
25462
|
+
/**
|
|
25463
|
+
* 读一个会话的报告(从旧到新),不抛出。
|
|
25464
|
+
* @param sessionId - 会话 id。
|
|
25465
|
+
* @returns 报告副本;没有或读不到时为空数组。
|
|
25466
|
+
*/
|
|
25467
|
+
function cacheWarmupUsageReports(sessionId) {
|
|
25468
|
+
try {
|
|
25469
|
+
if (typeof sessionId !== "string" || sessionId === "") return [];
|
|
25470
|
+
const reports = registry.get(sessionId);
|
|
25471
|
+
return reports === void 0 ? [] : [...reports];
|
|
25472
|
+
} catch {
|
|
25473
|
+
return [];
|
|
25474
|
+
}
|
|
25475
|
+
}
|
|
25476
|
+
/**
|
|
25477
|
+
* 列出所有有报告的会话,按最近活动倒序;不抛出。
|
|
25478
|
+
* @returns 会话摘要数组。
|
|
25479
|
+
*/
|
|
25480
|
+
function cacheWarmupUsageSessions() {
|
|
25481
|
+
try {
|
|
25482
|
+
const summaries = [];
|
|
25483
|
+
for (const [sessionId, reports] of registry.entries()) {
|
|
25484
|
+
if (reports.length === 0) continue;
|
|
25485
|
+
const last = reports[reports.length - 1];
|
|
25486
|
+
summaries.push({
|
|
25487
|
+
sessionId,
|
|
25488
|
+
reports: reports.length,
|
|
25489
|
+
lastAt: last.at,
|
|
25490
|
+
hitRatio: cacheWarmupHitRatio(last)
|
|
25491
|
+
});
|
|
25492
|
+
}
|
|
25493
|
+
summaries.sort((left, right) => right.lastAt - left.lastAt);
|
|
25494
|
+
return summaries;
|
|
25495
|
+
} catch {
|
|
25496
|
+
return [];
|
|
25497
|
+
}
|
|
25498
|
+
}
|
|
25499
|
+
//#endregion
|
|
25500
|
+
//#region src/host/cache-warmup/budget.ts
|
|
25501
|
+
/**
|
|
25502
|
+
* 预热预算:预热期保守、连续命中后放大、漂移立刻收回。
|
|
25503
|
+
*
|
|
25504
|
+
* 控制器是**纯函数**:不发起请求、不改消息、不改登记表。它只回答一个问题 ——
|
|
25505
|
+
* 「这一次预览最多能占多少字符」。把决策与执行分开,才能保证预算收紧或放开
|
|
25506
|
+
* 都不会悄悄改写已经进入历史的文本(历史里的 preview 是常量)。
|
|
25507
|
+
*
|
|
25508
|
+
* @module dsh-chatgpt-subscription/host/cache-warmup/budget
|
|
25509
|
+
*/
|
|
25510
|
+
/** 预热期的保守预算(构造一次,避免每次读模块常量)。 */
|
|
25511
|
+
const WARMUP_BUDGET = Object.freeze({
|
|
25512
|
+
maxChars: CACHE_WARMUP_DEFAULTS.warmupMaxChars,
|
|
25513
|
+
warmup: true,
|
|
25514
|
+
consecutiveHits: 0,
|
|
25515
|
+
hitRatio: 0
|
|
25516
|
+
});
|
|
25517
|
+
/**
|
|
25518
|
+
* 当前会话的预览预算。
|
|
25519
|
+
*
|
|
25520
|
+
* 规则(约束 4):预热期 `maxChars = 1200`(约 600-750 tokens);连续
|
|
25521
|
+
* `consecutiveHitsRequired`(2) 次命中(比例 ≥ 0.97 或前缀完整延续且新增 ≤ 256 tokens)
|
|
25522
|
+
* 且期间没有 drift,才进入稳态 `maxChars = 8000`。任何一次 drift 都清零连续计数,
|
|
25523
|
+
* 于是下一条请求立刻回落到预热预算。
|
|
25524
|
+
*
|
|
25525
|
+
* 空闲 TTL 预判:若距离最近一条报告已超过 `cacheTtlMs`(25 分钟,Kimi 实测缓存
|
|
25526
|
+
* 全热上限),说明上游缓存大概率已冷,无需等下一轮请求失败,当场退回预热期保守预算。
|
|
25527
|
+
*
|
|
25528
|
+
* **不抛出**:没有会话、没有报告、登记表异常,一律返回预热期的保守预算。
|
|
25529
|
+
* @param sessionId - 会话 id;不传则按最保守的预热预算处理。
|
|
25530
|
+
* @param now - 当前时间戳(毫秒),默认 `Date.now()`;测试可注入。
|
|
25531
|
+
* @param durable - 进程重启后尚无内存报告时的持久化回退摘要(可选)。
|
|
25532
|
+
* @returns 本次投影可用的预算。
|
|
25533
|
+
*/
|
|
25534
|
+
function cacheWarmupBudget(sessionId, now = Date.now(), durable) {
|
|
25535
|
+
try {
|
|
25536
|
+
if (typeof sessionId !== "string" || sessionId.trim() === "") return { ...WARMUP_BUDGET };
|
|
25537
|
+
const reports = cacheWarmupUsageReports(sessionId);
|
|
25538
|
+
if (reports.length === 0) {
|
|
25539
|
+
if (durable !== void 0 && durable !== null && typeof durable === "object") {
|
|
25540
|
+
const lastAt = durable.lastActiveAt;
|
|
25541
|
+
const hits = typeof lastAt === "number" && Number.isFinite(lastAt) && Number.isFinite(now) && now - lastAt <= CACHE_WARMUP_DEFAULTS.cacheTtlMs && typeof durable.consecutiveHits === "number" && Number.isFinite(durable.consecutiveHits) && durable.consecutiveHits > 0 ? Math.floor(durable.consecutiveHits) : 0;
|
|
25542
|
+
const hitRatio = typeof durable.hitRatio === "number" && Number.isFinite(durable.hitRatio) && durable.hitRatio >= 0 ? Math.min(1, durable.hitRatio) : 0;
|
|
25543
|
+
const warmup = hits < CACHE_WARMUP_DEFAULTS.consecutiveHitsRequired;
|
|
25544
|
+
return {
|
|
25545
|
+
maxChars: warmup ? CACHE_WARMUP_DEFAULTS.warmupMaxChars : CACHE_WARMUP_DEFAULTS.steadyStateMaxChars,
|
|
25546
|
+
warmup,
|
|
25547
|
+
consecutiveHits: hits,
|
|
25548
|
+
hitRatio
|
|
25549
|
+
};
|
|
25550
|
+
}
|
|
25551
|
+
return { ...WARMUP_BUDGET };
|
|
25552
|
+
}
|
|
25553
|
+
const last = reports[reports.length - 1];
|
|
25554
|
+
const hitRatio = last === void 0 ? 0 : cacheWarmupHitRatio(last);
|
|
25555
|
+
const consecutiveHits = last !== void 0 && typeof last.at === "number" && Number.isFinite(last.at) && Number.isFinite(now) && now - last.at > CACHE_WARMUP_DEFAULTS.cacheTtlMs ? 0 : consecutiveWarmupHits(reports);
|
|
25556
|
+
const warmup = consecutiveHits < CACHE_WARMUP_DEFAULTS.consecutiveHitsRequired;
|
|
25557
|
+
return {
|
|
25558
|
+
maxChars: warmup ? CACHE_WARMUP_DEFAULTS.warmupMaxChars : CACHE_WARMUP_DEFAULTS.steadyStateMaxChars,
|
|
25559
|
+
warmup,
|
|
25560
|
+
consecutiveHits,
|
|
25561
|
+
hitRatio
|
|
25562
|
+
};
|
|
25563
|
+
} catch {
|
|
25564
|
+
return { ...WARMUP_BUDGET };
|
|
25565
|
+
}
|
|
25566
|
+
}
|
|
25567
|
+
/**
|
|
25568
|
+
* 一份可读快照。**不抛出**,且完全无副作用(只读登记表)。
|
|
25569
|
+
* @param sessionId - 会话 id;不传则给全局汇总。
|
|
25570
|
+
* @param now - 当前时间戳(毫秒),默认 `Date.now()`。
|
|
25571
|
+
* @returns 快照。
|
|
25572
|
+
*/
|
|
25573
|
+
function cacheWarmupSnapshot(sessionId, now = Date.now()) {
|
|
25574
|
+
const fallback = {
|
|
25575
|
+
budget: { ...WARMUP_BUDGET },
|
|
25576
|
+
defaults: CACHE_WARMUP_DEFAULTS,
|
|
25577
|
+
usage: {
|
|
25578
|
+
reports: 0,
|
|
25579
|
+
hitRatio: 0,
|
|
25580
|
+
totals: {
|
|
25581
|
+
inputTokens: 0,
|
|
25582
|
+
cacheReadTokens: 0,
|
|
25583
|
+
outputTokens: 0,
|
|
25584
|
+
totalTokens: 0
|
|
25585
|
+
},
|
|
25586
|
+
driftCauses: []
|
|
25587
|
+
},
|
|
25588
|
+
sessions: 0
|
|
25589
|
+
};
|
|
25590
|
+
try {
|
|
25591
|
+
const budget = cacheWarmupBudget(sessionId, now);
|
|
25592
|
+
const reports = typeof sessionId === "string" && sessionId.trim() !== "" ? cacheWarmupUsageReports(sessionId) : [];
|
|
25593
|
+
const totals = {
|
|
25594
|
+
inputTokens: 0,
|
|
25595
|
+
cacheReadTokens: 0,
|
|
25596
|
+
outputTokens: 0,
|
|
25597
|
+
totalTokens: 0
|
|
25598
|
+
};
|
|
25599
|
+
const driftCauses = [];
|
|
25600
|
+
for (const report of reports) {
|
|
25601
|
+
totals.inputTokens += report.inputTokens;
|
|
25602
|
+
totals.cacheReadTokens += report.cacheReadTokens;
|
|
25603
|
+
totals.outputTokens += report.outputTokens;
|
|
25604
|
+
totals.totalTokens += report.totalTokens;
|
|
25605
|
+
const drift = report.driftCause;
|
|
25606
|
+
if (typeof drift === "string" && drift !== "" && !driftCauses.includes(drift)) driftCauses.push(drift);
|
|
25607
|
+
}
|
|
25608
|
+
const last = reports[reports.length - 1];
|
|
25609
|
+
const snapshot = {
|
|
25610
|
+
budget,
|
|
25611
|
+
defaults: CACHE_WARMUP_DEFAULTS,
|
|
25612
|
+
usage: {
|
|
25613
|
+
reports: reports.length,
|
|
25614
|
+
hitRatio: last === void 0 ? 0 : cacheWarmupHitRatio(last),
|
|
25615
|
+
totals,
|
|
25616
|
+
driftCauses
|
|
25617
|
+
},
|
|
25618
|
+
sessions: cacheWarmupUsageSessions().length
|
|
25619
|
+
};
|
|
25620
|
+
if (last !== void 0) snapshot.usage.lastAt = last.at;
|
|
25621
|
+
if (typeof sessionId === "string" && sessionId.trim() !== "") snapshot.sessionId = sessionId;
|
|
25622
|
+
return snapshot;
|
|
25623
|
+
} catch {
|
|
25624
|
+
return fallback;
|
|
25625
|
+
}
|
|
25626
|
+
}
|
|
25627
|
+
//#endregion
|
|
25628
|
+
//#region src/host/cache-warmup/projection.ts
|
|
25629
|
+
/**
|
|
25630
|
+
* 保守结构化投影:只有**可证明**的结构才换成预览,其余一律原样。
|
|
25631
|
+
*
|
|
25632
|
+
* 默认保守的四条线:
|
|
25633
|
+
*
|
|
25634
|
+
* - 小结果原样(不超过预算就不动),因为换来换去只会扰乱缓存前缀。
|
|
25635
|
+
* - 无法可靠解析的大文本原样;未知大文本原样;关键源码、接口/类型定义、子代理报告原样。
|
|
25636
|
+
* - 失败结果只允许换成「完整失败集」型预览(测试失败清单、shell 错误行),
|
|
25637
|
+
* 永远不会被压成一句计数 —— 计数看起来像结论,实际会把下一步的调试带偏。
|
|
25638
|
+
* - 预览必须自述完整度:带 ref、带省略字符数,读全文的路径写在预览里。
|
|
25639
|
+
*
|
|
25640
|
+
* 另一个不变量:本模块**不修改历史**。它返回一份新内容,调用方决定要不要写进新结果;
|
|
25641
|
+
* 已经进入历史的 preview 与其 refId 是常量,存储过期也不会回头改它。
|
|
25642
|
+
*
|
|
25643
|
+
* @module dsh-chatgpt-subscription/host/cache-warmup/projection
|
|
25644
|
+
*/
|
|
25645
|
+
/** 子代理报告:一律原样,摘要会丢掉调用方真正要的判断依据。 */
|
|
25646
|
+
const TOOL_SUBAGENT = /(?:^|[-_/])(?:subagent|agent|task|delegate|ralph|teammate)(?:[-_/]|$)/i;
|
|
25647
|
+
/** 源码读取/写入类工具:一律原样。 */
|
|
25648
|
+
const TOOL_SOURCE = /(?:^|[-_/])(?:read|view|cat|open|edit|write|patch|apply_patch|notebook|document|diff)(?:[-_/]|$)/i;
|
|
25649
|
+
/** 一行文本的裁剪(超长行才裁剪,并留下省略号)。 */
|
|
25650
|
+
function clip(text, max) {
|
|
25651
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
25652
|
+
}
|
|
25653
|
+
/**
|
|
25654
|
+
* 剥离终端 ANSI/CSI 转义序列(如 `\x1b[31mFAIL\x1b[0m`)与回车符 `\r`。
|
|
25655
|
+
*
|
|
25656
|
+
* JSON 解码后,原先源码/日志里的 `\u001b[31m` 会还原成控制字符 `\x1b`;
|
|
25657
|
+
* 如果不剥离,`^FAIL` 锚点匹配会被颜色码挡住,而再次 `JSON.stringify` 时每个
|
|
25658
|
+
* `\x1b` 又会膨胀成 6 个字符 `\u001b`。
|
|
25659
|
+
*/
|
|
25660
|
+
const ANSI_ESCAPE = /\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g;
|
|
25661
|
+
function stripAnsi(text) {
|
|
25662
|
+
return text.replace(ANSI_ESCAPE, "").replace(/\r/g, "");
|
|
25663
|
+
}
|
|
25664
|
+
/**
|
|
25665
|
+
* 计算一行文本在最终输出里占用的精确字符宽度:
|
|
25666
|
+
* - 普通纯文本模式:`line.length + 1`(含换行符 `\n`);
|
|
25667
|
+
* - JSON 字符串内嵌模式:`JSON.stringify(line).length`(去首尾引号后 `+ 2` 个字符的 `\\n` 换行转义,
|
|
25668
|
+
* 恰好等于 `JSON.stringify(line).length`,对 `"`、`\`、`\t`、`\u00xx` 的转义膨胀 100% 精确)。
|
|
25669
|
+
*/
|
|
25670
|
+
function lineCost(line, jsonEscaped) {
|
|
25671
|
+
return jsonEscaped ? JSON.stringify(line).length : line.length + 1;
|
|
25672
|
+
}
|
|
25673
|
+
/**
|
|
25674
|
+
* 按预算装配预览正文。
|
|
25675
|
+
*
|
|
25676
|
+
* 必需段落要么整段进来,要么整体装不下就返回 undefined(调用方据此保持原文);
|
|
25677
|
+
* 可选段落逐段尝试,装不下就整段跳过。这样任何输出都不会出现「半截失败清单」。
|
|
25678
|
+
*/
|
|
25679
|
+
function fitSections(sections, maxChars, jsonEscaped = false) {
|
|
25680
|
+
const lines = [];
|
|
25681
|
+
let used = 0;
|
|
25682
|
+
const trailingNewlineAllowance = jsonEscaped ? 2 : 1;
|
|
25683
|
+
for (const section of sections) {
|
|
25684
|
+
if (section.optional === true) continue;
|
|
25685
|
+
for (const line of section.lines) {
|
|
25686
|
+
used += lineCost(line, jsonEscaped);
|
|
25687
|
+
lines.push(line);
|
|
25688
|
+
}
|
|
25689
|
+
}
|
|
25690
|
+
if (used > maxChars + trailingNewlineAllowance) return void 0;
|
|
25691
|
+
for (const section of sections) {
|
|
25692
|
+
if (section.optional !== true) continue;
|
|
25693
|
+
const size = section.lines.reduce((sum, line) => sum + lineCost(line, jsonEscaped), 0);
|
|
25694
|
+
if (used + size > maxChars + trailingNewlineAllowance) continue;
|
|
25695
|
+
for (const line of section.lines) lines.push(line);
|
|
25696
|
+
used += size;
|
|
25697
|
+
}
|
|
25698
|
+
return lines.join("\n");
|
|
25699
|
+
}
|
|
25700
|
+
/** 从测试输出里抽取「完整失败集」与汇总行;抽不全就整体放弃。 */
|
|
25701
|
+
function extractTestEvidence(text) {
|
|
25702
|
+
const summary = [];
|
|
25703
|
+
const failures = [];
|
|
25704
|
+
for (const raw of text.split("\n")) {
|
|
25705
|
+
const trimmed = raw.trim();
|
|
25706
|
+
if (trimmed === "") continue;
|
|
25707
|
+
if (/^(?:FAIL|FAILED|✗|✕|×)\s+\S/.test(trimmed) || /^not ok \d+ - /.test(trimmed) || /^●\s+\S/.test(trimmed) || /^--- FAIL:\s/.test(trimmed)) {
|
|
25708
|
+
failures.push(clip(trimmed, 240));
|
|
25709
|
+
continue;
|
|
25710
|
+
}
|
|
25711
|
+
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));
|
|
25712
|
+
}
|
|
25713
|
+
if (summary.length === 0 && failures.length === 0) return void 0;
|
|
25714
|
+
if (summary.length === 0 && !/\b(?:test|tests|spec|suite|assert)\b/i.test(text.slice(0, 4e3))) return void 0;
|
|
25715
|
+
return {
|
|
25716
|
+
summary,
|
|
25717
|
+
failures
|
|
25718
|
+
};
|
|
25719
|
+
}
|
|
25720
|
+
/** 测试预览:失败集必须完整,汇总行紧随其后。 */
|
|
25721
|
+
function buildTestSections(evidence) {
|
|
25722
|
+
const sections = [{ lines: [`failures: ${evidence.failures.length} (complete set)`] }];
|
|
25723
|
+
if (evidence.failures.length > 0) sections.push({ lines: evidence.failures.map((line) => ` ${line}`) });
|
|
25724
|
+
if (evidence.summary.length > 0) sections.push({ lines: ["summary:", ...evidence.summary.map((line) => ` ${line}`)] });
|
|
25725
|
+
return sections;
|
|
25726
|
+
}
|
|
25727
|
+
/** 一行 shell 错误。用于「完整错误集」,所以匹配宁可宽一点。 */
|
|
25728
|
+
const SHELL_ERROR_LINE = /(?:error|fatal|panic|traceback|exception|EACCES|EPERM|ENOENT|EEXIST|ELIFECYCLE|ERR!|FAILED|permission denied|cannot |could not |not found)/i;
|
|
25729
|
+
/** TypeScript 编译器报错首行:`path(line,col): error TSxxxx:` 或 `path:line:col - error TSxxxx:`。 */
|
|
25730
|
+
const TS_DIAGNOSTIC_HEADER = /^(?:.+?\(\d+,\d+\):\s+error\s+TS\d+:|.+?:\d+:\d+\s+-\s+error\s+TS\d+:)/;
|
|
25731
|
+
/**
|
|
25732
|
+
* 紧跟在 TypeScript 诊断首行后面的续行(缩进的类型比对、代码高亮行、波浪线指示行),
|
|
25733
|
+
* 最多连带收集 6 行,使模型无需补读就能看全 `Type A is not assignable to Type B` 的完整原因。
|
|
25734
|
+
*/
|
|
25735
|
+
function isTsDiagnosticDetailLine(raw) {
|
|
25736
|
+
if (/^\s{2,}\S/.test(raw)) return true;
|
|
25737
|
+
if (/^\d+\s+\|/.test(raw.trimStart())) return true;
|
|
25738
|
+
if (/^~+$/.test(raw.trim())) return true;
|
|
25739
|
+
return false;
|
|
25740
|
+
}
|
|
25741
|
+
/** 一行 glob 结果的判定:无空白、像文件路径(至少 80% 含路径分隔符或文件扩展名,不含引号/括号/冒号行号)。 */
|
|
25742
|
+
function looksLikeGlobResult(text) {
|
|
25743
|
+
const lines = text.split("\n").filter((line) => line.trim() !== "");
|
|
25744
|
+
if (lines.length < 10) return false;
|
|
25745
|
+
let paths = 0;
|
|
25746
|
+
let pathWithSepOrExt = 0;
|
|
25747
|
+
for (const line of lines) {
|
|
25748
|
+
const trimmed = line.trim();
|
|
25749
|
+
if (trimmed.length === 0 || trimmed.length > 400) return false;
|
|
25750
|
+
if (GREP_LINE.test(trimmed)) return false;
|
|
25751
|
+
if (/^===.*===$/.test(trimmed)) return false;
|
|
25752
|
+
if (/['"[\]{}()]/.test(trimmed)) return false;
|
|
25753
|
+
if (/\s/.test(trimmed)) continue;
|
|
25754
|
+
if (/^[-*+]$/.test(trimmed)) continue;
|
|
25755
|
+
paths += 1;
|
|
25756
|
+
if (/[\\/]/.test(trimmed) || /\.[A-Za-z0-9]{1,8}$/.test(trimmed)) pathWithSepOrExt += 1;
|
|
25757
|
+
}
|
|
25758
|
+
return paths / lines.length >= .9 && pathWithSepOrExt / lines.length >= .8;
|
|
25759
|
+
}
|
|
25760
|
+
/** 判断一行是否只是单纯的文件路径(含可选引号/逗号,如 `"test\\stream-error.test.ts",`),而非报错消息。 */
|
|
25761
|
+
function isBareFilePathLine(trimmed) {
|
|
25762
|
+
const unquoted = trimmed.replace(/^['"]|['"],?$/g, "").trim();
|
|
25763
|
+
if (unquoted.length === 0 || unquoted.length > 300 || /\s/.test(unquoted)) return false;
|
|
25764
|
+
return /[\\/]/.test(unquoted) && /\.[A-Za-z0-9]{1,8}$/.test(unquoted) && !/:(\d+)/.test(unquoted);
|
|
25765
|
+
}
|
|
25766
|
+
/**
|
|
25767
|
+
* 判断文本是否为混合证据(如包含已投影的 `[cache-warmup preview]`、内嵌大型 JSON 对象行、
|
|
25768
|
+
* 多行 pretty-printed JSON 块、`util.inspect` 字符串拼接对象、分节头等)。
|
|
25769
|
+
*/
|
|
25770
|
+
function isMixedEvidenceOutput(text) {
|
|
25771
|
+
if (text.includes("[cache-warmup preview]")) return true;
|
|
25772
|
+
if (/^(?:===|---)\s+.+?(?:\s+(?:===|---))?$/m.test(text) || /^###\s+\S/m.test(text) || /^--\s+.+\s+--$/m.test(text)) return true;
|
|
25773
|
+
const lines = text.split("\n");
|
|
25774
|
+
let inspectStringConcat = 0;
|
|
25775
|
+
let jsonPropertyLines = 0;
|
|
25776
|
+
for (const raw of lines.slice(0, 200)) {
|
|
25777
|
+
const trimmed = raw.trim();
|
|
25778
|
+
if (trimmed === "") continue;
|
|
25779
|
+
if (trimmed.length > 80 && (trimmed.startsWith("{\"") && (trimmed.endsWith("}") || trimmed.includes("\":")) || trimmed.startsWith("[{\"") && trimmed.includes("\":"))) return true;
|
|
25780
|
+
if (/^"[A-Za-z0-9_$-]+"\s*:/.test(trimmed)) {
|
|
25781
|
+
jsonPropertyLines += 1;
|
|
25782
|
+
if (jsonPropertyLines >= 2) return true;
|
|
25783
|
+
}
|
|
25784
|
+
if (/^['"].*\\n['"]\s*\+$/.test(trimmed)) {
|
|
25785
|
+
inspectStringConcat += 1;
|
|
25786
|
+
if (inspectStringConcat >= 2) return true;
|
|
25787
|
+
}
|
|
25788
|
+
}
|
|
25789
|
+
return false;
|
|
25790
|
+
}
|
|
25791
|
+
/**
|
|
25792
|
+
* shell 输出判定:
|
|
25793
|
+
* - 排除混合证据输出(内嵌 JSON 行、`[cache-warmup preview]`、分节头、`util.inspect` 对象);
|
|
25794
|
+
* - 有明确的退出码标记(`[exit code: N]`、`exit code: N`、`EXIT=N`);
|
|
25795
|
+
* - 有 Python `Traceback (most recent call last):` 或 JS 异常头 + 堆栈行(`^\s+at\s+`);
|
|
25796
|
+
* - 若没有任何退出码或堆栈,只有纯文本行时,要求不存在 grep 行,且错误行不是单纯的文件名。
|
|
25797
|
+
*/
|
|
25798
|
+
function looksLikeShellOutput(text) {
|
|
25799
|
+
if (isMixedEvidenceOutput(text)) return false;
|
|
25800
|
+
if (/(?:exit code|exited with code|process exited with code)[:=\s]+-?\d+/i.test(text)) return true;
|
|
25801
|
+
if (/\[exit code: -?\d+\]/i.test(text)) return true;
|
|
25802
|
+
if (/^EXIT=-?\d+$/m.test(text)) return true;
|
|
25803
|
+
if (/^Traceback \(most recent call last\):/m.test(text)) return true;
|
|
25804
|
+
if (/^(?:[A-Za-z]*Error|Exception): /m.test(text) && /^\s+at\s+\S/m.test(text)) return true;
|
|
25805
|
+
const lines = text.split("\n");
|
|
25806
|
+
let grepLikes = 0;
|
|
25807
|
+
let errors = 0;
|
|
25808
|
+
for (const line of lines) {
|
|
25809
|
+
const trimmed = line.trim();
|
|
25810
|
+
if (trimmed === "") continue;
|
|
25811
|
+
if (/^(?:PASS\b|[✓✔]|ok\s+\d+\b)/.test(trimmed)) continue;
|
|
25812
|
+
if (/^stdout\s+\|\s+/.test(trimmed)) continue;
|
|
25813
|
+
if (/^\(node:\d+\)\s+\[\w+\]\s+Warning:/.test(trimmed)) continue;
|
|
25814
|
+
if (isBareFilePathLine(trimmed)) continue;
|
|
25815
|
+
if (GREP_LINE.test(trimmed) && !TS_DIAGNOSTIC_HEADER.test(trimmed)) {
|
|
25816
|
+
grepLikes += 1;
|
|
25817
|
+
if (grepLikes >= 2) return false;
|
|
25818
|
+
}
|
|
25819
|
+
if (/^\s*(?:0|no)\s+(?:errors?|failures?|failed)\b/i.test(trimmed)) continue;
|
|
25820
|
+
if (SHELL_ERROR_LINE.test(line)) errors += 1;
|
|
25821
|
+
}
|
|
25822
|
+
return errors >= 1;
|
|
25823
|
+
}
|
|
25824
|
+
/** shell 预览:退出码 + 完整错误集(必需,含 TS 多行诊断上下文),首尾片段(可选)。 */
|
|
25825
|
+
function buildShellSections(text) {
|
|
25826
|
+
const lines = text.split("\n");
|
|
25827
|
+
const errors = [];
|
|
25828
|
+
let errorCount = 0;
|
|
25829
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
25830
|
+
const trimmed = lines[index].trim();
|
|
25831
|
+
if (trimmed === "") continue;
|
|
25832
|
+
if (/^\[exit code: -?\d+\]/i.test(trimmed)) continue;
|
|
25833
|
+
if (/^\s*(?:0|no)\s+(?:errors?|failures?|failed)\b/i.test(trimmed)) continue;
|
|
25834
|
+
if (/^(?:PASS\b|[✓✔]|ok\s+\d+\b)/.test(trimmed)) continue;
|
|
25835
|
+
if (/^stdout\s+\|\s+/.test(trimmed)) continue;
|
|
25836
|
+
if (/^\(node:\d+\)\s+\[\w+\]\s+Warning:/.test(trimmed)) continue;
|
|
25837
|
+
if (isBareFilePathLine(trimmed)) continue;
|
|
25838
|
+
if (TS_DIAGNOSTIC_HEADER.test(trimmed)) {
|
|
25839
|
+
errorCount += 1;
|
|
25840
|
+
errors.push(` ${clip(trimmed, 240)}`);
|
|
25841
|
+
let details = 0;
|
|
25842
|
+
while (index + 1 < lines.length && details < 6) {
|
|
25843
|
+
const nextRaw = lines[index + 1];
|
|
25844
|
+
const nextTrimmed = nextRaw.trim();
|
|
25845
|
+
if (nextTrimmed === "" || TS_DIAGNOSTIC_HEADER.test(nextTrimmed) || /^\[exit code: -?\d+\]/i.test(nextTrimmed)) break;
|
|
25846
|
+
if (!isTsDiagnosticDetailLine(nextRaw)) break;
|
|
25847
|
+
errors.push(` ${clip(nextTrimmed, 240)}`);
|
|
25848
|
+
index += 1;
|
|
25849
|
+
details += 1;
|
|
25850
|
+
}
|
|
25851
|
+
continue;
|
|
25852
|
+
}
|
|
25853
|
+
if (SHELL_ERROR_LINE.test(trimmed)) {
|
|
25854
|
+
errorCount += 1;
|
|
25855
|
+
errors.push(` ${clip(trimmed, 360)}`);
|
|
25856
|
+
}
|
|
25857
|
+
}
|
|
25858
|
+
if (errorCount > 300 || errors.length > 600) return void 0;
|
|
25859
|
+
const exitMatch = /(?:exit code|exited with code|process exited with code)[:=\s]+(-?\d+)/i.exec(text) ?? /\[exit code: (-?\d+)\]/i.exec(text);
|
|
25860
|
+
const sections = [];
|
|
25861
|
+
if (exitMatch !== null) sections.push({ lines: [`exit code: ${exitMatch[1]}`] });
|
|
25862
|
+
sections.push({ lines: [`errors: ${errorCount} (complete set)`] });
|
|
25863
|
+
if (errors.length > 0) sections.push({ lines: errors });
|
|
25864
|
+
const nonEmpty = lines.map((line) => line.trimEnd()).filter((line) => line.trim() !== "");
|
|
25865
|
+
const head = nonEmpty.slice(0, 5).map((line) => ` ${clip(line.trim(), 160)}`);
|
|
25866
|
+
const tail = nonEmpty.slice(-5).map((line) => ` ${clip(line.trim(), 160)}`);
|
|
25867
|
+
if (head.length > 0) sections.push({
|
|
25868
|
+
lines: ["head:", ...head],
|
|
25869
|
+
optional: true
|
|
25870
|
+
});
|
|
25871
|
+
if (tail.length > 0) sections.push({
|
|
25872
|
+
lines: ["tail:", ...tail],
|
|
25873
|
+
optional: true
|
|
25874
|
+
});
|
|
25875
|
+
return sections;
|
|
25876
|
+
}
|
|
25877
|
+
/** 一行 grep 结果:`<path>:<line>[:<column>]:<text>` 或 `<path>:<line>(?:\t|\||\s{2,})<text>`。 */
|
|
25878
|
+
const GREP_LINE = /^(.+?):(\d+)(?::(?:\d+:)?|\t|\|\s?|\s{2,})(.*)$/;
|
|
25879
|
+
/** 抽取 grep 证据;命中线占比不到 80% 就当它不是 grep 输出。 */
|
|
25880
|
+
function extractGrepEvidence(text) {
|
|
25881
|
+
const lines = text.split("\n").filter((line) => line.trim() !== "");
|
|
25882
|
+
if (lines.length < 10) return void 0;
|
|
25883
|
+
const files = /* @__PURE__ */ new Map();
|
|
25884
|
+
let matched = 0;
|
|
25885
|
+
for (const line of lines) {
|
|
25886
|
+
const match = GREP_LINE.exec(line);
|
|
25887
|
+
if (match === null) continue;
|
|
25888
|
+
const file = match[1];
|
|
25889
|
+
if (!/[\\/.]/.test(file) || file.length > 400) continue;
|
|
25890
|
+
matched += 1;
|
|
25891
|
+
const entry = files.get(file);
|
|
25892
|
+
if (entry === void 0) files.set(file, {
|
|
25893
|
+
count: 1,
|
|
25894
|
+
first: match[3]
|
|
25895
|
+
});
|
|
25896
|
+
else entry.count += 1;
|
|
25897
|
+
}
|
|
25898
|
+
if (matched < 10 || matched / lines.length < .8) return void 0;
|
|
25899
|
+
return {
|
|
25900
|
+
files,
|
|
25901
|
+
total: matched
|
|
25902
|
+
};
|
|
25903
|
+
}
|
|
25904
|
+
/** grep 预览:命中总数与每文件计数(最多列 30 个文件,其余显式说明)。 */
|
|
25905
|
+
function buildGrepSections(evidence) {
|
|
25906
|
+
const ranked = [...evidence.files.entries()].sort((left, right) => right[1].count - left[1].count);
|
|
25907
|
+
const shown = ranked.slice(0, 30);
|
|
25908
|
+
const sections = [{ lines: [`matches: ${evidence.total} across ${ranked.length} files`] }, { lines: ["by file:", ...shown.map(([file, entry]) => ` ${clip(file, 120)}: ${entry.count}`)] }];
|
|
25909
|
+
if (ranked.length > shown.length) sections.push({ lines: [` … ${ranked.length - shown.length} more files (full list via ref)`] });
|
|
25910
|
+
sections.push({
|
|
25911
|
+
lines: ["first matches:", ...shown.slice(0, 8).map(([file, entry]) => ` ${clip(file, 80)}: ${clip(entry.first, 120)}`)],
|
|
25912
|
+
optional: true
|
|
25913
|
+
});
|
|
25914
|
+
return sections;
|
|
25915
|
+
}
|
|
25916
|
+
/** glob 预览:总数 + 扩展名分布(必需),样本路径(可选)。 */
|
|
25917
|
+
function buildGlobSections(text) {
|
|
25918
|
+
const paths = text.split("\n").map((line) => line.trim()).filter((line) => line !== "");
|
|
25919
|
+
const byExtension = /* @__PURE__ */ new Map();
|
|
25920
|
+
for (const entry of paths) {
|
|
25921
|
+
const dot = entry.lastIndexOf(".");
|
|
25922
|
+
const extension = dot > Math.max(entry.lastIndexOf("/"), entry.lastIndexOf("\\")) ? entry.slice(dot).toLowerCase() : "(none)";
|
|
25923
|
+
byExtension.set(extension, (byExtension.get(extension) ?? 0) + 1);
|
|
25924
|
+
}
|
|
25925
|
+
const top = [...byExtension.entries()].sort((left, right) => right[1] - left[1]).slice(0, 6);
|
|
25926
|
+
const sample = paths.slice(0, 10).map((entry) => ` ${clip(entry, 140)}`);
|
|
25927
|
+
if (paths.length > 10) sample.push(` … ${paths.length - 10} more`);
|
|
25928
|
+
return [
|
|
25929
|
+
{ lines: [`paths: ${paths.length}`] },
|
|
25930
|
+
{ lines: ["by extension:", ...top.map(([extension, count]) => ` ${extension}: ${count}`)] },
|
|
25931
|
+
{
|
|
25932
|
+
lines: ["sample:", ...sample],
|
|
25933
|
+
optional: true
|
|
25934
|
+
}
|
|
25935
|
+
];
|
|
25936
|
+
}
|
|
25937
|
+
/** 结构化预览:行数与字段(必需),样本行(可选)。 */
|
|
25938
|
+
function buildStructuredSections(text) {
|
|
25939
|
+
const parsed = parseStructuredResultRows(text);
|
|
25940
|
+
if (parsed === void 0) return void 0;
|
|
25941
|
+
const sample = parsed.rows.slice(0, 3).map((row) => ` ${clip(JSON.stringify(row), 240)}`);
|
|
25942
|
+
return [{ lines: [`rows: ${parsed.rows.length}`, `fields: ${parsed.fields.join(", ")}`] }, {
|
|
25943
|
+
lines: ["sample:", ...sample],
|
|
25944
|
+
optional: true
|
|
25945
|
+
}];
|
|
25946
|
+
}
|
|
25947
|
+
/** 剥离 `read` 工具或 `grep -C` 常见的行号前缀(如 `1: `、`1- `、`1\t`、`1| `、` 12→`)。 */
|
|
25948
|
+
const LINE_NUMBER_PREFIX = /^\s*\d+(?::(?:\s|$)|-\s{2,}|\t|\|(?:\s|$)|→\s?)/;
|
|
25949
|
+
function stripLineNumberPrefix(line) {
|
|
25950
|
+
return line.replace(LINE_NUMBER_PREFIX, "");
|
|
25951
|
+
}
|
|
25952
|
+
/** 契约/类型定义:命中两行以上声明就当它是契约,保持原样。 */
|
|
25953
|
+
function looksLikeContract(text) {
|
|
25954
|
+
let declarations = 0;
|
|
25955
|
+
for (const raw of text.split("\n").slice(0, 800)) {
|
|
25956
|
+
const line = stripLineNumberPrefix(raw);
|
|
25957
|
+
if (/^\s*(?:export\s+)?(?:declare\s+)?(?:interface|type)\s+[A-Za-z_$][\w$]*/.test(line)) declarations += 1;
|
|
25958
|
+
if (declarations >= 2) return true;
|
|
25959
|
+
}
|
|
25960
|
+
return false;
|
|
25961
|
+
}
|
|
25962
|
+
/**
|
|
25963
|
+
* 源码或混合证据判定:
|
|
25964
|
+
* - 含多处 markdown 代码围栏;
|
|
25965
|
+
* - 含 `git diff` 补丁块(`diff --git` / `@@ -x,y +x,y @@`);
|
|
25966
|
+
* - 含至少 5 行带行号的源码切片(`1: ...` 或 `1\t...`),说明是单文件或多文件混合读取输出;
|
|
25967
|
+
* - 代码行占比 >= 45%。
|
|
25968
|
+
*/
|
|
25969
|
+
function looksLikeSourceCode(text) {
|
|
25970
|
+
if (/^diff --git\s+/m.test(text) || /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/m.test(text)) return true;
|
|
25971
|
+
let fences = 0;
|
|
25972
|
+
for (const line of text.split("\n").slice(0, 600)) if (stripLineNumberPrefix(line).trim().startsWith("```")) fences += 1;
|
|
25973
|
+
if (fences >= 4) return true;
|
|
25974
|
+
let code = 0;
|
|
25975
|
+
let numbered = 0;
|
|
25976
|
+
let nonEmpty = 0;
|
|
25977
|
+
for (const raw of text.split("\n").slice(0, 600)) {
|
|
25978
|
+
if (raw.trim() === "") continue;
|
|
25979
|
+
if (LINE_NUMBER_PREFIX.test(raw)) numbered += 1;
|
|
25980
|
+
const trimmed = stripLineNumberPrefix(raw).trim();
|
|
25981
|
+
if (trimmed === "") continue;
|
|
25982
|
+
nonEmpty += 1;
|
|
25983
|
+
if (/^(?:import|export|from|const|let|var|function|class|interface|type|enum|async|await|return|if|for|while|switch|case|break|continue|try|catch|finally|throw|new|public|private|protected|static|readonly|def|using|namespace|package|#include|#pragma)\b/.test(trimmed) || /^[})\];]/.test(trimmed) || /^(?:\/\/|\/\*|\*)/.test(trimmed) || /^<\/?[A-Za-z][\w.-]*/.test(trimmed)) code += 1;
|
|
25984
|
+
}
|
|
25985
|
+
if (numbered >= 5) return true;
|
|
25986
|
+
return nonEmpty >= 10 && code / nonEmpty >= .45;
|
|
25987
|
+
}
|
|
25988
|
+
/** 完整 JSON 文档(对象、数组、JSON 字符串)或被截断的 JSON 对象:不按裸日志猜测。 */
|
|
25989
|
+
function isJsonDocument(text) {
|
|
25990
|
+
const trimmed = text.trim();
|
|
25991
|
+
if (trimmed.length < 2) return false;
|
|
25992
|
+
const first = trimmed[0];
|
|
25993
|
+
const last = trimmed[trimmed.length - 1];
|
|
25994
|
+
if (first === "\"" && last === "\"") try {
|
|
25995
|
+
return typeof JSON.parse(trimmed) === "string";
|
|
25996
|
+
} catch {
|
|
25997
|
+
return false;
|
|
25998
|
+
}
|
|
25999
|
+
if (first !== "{" && first !== "[") return false;
|
|
26000
|
+
if (first === "{" && last === "}" || first === "[" && last === "]") try {
|
|
26001
|
+
const value = JSON.parse(trimmed);
|
|
26002
|
+
return typeof value === "object" && value !== null;
|
|
26003
|
+
} catch {}
|
|
26004
|
+
if (first === "{") {
|
|
26005
|
+
let fields = 0;
|
|
26006
|
+
for (const line of trimmed.split("\n").slice(0, 24)) if (/^\s*"[^"]+"\s*:/.test(line)) fields += 1;
|
|
26007
|
+
return fields >= 2;
|
|
26008
|
+
}
|
|
26009
|
+
return false;
|
|
26010
|
+
}
|
|
26011
|
+
/** PTC `run_code` JSON envelope 中允许独立投影日志的字段白名单。 */
|
|
26012
|
+
const JSON_ENVELOPE_LOG_KEYS = [
|
|
26013
|
+
"stdout",
|
|
26014
|
+
"stderr",
|
|
26015
|
+
"output",
|
|
26016
|
+
"logs",
|
|
26017
|
+
"log"
|
|
26018
|
+
];
|
|
26019
|
+
/** JSON envelope 其余字段允许占用的最大序列化字符数(防止把含批量源码的混合 envelope 压掉)。 */
|
|
26020
|
+
const JSON_ENVELOPE_MAX_OTHER_CHARS = 800;
|
|
26021
|
+
/**
|
|
26022
|
+
* 解开一层或两层 JSON 包装(支持顶层 JSON 字符串 `"..."` 或双重序列化的 `"{...}"`)。
|
|
26023
|
+
*/
|
|
26024
|
+
function parseJsonCandidate(text) {
|
|
26025
|
+
const trimmed = text.trim();
|
|
26026
|
+
if (trimmed.length < 2) return void 0;
|
|
26027
|
+
const first = trimmed[0];
|
|
26028
|
+
const last = trimmed[trimmed.length - 1];
|
|
26029
|
+
if (first === "\"" && last === "\"") try {
|
|
26030
|
+
const inner = JSON.parse(trimmed);
|
|
26031
|
+
if (typeof inner !== "string") return void 0;
|
|
26032
|
+
const innerTrimmed = inner.trim();
|
|
26033
|
+
if (innerTrimmed.startsWith("{") && innerTrimmed.endsWith("}")) try {
|
|
26034
|
+
const innerObj = JSON.parse(innerTrimmed);
|
|
26035
|
+
if (innerObj !== null && typeof innerObj === "object" && !Array.isArray(innerObj)) return {
|
|
26036
|
+
kind: "object",
|
|
26037
|
+
record: innerObj,
|
|
26038
|
+
wrappedInString: true
|
|
26039
|
+
};
|
|
26040
|
+
} catch {}
|
|
26041
|
+
return {
|
|
26042
|
+
kind: "string",
|
|
26043
|
+
decoded: inner
|
|
26044
|
+
};
|
|
26045
|
+
} catch {
|
|
26046
|
+
return;
|
|
26047
|
+
}
|
|
26048
|
+
if (first === "{" && last === "}") try {
|
|
26049
|
+
const parsed = JSON.parse(trimmed);
|
|
26050
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return {
|
|
26051
|
+
kind: "object",
|
|
26052
|
+
record: parsed,
|
|
26053
|
+
wrappedInString: false
|
|
26054
|
+
};
|
|
26055
|
+
} catch {
|
|
26056
|
+
return;
|
|
26057
|
+
}
|
|
26058
|
+
}
|
|
26059
|
+
/**
|
|
26060
|
+
* 尝试从完整 JSON(顶层 JSON 字符串或对象 envelope)中解出可结构化投影的日志正文。
|
|
26061
|
+
*
|
|
26062
|
+
* 规则刻意保守:
|
|
26063
|
+
* - 截断 JSON、数组一律返回 undefined;
|
|
26064
|
+
* - 对象 envelope 除白名单字段外,其余字段序列化后的总长度不超过 `JSON_ENVELOPE_MAX_OTHER_CHARS`;
|
|
26065
|
+
* - 解码并剥离 ANSI 后的日志文本自身必须能通过非源码/非契约的结构化日志投影计划;
|
|
26066
|
+
* - 返回的 `storeContent` 把解码后的多行日志直接展开(对象 envelope 还会把其余元数据缩进在头部),
|
|
26067
|
+
* 彻底避免单行 minified JSON 因没有真实 `\n` 导致 `store.page` 的按行分页失效。
|
|
26068
|
+
*/
|
|
26069
|
+
function extractJsonEnvelopeLogField(text, toolName) {
|
|
26070
|
+
const candidate = parseJsonCandidate(text);
|
|
26071
|
+
if (candidate === void 0) return void 0;
|
|
26072
|
+
if (candidate.kind === "string") {
|
|
26073
|
+
const clean = stripAnsi(candidate.decoded);
|
|
26074
|
+
if (isJsonDocument(clean) || isMixedEvidenceOutput(clean)) return void 0;
|
|
26075
|
+
const plan = planPlainTextProjection(clean, toolName);
|
|
26076
|
+
if (plan.kind !== "preview") return void 0;
|
|
26077
|
+
return { plan };
|
|
26078
|
+
}
|
|
26079
|
+
const { record } = candidate;
|
|
26080
|
+
let candidateKey;
|
|
26081
|
+
let candidateText = "";
|
|
26082
|
+
for (const key of JSON_ENVELOPE_LOG_KEYS) {
|
|
26083
|
+
const val = record[key];
|
|
26084
|
+
if (typeof val === "string" && val.length > candidateText.length) {
|
|
26085
|
+
candidateKey = key;
|
|
26086
|
+
candidateText = val;
|
|
26087
|
+
}
|
|
26088
|
+
}
|
|
26089
|
+
if (candidateKey === void 0 || candidateText.length === 0) return void 0;
|
|
26090
|
+
const rest = {};
|
|
26091
|
+
for (const [key, value] of Object.entries(record)) {
|
|
26092
|
+
if (key === candidateKey) continue;
|
|
26093
|
+
if (typeof value === "string" && (value.length > 400 || looksLikeSourceCode(value) || looksLikeContract(value))) return;
|
|
26094
|
+
rest[key] = value;
|
|
26095
|
+
}
|
|
26096
|
+
if (JSON.stringify(rest).length > JSON_ENVELOPE_MAX_OTHER_CHARS) return void 0;
|
|
26097
|
+
const cleanField = stripAnsi(candidateText);
|
|
26098
|
+
if (isJsonDocument(cleanField) || isMixedEvidenceOutput(cleanField)) return void 0;
|
|
26099
|
+
const plan = planPlainTextProjection(cleanField, toolName);
|
|
26100
|
+
if (plan.kind !== "preview") return void 0;
|
|
26101
|
+
return {
|
|
26102
|
+
envelope: {
|
|
26103
|
+
record,
|
|
26104
|
+
key: candidateKey
|
|
26105
|
+
},
|
|
26106
|
+
plan
|
|
26107
|
+
};
|
|
26108
|
+
}
|
|
26109
|
+
/** 对一段纯文本(非 JSON 文档)做投影计划判定。 */
|
|
26110
|
+
function planPlainTextProjection(rawText, toolName) {
|
|
26111
|
+
const text = stripAnsi(rawText);
|
|
26112
|
+
if (looksLikeContract(text)) return {
|
|
26113
|
+
kind: "verbatim",
|
|
26114
|
+
strategy: "verbatim-contract"
|
|
26115
|
+
};
|
|
26116
|
+
if (looksLikeSourceCode(text)) return {
|
|
26117
|
+
kind: "verbatim",
|
|
26118
|
+
strategy: "verbatim-source-code"
|
|
26119
|
+
};
|
|
26120
|
+
const test = extractTestEvidence(text);
|
|
26121
|
+
if (test !== void 0 && test.failures.length > 0) return {
|
|
26122
|
+
kind: "preview",
|
|
26123
|
+
strategy: "test-failure",
|
|
26124
|
+
sections: buildTestSections(test)
|
|
26125
|
+
};
|
|
26126
|
+
const grep = extractGrepEvidence(text);
|
|
26127
|
+
if (grep !== void 0) return {
|
|
26128
|
+
kind: "preview",
|
|
26129
|
+
strategy: "grep-results",
|
|
26130
|
+
sections: buildGrepSections(grep)
|
|
26131
|
+
};
|
|
26132
|
+
const structured = buildStructuredSections(text);
|
|
26133
|
+
if (structured !== void 0) return {
|
|
26134
|
+
kind: "preview",
|
|
26135
|
+
strategy: "structured-rows",
|
|
26136
|
+
sections: structured
|
|
26137
|
+
};
|
|
26138
|
+
if (looksLikeGlobResult(text)) return {
|
|
26139
|
+
kind: "preview",
|
|
26140
|
+
strategy: "glob-results",
|
|
26141
|
+
sections: buildGlobSections(text)
|
|
26142
|
+
};
|
|
26143
|
+
if (looksLikeShellOutput(text)) {
|
|
26144
|
+
const sections = buildShellSections(text);
|
|
26145
|
+
if (sections === void 0) return {
|
|
26146
|
+
kind: "verbatim",
|
|
26147
|
+
strategy: "verbatim-unparsed"
|
|
26148
|
+
};
|
|
26149
|
+
return {
|
|
26150
|
+
kind: "preview",
|
|
26151
|
+
strategy: "shell-output",
|
|
26152
|
+
sections
|
|
26153
|
+
};
|
|
26154
|
+
}
|
|
26155
|
+
return {
|
|
26156
|
+
kind: "verbatim",
|
|
26157
|
+
strategy: "verbatim-unparsed"
|
|
26158
|
+
};
|
|
26159
|
+
}
|
|
26160
|
+
/**
|
|
26161
|
+
* 决定这一段文本该怎么处理。
|
|
26162
|
+
*
|
|
26163
|
+
* 顺序就是「可证明性」的顺序:子代理报告/源码工具/契约先被判成原样,
|
|
26164
|
+
* 然后是 JSON envelope(只允许纯日志字段投影,混合证据一律原样),
|
|
26165
|
+
* 接着是测试失败集、grep、结构化、glob、shell 这些有明确形状的,
|
|
26166
|
+
* 最后落到「无法解析 → 原样」。
|
|
26167
|
+
*/
|
|
26168
|
+
function planProjection(text, toolName) {
|
|
26169
|
+
if (TOOL_SUBAGENT.test(toolName)) return {
|
|
26170
|
+
kind: "verbatim",
|
|
26171
|
+
strategy: "verbatim-subagent-report"
|
|
26172
|
+
};
|
|
26173
|
+
if (TOOL_SOURCE.test(toolName)) return {
|
|
26174
|
+
kind: "verbatim",
|
|
26175
|
+
strategy: "verbatim-source-code"
|
|
26176
|
+
};
|
|
26177
|
+
if (looksLikeContract(text)) return {
|
|
26178
|
+
kind: "verbatim",
|
|
26179
|
+
strategy: "verbatim-contract"
|
|
26180
|
+
};
|
|
26181
|
+
if (isJsonDocument(text)) {
|
|
26182
|
+
const extracted = extractJsonEnvelopeLogField(text, toolName);
|
|
26183
|
+
if (extracted !== void 0) return {
|
|
26184
|
+
kind: "preview",
|
|
26185
|
+
strategy: extracted.plan.strategy,
|
|
26186
|
+
sections: extracted.plan.sections,
|
|
26187
|
+
...extracted.envelope === void 0 ? {} : { envelope: extracted.envelope }
|
|
26188
|
+
};
|
|
26189
|
+
return {
|
|
26190
|
+
kind: "verbatim",
|
|
26191
|
+
strategy: "verbatim-unparsed"
|
|
26192
|
+
};
|
|
26193
|
+
}
|
|
26194
|
+
return planPlainTextProjection(text, toolName);
|
|
26195
|
+
}
|
|
26196
|
+
/** 预览尾部:把「怎么读全文」写死在预览里,模型不用猜。 */
|
|
26197
|
+
function footerFor(refId) {
|
|
26198
|
+
return `[full result: ${refId} · page with offset/limit(default 64 lines)/maxBytes(default 16384 bytes)]`;
|
|
26199
|
+
}
|
|
26200
|
+
/**
|
|
26201
|
+
* 核心决策:预算 → 计划 → 装配 → 入库。
|
|
26202
|
+
*
|
|
26203
|
+
* 入库放在最后:预览装不进预算就不写文件,也不会留下一条没人引用的引用。
|
|
26204
|
+
* 入库失败一律降级为「保持原样」—— 没有 ref 的预览等于把原文丢了。
|
|
26205
|
+
*/
|
|
26206
|
+
async function decide(args) {
|
|
26207
|
+
const { text, input, budget } = args;
|
|
26208
|
+
if (text.length <= budget.maxChars) return { reason: "verbatim-small" };
|
|
26209
|
+
const plan = planProjection(text, input.toolName);
|
|
26210
|
+
if (plan.kind === "verbatim") return { reason: plan.strategy };
|
|
26211
|
+
if (input.isError === true && plan.strategy !== "test-failure" && plan.strategy !== "shell-output") return { reason: "verbatim-unparsed" };
|
|
26212
|
+
const header = `[cache-warmup preview] strategy=${plan.strategy} input=${text.length}ch`;
|
|
26213
|
+
const footerBudget = footerFor(`ref://${input.sessionId}/${"0".repeat(64)}`).length;
|
|
26214
|
+
const jsonEscaped = plan.envelope !== void 0;
|
|
26215
|
+
const envelopeShellLength = plan.envelope === void 0 ? 0 : JSON.stringify({
|
|
26216
|
+
...plan.envelope.record,
|
|
26217
|
+
[plan.envelope.key]: ""
|
|
26218
|
+
}).length;
|
|
26219
|
+
const bodyBudget = budget.maxChars - header.length - footerBudget - envelopeShellLength - (jsonEscaped ? 4 : 2);
|
|
26220
|
+
if (bodyBudget <= 0) return { reason: "verbatim-budget" };
|
|
26221
|
+
const body = fitSections(plan.sections, bodyBudget, jsonEscaped);
|
|
26222
|
+
if (body === void 0) return { reason: "verbatim-budget" };
|
|
26223
|
+
const store = input.store;
|
|
26224
|
+
if (store === void 0) return { reason: "store-unavailable" };
|
|
26225
|
+
const placeholderInner = `${header}\n${body}\n${footerFor(`ref://${input.sessionId}/${"0".repeat(64)}`)}`;
|
|
26226
|
+
if ((plan.envelope === void 0 ? placeholderInner : JSON.stringify({
|
|
26227
|
+
...plan.envelope.record,
|
|
26228
|
+
[plan.envelope.key]: placeholderInner
|
|
26229
|
+
})).length > budget.maxChars) return { reason: "verbatim-budget" };
|
|
26230
|
+
let reference;
|
|
26231
|
+
try {
|
|
26232
|
+
reference = await store.put({
|
|
26233
|
+
sessionId: input.sessionId,
|
|
26234
|
+
content: text,
|
|
26235
|
+
toolName: input.toolName,
|
|
26236
|
+
at: args.at
|
|
26237
|
+
});
|
|
26238
|
+
} catch {
|
|
26239
|
+
return { reason: "store-unavailable" };
|
|
26240
|
+
}
|
|
26241
|
+
const innerPreview = `${header}\n${body}\n${footerFor(reference.refId)}`;
|
|
26242
|
+
const preview = plan.envelope === void 0 ? innerPreview : JSON.stringify({
|
|
26243
|
+
...plan.envelope.record,
|
|
26244
|
+
[plan.envelope.key]: innerPreview
|
|
26245
|
+
});
|
|
26246
|
+
if (preview.length > budget.maxChars) return { reason: "verbatim-budget" };
|
|
26247
|
+
return {
|
|
26248
|
+
preview,
|
|
26249
|
+
refId: reference.refId,
|
|
26250
|
+
strategy: plan.strategy
|
|
26251
|
+
};
|
|
26252
|
+
}
|
|
26253
|
+
/** 块数组里文本字符数的合计。 */
|
|
26254
|
+
function textCharsOf(blocks) {
|
|
26255
|
+
let total = 0;
|
|
26256
|
+
for (const block of blocks) if (typeof block.text === "string") total += block.text.length;
|
|
26257
|
+
return total;
|
|
26258
|
+
}
|
|
26259
|
+
/**
|
|
26260
|
+
* 投影一条工具结果。
|
|
26261
|
+
*
|
|
26262
|
+
* 纯函数式语义:不改入参、不写日志、不碰历史;返回新内容 + 度量。
|
|
26263
|
+
* 只有 `changed: true` 时才有 `refId`/`preview`。
|
|
26264
|
+
* @param input - 工具结果与预算。
|
|
26265
|
+
* @returns 投影结果。
|
|
26266
|
+
*/
|
|
26267
|
+
async function projectToolResult(input) {
|
|
26268
|
+
const at = input.at ?? Date.now();
|
|
26269
|
+
const budget = input.budget ?? cacheWarmupBudget(input.sessionId);
|
|
26270
|
+
const base = {
|
|
26271
|
+
toolName: input.toolName,
|
|
26272
|
+
sessionId: input.sessionId,
|
|
26273
|
+
provider: input.provider,
|
|
26274
|
+
at
|
|
26275
|
+
};
|
|
26276
|
+
if (typeof input.content === "string") {
|
|
26277
|
+
const text = input.content;
|
|
26278
|
+
const decision = await decide({
|
|
26279
|
+
text,
|
|
26280
|
+
input,
|
|
26281
|
+
budget,
|
|
26282
|
+
at
|
|
26283
|
+
});
|
|
26284
|
+
if ("reason" in decision) return {
|
|
26285
|
+
...base,
|
|
26286
|
+
content: input.content,
|
|
26287
|
+
changed: false,
|
|
26288
|
+
metrics: {
|
|
26289
|
+
inputChars: text.length,
|
|
26290
|
+
outputChars: text.length,
|
|
26291
|
+
omittedChars: 0,
|
|
26292
|
+
strategy: decision.reason
|
|
26293
|
+
}
|
|
26294
|
+
};
|
|
26295
|
+
return {
|
|
26296
|
+
...base,
|
|
26297
|
+
content: decision.preview,
|
|
26298
|
+
changed: true,
|
|
26299
|
+
refId: decision.refId,
|
|
26300
|
+
preview: decision.preview,
|
|
26301
|
+
metrics: {
|
|
26302
|
+
inputChars: text.length,
|
|
26303
|
+
outputChars: decision.preview.length,
|
|
26304
|
+
omittedChars: Math.max(0, text.length - decision.preview.length),
|
|
26305
|
+
strategy: decision.strategy
|
|
26306
|
+
}
|
|
26307
|
+
};
|
|
26308
|
+
}
|
|
26309
|
+
const blocks = input.content;
|
|
26310
|
+
if (!(blocks.length === 1 && blocks[0]?.type === "text" && typeof blocks[0]?.text === "string")) {
|
|
26311
|
+
const chars = textCharsOf(blocks);
|
|
26312
|
+
const hasNonText = blocks.some((block) => block.type !== "text");
|
|
26313
|
+
return {
|
|
26314
|
+
...base,
|
|
26315
|
+
content: input.content,
|
|
26316
|
+
changed: false,
|
|
26317
|
+
metrics: {
|
|
26318
|
+
inputChars: chars,
|
|
26319
|
+
outputChars: chars,
|
|
26320
|
+
omittedChars: 0,
|
|
26321
|
+
strategy: hasNonText ? "verbatim-non-text" : "verbatim-unparsed"
|
|
26322
|
+
}
|
|
26323
|
+
};
|
|
26324
|
+
}
|
|
26325
|
+
const text = blocks[0].text;
|
|
26326
|
+
const decision = await decide({
|
|
26327
|
+
text,
|
|
26328
|
+
input,
|
|
26329
|
+
budget,
|
|
26330
|
+
at
|
|
26331
|
+
});
|
|
26332
|
+
if ("reason" in decision) return {
|
|
26333
|
+
...base,
|
|
26334
|
+
content: input.content,
|
|
26335
|
+
changed: false,
|
|
26336
|
+
metrics: {
|
|
26337
|
+
inputChars: text.length,
|
|
26338
|
+
outputChars: text.length,
|
|
26339
|
+
omittedChars: 0,
|
|
26340
|
+
strategy: decision.reason
|
|
26341
|
+
}
|
|
26342
|
+
};
|
|
26343
|
+
const rebuilt = {
|
|
26344
|
+
...blocks[0],
|
|
26345
|
+
text: decision.preview
|
|
26346
|
+
};
|
|
26347
|
+
return {
|
|
26348
|
+
...base,
|
|
26349
|
+
content: [rebuilt],
|
|
26350
|
+
changed: true,
|
|
26351
|
+
refId: decision.refId,
|
|
26352
|
+
preview: decision.preview,
|
|
26353
|
+
metrics: {
|
|
26354
|
+
inputChars: text.length,
|
|
26355
|
+
outputChars: decision.preview.length,
|
|
26356
|
+
omittedChars: Math.max(0, text.length - decision.preview.length),
|
|
26357
|
+
strategy: decision.strategy
|
|
26358
|
+
}
|
|
26359
|
+
};
|
|
26360
|
+
}
|
|
26361
|
+
//#endregion
|
|
24673
26362
|
//#region src/host/common/request-images.ts
|
|
24674
26363
|
function isRecord$41(value) {
|
|
24675
26364
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -28861,7 +30550,7 @@ function messageToolsOf(message) {
|
|
|
28861
30550
|
* lives in the request's declaration list, which is why the two are resolved
|
|
28862
30551
|
* together rather than the block carrying a schema of its own.
|
|
28863
30552
|
*/
|
|
28864
|
-
function toolAdditionNames(message) {
|
|
30553
|
+
function toolAdditionNames$1(message) {
|
|
28865
30554
|
const names = [];
|
|
28866
30555
|
for (const block of message.content) {
|
|
28867
30556
|
if (block.type !== "tool-addition") continue;
|
|
@@ -28878,7 +30567,7 @@ function toolAdditionNames(message) {
|
|
|
28878
30567
|
* `addition-only` never sees one, because the harness removes the declaration
|
|
28879
30568
|
* from the list instead.
|
|
28880
30569
|
*/
|
|
28881
|
-
function toolRemovalNames(message) {
|
|
30570
|
+
function toolRemovalNames$1(message) {
|
|
28882
30571
|
const names = [];
|
|
28883
30572
|
for (const block of message.content) {
|
|
28884
30573
|
if (block.type !== "tool-removal") continue;
|
|
@@ -28895,7 +30584,7 @@ function toolRemovalNames(message) {
|
|
|
28895
30584
|
* list (that is what keeps the prefix intact), so the only thing standing
|
|
28896
30585
|
* between the model and a call that would fail is this sentence.
|
|
28897
30586
|
*/
|
|
28898
|
-
function removalNotice(names) {
|
|
30587
|
+
function removalNotice$1(names) {
|
|
28899
30588
|
const list = names.map((name) => "\"" + name + "\"").join(", ");
|
|
28900
30589
|
return names.length === 1 ? "[" + list + " is no longer available. Do not call it again: any call will fail.]" : "[" + list + " are no longer available. Do not call them again: any call will fail.]";
|
|
28901
30590
|
}
|
|
@@ -29039,7 +30728,7 @@ function declarationSlots(options, media) {
|
|
|
29039
30728
|
let beforeIndex = 0;
|
|
29040
30729
|
for (const message of options.messages) {
|
|
29041
30730
|
if (message.role === "developer") {
|
|
29042
|
-
const additions = declarationsFor(toolAdditionNames(message), options.tools);
|
|
30731
|
+
const additions = declarationsFor(toolAdditionNames$1(message), options.tools);
|
|
29043
30732
|
const entries = [];
|
|
29044
30733
|
if (additions.length > 0) entries.push(media.messageTools === true ? {
|
|
29045
30734
|
role: "system",
|
|
@@ -29048,10 +30737,10 @@ function declarationSlots(options, media) {
|
|
|
29048
30737
|
role: "system",
|
|
29049
30738
|
content: declarationNotice(options.model, additions.length, "capability")
|
|
29050
30739
|
});
|
|
29051
|
-
const removals = toolRemovalNames(message);
|
|
30740
|
+
const removals = toolRemovalNames$1(message);
|
|
29052
30741
|
if (removals.length > 0) entries.push({
|
|
29053
30742
|
role: "system",
|
|
29054
|
-
content: removalNotice(removals)
|
|
30743
|
+
content: removalNotice$1(removals)
|
|
29055
30744
|
});
|
|
29056
30745
|
if (entries.length > 0) slots.push({
|
|
29057
30746
|
beforeIndex,
|
|
@@ -30288,6 +31977,27 @@ var SessionCacheStateStore = class {
|
|
|
30288
31977
|
this.schedule();
|
|
30289
31978
|
}
|
|
30290
31979
|
/**
|
|
31980
|
+
* Update the warmup consecutive-hit streak on an already-written session record
|
|
31981
|
+
* in place, without issuing a second `write()` call.
|
|
31982
|
+
*/
|
|
31983
|
+
noteWarmupStreak(sessionId, consecutiveHits) {
|
|
31984
|
+
const key = sessionId.trim();
|
|
31985
|
+
if (key === "") return;
|
|
31986
|
+
const document = this.load();
|
|
31987
|
+
const existing = document.sessions[key];
|
|
31988
|
+
if (existing === void 0) return;
|
|
31989
|
+
const normalized = count$1(consecutiveHits);
|
|
31990
|
+
if (normalized > 0) document.sessions[key] = {
|
|
31991
|
+
...existing,
|
|
31992
|
+
warmupConsecutiveHits: normalized
|
|
31993
|
+
};
|
|
31994
|
+
else if (existing.warmupConsecutiveHits !== void 0) {
|
|
31995
|
+
const { warmupConsecutiveHits: _dropped, ...rest } = existing;
|
|
31996
|
+
document.sessions[key] = rest;
|
|
31997
|
+
} else return;
|
|
31998
|
+
this.schedule();
|
|
31999
|
+
}
|
|
32000
|
+
/**
|
|
30291
32001
|
* Write the document now, synchronously.
|
|
30292
32002
|
*
|
|
30293
32003
|
* Exposed for tests and for a caller that is about to lose the process; the
|
|
@@ -30364,6 +32074,7 @@ function normalizeRecord(value) {
|
|
|
30364
32074
|
const updatedAt = finite(value.updatedAt);
|
|
30365
32075
|
if (lastActiveAt === void 0 || updatedAt === void 0) return void 0;
|
|
30366
32076
|
const drift = typeof value.lastDrift === "string" && value.lastDrift !== "" ? value.lastDrift : void 0;
|
|
32077
|
+
const warmupStreak = count$1(value.warmupConsecutiveHits);
|
|
30367
32078
|
return {
|
|
30368
32079
|
lastActiveAt,
|
|
30369
32080
|
updatedAt,
|
|
@@ -30375,7 +32086,8 @@ function normalizeRecord(value) {
|
|
|
30375
32086
|
cacheWriteTokens: count$1(value.cacheWriteTokens),
|
|
30376
32087
|
cacheWrite5mTokens: count$1(value.cacheWrite5mTokens),
|
|
30377
32088
|
cacheWrite1hTokens: count$1(value.cacheWrite1hTokens),
|
|
30378
|
-
...drift === void 0 ? {} : { lastDrift: drift }
|
|
32089
|
+
...drift === void 0 ? {} : { lastDrift: drift },
|
|
32090
|
+
...warmupStreak > 0 ? { warmupConsecutiveHits: warmupStreak } : {}
|
|
30379
32091
|
};
|
|
30380
32092
|
}
|
|
30381
32093
|
/**
|
|
@@ -31475,7 +33187,7 @@ const KIMI_CODE_REQUEST_IMAGE_MAX_EDGE = 2e3;
|
|
|
31475
33187
|
* @param catalog - live catalog, possibly empty when offline.
|
|
31476
33188
|
* @returns the tool-update mode, or undefined when this route must resend everything.
|
|
31477
33189
|
*/
|
|
31478
|
-
function toolUpdateMode(modelId, catalog) {
|
|
33190
|
+
function toolUpdateMode$1(modelId, catalog) {
|
|
31479
33191
|
if (!dynamicToolsForEntry(modelId, catalog)) return void 0;
|
|
31480
33192
|
if (wireForCatalogEntry(modelId, catalog) !== "openai") return void 0;
|
|
31481
33193
|
const raw = (process.env.DSH_KIMI_CODE_TOOL_UPDATE ?? "").trim().toLowerCase();
|
|
@@ -31526,6 +33238,16 @@ function noteSessionCacheState(sessionId, state) {
|
|
|
31526
33238
|
...lastDrift === void 0 ? {} : { lastDrift },
|
|
31527
33239
|
updatedAt: now
|
|
31528
33240
|
});
|
|
33241
|
+
const warmupState = recordCacheWarmupUsage({
|
|
33242
|
+
sessionId,
|
|
33243
|
+
inputTokens: state.inputTokens,
|
|
33244
|
+
cacheReadTokens: state.cacheReadTokens,
|
|
33245
|
+
outputTokens: state.outputTokens,
|
|
33246
|
+
totalTokens: state.cacheReadTokens + state.inputTokens,
|
|
33247
|
+
at: now,
|
|
33248
|
+
...lastDrift === void 0 ? {} : { driftCause: lastDrift }
|
|
33249
|
+
});
|
|
33250
|
+
sessionCacheState.noteWarmupStreak(sessionId, warmupState.consecutiveHits);
|
|
31529
33251
|
}
|
|
31530
33252
|
var KimiCodeAdapter = class extends LlmAdapter {
|
|
31531
33253
|
store;
|
|
@@ -31709,7 +33431,7 @@ var KimiCodeAdapter = class extends LlmAdapter {
|
|
|
31709
33431
|
id: modelId,
|
|
31710
33432
|
name: entry?.name ?? modelId,
|
|
31711
33433
|
inputModalities: inputModalitiesForEntry(modelId, catalog),
|
|
31712
|
-
...toolUpdateMode(modelId, catalog) === void 0 ? {} : { toolUpdate: toolUpdateMode(modelId, catalog) },
|
|
33434
|
+
...toolUpdateMode$1(modelId, catalog) === void 0 ? {} : { toolUpdate: toolUpdateMode$1(modelId, catalog) },
|
|
31713
33435
|
context: { contextWindow: this.contextWindowFor(modelId, entry, settings.contextWindowOverrides) },
|
|
31714
33436
|
...efforts.length === 0 ? {} : { reasoning: {
|
|
31715
33437
|
efforts: efforts.map((effort) => ({
|
|
@@ -32506,13 +34228,32 @@ async function getKimiCodeWebStatus(store, modelSettings, preferences, options =
|
|
|
32506
34228
|
* answers with the process aggregate, which is the right answer for a settings
|
|
32507
34229
|
* card that is not showing a specific conversation.
|
|
32508
34230
|
*/
|
|
34231
|
+
function warmupStatusOf(sessionId) {
|
|
34232
|
+
const targetSession = sessionId?.trim() || cacheWarmupUsageSessions()[0]?.sessionId;
|
|
34233
|
+
const durable = sessionCacheState.read(targetSession);
|
|
34234
|
+
const hint = durable === void 0 ? void 0 : {
|
|
34235
|
+
lastActiveAt: durable.lastActiveAt,
|
|
34236
|
+
consecutiveHits: durable.warmupConsecutiveHits ?? 0,
|
|
34237
|
+
hitRatio: durable.cachedTokens + durable.freshTokens > 0 ? durable.cachedTokens / (durable.cachedTokens + durable.freshTokens) : 0
|
|
34238
|
+
};
|
|
34239
|
+
if (targetSession === void 0 && hint === void 0) return void 0;
|
|
34240
|
+
const budget = cacheWarmupBudget(targetSession ?? "durable-latest", Date.now(), hint);
|
|
34241
|
+
return {
|
|
34242
|
+
warmup: budget.warmup,
|
|
34243
|
+
maxChars: budget.maxChars,
|
|
34244
|
+
consecutiveHits: budget.consecutiveHits,
|
|
34245
|
+
requiredHits: CACHE_WARMUP_DEFAULTS.consecutiveHitsRequired
|
|
34246
|
+
};
|
|
34247
|
+
}
|
|
32509
34248
|
function cacheStatsOrNull(sessionId) {
|
|
32510
34249
|
const stats = getCacheStats(sessionId);
|
|
32511
34250
|
if (stats.requests === 0) return durableCacheStats(sessionId);
|
|
32512
34251
|
const lastDrift = getLastDriftCause(sessionId);
|
|
34252
|
+
const warmup = warmupStatusOf(sessionId);
|
|
32513
34253
|
return {
|
|
32514
34254
|
...stats,
|
|
32515
34255
|
...lastDrift === void 0 ? {} : { lastDrift },
|
|
34256
|
+
...warmup === void 0 ? {} : { warmup },
|
|
32516
34257
|
cacheWriteTokens: stats.cacheWriteTokens,
|
|
32517
34258
|
cacheWrite5mTokens: stats.cacheWrite5mTokens,
|
|
32518
34259
|
cacheWrite1hTokens: stats.cacheWrite1hTokens
|
|
@@ -32554,6 +34295,7 @@ function durableCacheStats(sessionId) {
|
|
|
32554
34295
|
const durable = sessionCacheState.read(sessionId);
|
|
32555
34296
|
if (durable === void 0 || durable.requests === 0) return null;
|
|
32556
34297
|
const lastDrift = driftCauseOf(durable.lastDrift);
|
|
34298
|
+
const warmup = warmupStatusOf(sessionId);
|
|
32557
34299
|
return {
|
|
32558
34300
|
requests: durable.requests,
|
|
32559
34301
|
cachedTokens: durable.cachedTokens,
|
|
@@ -32563,7 +34305,8 @@ function durableCacheStats(sessionId) {
|
|
|
32563
34305
|
cacheWriteTokens: durable.cacheWriteTokens,
|
|
32564
34306
|
cacheWrite5mTokens: durable.cacheWrite5mTokens,
|
|
32565
34307
|
cacheWrite1hTokens: durable.cacheWrite1hTokens,
|
|
32566
|
-
...lastDrift === void 0 ? {} : { lastDrift }
|
|
34308
|
+
...lastDrift === void 0 ? {} : { lastDrift },
|
|
34309
|
+
...warmup === void 0 ? {} : { warmup }
|
|
32567
34310
|
};
|
|
32568
34311
|
}
|
|
32569
34312
|
/**
|
|
@@ -53701,7 +55444,7 @@ function withTimeout$3(signal, ms) {
|
|
|
53701
55444
|
return signal === void 0 ? timeout : AbortSignal.any([signal, timeout]);
|
|
53702
55445
|
}
|
|
53703
55446
|
/** 一句可以直接展示给用户的失败原因;正文只截前 200 字,且不含任何请求头。 */
|
|
53704
|
-
function failure(detail) {
|
|
55447
|
+
function failure$1(detail) {
|
|
53705
55448
|
return "GLM(ZCode 账号) " + detail;
|
|
53706
55449
|
}
|
|
53707
55450
|
/** 响应正文的一句话摘要(去换行、截断),用于把上游原因带给调用方。 */
|
|
@@ -53728,7 +55471,7 @@ function glmOpenAiProbeHeaders(codingPlanKey) {
|
|
|
53728
55471
|
*/
|
|
53729
55472
|
function quotaRequestHeaders$1(codingPlanKey) {
|
|
53730
55473
|
const key = codingPlanKey.trim();
|
|
53731
|
-
if (key === "") throw new Error(failure("的配额接口需要一把 Coding Plan Key(裸值,不加 Bearer);空凭据不会变成一个能通过鉴权的请求。"));
|
|
55474
|
+
if (key === "") throw new Error(failure$1("的配额接口需要一把 Coding Plan Key(裸值,不加 Bearer);空凭据不会变成一个能通过鉴权的请求。"));
|
|
53732
55475
|
return {
|
|
53733
55476
|
"content-type": "application/json",
|
|
53734
55477
|
accept: "application/json",
|
|
@@ -53810,13 +55553,13 @@ async function fetchCatalog(region, codingPlanKey, options = {}) {
|
|
|
53810
55553
|
const text = await response.text().catch(() => "");
|
|
53811
55554
|
if (!response.ok) {
|
|
53812
55555
|
const detail = summarize(text);
|
|
53813
|
-
throw new Error(failure("读取模型目录失败(HTTP " + response.status + "): " + url) + (detail === "" ? "" : ":" + detail));
|
|
55556
|
+
throw new Error(failure$1("读取模型目录失败(HTTP " + response.status + "): " + url) + (detail === "" ? "" : ":" + detail));
|
|
53814
55557
|
}
|
|
53815
55558
|
let payload;
|
|
53816
55559
|
try {
|
|
53817
55560
|
payload = JSON.parse(text);
|
|
53818
55561
|
} catch {
|
|
53819
|
-
throw new Error(failure("返回的模型目录不是可读的 JSON:" + url));
|
|
55562
|
+
throw new Error(failure$1("返回的模型目录不是可读的 JSON:" + url));
|
|
53820
55563
|
}
|
|
53821
55564
|
return parseCatalogListing(payload, region);
|
|
53822
55565
|
}
|
|
@@ -54071,7 +55814,7 @@ async function fetchSubscriptionPlan(region, codingPlanKey, options = {}) {
|
|
|
54071
55814
|
* 所以它同步抛出,与「上游读失败返回 `error`」那条路径分开。
|
|
54072
55815
|
*/
|
|
54073
55816
|
async function fetchGlmQuota(region, codingPlanKey, options = {}) {
|
|
54074
|
-
if (codingPlanKey.trim() === "") throw new Error(failure("的配额读取需要一把 Coding Plan Key(裸值,不加 Bearer)。"));
|
|
55817
|
+
if (codingPlanKey.trim() === "") throw new Error(failure$1("的配额读取需要一把 Coding Plan Key(裸值,不加 Bearer)。"));
|
|
54075
55818
|
const accountId = options.accountId;
|
|
54076
55819
|
const cached = quotaEntryFor(accountId);
|
|
54077
55820
|
if (options.force !== true && cached !== null && Date.now() - cached.at < 12e4) return {
|
|
@@ -54097,7 +55840,7 @@ async function fetchGlmQuota(region, codingPlanKey, options = {}) {
|
|
|
54097
55840
|
} catch (error) {
|
|
54098
55841
|
return {
|
|
54099
55842
|
snapshot: null,
|
|
54100
|
-
error: failure("配额接口不可达:" + (error instanceof Error ? error.message : String(error))),
|
|
55843
|
+
error: failure$1("配额接口不可达:" + (error instanceof Error ? error.message : String(error))),
|
|
54101
55844
|
fromCache: false
|
|
54102
55845
|
};
|
|
54103
55846
|
}
|
|
@@ -54106,7 +55849,7 @@ async function fetchGlmQuota(region, codingPlanKey, options = {}) {
|
|
|
54106
55849
|
const detail = summarize(text);
|
|
54107
55850
|
return {
|
|
54108
55851
|
snapshot: null,
|
|
54109
|
-
error: failure("配额查询失败(HTTP " + response.status + ")") + (detail === "" ? "" : ":" + detail),
|
|
55852
|
+
error: failure$1("配额查询失败(HTTP " + response.status + ")") + (detail === "" ? "" : ":" + detail),
|
|
54110
55853
|
fromCache: false
|
|
54111
55854
|
};
|
|
54112
55855
|
}
|
|
@@ -54116,7 +55859,7 @@ async function fetchGlmQuota(region, codingPlanKey, options = {}) {
|
|
|
54116
55859
|
} catch {
|
|
54117
55860
|
return {
|
|
54118
55861
|
snapshot: null,
|
|
54119
|
-
error: failure("配额查询返回的不是 JSON。"),
|
|
55862
|
+
error: failure$1("配额查询返回的不是 JSON。"),
|
|
54120
55863
|
fromCache: false
|
|
54121
55864
|
};
|
|
54122
55865
|
}
|
|
@@ -54129,7 +55872,7 @@ async function fetchGlmQuota(region, codingPlanKey, options = {}) {
|
|
|
54129
55872
|
});
|
|
54130
55873
|
if (snapshot === null) return {
|
|
54131
55874
|
snapshot: null,
|
|
54132
|
-
error: failure("上游没有返回可显示的额度窗口。"),
|
|
55875
|
+
error: failure$1("上游没有返回可显示的额度窗口。"),
|
|
54133
55876
|
fromCache: false
|
|
54134
55877
|
};
|
|
54135
55878
|
return {
|
|
@@ -63606,6 +65349,52 @@ function imagePart$1(block, images) {
|
|
|
63606
65349
|
image_url: { url: `data:${inline.mediaType};base64,${inline.data}` }
|
|
63607
65350
|
};
|
|
63608
65351
|
}
|
|
65352
|
+
/**
|
|
65353
|
+
* Whether one model id names a DeepSeek model.
|
|
65354
|
+
*
|
|
65355
|
+
* DeepSeek's own Chat Completions contract carries a disabled reasoning level as
|
|
65356
|
+
* `thinking: { type: 'disabled' }` rather than as a `reasoning_effort` value, so
|
|
65357
|
+
* the translation below is scoped to this model family. Read from the model id's
|
|
65358
|
+
* own segment so a provider-prefixed spelling (`cline-pass/...`, or a canonical
|
|
65359
|
+
* `deepseek/...` slug) is recognised either way.
|
|
65360
|
+
*/
|
|
65361
|
+
function isDeepSeekModel$1(modelId) {
|
|
65362
|
+
const bare = modelId.slice(modelId.lastIndexOf("/") + 1).trim();
|
|
65363
|
+
return /^deepseek/i.test(bare);
|
|
65364
|
+
}
|
|
65365
|
+
/** The names one developer message declares or revokes, in history order. */
|
|
65366
|
+
function toolNamesOf(blocks, type) {
|
|
65367
|
+
const names = [];
|
|
65368
|
+
for (const block of blocks ?? []) {
|
|
65369
|
+
const record = block;
|
|
65370
|
+
if (record.type !== type) continue;
|
|
65371
|
+
const name = record.toolName;
|
|
65372
|
+
if (typeof name !== "string" || name.trim().length === 0) continue;
|
|
65373
|
+
names.push(name.trim());
|
|
65374
|
+
}
|
|
65375
|
+
return names;
|
|
65376
|
+
}
|
|
65377
|
+
/** One quoted, comma-joined list of tool names. */
|
|
65378
|
+
function quotedList(names) {
|
|
65379
|
+
return names.map((name) => "\"" + name + "\"").join(", ");
|
|
65380
|
+
}
|
|
65381
|
+
/**
|
|
65382
|
+
* The model-facing text of a mid-conversation tool change, or '' when there is none.
|
|
65383
|
+
*
|
|
65384
|
+
* `in-history` means the harness stopped rewriting the declaration list: an
|
|
65385
|
+
* added tool still travels in `tools`, and a removed one keeps its declaration
|
|
65386
|
+
* so the prefix survives. The history note is therefore the only thing that
|
|
65387
|
+
* tells the model a removal happened, which is why the removal sentence is
|
|
65388
|
+
* imperative rather than descriptive.
|
|
65389
|
+
*/
|
|
65390
|
+
function toolUpdateNotice(blocks) {
|
|
65391
|
+
const added = toolNamesOf(blocks, "tool-addition");
|
|
65392
|
+
const removed = toolNamesOf(blocks, "tool-removal");
|
|
65393
|
+
const notices = [];
|
|
65394
|
+
if (added.length > 0) notices.push(added.length === 1 ? "[The tool " + quotedList(added) + " is now available.]" : "[The tools " + quotedList(added) + " are now available.]");
|
|
65395
|
+
if (removed.length > 0) notices.push(removed.length === 1 ? "[" + quotedList(removed) + " is no longer available. Do not call it again: any call will fail.]" : "[" + quotedList(removed) + " are no longer available. Do not call them again: any call will fail.]");
|
|
65396
|
+
return notices.join("\n");
|
|
65397
|
+
}
|
|
63609
65398
|
/** Recursively collect image parts from blocks, including nested tool results. */
|
|
63610
65399
|
function collectImageParts$1(blocks, images, target = []) {
|
|
63611
65400
|
if (!Array.isArray(blocks)) return target;
|
|
@@ -63658,8 +65447,17 @@ function serializeMessages(messages, images) {
|
|
|
63658
65447
|
pendingToolImages = [];
|
|
63659
65448
|
emit(message);
|
|
63660
65449
|
};
|
|
63661
|
-
/**
|
|
65450
|
+
/**
|
|
65451
|
+
* A tool answer: it closes its own id, and the group when it was the last.
|
|
65452
|
+
*
|
|
65453
|
+
* An answer whose call id has no pending `tool_call` is refused here, before
|
|
65454
|
+
* anything is sent. Forwarding it produces an OpenAI message sequence the
|
|
65455
|
+
* gateway rejects (a `tool` message with no declaration in front of it), and
|
|
65456
|
+
* the gateway's 400 names neither the call nor the turn, so the failure is
|
|
65457
|
+
* raised locally with the id that has no question.
|
|
65458
|
+
*/
|
|
63662
65459
|
const answer = (callId, content) => {
|
|
65460
|
+
if (!awaiting.includes(callId)) throw new LlmError(`tool result for call "${callId}" has no tool_call awaiting it: the conversation cannot be expressed as a valid OpenAI message sequence`, "INVALID_REQUEST");
|
|
63663
65461
|
wire.push({
|
|
63664
65462
|
role: "tool",
|
|
63665
65463
|
tool_call_id: callId,
|
|
@@ -63672,9 +65470,11 @@ function serializeMessages(messages, images) {
|
|
|
63672
65470
|
for (const message of messages) {
|
|
63673
65471
|
if (message.role === "system" || message.role === "developer") {
|
|
63674
65472
|
flushToolImages();
|
|
65473
|
+
const content = [toolUpdateNotice(message.content), flattenText$1(message.content)].filter((part) => part.length > 0).join("\n");
|
|
65474
|
+
if (content.length === 0) continue;
|
|
63675
65475
|
emit({
|
|
63676
65476
|
role: "system",
|
|
63677
|
-
content
|
|
65477
|
+
content
|
|
63678
65478
|
});
|
|
63679
65479
|
continue;
|
|
63680
65480
|
}
|
|
@@ -63694,7 +65494,7 @@ function serializeMessages(messages, images) {
|
|
|
63694
65494
|
else if (block.type === "image") parts.push(imagePart$1(block, images));
|
|
63695
65495
|
const hasImage = parts.some((part) => part.type === "image_url");
|
|
63696
65496
|
const text = parts.filter((part) => part.type === "text").map((part) => String(part.text)).join("");
|
|
63697
|
-
if (hasImage || text.length > 0
|
|
65497
|
+
if (hasImage || text.length > 0) {
|
|
63698
65498
|
flushToolImages();
|
|
63699
65499
|
emit({
|
|
63700
65500
|
role: "user",
|
|
@@ -63729,6 +65529,7 @@ function buildClinePassBody(options, connection, images) {
|
|
|
63729
65529
|
}
|
|
63730
65530
|
}));
|
|
63731
65531
|
const maxTokens = options.maxTokens ?? connection.maxTokens;
|
|
65532
|
+
const thinkingOff = options.reasoningEffort !== void 0 && String(options.reasoningEffort) === "none" && isDeepSeekModel$1(options.model);
|
|
63732
65533
|
return {
|
|
63733
65534
|
model: options.model,
|
|
63734
65535
|
messages,
|
|
@@ -63738,7 +65539,8 @@ function buildClinePassBody(options, connection, images) {
|
|
|
63738
65539
|
...options.temperature === void 0 ? {} : { temperature: options.temperature },
|
|
63739
65540
|
...maxTokens === void 0 || maxTokens <= 0 ? {} : { max_tokens: maxTokens },
|
|
63740
65541
|
...options.stop === void 0 ? {} : { stop: options.stop },
|
|
63741
|
-
...options.reasoningEffort === void 0 ? {} : { reasoning_effort: String(options.reasoningEffort) }
|
|
65542
|
+
...options.reasoningEffort === void 0 || thinkingOff ? {} : { reasoning_effort: String(options.reasoningEffort) },
|
|
65543
|
+
...thinkingOff ? { thinking: { type: "disabled" } } : {}
|
|
63742
65544
|
};
|
|
63743
65545
|
}
|
|
63744
65546
|
/** Map an OpenAI `finish_reason` to the harness finish reason. */
|
|
@@ -63899,12 +65701,47 @@ function closeBlock$1(block) {
|
|
|
63899
65701
|
}
|
|
63900
65702
|
}
|
|
63901
65703
|
/**
|
|
65704
|
+
* Whether one failure is the SSE parser's own "the body ended without [DONE]".
|
|
65705
|
+
*
|
|
65706
|
+
* Structural rather than `instanceof`: what has to match is the code the parser
|
|
65707
|
+
* assigns, and a second copy of this module (or a harness generation that ships
|
|
65708
|
+
* its own LlmError class) would fail an identity test on a value that is
|
|
65709
|
+
* otherwise exactly the signal this decision is about.
|
|
65710
|
+
*/
|
|
65711
|
+
function isMissingSentinel$1(error) {
|
|
65712
|
+
return typeof error === "object" && error !== null && error.code === "STREAM_CLOSED";
|
|
65713
|
+
}
|
|
65714
|
+
/**
|
|
65715
|
+
* End the payload iteration instead of throwing when a body stops cleanly.
|
|
65716
|
+
*
|
|
65717
|
+
* The parser can only report "no [DONE] arrived" by throwing, and that is NOT a
|
|
65718
|
+
* failure on its own: the gateway sometimes closes the response right after the
|
|
65719
|
+
* terminal frame. Whether the response actually completed is decided by the
|
|
65720
|
+
* frames that were seen, which is knowledge this generator does not have — so it
|
|
65721
|
+
* only removes the transport signal and leaves the verdict to {@link translate}.
|
|
65722
|
+
* Every other failure passes through untouched, including the error frames a 200
|
|
65723
|
+
* body can carry.
|
|
65724
|
+
*/
|
|
65725
|
+
async function* tolerateMissingSentinel$1(payloads) {
|
|
65726
|
+
try {
|
|
65727
|
+
for await (const payload of payloads) yield payload;
|
|
65728
|
+
} catch (error) {
|
|
65729
|
+
if (!isMissingSentinel$1(error)) throw error;
|
|
65730
|
+
}
|
|
65731
|
+
}
|
|
65732
|
+
/**
|
|
63902
65733
|
* Translate decoded SSE payloads into harness stream chunks.
|
|
63903
65734
|
*
|
|
63904
65735
|
* Deltas stream through as they arrive; block ends, usage and the finish reason
|
|
63905
65736
|
* are all deferred to the `[DONE]` sentinel, so no chunk ever follows `finish`.
|
|
63906
65737
|
* A completion with no content at all becomes an `EMPTY_RESPONSE` error finish
|
|
63907
65738
|
* rather than a silent empty message.
|
|
65739
|
+
*
|
|
65740
|
+
* A missing `[DONE]` is not a failure by itself. Once a valid `finish_reason`
|
|
65741
|
+
* has been seen the response IS complete, and it closes exactly as a `[DONE]`
|
|
65742
|
+
* one does — block ends, usage and finish all preserved. A body that carried
|
|
65743
|
+
* content but never named a finish reason is a truncation, and still fails with
|
|
65744
|
+
* `STREAM_CLOSED`.
|
|
63908
65745
|
*/
|
|
63909
65746
|
async function* translate(payloads) {
|
|
63910
65747
|
let nextIndex = 0;
|
|
@@ -63931,38 +65768,48 @@ async function* translate(payloads) {
|
|
|
63931
65768
|
order.push(block);
|
|
63932
65769
|
return block;
|
|
63933
65770
|
};
|
|
63934
|
-
|
|
65771
|
+
/**
|
|
65772
|
+
* The chunks that close a completed response: every open block, then usage,
|
|
65773
|
+
* then the single finish. Both terminal paths use this one assembly, so a
|
|
65774
|
+
* response read from a clean EOF and one read from `[DONE]` cannot drift.
|
|
65775
|
+
*/
|
|
65776
|
+
const closing = () => {
|
|
65777
|
+
const chunks = [];
|
|
65778
|
+
for (const block of order) chunks.push({
|
|
65779
|
+
type: "block-end",
|
|
65780
|
+
index: block.index,
|
|
65781
|
+
block: closeBlock$1(block)
|
|
65782
|
+
});
|
|
65783
|
+
if (pendingUsage !== void 0) chunks.push({
|
|
65784
|
+
type: "usage",
|
|
65785
|
+
usage: pendingUsage
|
|
65786
|
+
});
|
|
65787
|
+
const reason = pendingFinish ?? {
|
|
65788
|
+
type: "finish",
|
|
65789
|
+
reason: { kind: "stop" }
|
|
65790
|
+
};
|
|
65791
|
+
const hasVisibleAnswer = toolBlocks.size > 0 || textBlock !== void 0 && textBlock.text.trim() !== "";
|
|
65792
|
+
chunks.push({
|
|
65793
|
+
type: "finish",
|
|
65794
|
+
reason: reason.reason.kind === "stop" && order.length === 0 ? {
|
|
65795
|
+
kind: "error",
|
|
65796
|
+
failure: {
|
|
65797
|
+
message: "model returned a completed response with no content",
|
|
65798
|
+
code: EMPTY_RESPONSE_CODE
|
|
65799
|
+
}
|
|
65800
|
+
} : reason.reason.kind === "stop" && !hasVisibleAnswer ? {
|
|
65801
|
+
kind: "error",
|
|
65802
|
+
failure: {
|
|
65803
|
+
message: "Cline Pass ended the turn without an answer or a tool call — the response stopped before it did anything",
|
|
65804
|
+
code: "PROVIDER_ERROR"
|
|
65805
|
+
}
|
|
65806
|
+
} : reason.reason
|
|
65807
|
+
});
|
|
65808
|
+
return chunks;
|
|
65809
|
+
};
|
|
65810
|
+
for await (const payload of tolerateMissingSentinel$1(payloads)) {
|
|
63935
65811
|
if (payload === "[DONE]") {
|
|
63936
|
-
for (const
|
|
63937
|
-
type: "block-end",
|
|
63938
|
-
index: block.index,
|
|
63939
|
-
block: closeBlock$1(block)
|
|
63940
|
-
};
|
|
63941
|
-
if (pendingUsage !== void 0) yield {
|
|
63942
|
-
type: "usage",
|
|
63943
|
-
usage: pendingUsage
|
|
63944
|
-
};
|
|
63945
|
-
const reason = pendingFinish ?? {
|
|
63946
|
-
type: "finish",
|
|
63947
|
-
reason: { kind: "stop" }
|
|
63948
|
-
};
|
|
63949
|
-
const hasVisibleAnswer = toolBlocks.size > 0 || textBlock !== void 0 && textBlock.text.trim() !== "";
|
|
63950
|
-
yield {
|
|
63951
|
-
type: "finish",
|
|
63952
|
-
reason: reason.reason.kind === "stop" && order.length === 0 ? {
|
|
63953
|
-
kind: "error",
|
|
63954
|
-
failure: {
|
|
63955
|
-
message: "model returned a completed response with no content",
|
|
63956
|
-
code: EMPTY_RESPONSE_CODE
|
|
63957
|
-
}
|
|
63958
|
-
} : reason.reason.kind === "stop" && !hasVisibleAnswer ? {
|
|
63959
|
-
kind: "error",
|
|
63960
|
-
failure: {
|
|
63961
|
-
message: "Cline Pass ended the turn without an answer or a tool call — the response stopped before it did anything",
|
|
63962
|
-
code: "PROVIDER_ERROR"
|
|
63963
|
-
}
|
|
63964
|
-
} : reason.reason
|
|
63965
|
-
};
|
|
65812
|
+
for (const chunk of closing()) yield chunk;
|
|
63966
65813
|
return;
|
|
63967
65814
|
}
|
|
63968
65815
|
const chunk = unwrapEnvelope(payload);
|
|
@@ -64036,6 +65883,10 @@ async function* translate(payloads) {
|
|
|
64036
65883
|
}
|
|
64037
65884
|
if (isRecord$11(chunk.usage)) pendingUsage = mapUsage$1(chunk.usage);
|
|
64038
65885
|
}
|
|
65886
|
+
if (pendingFinish !== void 0) {
|
|
65887
|
+
for (const chunk of closing()) yield chunk;
|
|
65888
|
+
return;
|
|
65889
|
+
}
|
|
64039
65890
|
throw new LlmError("stream ended without [DONE]", "STREAM_CLOSED");
|
|
64040
65891
|
}
|
|
64041
65892
|
//#endregion
|
|
@@ -64172,6 +66023,7 @@ var ClinePassAdapter = class extends LlmAdapter {
|
|
|
64172
66023
|
id: modelId,
|
|
64173
66024
|
name: info.displayName,
|
|
64174
66025
|
inputModalities: info.input,
|
|
66026
|
+
toolUpdate: "in-history",
|
|
64175
66027
|
context: { contextWindow: info.contextWindow },
|
|
64176
66028
|
...outputReservation(info.contextWindow, info.maxTokens),
|
|
64177
66029
|
...info.reasoning ? { reasoning: { efforts: info.efforts.map((effort) => ({
|
|
@@ -64192,11 +66044,12 @@ var ClinePassAdapter = class extends LlmAdapter {
|
|
|
64192
66044
|
}
|
|
64193
66045
|
async *requestStream(rawOptions, settings, signal, activity) {
|
|
64194
66046
|
if (settings.enabled === false) throw new LlmError(`${PROVIDER_NAME$4} is disabled in Settings > Subscription. Enable the line there, then retry.`, "INVALID_REQUEST");
|
|
64195
|
-
const
|
|
64196
|
-
const images = await resolveRequestImages$1(
|
|
66047
|
+
const normalized = normalizeGenerateOptions(rawOptions);
|
|
66048
|
+
const images = await resolveRequestImages$1(normalized, this.options.attachments, signal).catch((error) => {
|
|
64197
66049
|
if (signal.aborted) throw new LlmError("Cline Pass request aborted by caller", "ABORTED", { cause: error });
|
|
64198
66050
|
return NO_RESOLVED_IMAGES$3;
|
|
64199
66051
|
});
|
|
66052
|
+
const options = offloadOldestRequestImages$1(normalized, void 0, images);
|
|
64200
66053
|
const body = buildClinePassBody(options, {
|
|
64201
66054
|
baseURL: settings.baseURL,
|
|
64202
66055
|
displayName: PROVIDER_NAME$4,
|
|
@@ -75043,6 +76896,52 @@ function collectImageParts(blocks, images, target = []) {
|
|
|
75043
76896
|
}
|
|
75044
76897
|
return target;
|
|
75045
76898
|
}
|
|
76899
|
+
/** Names of the tools one developer message adds at its own history position. */
|
|
76900
|
+
function toolAdditionNames(message) {
|
|
76901
|
+
const names = [];
|
|
76902
|
+
for (const block of message.content) {
|
|
76903
|
+
if (block.type !== "tool-addition") continue;
|
|
76904
|
+
const name = block.toolName;
|
|
76905
|
+
if (typeof name !== "string" || name.trim() === "") continue;
|
|
76906
|
+
names.push(name.trim());
|
|
76907
|
+
}
|
|
76908
|
+
return names;
|
|
76909
|
+
}
|
|
76910
|
+
/** Names of the tools one developer message revokes at its own history position. */
|
|
76911
|
+
function toolRemovalNames(message) {
|
|
76912
|
+
const names = [];
|
|
76913
|
+
for (const block of message.content) {
|
|
76914
|
+
if (block.type !== "tool-removal") continue;
|
|
76915
|
+
const name = block.toolName;
|
|
76916
|
+
if (typeof name !== "string" || name.trim() === "") continue;
|
|
76917
|
+
names.push(name.trim());
|
|
76918
|
+
}
|
|
76919
|
+
return names;
|
|
76920
|
+
}
|
|
76921
|
+
/**
|
|
76922
|
+
* The model-facing note that announces tools a conversation just gained.
|
|
76923
|
+
*
|
|
76924
|
+
* This route declares `toolUpdate: 'in-history'`, so the harness states an
|
|
76925
|
+
* addition as a block at its own history position instead of rewriting the whole
|
|
76926
|
+
* request's tool list. OpenAI has no message-level tool declaration, so the
|
|
76927
|
+
* change travels as this sentence: it costs one short system turn and leaves the
|
|
76928
|
+
* top-level `tools` array — and therefore the prefix cache — intact.
|
|
76929
|
+
*/
|
|
76930
|
+
function additionNotice(names) {
|
|
76931
|
+
const list = names.map((name) => "\"" + name + "\"").join(", ");
|
|
76932
|
+
return names.length === 1 ? "[" + list + " is now available. Call it when it helps with the task.]" : "[" + list + " are now available. Call them when they help with the task.]";
|
|
76933
|
+
}
|
|
76934
|
+
/**
|
|
76935
|
+
* The model-facing note that revokes one or more tools.
|
|
76936
|
+
*
|
|
76937
|
+
* Imperative and unambiguous on purpose: the tool is still in the declaration
|
|
76938
|
+
* list, so this sentence is the only thing between the model and a call that
|
|
76939
|
+
* would fail.
|
|
76940
|
+
*/
|
|
76941
|
+
function removalNotice(names) {
|
|
76942
|
+
const list = names.map((name) => "\"" + name + "\"").join(", ");
|
|
76943
|
+
return names.length === 1 ? "[" + list + " is no longer available. Do not call it again: any call will fail.]" : "[" + list + " are no longer available. Do not call them again: any call will fail.]";
|
|
76944
|
+
}
|
|
75046
76945
|
/**
|
|
75047
76946
|
* Convert the harness conversation to wire messages.
|
|
75048
76947
|
*
|
|
@@ -75054,7 +76953,15 @@ function collectImageParts(blocks, images, target = []) {
|
|
|
75054
76953
|
* message keeps a parallel group's answers contiguous.
|
|
75055
76954
|
*
|
|
75056
76955
|
* `developer` becomes `system`: the newer OpenAI spelling is not accepted by
|
|
75057
|
-
* every OpenAI-compatible gateway, while `system` is.
|
|
76956
|
+
* every OpenAI-compatible gateway, while `system` is. A tool addition or
|
|
76957
|
+
* removal carried by such a message is replayed as its own system sentence —
|
|
76958
|
+
* the `in-history` shape this route declares — and ordinary text is kept
|
|
76959
|
+
* beside it. A message that contributes neither is dropped rather than sent as
|
|
76960
|
+
* an empty system turn.
|
|
76961
|
+
*
|
|
76962
|
+
* A user message that carries no text and no image contributes nothing to a
|
|
76963
|
+
* chat request, so it is dropped too; a pure tool-result message never took that
|
|
76964
|
+
* path, because its answers travel as `tool` messages.
|
|
75058
76965
|
*/
|
|
75059
76966
|
function serializeMessages$1(messages, images) {
|
|
75060
76967
|
const wire = [];
|
|
@@ -75087,6 +76994,7 @@ function serializeMessages$1(messages, images) {
|
|
|
75087
76994
|
};
|
|
75088
76995
|
/** A tool answer: it closes its own id, and the group when it was the last. */
|
|
75089
76996
|
const answer = (callId, content) => {
|
|
76997
|
+
if (!awaiting.includes(callId)) throw new LlmError(`OpenDesign request is invalid: the tool result for call id "${callId}" has no pending tool call to answer, so the message list would carry a tool message that no assistant tool_calls message introduced. Nothing was sent.`, "INVALID_REQUEST");
|
|
75090
76998
|
wire.push({
|
|
75091
76999
|
role: "tool",
|
|
75092
77000
|
tool_call_id: callId,
|
|
@@ -75099,9 +77007,20 @@ function serializeMessages$1(messages, images) {
|
|
|
75099
77007
|
for (const message of messages) {
|
|
75100
77008
|
if (message.role === "system" || message.role === "developer") {
|
|
75101
77009
|
flushToolImages();
|
|
75102
|
-
|
|
77010
|
+
const text = flattenText(message.content);
|
|
77011
|
+
if (text.length > 0) emit({
|
|
77012
|
+
role: "system",
|
|
77013
|
+
content: text
|
|
77014
|
+
});
|
|
77015
|
+
const additions = toolAdditionNames(message);
|
|
77016
|
+
if (additions.length > 0) emit({
|
|
77017
|
+
role: "system",
|
|
77018
|
+
content: additionNotice(additions)
|
|
77019
|
+
});
|
|
77020
|
+
const removals = toolRemovalNames(message);
|
|
77021
|
+
if (removals.length > 0) emit({
|
|
75103
77022
|
role: "system",
|
|
75104
|
-
content:
|
|
77023
|
+
content: removalNotice(removals)
|
|
75105
77024
|
});
|
|
75106
77025
|
continue;
|
|
75107
77026
|
}
|
|
@@ -75121,7 +77040,7 @@ function serializeMessages$1(messages, images) {
|
|
|
75121
77040
|
else if (block.type === "image") parts.push(imagePart(block, images));
|
|
75122
77041
|
const hasImage = parts.some((part) => part.type === "image_url");
|
|
75123
77042
|
const text = parts.filter((part) => part.type === "text").map((part) => String(part.text)).join("");
|
|
75124
|
-
if (hasImage || text.length > 0
|
|
77043
|
+
if (hasImage || text.length > 0) {
|
|
75125
77044
|
flushToolImages();
|
|
75126
77045
|
emit({
|
|
75127
77046
|
role: "user",
|
|
@@ -75135,6 +77054,21 @@ function serializeMessages$1(messages, images) {
|
|
|
75135
77054
|
return wire;
|
|
75136
77055
|
}
|
|
75137
77056
|
/**
|
|
77057
|
+
* Whether one model id is a DeepSeek model.
|
|
77058
|
+
*
|
|
77059
|
+
* The vendor prefix is stripped before the compare, so the bare wire id this
|
|
77060
|
+
* line ships (`deepseek-v4.1-flash`) and a vendor-qualified spelling
|
|
77061
|
+
* (`deepseek/deepseek-v4-pro`, as a sibling registry writes it) both match,
|
|
77062
|
+
* while an id that merely mentions the word — a gateway alias such as
|
|
77063
|
+
* `my-deepseek-proxy` — does not. Only DeepSeek's own chat endpoint documents
|
|
77064
|
+
* the `thinking` field, which is why the translation below is scoped to it
|
|
77065
|
+
* rather than applied to every model that offers a `none` level.
|
|
77066
|
+
*/
|
|
77067
|
+
function isDeepSeekModel(model) {
|
|
77068
|
+
const id = String(model ?? "").trim().toLowerCase();
|
|
77069
|
+
return id.slice(Math.max(id.lastIndexOf("/"), id.lastIndexOf(":")) + 1).startsWith("deepseek");
|
|
77070
|
+
}
|
|
77071
|
+
/**
|
|
75138
77072
|
* Build the wire request for one harness call.
|
|
75139
77073
|
* @param options - the harness request (model, system, history, tools, sampling).
|
|
75140
77074
|
* @param connection - resolved connection facts (default output budget).
|
|
@@ -75156,6 +77090,8 @@ function buildOpenDesignBody(options, connection, images) {
|
|
|
75156
77090
|
}
|
|
75157
77091
|
}));
|
|
75158
77092
|
const maxTokens = options.maxTokens ?? connection.maxTokens;
|
|
77093
|
+
const reasoningEffort = options.reasoningEffort === void 0 ? void 0 : String(options.reasoningEffort);
|
|
77094
|
+
const thinkingOff = reasoningEffort !== void 0 && reasoningEffort.toLowerCase() === "none" && isDeepSeekModel(options.model);
|
|
75159
77095
|
return {
|
|
75160
77096
|
model: options.model,
|
|
75161
77097
|
messages,
|
|
@@ -75165,7 +77101,8 @@ function buildOpenDesignBody(options, connection, images) {
|
|
|
75165
77101
|
...options.temperature === void 0 ? {} : { temperature: options.temperature },
|
|
75166
77102
|
...maxTokens === void 0 || maxTokens <= 0 ? {} : { max_tokens: maxTokens },
|
|
75167
77103
|
...options.stop === void 0 ? {} : { stop: options.stop },
|
|
75168
|
-
...
|
|
77104
|
+
...thinkingOff || reasoningEffort === void 0 ? {} : { reasoning_effort: reasoningEffort },
|
|
77105
|
+
...thinkingOff ? { thinking: { type: "disabled" } } : {}
|
|
75169
77106
|
};
|
|
75170
77107
|
}
|
|
75171
77108
|
/** Map an OpenAI `finish_reason` to the harness finish reason. */
|
|
@@ -75284,8 +77221,16 @@ function streamErrorCode(message, status) {
|
|
|
75284
77221
|
}
|
|
75285
77222
|
/**
|
|
75286
77223
|
* Parse an SSE body into decoded JSON frames.
|
|
77224
|
+
*
|
|
77225
|
+
* The parser's contract is the sibling line's and stays unsoftened: a body that
|
|
77226
|
+
* ends without `[DONE]` is reported by THROWING `STREAM_CLOSED` after its last
|
|
77227
|
+
* frame. An SSE parser cannot tell a gateway that closes politely after the
|
|
77228
|
+
* terminal frame from a truncated response, because it never interprets
|
|
77229
|
+
* `finish_reason` — so it must not decide that question for its callers either.
|
|
77230
|
+
* That verdict belongs to {@link translate}, which has the frames, and absorbs
|
|
77231
|
+
* this one code from its input.
|
|
75287
77232
|
* @param body - a web ReadableStream of UTF-8 bytes.
|
|
75288
|
-
* @returns every `data:` payload up to `[DONE]`.
|
|
77233
|
+
* @returns every `data:` payload up to `[DONE]`, then throws `STREAM_CLOSED`.
|
|
75289
77234
|
*/
|
|
75290
77235
|
async function* parseServerSentEvents(body) {
|
|
75291
77236
|
const decoder = new TextDecoder();
|
|
@@ -75331,6 +77276,36 @@ async function* parseServerSentEvents(body) {
|
|
|
75331
77276
|
}
|
|
75332
77277
|
throw new LlmError("stream ended without [DONE]", "STREAM_CLOSED");
|
|
75333
77278
|
}
|
|
77279
|
+
/**
|
|
77280
|
+
* Whether one failure is the SSE parser's own "the body ended without [DONE]".
|
|
77281
|
+
*
|
|
77282
|
+
* Structural rather than `instanceof`: what has to match is the CODE the parser
|
|
77283
|
+
* assigns, and a second copy of this module (or a harness generation that ships
|
|
77284
|
+
* its own LlmError class) would fail an identity test on a value that is
|
|
77285
|
+
* otherwise exactly the signal this decision is about.
|
|
77286
|
+
*/
|
|
77287
|
+
function isMissingSentinel(error) {
|
|
77288
|
+
return typeof error === "object" && error !== null && error.code === "STREAM_CLOSED";
|
|
77289
|
+
}
|
|
77290
|
+
/**
|
|
77291
|
+
* End the payload iteration instead of throwing when a body stops without the
|
|
77292
|
+
* sentinel.
|
|
77293
|
+
*
|
|
77294
|
+
* The parser can only report "no [DONE] arrived" by throwing, and that is NOT a
|
|
77295
|
+
* failure on its own: the gateway sometimes closes the response right after the
|
|
77296
|
+
* terminal frame. Whether the response actually completed is decided by the
|
|
77297
|
+
* frames that were seen, which is knowledge this generator does not have — so it
|
|
77298
|
+
* only removes the transport signal and leaves the verdict to {@link translate}.
|
|
77299
|
+
* Every other failure passes through untouched, including the error frames a 200
|
|
77300
|
+
* body can carry.
|
|
77301
|
+
*/
|
|
77302
|
+
async function* tolerateMissingSentinel(payloads) {
|
|
77303
|
+
try {
|
|
77304
|
+
for await (const payload of payloads) yield payload;
|
|
77305
|
+
} catch (error) {
|
|
77306
|
+
if (!isMissingSentinel(error)) throw error;
|
|
77307
|
+
}
|
|
77308
|
+
}
|
|
75334
77309
|
/** Assemble the terminal content block for one open block. */
|
|
75335
77310
|
function closeBlock(block) {
|
|
75336
77311
|
switch (block.kind) {
|
|
@@ -75354,9 +77329,21 @@ function closeBlock(block) {
|
|
|
75354
77329
|
* Translate decoded SSE payloads into harness stream chunks.
|
|
75355
77330
|
*
|
|
75356
77331
|
* Deltas stream through as they arrive; block ends, usage and the finish reason
|
|
75357
|
-
* are all deferred to the
|
|
77332
|
+
* are all deferred to the end of the answer, so no chunk ever follows `finish`.
|
|
75358
77333
|
* A completion with no content at all becomes an `EMPTY_RESPONSE` error finish
|
|
75359
77334
|
* rather than a silent empty message.
|
|
77335
|
+
*
|
|
77336
|
+
* The end of the answer is either the `[DONE]` sentinel or the end of the body
|
|
77337
|
+
* AFTER a terminal `finish_reason` arrived. Some gateways close the socket
|
|
77338
|
+
* instead of writing the sentinel, and failing such an answer threw away content
|
|
77339
|
+
* the caller had already received.
|
|
77340
|
+
*
|
|
77341
|
+
* The parser still raises `STREAM_CLOSED` for a body with no sentinel — that is
|
|
77342
|
+
* its contract and it is not widened here. The signal is absorbed where the
|
|
77343
|
+
* frames are available instead (see {@link tolerateMissingSentinel}), and only
|
|
77344
|
+
* then does the finish reason decide: a body that stated one closes like any
|
|
77345
|
+
* other, and a body that never stated one is truncation and still fails with
|
|
77346
|
+
* `STREAM_CLOSED`. No other input failure is absorbed.
|
|
75360
77347
|
*/
|
|
75361
77348
|
async function* translate$1(payloads) {
|
|
75362
77349
|
let nextIndex = 0;
|
|
@@ -75383,38 +77370,44 @@ async function* translate$1(payloads) {
|
|
|
75383
77370
|
order.push(block);
|
|
75384
77371
|
return block;
|
|
75385
77372
|
};
|
|
75386
|
-
|
|
77373
|
+
/** The terminal chunks of one completed answer, in the order the harness accepts. */
|
|
77374
|
+
const terminal = (reason) => {
|
|
77375
|
+
const chunks = [];
|
|
77376
|
+
for (const block of order) chunks.push({
|
|
77377
|
+
type: "block-end",
|
|
77378
|
+
index: block.index,
|
|
77379
|
+
block: closeBlock(block)
|
|
77380
|
+
});
|
|
77381
|
+
if (pendingUsage !== void 0) chunks.push({
|
|
77382
|
+
type: "usage",
|
|
77383
|
+
usage: pendingUsage
|
|
77384
|
+
});
|
|
77385
|
+
const hasVisibleAnswer = toolBlocks.size > 0 || textBlock !== void 0 && textBlock.text.trim() !== "";
|
|
77386
|
+
chunks.push({
|
|
77387
|
+
type: "finish",
|
|
77388
|
+
reason: reason.reason.kind === "stop" && order.length === 0 ? {
|
|
77389
|
+
kind: "error",
|
|
77390
|
+
failure: {
|
|
77391
|
+
message: "model returned a completed response with no content",
|
|
77392
|
+
code: EMPTY_RESPONSE_CODE
|
|
77393
|
+
}
|
|
77394
|
+
} : reason.reason.kind === "stop" && !hasVisibleAnswer ? {
|
|
77395
|
+
kind: "error",
|
|
77396
|
+
failure: {
|
|
77397
|
+
message: "OpenDesign ended the turn without an answer or a tool call — the response stopped before it did anything",
|
|
77398
|
+
code: "PROVIDER_ERROR"
|
|
77399
|
+
}
|
|
77400
|
+
} : reason.reason
|
|
77401
|
+
});
|
|
77402
|
+
return chunks;
|
|
77403
|
+
};
|
|
77404
|
+
for await (const payload of tolerateMissingSentinel(payloads)) {
|
|
75387
77405
|
if (payload === "[DONE]") {
|
|
75388
|
-
for (const block of order) yield {
|
|
75389
|
-
type: "block-end",
|
|
75390
|
-
index: block.index,
|
|
75391
|
-
block: closeBlock(block)
|
|
75392
|
-
};
|
|
75393
|
-
if (pendingUsage !== void 0) yield {
|
|
75394
|
-
type: "usage",
|
|
75395
|
-
usage: pendingUsage
|
|
75396
|
-
};
|
|
75397
77406
|
const reason = pendingFinish ?? {
|
|
75398
77407
|
type: "finish",
|
|
75399
77408
|
reason: { kind: "stop" }
|
|
75400
77409
|
};
|
|
75401
|
-
const
|
|
75402
|
-
yield {
|
|
75403
|
-
type: "finish",
|
|
75404
|
-
reason: reason.reason.kind === "stop" && order.length === 0 ? {
|
|
75405
|
-
kind: "error",
|
|
75406
|
-
failure: {
|
|
75407
|
-
message: "model returned a completed response with no content",
|
|
75408
|
-
code: EMPTY_RESPONSE_CODE
|
|
75409
|
-
}
|
|
75410
|
-
} : reason.reason.kind === "stop" && !hasVisibleAnswer ? {
|
|
75411
|
-
kind: "error",
|
|
75412
|
-
failure: {
|
|
75413
|
-
message: "OpenDesign ended the turn without an answer or a tool call — the response stopped before it did anything",
|
|
75414
|
-
code: "PROVIDER_ERROR"
|
|
75415
|
-
}
|
|
75416
|
-
} : reason.reason
|
|
75417
|
-
};
|
|
77410
|
+
for (const chunk of terminal(reason)) yield chunk;
|
|
75418
77411
|
return;
|
|
75419
77412
|
}
|
|
75420
77413
|
if (isErrorPayload(payload)) throw new LlmError(errorText(payload.error), streamErrorCode(errorText(payload.error)));
|
|
@@ -75488,6 +77481,10 @@ async function* translate$1(payloads) {
|
|
|
75488
77481
|
}
|
|
75489
77482
|
if (isRecord$2(chunk.usage)) pendingUsage = mapUsage(chunk.usage);
|
|
75490
77483
|
}
|
|
77484
|
+
if (pendingFinish !== void 0) {
|
|
77485
|
+
for (const chunk of terminal(pendingFinish)) yield chunk;
|
|
77486
|
+
return;
|
|
77487
|
+
}
|
|
75491
77488
|
throw new LlmError("stream ended without [DONE]", "STREAM_CLOSED");
|
|
75492
77489
|
}
|
|
75493
77490
|
//#endregion
|
|
@@ -75548,6 +77545,23 @@ const TRANSPORT_RETRY_BASE_MS = 400;
|
|
|
75548
77545
|
* but the ladder still prefers published data for anything the tables know.
|
|
75549
77546
|
*/
|
|
75550
77547
|
const REASONING_EFFORT_IDS = DEFAULT_EFFORTS;
|
|
77548
|
+
/**
|
|
77549
|
+
* How this route carries a mid-conversation tool change, or undefined for none.
|
|
77550
|
+
*
|
|
77551
|
+
* `in-history`, not `addition-only`: both append an addition, but only
|
|
77552
|
+
* `in-history` can also express a REMOVAL. Under `addition-only` a revoked
|
|
77553
|
+
* tool is deleted from the declaration list, the top-level `tools` array
|
|
77554
|
+
* shrinks, and the gateway's prefix match is thrown away for the whole prompt.
|
|
77555
|
+
*
|
|
77556
|
+
* The field is spread into the descriptor only when this route wants one, so a
|
|
77557
|
+
* harness generation that does not know it simply never reads it — no version
|
|
77558
|
+
* check, and no change to the message shape either way: mapper.ts renders each
|
|
77559
|
+
* change as a system sentence and never introduces a native dynamic-tool
|
|
77560
|
+
* declaration, which an OpenAI-compatible gateway has no field for.
|
|
77561
|
+
*/
|
|
77562
|
+
function toolUpdateMode() {
|
|
77563
|
+
return "in-history";
|
|
77564
|
+
}
|
|
75551
77565
|
var OpenDesignAdapter = class extends LlmAdapter {
|
|
75552
77566
|
modelSettings;
|
|
75553
77567
|
options;
|
|
@@ -75606,6 +77620,7 @@ var OpenDesignAdapter = class extends LlmAdapter {
|
|
|
75606
77620
|
id: modelId,
|
|
75607
77621
|
name: info.displayName,
|
|
75608
77622
|
inputModalities: info.input,
|
|
77623
|
+
...toolUpdateMode() === void 0 ? {} : { toolUpdate: toolUpdateMode() },
|
|
75609
77624
|
context: { contextWindow: info.contextWindow },
|
|
75610
77625
|
...outputReservation(info.contextWindow, info.maxTokens),
|
|
75611
77626
|
...info.reasoning && info.efforts.length > 0 ? { reasoning: {
|
|
@@ -75633,11 +77648,12 @@ var OpenDesignAdapter = class extends LlmAdapter {
|
|
|
75633
77648
|
}
|
|
75634
77649
|
async *requestStream(rawOptions, settings, signal, activity) {
|
|
75635
77650
|
if (settings.enabled === false) throw new LlmError(`${PROVIDER_NAME$2} is disabled in Settings > Subscription. Enable the line there, then retry.`, "INVALID_REQUEST");
|
|
75636
|
-
const
|
|
75637
|
-
const images = await resolveRequestImages$1(
|
|
77651
|
+
const normalized = normalizeGenerateOptions(rawOptions);
|
|
77652
|
+
const images = await resolveRequestImages$1(normalized, this.options.attachments, signal).catch((error) => {
|
|
75638
77653
|
if (signal.aborted) throw new LlmError("OpenDesign request aborted by caller", "ABORTED", { cause: error });
|
|
75639
77654
|
return NO_RESOLVED_IMAGES$3;
|
|
75640
77655
|
});
|
|
77656
|
+
const options = offloadOldestRequestImages$1(normalized, MAX_REQUEST_IMAGE_BYTES$1, images);
|
|
75641
77657
|
const body = buildOpenDesignBody(options, { maxTokens: (await this.modelInfo(options.model)).maxTokens }, images);
|
|
75642
77658
|
const refused = /* @__PURE__ */ new Set();
|
|
75643
77659
|
let lastError = null;
|
|
@@ -80962,6 +82978,771 @@ function releaseListener(handle) {
|
|
|
80962
82978
|
handle?.dispose?.();
|
|
80963
82979
|
}
|
|
80964
82980
|
//#endregion
|
|
82981
|
+
//#region src/host/cache-warmup/logging.ts
|
|
82982
|
+
/**
|
|
82983
|
+
* 把一条事件序列化成日志行。
|
|
82984
|
+
*
|
|
82985
|
+
* 形状写死在这里,而不是让调用方自己拼字符串:任何一次「顺手把 content 也带进
|
|
82986
|
+
* 去」的改动都只会发生在这个函数里,而它有测试盯着(断言日志里不含原文)。
|
|
82987
|
+
* 值为 `undefined` 的字段直接不出现在行里,保持每一行只描述它确实测到的指标。
|
|
82988
|
+
* @param event - 指标事件。
|
|
82989
|
+
* @returns 一行日志文本(带上本模块前缀)。
|
|
82990
|
+
*/
|
|
82991
|
+
function formatCacheWarmupLog(event) {
|
|
82992
|
+
const record = {};
|
|
82993
|
+
for (const [key, value] of Object.entries(event)) if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") record[key] = value;
|
|
82994
|
+
return "[cache-warmup] " + JSON.stringify(record);
|
|
82995
|
+
}
|
|
82996
|
+
/**
|
|
82997
|
+
* 把一个异常压成**稳定的身份标签**,只用于宿主日志。
|
|
82998
|
+
*
|
|
82999
|
+
* 只取两样东西:`name`(构造器名;缺失或不是非空字符串时退成 `'Error'`)和
|
|
83000
|
+
* `code`(有就带上——`CacheWarmupStoreError` 与 Node 的系统错误都靠它区分)。
|
|
83001
|
+
*
|
|
83002
|
+
* **`message` 一律不进日志**,这是本函数存在的全部理由:异常消息里常常带着绝对
|
|
83003
|
+
* 路径、命令行片段、凭据,或者被读取结果的一段原文。一条日志就够把原文复制一份,
|
|
83004
|
+
* 或者把主机的目录结构写进部署方的日志管道。想知道"哪一类错误、来自哪一层",
|
|
83005
|
+
* name/code 就够了;想知道"具体哪一条",该去会话本身看,不该从日志里捞。
|
|
83006
|
+
* @param error - 捕获到的任意值。
|
|
83007
|
+
* @returns 形如 `Error`、`RangeError`、`CacheWarmupStoreError/SESSION_MISMATCH`
|
|
83008
|
+
* 的标签(`code` 是非空字符串时以 `/` 连接)。
|
|
83009
|
+
*/
|
|
83010
|
+
function cacheWarmupErrorLabel(error) {
|
|
83011
|
+
if (typeof error !== "object" || error === null) return "Error";
|
|
83012
|
+
const record = error;
|
|
83013
|
+
const name = typeof record.name === "string" && record.name !== "" ? record.name : "Error";
|
|
83014
|
+
const code = typeof record.code === "string" && record.code !== "" ? record.code : void 0;
|
|
83015
|
+
return code === void 0 ? name : name + "/" + code;
|
|
83016
|
+
}
|
|
83017
|
+
/**
|
|
83018
|
+
* 发一条指标日志。
|
|
83019
|
+
*
|
|
83020
|
+
* logger 缺席时**安静跳过**:日志不是功能,装不上日志服务的宿主不该因此装不上
|
|
83021
|
+
* 这个模块。写入失败同样吞掉——一条日志把工具调用搞挂是最不划算的失败。
|
|
83022
|
+
* @param logger - 宿主 logger(可选)。
|
|
83023
|
+
* @param event - 指标事件。
|
|
83024
|
+
* @returns 写出的那一行(调用方/测试可以拿去断言);没写出去时 undefined。
|
|
83025
|
+
*/
|
|
83026
|
+
function logCacheWarmup(logger, event) {
|
|
83027
|
+
const line = formatCacheWarmupLog(event);
|
|
83028
|
+
const info = logger?.info;
|
|
83029
|
+
if (typeof info !== "function") return void 0;
|
|
83030
|
+
try {
|
|
83031
|
+
info.call(logger, line);
|
|
83032
|
+
} catch {
|
|
83033
|
+
return;
|
|
83034
|
+
}
|
|
83035
|
+
return line;
|
|
83036
|
+
}
|
|
83037
|
+
//#endregion
|
|
83038
|
+
//#region src/host/cache-warmup/result-tool.ts
|
|
83039
|
+
/** 注册的工具名。冻结契约的一部分,不要改动。 */
|
|
83040
|
+
const CACHE_WARMUP_RESULT_TOOL = "cache_warmup_result";
|
|
83041
|
+
/** 是不是一个可用的非负整数。 */
|
|
83042
|
+
function isCount(value) {
|
|
83043
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
83044
|
+
}
|
|
83045
|
+
/** 读一个未知值为有限数字(`null` 不算:契约用 `null` 表示"没有下一页")。 */
|
|
83046
|
+
function numberOf$1(value) {
|
|
83047
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
83048
|
+
}
|
|
83049
|
+
/** 读一个未知值为布尔。 */
|
|
83050
|
+
function booleanOf$1(value) {
|
|
83051
|
+
return typeof value === "boolean" ? value : void 0;
|
|
83052
|
+
}
|
|
83053
|
+
/** 读一个未知值为非空字符串。 */
|
|
83054
|
+
function stringOf$1(value) {
|
|
83055
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
83056
|
+
}
|
|
83057
|
+
/**
|
|
83058
|
+
* 从存储交回来的 `omitted` 里读出省略字符数。
|
|
83059
|
+
*
|
|
83060
|
+
* 契约给的是一个结构化对象(`{ reason, chars, lines, rows, bytes }`,`reason: 'none'`
|
|
83061
|
+
* 表示这一页就是全部);这里同时容忍一个裸数字,因为"省略了多少字符"是唯一
|
|
83062
|
+
* 模型真正需要的那个数,而形状变化不该让整页读取失败。
|
|
83063
|
+
* @param value - 存储返回的 `omitted`。
|
|
83064
|
+
* @returns 省略的字符数;读不到时 undefined。
|
|
83065
|
+
*/
|
|
83066
|
+
function omittedCharsOf(value) {
|
|
83067
|
+
const direct = numberOf$1(value);
|
|
83068
|
+
if (direct !== void 0) return direct;
|
|
83069
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
83070
|
+
return numberOf$1(value.chars);
|
|
83071
|
+
}
|
|
83072
|
+
/**
|
|
83073
|
+
* 造一个失败值。
|
|
83074
|
+
* @param action - 回显的动作。
|
|
83075
|
+
* @param refId - 被拒绝的引用。
|
|
83076
|
+
* @param sessionId - 当前会话(拿不到时为空串)。
|
|
83077
|
+
* @param code - 稳定错误码。
|
|
83078
|
+
* @param message - 一句话说明。
|
|
83079
|
+
* @returns 规范值。
|
|
83080
|
+
*/
|
|
83081
|
+
function failure(action, refId, sessionId, code, message) {
|
|
83082
|
+
return {
|
|
83083
|
+
ok: false,
|
|
83084
|
+
action,
|
|
83085
|
+
refId,
|
|
83086
|
+
sessionId,
|
|
83087
|
+
error: {
|
|
83088
|
+
code,
|
|
83089
|
+
message
|
|
83090
|
+
}
|
|
83091
|
+
};
|
|
83092
|
+
}
|
|
83093
|
+
/**
|
|
83094
|
+
* 把未知异常压成一句不含原文的话。
|
|
83095
|
+
*
|
|
83096
|
+
* 异常消息里可能带着被读取内容的片段(解析错误最常见),所以只取消息的第一行,
|
|
83097
|
+
* 长度也截断——工具输出不该成为原文的第二条出口。
|
|
83098
|
+
* @param error - 捕获到的值。
|
|
83099
|
+
* @returns 一句话描述。
|
|
83100
|
+
*/
|
|
83101
|
+
function describe(error) {
|
|
83102
|
+
const firstLine = (error instanceof Error ? error.message : String(error)).split("\n", 1)[0] ?? "";
|
|
83103
|
+
return firstLine.length > 160 ? firstLine.slice(0, 157) + "..." : firstLine;
|
|
83104
|
+
}
|
|
83105
|
+
/**
|
|
83106
|
+
* 取结构化错误码:存储自己的码优先,否则用本工具给的兜底码。
|
|
83107
|
+
* @param error - 捕获到的值。
|
|
83108
|
+
* @param fallback - 兜底码。
|
|
83109
|
+
* @returns 错误码。
|
|
83110
|
+
*/
|
|
83111
|
+
function errorCodeOf(error, fallback) {
|
|
83112
|
+
const code = error?.code;
|
|
83113
|
+
return typeof code === "string" && code !== "" ? code : fallback;
|
|
83114
|
+
}
|
|
83115
|
+
/**
|
|
83116
|
+
* 把共享存储交回来的页读成规范值。
|
|
83117
|
+
*
|
|
83118
|
+
* 字段一律按「读得到就用、读不到就不出现」处理:核心侧多给字段不会让这里报错,
|
|
83119
|
+
* 少给字段也不会伪造一个 `complete:true`——「不确定是否读完」必须表现成
|
|
83120
|
+
* `complete:false`,把 partial 伪装成完整是这条路线上最贵的一种错。
|
|
83121
|
+
* @param raw - 存储的返回值(`ResultPage`,按共享约定结构化读取)。
|
|
83122
|
+
* @param refId - 引用。
|
|
83123
|
+
* @param sessionId - 会话。
|
|
83124
|
+
* @returns 规范值。
|
|
83125
|
+
*/
|
|
83126
|
+
function pageValue(raw, refId, sessionId) {
|
|
83127
|
+
const page = typeof raw === "object" && raw !== null ? raw : {};
|
|
83128
|
+
const text = stringOf$1(page.content) ?? stringOf$1(page.text);
|
|
83129
|
+
const complete = booleanOf$1(page.complete) === true;
|
|
83130
|
+
const offset = numberOf$1(page.offset);
|
|
83131
|
+
const nextOffset = complete ? void 0 : numberOf$1(page.nextOffset);
|
|
83132
|
+
const omitted = omittedCharsOf(page.omitted);
|
|
83133
|
+
const totalChars = numberOf$1(page.totalChars);
|
|
83134
|
+
return {
|
|
83135
|
+
ok: true,
|
|
83136
|
+
action: "page",
|
|
83137
|
+
refId,
|
|
83138
|
+
sessionId,
|
|
83139
|
+
complete,
|
|
83140
|
+
...offset === void 0 ? {} : { offset },
|
|
83141
|
+
...nextOffset === void 0 ? {} : { nextOffset },
|
|
83142
|
+
...omitted === void 0 ? {} : { omitted },
|
|
83143
|
+
...totalChars === void 0 ? {} : { totalChars },
|
|
83144
|
+
...text === void 0 ? {} : {
|
|
83145
|
+
text,
|
|
83146
|
+
chars: text.length
|
|
83147
|
+
}
|
|
83148
|
+
};
|
|
83149
|
+
}
|
|
83150
|
+
/**
|
|
83151
|
+
* 造 `cache_warmup_result` 工具。
|
|
83152
|
+
* @param deps - 依赖:线路校验、存储读取、指标日志。
|
|
83153
|
+
* @returns 可注册的工具定义。
|
|
83154
|
+
*/
|
|
83155
|
+
function createCacheWarmupResultTool(deps) {
|
|
83156
|
+
/** 发一条日志;安装方没接线时什么也不做。 */
|
|
83157
|
+
const log = (event) => {
|
|
83158
|
+
try {
|
|
83159
|
+
deps.log?.(event);
|
|
83160
|
+
} catch {}
|
|
83161
|
+
};
|
|
83162
|
+
return defineTool({
|
|
83163
|
+
name: CACHE_WARMUP_RESULT_TOOL,
|
|
83164
|
+
description: [
|
|
83165
|
+
"Read back a result that was replaced by a preview, using the `ref://<session>/<sha256>` handle written in that preview.",
|
|
83166
|
+
"Use `action: \"page\"` to read a slice: pass `offset`, and optionally `limit`, `maxBytes`, `fields` and `equals`.",
|
|
83167
|
+
"Every page reports `complete`, `nextOffset`, `omitted` and `totalChars`, so you can see whether more remains instead of assuming a slice is the whole thing.",
|
|
83168
|
+
"Use `action: \"release\"` when the stored copy is no longer needed."
|
|
83169
|
+
].join(" "),
|
|
83170
|
+
parameters: {
|
|
83171
|
+
action: {
|
|
83172
|
+
type: "string",
|
|
83173
|
+
required: true,
|
|
83174
|
+
description: "page reads a slice of the stored result; release drops the stored copy.",
|
|
83175
|
+
enum: ["page", "release"]
|
|
83176
|
+
},
|
|
83177
|
+
refId: {
|
|
83178
|
+
type: "string",
|
|
83179
|
+
required: true,
|
|
83180
|
+
description: "The `ref://<session>/<sha256>` handle exactly as written in the preview."
|
|
83181
|
+
},
|
|
83182
|
+
offset: {
|
|
83183
|
+
type: "integer",
|
|
83184
|
+
description: "Where to start: a UTF-8 byte offset for text results, a row index for structured ones."
|
|
83185
|
+
},
|
|
83186
|
+
limit: {
|
|
83187
|
+
type: "integer",
|
|
83188
|
+
description: "Maximum number of rows for a structured result."
|
|
83189
|
+
},
|
|
83190
|
+
maxBytes: {
|
|
83191
|
+
type: "integer",
|
|
83192
|
+
description: "Byte budget for this page; the store returns less when the budget is smaller."
|
|
83193
|
+
},
|
|
83194
|
+
fields: {
|
|
83195
|
+
type: "array",
|
|
83196
|
+
items: { type: "string" },
|
|
83197
|
+
description: "Restrict a structured result to these fields."
|
|
83198
|
+
},
|
|
83199
|
+
equals: {
|
|
83200
|
+
type: "object",
|
|
83201
|
+
additionalProperties: true,
|
|
83202
|
+
description: "Keep only structured rows whose named field equals the given string."
|
|
83203
|
+
}
|
|
83204
|
+
},
|
|
83205
|
+
output: {
|
|
83206
|
+
schema: {
|
|
83207
|
+
type: "object",
|
|
83208
|
+
additionalProperties: false,
|
|
83209
|
+
properties: {
|
|
83210
|
+
ok: {
|
|
83211
|
+
type: "boolean",
|
|
83212
|
+
description: "Whether the call succeeded."
|
|
83213
|
+
},
|
|
83214
|
+
action: {
|
|
83215
|
+
type: "string",
|
|
83216
|
+
enum: ["page", "release"],
|
|
83217
|
+
description: "The action that ran."
|
|
83218
|
+
},
|
|
83219
|
+
refId: {
|
|
83220
|
+
type: "string",
|
|
83221
|
+
description: "The handle this call addressed."
|
|
83222
|
+
},
|
|
83223
|
+
sessionId: {
|
|
83224
|
+
type: "string",
|
|
83225
|
+
description: "The session that owns the handle."
|
|
83226
|
+
},
|
|
83227
|
+
complete: {
|
|
83228
|
+
type: "boolean",
|
|
83229
|
+
description: "Whether this page reached the end of the stored result."
|
|
83230
|
+
},
|
|
83231
|
+
offset: {
|
|
83232
|
+
type: "number",
|
|
83233
|
+
description: "Where this page actually started."
|
|
83234
|
+
},
|
|
83235
|
+
nextOffset: {
|
|
83236
|
+
type: "number",
|
|
83237
|
+
description: "Where the next page should start."
|
|
83238
|
+
},
|
|
83239
|
+
omitted: {
|
|
83240
|
+
type: "number",
|
|
83241
|
+
description: "Characters still not returned after this page."
|
|
83242
|
+
},
|
|
83243
|
+
chars: {
|
|
83244
|
+
type: "number",
|
|
83245
|
+
description: "Characters in this page."
|
|
83246
|
+
},
|
|
83247
|
+
totalChars: {
|
|
83248
|
+
type: "number",
|
|
83249
|
+
description: "Characters in the whole stored result."
|
|
83250
|
+
},
|
|
83251
|
+
released: {
|
|
83252
|
+
type: "boolean",
|
|
83253
|
+
description: "Whether a stored copy was actually released."
|
|
83254
|
+
},
|
|
83255
|
+
text: {
|
|
83256
|
+
type: "string",
|
|
83257
|
+
description: "This page of the stored result."
|
|
83258
|
+
},
|
|
83259
|
+
error: {
|
|
83260
|
+
type: "object",
|
|
83261
|
+
additionalProperties: false,
|
|
83262
|
+
properties: {
|
|
83263
|
+
code: {
|
|
83264
|
+
type: "string",
|
|
83265
|
+
description: "Stable error code."
|
|
83266
|
+
},
|
|
83267
|
+
message: {
|
|
83268
|
+
type: "string",
|
|
83269
|
+
description: "One-line explanation."
|
|
83270
|
+
}
|
|
83271
|
+
}
|
|
83272
|
+
}
|
|
83273
|
+
}
|
|
83274
|
+
},
|
|
83275
|
+
render(_args, value) {
|
|
83276
|
+
if (value.ok !== true) {
|
|
83277
|
+
const code = value.error?.code ?? "FAILED";
|
|
83278
|
+
const message = value.error?.message ?? "the request was refused";
|
|
83279
|
+
return [{
|
|
83280
|
+
type: "text",
|
|
83281
|
+
text: "cache_warmup_result " + value.action + " refused (" + code + "): " + message
|
|
83282
|
+
}];
|
|
83283
|
+
}
|
|
83284
|
+
if (value.action === "release") return [{
|
|
83285
|
+
type: "text",
|
|
83286
|
+
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."
|
|
83287
|
+
}];
|
|
83288
|
+
const at = value.offset ?? 0;
|
|
83289
|
+
const span = typeof value.chars === "number" ? value.chars + " chars" : "no content";
|
|
83290
|
+
const total = typeof value.totalChars === "number" ? " of " + value.totalChars : "";
|
|
83291
|
+
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" : "");
|
|
83292
|
+
const body = typeof value.text === "string" && value.text !== "" ? value.text : "(empty page)";
|
|
83293
|
+
return [{
|
|
83294
|
+
type: "text",
|
|
83295
|
+
text: value.refId + " from offset " + at + " — " + span + total + ", " + tail + "\n\n" + body
|
|
83296
|
+
}];
|
|
83297
|
+
}
|
|
83298
|
+
},
|
|
83299
|
+
async execute(args, exec) {
|
|
83300
|
+
const route = deps.routeOf(exec);
|
|
83301
|
+
const refId = args.refId;
|
|
83302
|
+
const action = args.action;
|
|
83303
|
+
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") + "\"");
|
|
83304
|
+
const sessionId = route.sessionId;
|
|
83305
|
+
if (sessionId === void 0) return failure(action, refId, "", "NO_SESSION", "the current session could not be resolved, so the handle cannot be checked");
|
|
83306
|
+
const store = deps.store();
|
|
83307
|
+
if (store === void 0) return failure(action, refId, sessionId, "NO_STORE", "the result store is unavailable on this host");
|
|
83308
|
+
if (action === "release") {
|
|
83309
|
+
let released = false;
|
|
83310
|
+
try {
|
|
83311
|
+
const raw = await store.release({
|
|
83312
|
+
sessionId,
|
|
83313
|
+
refId
|
|
83314
|
+
});
|
|
83315
|
+
released = booleanOf$1(raw) ?? booleanOf$1(raw?.released) ?? false;
|
|
83316
|
+
} catch (error) {
|
|
83317
|
+
return failure(action, refId, sessionId, errorCodeOf(error, "STORE_FAILED"), "releasing the stored copy failed: " + describe(error));
|
|
83318
|
+
}
|
|
83319
|
+
log({
|
|
83320
|
+
event: "ref-release",
|
|
83321
|
+
session: sessionId,
|
|
83322
|
+
refId,
|
|
83323
|
+
released
|
|
83324
|
+
});
|
|
83325
|
+
return {
|
|
83326
|
+
ok: true,
|
|
83327
|
+
action,
|
|
83328
|
+
refId,
|
|
83329
|
+
sessionId,
|
|
83330
|
+
released
|
|
83331
|
+
};
|
|
83332
|
+
}
|
|
83333
|
+
for (const [name, value] of [
|
|
83334
|
+
["offset", args.offset],
|
|
83335
|
+
["limit", args.limit],
|
|
83336
|
+
["maxBytes", args.maxBytes]
|
|
83337
|
+
]) if (value !== void 0 && !isCount(value)) return failure(action, refId, sessionId, "BAD_ARGUMENT", name + " must be a non-negative integer");
|
|
83338
|
+
const request = {
|
|
83339
|
+
sessionId,
|
|
83340
|
+
refId
|
|
83341
|
+
};
|
|
83342
|
+
if (args.offset !== void 0) request.offset = args.offset;
|
|
83343
|
+
if (args.limit !== void 0) request.limit = args.limit;
|
|
83344
|
+
if (args.maxBytes !== void 0) request.maxBytes = args.maxBytes;
|
|
83345
|
+
if (args.fields !== void 0) request.fields = args.fields;
|
|
83346
|
+
if (args.equals !== void 0) request.equals = args.equals;
|
|
83347
|
+
let raw;
|
|
83348
|
+
try {
|
|
83349
|
+
raw = await store.page(request);
|
|
83350
|
+
} catch (error) {
|
|
83351
|
+
return failure(action, refId, sessionId, errorCodeOf(error, "STORE_FAILED"), "reading the stored copy failed: " + describe(error));
|
|
83352
|
+
}
|
|
83353
|
+
const value = pageValue(raw, refId, sessionId);
|
|
83354
|
+
log({
|
|
83355
|
+
event: "ref-page",
|
|
83356
|
+
session: sessionId,
|
|
83357
|
+
refId,
|
|
83358
|
+
offset: value.offset ?? args.offset ?? 0,
|
|
83359
|
+
returnedChars: value.chars ?? 0,
|
|
83360
|
+
complete: value.complete === true,
|
|
83361
|
+
...value.nextOffset === void 0 ? {} : { nextOffset: value.nextOffset }
|
|
83362
|
+
});
|
|
83363
|
+
return value;
|
|
83364
|
+
}
|
|
83365
|
+
});
|
|
83366
|
+
}
|
|
83367
|
+
//#endregion
|
|
83368
|
+
//#region src/host/cache-warmup/install.ts
|
|
83369
|
+
/**
|
|
83370
|
+
* 本模块服务的线路。
|
|
83371
|
+
*
|
|
83372
|
+
* 结果投影的正确性依赖这条线路的缓存语义(内容前缀命中),别的线路上把结果换成
|
|
83373
|
+
* 预览只有坏处没有好处,所以这里写死一个 provider 名而不是做成开关。
|
|
83374
|
+
*/
|
|
83375
|
+
const CACHE_WARMUP_PROVIDER = "kimi-code";
|
|
83376
|
+
/**
|
|
83377
|
+
* 允许进入结果投影的工具集合。
|
|
83378
|
+
*
|
|
83379
|
+
* 除了 PTC 的 `run_code`,宿主的独立 Shell 与后台任务输出工具(`win_bash`、
|
|
83380
|
+
* `bash`、`pwsh`、`job_output`)同样经常吐出整屏测试/构建日志。源码读取/编辑类
|
|
83381
|
+
* 工具(`read`、`edit`、`grep`、`glob`、CodeGraph 等)不在此列,保持 100% 原样。
|
|
83382
|
+
*/
|
|
83383
|
+
const CACHE_WARMUP_SOURCE_TOOLS = /* @__PURE__ */ new Set([
|
|
83384
|
+
"run_code",
|
|
83385
|
+
"win_bash",
|
|
83386
|
+
"bash",
|
|
83387
|
+
"pwsh",
|
|
83388
|
+
"job_output"
|
|
83389
|
+
]);
|
|
83390
|
+
/** 提示词分节名,全局唯一(重复注册会抛错)。 */
|
|
83391
|
+
const CACHE_WARMUP_SECTION_NAME = "plugin:cache-warmup";
|
|
83392
|
+
/**
|
|
83393
|
+
* 一段**恒定**的引导词:告诉模型预览是什么、怎么补读。
|
|
83394
|
+
*
|
|
83395
|
+
* 文本与开关无关,这是刻意的——系统提示是缓存前缀的第一段,会话中途改一个字就
|
|
83396
|
+
* 让整段前缀失效、整段重新计费。开关的语义只交给 post-execute 与工具。
|
|
83397
|
+
*/
|
|
83398
|
+
const CACHE_WARMUP_GUIDANCE = [
|
|
83399
|
+
"## Result previews",
|
|
83400
|
+
"",
|
|
83401
|
+
"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.",
|
|
83402
|
+
"",
|
|
83403
|
+
"- 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.",
|
|
83404
|
+
"- To read the original, call `cache_warmup_result` with `action: \"page\"` and that `refId`, then follow `nextOffset` until `complete` is true.",
|
|
83405
|
+
"- `action: \"release\"` drops the stored copy. The preview already in the conversation stays identical; it simply can no longer be read back.",
|
|
83406
|
+
"- Source code, type and contract definitions, mixed evidence and anything that could not be parsed reliably are never replaced: they arrive in full."
|
|
83407
|
+
].join("\n");
|
|
83408
|
+
/**
|
|
83409
|
+
* 静态引导词的文本提供者。
|
|
83410
|
+
* @returns 一个永远返回同一段文本的函数(系统提示的稳定前缀)。
|
|
83411
|
+
*/
|
|
83412
|
+
function cacheWarmupSectionText() {
|
|
83413
|
+
return () => CACHE_WARMUP_GUIDANCE;
|
|
83414
|
+
}
|
|
83415
|
+
/**
|
|
83416
|
+
* 读一次工具调用所属的线路。
|
|
83417
|
+
*
|
|
83418
|
+
* provider 先问会话的 request header(那是本轮请求真正走的那条线),再退回
|
|
83419
|
+
* agent 自己的 options。两处都没有就返回空对象——调用方据此放手,而不是猜一个。
|
|
83420
|
+
* @param agent - 事件里的调用方 agent。
|
|
83421
|
+
* @returns 线路信息;读不到的字段省略。
|
|
83422
|
+
*/
|
|
83423
|
+
function cacheWarmupRouteOf(agent) {
|
|
83424
|
+
const record = typeof agent === "object" && agent !== null ? agent : void 0;
|
|
83425
|
+
const session = record?.session;
|
|
83426
|
+
let provider;
|
|
83427
|
+
try {
|
|
83428
|
+
provider = stringOf(session?.requestHeader?.()?.config?.provider);
|
|
83429
|
+
} catch {
|
|
83430
|
+
provider = void 0;
|
|
83431
|
+
}
|
|
83432
|
+
if (provider === void 0) provider = stringOf(record?.options?.provider);
|
|
83433
|
+
const sessionId = stringOf(session?.header?.id) ?? stringOf(record?.id);
|
|
83434
|
+
return {
|
|
83435
|
+
...provider === void 0 ? {} : { provider },
|
|
83436
|
+
...sessionId === void 0 ? {} : { sessionId }
|
|
83437
|
+
};
|
|
83438
|
+
}
|
|
83439
|
+
/**
|
|
83440
|
+
* 预热窗口结束时,本次算「稳住了」还是「掉了」。
|
|
83441
|
+
*
|
|
83442
|
+
* 阈值取核心契约里的 `consecutiveHitsRequired`(连续高命中率报告条数),而不是在
|
|
83443
|
+
* 这里另编一个命中率——判定标准只有核心一份,日志只如实记录它当时的结论。
|
|
83444
|
+
* @param consecutiveHits - 退出时的连续命中条数;读不到时 undefined。
|
|
83445
|
+
* @returns 稳定的退出原因。
|
|
83446
|
+
*/
|
|
83447
|
+
function exitReasonOf(consecutiveHits) {
|
|
83448
|
+
return consecutiveHits !== void 0 && consecutiveHits >= CACHE_WARMUP_DEFAULTS.consecutiveHitsRequired ? "settled" : "dropped";
|
|
83449
|
+
}
|
|
83450
|
+
/** 单实例登记表,走一个 global symbol(与进展更新模块共用一个表)。 */
|
|
83451
|
+
const MOUNTED = Symbol.for("dsh-chatgpt-subscription.mounted-modules");
|
|
83452
|
+
/** 本模块在登记表里的名字。 */
|
|
83453
|
+
const MODULE_ID = "cache-warmup";
|
|
83454
|
+
/**
|
|
83455
|
+
* 读一个未知值为非空字符串。
|
|
83456
|
+
* @param value - 候选值。
|
|
83457
|
+
* @returns 非空字符串,其它类型的 undefined。
|
|
83458
|
+
*/
|
|
83459
|
+
function stringOf(value) {
|
|
83460
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
83461
|
+
}
|
|
83462
|
+
/**
|
|
83463
|
+
* 读一个未知值为有限数字。
|
|
83464
|
+
* @param value - 候选值。
|
|
83465
|
+
* @returns 有限数字,其它类型的 undefined。
|
|
83466
|
+
*/
|
|
83467
|
+
function numberOf(value) {
|
|
83468
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
83469
|
+
}
|
|
83470
|
+
/**
|
|
83471
|
+
* 读一个未知值为布尔。
|
|
83472
|
+
* @param value - 候选值。
|
|
83473
|
+
* @returns 布尔,其它类型的 undefined。
|
|
83474
|
+
*/
|
|
83475
|
+
function booleanOf(value) {
|
|
83476
|
+
return typeof value === "boolean" ? value : void 0;
|
|
83477
|
+
}
|
|
83478
|
+
/**
|
|
83479
|
+
* 单实例守卫:本模块已挂载时放弃本次安装。
|
|
83480
|
+
* @param ctx - 宿主上下文。
|
|
83481
|
+
* @returns 释放函数(交出挂载权);已经挂载过时返回 undefined。
|
|
83482
|
+
*/
|
|
83483
|
+
function claimMount(ctx) {
|
|
83484
|
+
const registry = globalThis;
|
|
83485
|
+
const existing = registry[MOUNTED];
|
|
83486
|
+
const mounted = existing instanceof Set ? existing : /* @__PURE__ */ new Set();
|
|
83487
|
+
if (existing !== mounted) registry[MOUNTED] = mounted;
|
|
83488
|
+
if (mounted.has(MODULE_ID)) return void 0;
|
|
83489
|
+
mounted.add(MODULE_ID);
|
|
83490
|
+
const release = () => {
|
|
83491
|
+
mounted.delete(MODULE_ID);
|
|
83492
|
+
};
|
|
83493
|
+
if (typeof ctx.effect === "function") ctx.effect(() => release, "dsh-chatgpt-subscription: cache warmup mount");
|
|
83494
|
+
return release;
|
|
83495
|
+
}
|
|
83496
|
+
/**
|
|
83497
|
+
* 读一次会话级指标快照。
|
|
83498
|
+
*
|
|
83499
|
+
* 形状按共享约定**结构化**推断,任何字段缺失都不影响日志;读不出来就返回
|
|
83500
|
+
* undefined,让调用方退回预算对象上的那一份。
|
|
83501
|
+
* @param sessionId - 会话标识。
|
|
83502
|
+
* @returns 快照,或读不到时的 undefined。
|
|
83503
|
+
*/
|
|
83504
|
+
function readCacheWarmupSnapshot(sessionId) {
|
|
83505
|
+
try {
|
|
83506
|
+
const raw = cacheWarmupSnapshot(sessionId);
|
|
83507
|
+
return typeof raw === "object" && raw !== null ? raw : void 0;
|
|
83508
|
+
} catch {
|
|
83509
|
+
return;
|
|
83510
|
+
}
|
|
83511
|
+
}
|
|
83512
|
+
/**
|
|
83513
|
+
* 挂载「缓存预热」的宿主半边。
|
|
83514
|
+
* @param ctx - 宿主插件上下文。
|
|
83515
|
+
* @param options - 挂载选项(设置读取器、可选的存储工厂)。
|
|
83516
|
+
* @returns 卸载函数;重复挂载时返回一个空操作。
|
|
83517
|
+
*/
|
|
83518
|
+
function installCacheWarmup(ctx, options) {
|
|
83519
|
+
const release = claimMount(ctx);
|
|
83520
|
+
if (release === void 0) return () => void 0;
|
|
83521
|
+
/** 每次调用重新读服务:宿主树可能在本模块挂载之后才 settle,读一次存下来会漏。 */
|
|
83522
|
+
const serviceOf = (name) => {
|
|
83523
|
+
const read = ctx.get;
|
|
83524
|
+
if (typeof read !== "function") return void 0;
|
|
83525
|
+
try {
|
|
83526
|
+
return read.call(ctx, name);
|
|
83527
|
+
} catch {
|
|
83528
|
+
return;
|
|
83529
|
+
}
|
|
83530
|
+
};
|
|
83531
|
+
/** 登记一个副作用;上下文没有 effect 时立即执行(测试用假上下文)。 */
|
|
83532
|
+
const track = (execute, label) => {
|
|
83533
|
+
if (typeof ctx.effect === "function") ctx.effect(execute, label);
|
|
83534
|
+
else execute();
|
|
83535
|
+
};
|
|
83536
|
+
/** 日志出口(每次现读,logger 可能晚于本模块挂载)。 */
|
|
83537
|
+
const loggerOf = () => ctx.logger ?? serviceOf("logger");
|
|
83538
|
+
/** 发一条指标日志。 */
|
|
83539
|
+
const log = (event) => {
|
|
83540
|
+
logCacheWarmup(loggerOf(), event);
|
|
83541
|
+
};
|
|
83542
|
+
/** 惰性构造出来的存储(注入优先)。 */
|
|
83543
|
+
let ownedStore;
|
|
83544
|
+
/**
|
|
83545
|
+
* 取结果引用存储。
|
|
83546
|
+
*
|
|
83547
|
+
* 构造失败只意味着「这台主机上没有引用存储」:此时投影不生效(原样返回),
|
|
83548
|
+
* `cache_warmup_result` 返回结构化错误。都不是一次安装失败。
|
|
83549
|
+
* @returns 存储,或拿不到时的 undefined。
|
|
83550
|
+
*/
|
|
83551
|
+
const storeOf = () => {
|
|
83552
|
+
if (options.store !== void 0) try {
|
|
83553
|
+
return options.store();
|
|
83554
|
+
} catch (error) {
|
|
83555
|
+
loggerOf()?.warn?.("[dsh-chatgpt-subscription] cache warmup store injection failed: " + cacheWarmupErrorLabel(error));
|
|
83556
|
+
return;
|
|
83557
|
+
}
|
|
83558
|
+
if (ownedStore !== void 0) return ownedStore;
|
|
83559
|
+
try {
|
|
83560
|
+
ownedStore = new FileResultReferenceStore();
|
|
83561
|
+
} catch (error) {
|
|
83562
|
+
loggerOf()?.warn?.("[dsh-chatgpt-subscription] cache warmup store unavailable: " + cacheWarmupErrorLabel(error));
|
|
83563
|
+
return;
|
|
83564
|
+
}
|
|
83565
|
+
return ownedStore;
|
|
83566
|
+
};
|
|
83567
|
+
track(() => {
|
|
83568
|
+
const tools = serviceOf("tools");
|
|
83569
|
+
if (tools === void 0) return () => void 0;
|
|
83570
|
+
return tools.register(createCacheWarmupResultTool({
|
|
83571
|
+
provider: CACHE_WARMUP_PROVIDER,
|
|
83572
|
+
routeOf: (exec) => cacheWarmupRouteOf(exec?.agent),
|
|
83573
|
+
store: storeOf,
|
|
83574
|
+
log
|
|
83575
|
+
}));
|
|
83576
|
+
}, "dsh-chatgpt-subscription: cache warmup result tool");
|
|
83577
|
+
track(() => {
|
|
83578
|
+
const systemPrompt = serviceOf("systemPrompt");
|
|
83579
|
+
if (systemPrompt === void 0) return () => void 0;
|
|
83580
|
+
return systemPrompt.section({
|
|
83581
|
+
name: CACHE_WARMUP_SECTION_NAME,
|
|
83582
|
+
order: 225,
|
|
83583
|
+
text: cacheWarmupSectionText()
|
|
83584
|
+
});
|
|
83585
|
+
}, "dsh-chatgpt-subscription: cache warmup prompt section");
|
|
83586
|
+
/** 上一轮看到的预热状态,用来在窗口结束时发一条 `warmup-exit`。 */
|
|
83587
|
+
const warmupState = /* @__PURE__ */ new Map();
|
|
83588
|
+
/**
|
|
83589
|
+
* 记账并(必要时)发一条 `warmup-exit`。
|
|
83590
|
+
* @param sessionId - 会话标识。
|
|
83591
|
+
* @param budget - 本轮读到的预算对象。
|
|
83592
|
+
*/
|
|
83593
|
+
const trackWarmup = (sessionId, budget) => {
|
|
83594
|
+
const snapshot = readCacheWarmupSnapshot(sessionId);
|
|
83595
|
+
const warmup = booleanOf(snapshot?.warmup) ?? booleanOf(budget.warmup);
|
|
83596
|
+
const consecutiveHits = numberOf(snapshot?.consecutiveHits) ?? numberOf(budget.consecutiveHits);
|
|
83597
|
+
const hitRatio = numberOf(snapshot?.hitRatio) ?? numberOf(budget.hitRatio);
|
|
83598
|
+
if (warmupState.get(sessionId) === true && warmup === false) {
|
|
83599
|
+
const reason = exitReasonOf(consecutiveHits);
|
|
83600
|
+
log({
|
|
83601
|
+
event: "warmup-exit",
|
|
83602
|
+
session: sessionId,
|
|
83603
|
+
reason,
|
|
83604
|
+
...consecutiveHits === void 0 ? {} : { consecutiveHits },
|
|
83605
|
+
...hitRatio === void 0 ? {} : { hitRatio }
|
|
83606
|
+
});
|
|
83607
|
+
}
|
|
83608
|
+
if (warmup !== void 0) warmupState.set(sessionId, warmup);
|
|
83609
|
+
return {
|
|
83610
|
+
...warmup === void 0 ? {} : { warmup },
|
|
83611
|
+
...consecutiveHits === void 0 ? {} : { consecutiveHits },
|
|
83612
|
+
...hitRatio === void 0 ? {} : { hitRatio }
|
|
83613
|
+
};
|
|
83614
|
+
};
|
|
83615
|
+
/**
|
|
83616
|
+
* 一次工具结果的投影。
|
|
83617
|
+
*
|
|
83618
|
+
* 决策取舍写在代码里,因为它全是「什么时候**不动**」:
|
|
83619
|
+
* - 不是 `accept`(`block` 的 feedback 是别人的东西);
|
|
83620
|
+
* - 已经带 `value`(执行本地的 canonical 值,replay 与结构化消费方读它,
|
|
83621
|
+
* 替换掉等于把一份 JSON 换成字符串);
|
|
83622
|
+
* - 工具名不是 `run_code`、线路不是本线、会话拿不到;
|
|
83623
|
+
* - 没有存储、内容为空。
|
|
83624
|
+
* 上面任何一条成立就原样返回同一个决策对象。
|
|
83625
|
+
* @param exec - 工具执行上下文。
|
|
83626
|
+
* @param result - 归一化后的执行结果。
|
|
83627
|
+
* @param decision - `next()` 交回来的决策。
|
|
83628
|
+
* @returns 投影后的决策(形状不变),或原决策。
|
|
83629
|
+
*/
|
|
83630
|
+
const projectDecision = async (exec, result, decision) => {
|
|
83631
|
+
if (decision.kind !== "accept") return decision;
|
|
83632
|
+
if (decision.value !== void 0) return decision;
|
|
83633
|
+
if (!CACHE_WARMUP_SOURCE_TOOLS.has(exec.name)) return decision;
|
|
83634
|
+
if (!options.settings().enabled) return decision;
|
|
83635
|
+
const route = cacheWarmupRouteOf(exec.agent);
|
|
83636
|
+
if (route.provider !== "kimi-code") return decision;
|
|
83637
|
+
const sessionId = route.sessionId;
|
|
83638
|
+
if (sessionId === void 0) return decision;
|
|
83639
|
+
const store = storeOf();
|
|
83640
|
+
if (store === void 0) return decision;
|
|
83641
|
+
const blocks = decision.content ?? result.content;
|
|
83642
|
+
if (blocks.length === 0) return decision;
|
|
83643
|
+
const content = blocks;
|
|
83644
|
+
let durable;
|
|
83645
|
+
try {
|
|
83646
|
+
durable = options.durableHint?.(sessionId);
|
|
83647
|
+
} catch {
|
|
83648
|
+
durable = void 0;
|
|
83649
|
+
}
|
|
83650
|
+
const budget = durable === void 0 ? cacheWarmupBudget(sessionId) : cacheWarmupBudget(sessionId, Date.now(), durable);
|
|
83651
|
+
const projected = await projectToolResult({
|
|
83652
|
+
toolName: exec.name,
|
|
83653
|
+
provider: route.provider,
|
|
83654
|
+
sessionId,
|
|
83655
|
+
content,
|
|
83656
|
+
isError: result.isError === true,
|
|
83657
|
+
budget,
|
|
83658
|
+
store
|
|
83659
|
+
}).catch((error) => {
|
|
83660
|
+
loggerOf()?.warn?.("[dsh-chatgpt-subscription] cache warmup projection skipped: " + cacheWarmupErrorLabel(error));
|
|
83661
|
+
});
|
|
83662
|
+
if (projected === void 0) return decision;
|
|
83663
|
+
const metrics = trackWarmup(sessionId, budget);
|
|
83664
|
+
log({
|
|
83665
|
+
event: "projection",
|
|
83666
|
+
session: sessionId,
|
|
83667
|
+
strategy: String(projected.metrics.strategy),
|
|
83668
|
+
changed: projected.changed === true,
|
|
83669
|
+
inputChars: projected.metrics.inputChars,
|
|
83670
|
+
outputChars: projected.metrics.outputChars,
|
|
83671
|
+
omittedChars: projected.metrics.omittedChars,
|
|
83672
|
+
...metrics.warmup === void 0 ? {} : { warmup: metrics.warmup }
|
|
83673
|
+
});
|
|
83674
|
+
if (projected.changed !== true) return decision;
|
|
83675
|
+
return {
|
|
83676
|
+
kind: "accept",
|
|
83677
|
+
content: blocksOf(projected.content),
|
|
83678
|
+
...decision.additionalContexts === void 0 ? {} : { additionalContexts: decision.additionalContexts }
|
|
83679
|
+
};
|
|
83680
|
+
};
|
|
83681
|
+
const disposeListener = ctx.on?.("tools/post-execute", (async (exec, result, next) => {
|
|
83682
|
+
const decision = await next();
|
|
83683
|
+
return projectDecision(exec, result, decision);
|
|
83684
|
+
}));
|
|
83685
|
+
return () => {
|
|
83686
|
+
disposeListener?.();
|
|
83687
|
+
warmupState.clear();
|
|
83688
|
+
release();
|
|
83689
|
+
};
|
|
83690
|
+
}
|
|
83691
|
+
/**
|
|
83692
|
+
* 把投影结果的内容转回宿主的内容块数组。
|
|
83693
|
+
*
|
|
83694
|
+
* 核心的 `ProjectionResult.content` 允许纯文本(预览就是一段文本),也允许块数组
|
|
83695
|
+
* (只替换了其中一个文本块,非文本块原样保留)。宿主的 `PostToolDecision.content`
|
|
83696
|
+
* 只认块数组,所以纯文本在这里包成一个 text 块——包一层,不改字。
|
|
83697
|
+
* @param content - 核心交回来的内容。
|
|
83698
|
+
* @returns 宿主的块数组。
|
|
83699
|
+
*/
|
|
83700
|
+
function blocksOf(content) {
|
|
83701
|
+
if (typeof content === "string") return [{
|
|
83702
|
+
type: "text",
|
|
83703
|
+
text: content
|
|
83704
|
+
}];
|
|
83705
|
+
return content;
|
|
83706
|
+
}
|
|
83707
|
+
/**
|
|
83708
|
+
* 本文件刻意**不保留**任何"把异常压成一句话"的辅助函数。
|
|
83709
|
+
*
|
|
83710
|
+
* 这里曾经有一个 `describe(error)`,把 `error.message` 截断后拼进宿主日志。它看上去
|
|
83711
|
+
* 无害(只取第一行、还截了长度),实际上是一条原文出口:异常消息里常常带着绝对
|
|
83712
|
+
* 路径、命令行片段、凭据,或者被读取结果的一段正文。三处降级日志现在统一走
|
|
83713
|
+
* {@link cacheWarmupErrorLabel}——只输出稳定的 name/code。
|
|
83714
|
+
*/
|
|
83715
|
+
//#endregion
|
|
83716
|
+
//#region src/host/cache-warmup/settings.ts
|
|
83717
|
+
/**
|
|
83718
|
+
* 设置卡上这一行的说明文字。
|
|
83719
|
+
*
|
|
83720
|
+
* schema(`src/index.ts` 的 `Config`)与设置卡共用同一份文案:两处各写一份,
|
|
83721
|
+
* 迟早变成「卡片说只影响新结果、schema 说立刻生效」这种自相矛盾。
|
|
83722
|
+
*/
|
|
83723
|
+
const CACHE_WARMUP_SETTING_DESCRIPTION = "启用缓存预热的结果投影:Kimi 线路上可以可靠结构化提取的超大工具结果会被替换成带 ref:// 引用的预览,需要原文时由 cache_warmup_result 精确补读。关闭只影响之后产生的新结果,已进入历史的内容不变。";
|
|
83724
|
+
/**
|
|
83725
|
+
* 读取一个配置字段,沿着 volatile 活引用取当前值。
|
|
83726
|
+
* @param field - 字段本身。
|
|
83727
|
+
* @returns 当前值,或字段缺失时的 undefined。
|
|
83728
|
+
*/
|
|
83729
|
+
function readCacheWarmupConfigField(field) {
|
|
83730
|
+
if (field === void 0 || field === null) return void 0;
|
|
83731
|
+
if (typeof field === "object" && typeof field.get === "function") return field.get();
|
|
83732
|
+
return field;
|
|
83733
|
+
}
|
|
83734
|
+
/**
|
|
83735
|
+
* 解析一次读取的有效设置。
|
|
83736
|
+
*
|
|
83737
|
+
* 每次调用都重读活引用,因此设置保存后立刻生效(对**之后**的新结果生效;历史
|
|
83738
|
+
* 结果不重新投影,见模块说明)。
|
|
83739
|
+
* @param config - 主入口收到的插件配置(活引用或普通值)。
|
|
83740
|
+
* @returns 生效的开关。
|
|
83741
|
+
*/
|
|
83742
|
+
function resolveCacheWarmupSettings(config) {
|
|
83743
|
+
return { enabled: readCacheWarmupConfigField(config?.cacheWarmup) !== false };
|
|
83744
|
+
}
|
|
83745
|
+
//#endregion
|
|
80965
83746
|
//#region src/index.ts
|
|
80966
83747
|
const Config = z.object({
|
|
80967
83748
|
fetchProvider: z.union([
|
|
@@ -80985,6 +83766,7 @@ const Config = z.object({
|
|
|
80985
83766
|
z.const("zh"),
|
|
80986
83767
|
z.const("en")
|
|
80987
83768
|
]).default("auto").volatile().description("进展更新的输出语言:auto 跟随对话语言,zh 中文,en 英文。"),
|
|
83769
|
+
cacheWarmup: z.boolean().default(true).volatile().description(CACHE_WARMUP_SETTING_DESCRIPTION),
|
|
80988
83770
|
codexStreamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).step(1).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS$2),
|
|
80989
83771
|
poolStorage: z.union([z.const("keyring"), z.const("credentials")]).default("keyring").description("号池存储后端:keyring(默认,桌面平台的系统密钥环)或 credentials(DSH 凭据库,服务器推荐)。")
|
|
80990
83772
|
});
|
|
@@ -81102,6 +83884,19 @@ function apply(ctx, pluginConfig = {}) {
|
|
|
81102
83884
|
return dispose;
|
|
81103
83885
|
}, "dsh-chatgpt-subscription: no-answer resume");
|
|
81104
83886
|
}
|
|
83887
|
+
ctx.effect(() => installCacheWarmup(ctx, {
|
|
83888
|
+
settings: () => resolveCacheWarmupSettings(pluginConfig),
|
|
83889
|
+
durableHint: (sessionId) => {
|
|
83890
|
+
const record = sessionCacheState.read(sessionId);
|
|
83891
|
+
if (record === void 0) return void 0;
|
|
83892
|
+
const total = record.cachedTokens + record.freshTokens;
|
|
83893
|
+
return {
|
|
83894
|
+
lastActiveAt: record.lastActiveAt,
|
|
83895
|
+
consecutiveHits: record.warmupConsecutiveHits ?? 0,
|
|
83896
|
+
hitRatio: total > 0 ? record.cachedTokens / total : 0
|
|
83897
|
+
};
|
|
83898
|
+
}
|
|
83899
|
+
}), "dsh-chatgpt-subscription: cache warmup");
|
|
81105
83900
|
const probeEnvFile = relayProbeEnvFile();
|
|
81106
83901
|
if (relayProbeEnabled(process.env, { envFile: probeEnvFile })) {
|
|
81107
83902
|
const probePath = relayProbeLogPath(process.env, { envFile: probeEnvFile });
|