dsh-compact-relay 0.3.5 → 0.4.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.
@@ -6,6 +6,9 @@
6
6
  * thresholdRatio: 0.8(80% 触发)
7
7
  * 用户可以在 profile 的 `cordis.patch.yml` 里按 `- id: compaction-basic` 覆盖。
8
8
  *
9
+ * 0.4 P2-3:解析 `headroomTokens` 与 `modelPolicies`,供「真实触发线」计算
10
+ * (thresholdTokens = floor(min(W × ratio, W − 输出预留 − headroom)),见 core/native-line.js)。
11
+ *
9
12
  * 本模块只做两件事:
10
13
  * 1. 读当前��效值(用户覆盖 + 默认值),供指示灯/设置页显示;
11
14
  * 2. 写开关(关闭/恢复自动压缩)——追加或修改那一条,**写前备份**、原子替换。
@@ -17,6 +20,8 @@ import fs from 'node:fs';
17
20
  import os from 'node:os';
18
21
  import path from 'node:path';
19
22
 
23
+ import { NATIVE_HEADROOM_DEFAULT } from '../core/native-line.js';
24
+
20
25
  export const NATIVE_PLUGIN_ID = 'compaction-basic';
21
26
  export const NATIVE_PLUGIN_NAME = '@deepseek-ai/dsh-compaction-basic';
22
27
  /** DSH 默认值(compaction-basic 源码:auto ?? true、thresholdRatio ?? 0.8)。 */
@@ -41,39 +46,270 @@ export function resolvePatchFile(profileDir = resolveProfileDir()) {
41
46
  return path.join(profileDir, 'cordis.patch.yml');
42
47
  }
43
48
 
49
+ /* ------------------------------------------------------------------ *
50
+ * YAML 缩进层级工具(0.3.6 / BUG-012)
51
+ *
52
+ * 为什么不用「正则扫全文件」:cordis.patch.yml 是 YAML,
53
+ * compaction-basic 的配置结构是
54
+ *
55
+ * - id: compaction-basic
56
+ * config:
57
+ * auto: false
58
+ * thresholdRatio: 0.7
59
+ * modelPolicies: ← 嵌套层,不是这个插件的「全局线」
60
+ * mac-unc:
61
+ * thresholdRatio: 0.5
62
+ *
63
+ * 旧实现只按「下一个顶层 `-` 条目」截断,会把 modelPolicies 里的 0.5 当成全局
64
+ * thresholdRatio(Windows 真实会话 session-1f90f5ee,2026-10-23:指示灯误报 50%)。
65
+ * 现在统一按**缩进**定位三层:插件条目 → 它的 `config:` 直接子键 → 只认这一层;
66
+ * 更深的嵌套层(modelPolicies[*] 等)一律忽略。parse / apply 共用同一套定位。
67
+ * ------------------------------------------------------------------ */
68
+
69
+ const ENTRY_ID_RE = /^(\s*)-\s+id:\s*['"]?compaction-basic['"]?\s*(?:#.*)?$/;
70
+ const CONFIG_RE = /^(\s*)config\s*:\s*(?:#.*)?$/;
71
+ const AUTO_RE = /^auto\s*:\s*(true|false)\s*(?:#.*)?$/;
72
+ const RATIO_RE = /^thresholdRatio\s*:\s*['"]?([0-9.]+)['"]?\s*(?:#.*)?$/;
73
+ const HEADROOM_RE = /^headroomTokens\s*:\s*['"]?(\d+)['"]?\s*(?:#.*)?$/;
74
+ /** modelPolicies 列表项里的键(- provider: x / model: y / thresholdRatio: 0.5 / headroomTokens: 4096)。 */
75
+ const POLICY_PROVIDER_RE = /^provider\s*:\s*['"]?([^'"#\s]+)['"]?\s*(?:#.*)?$/;
76
+ const POLICY_MODEL_RE = /^model\s*:\s*['"]?([^'"#\s]+)['"]?\s*(?:#.*)?$/;
77
+ const POLICY_ITEM_RE = /^(\s*)-\s+(.*)$/;
78
+
79
+ /** 按行拆文本,保留原换行风格(CRLF 文件 apply 后仍是 CRLF)。 */
80
+ function splitText(text) {
81
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
82
+ return { lines: text.split(/\r?\n/), eol };
83
+ }
84
+
85
+ function indentOf(line) {
86
+ return /^(\s*)/.exec(line)[1].length;
87
+ }
88
+
89
+ /**
90
+ * 找第一个 compaction-basic 条目,返回 `{start, end, indent}`(end 为开区间)。
91
+ * 条目范围到「同缩进或更浅的下一个 - 列表项」为止;嵌套列表项缩进更深,不算结束。
92
+ */
93
+ function locateEntry(lines) {
94
+ for (let index = 0; index < lines.length; index += 1) {
95
+ const match = ENTRY_ID_RE.exec(lines[index]);
96
+ if (match === null) continue;
97
+ const indent = match[1].length;
98
+ let entryEnd = lines.length;
99
+ for (let next = index + 1; next < lines.length; next += 1) {
100
+ const line = lines[next];
101
+ if (line.trim().length === 0) continue;
102
+ if (/^\s*-\s/.test(line) && indentOf(line) <= indent) {
103
+ entryEnd = next;
104
+ break;
105
+ }
106
+ }
107
+ return { start: index, end: entryEnd, indent };
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * 在条目范围内找 `config:`(必须是条目的直接子键),并确定其直接子键缩进。
114
+ * @returns {{configIndex:number, configIndent:number, childIndent:number|null}|null}
115
+ * null = 条目里没有 config:;childIndent=null = config: 是空的/列表(没有直接子键)。
116
+ */
117
+ function locateConfig(lines, entry) {
118
+ for (let index = entry.start + 1; index < entry.end; index += 1) {
119
+ const match = CONFIG_RE.exec(lines[index]);
120
+ if (match === null) continue;
121
+ const configIndent = match[1].length;
122
+ if (configIndent <= entry.indent) continue; // 必须是条目的直接子键,不是更浅层级
123
+ let childIndent = null;
124
+ for (let next = index + 1; next < entry.end; next += 1) {
125
+ const line = lines[next];
126
+ if (line.trim().length === 0 || /^\s*#/.test(line)) continue;
127
+ const indent = indentOf(line);
128
+ if (indent <= configIndent) break; // config 结束(回到同级/更浅)
129
+ if (/^\s*-\s/.test(line)) break; // config 是列表,不是键值映射
130
+ childIndent = indent;
131
+ break;
132
+ }
133
+ return { configIndex: index, configIndent, childIndent };
134
+ }
135
+ return null;
136
+ }
137
+
138
+ /**
139
+ * 在 config 的**直接子键**里找匹配的行号;缩进更深(嵌套层)的行不认。
140
+ * @returns {number} 行号;没有返回 -1
141
+ */
142
+ function findDirectChild(lines, entry, config, keyRe) {
143
+ if (config === null || config.childIndent === null) return -1;
144
+ for (let index = config.configIndex + 1; index < entry.end; index += 1) {
145
+ const line = lines[index];
146
+ if (line.trim().length === 0 || /^\s*#/.test(line)) continue;
147
+ const indent = indentOf(line);
148
+ if (indent < config.childIndent) break; // 出了 config 块
149
+ if (indent !== config.childIndent) continue; // 更深 = 嵌套层,忽略
150
+ if (/^\s*-\s/.test(line)) continue;
151
+ if (keyRe.test(line.trim())) return index;
152
+ }
153
+ return -1;
154
+ }
155
+
44
156
  /**
45
157
  * 解析 patch 文本里 compaction-basic 条目的覆盖值。
158
+ *
159
+ * 只读 `config:` 的**直接子键** `auto` / `thresholdRatio`:
160
+ * - `config.modelPolicies[*]` 或任意更深嵌套层里的同名键不算全局线(BUG-012);
161
+ * - 没有 `config:` / config 为空 → `{found:true}`(found 只表示条目存在,读侧回落默认值);
162
+ * - 多个 compaction-basic 条目时取第一个。
163
+ *
46
164
  * @param {string} text
47
165
  * @returns {{found:boolean, autoEnabled?:boolean, thresholdRatio?:number}}
48
166
  */
49
167
  export function parseCompactionOverride(text) {
50
168
  if (typeof text !== 'string' || text.length === 0) return { found: false };
51
- const lines = text.split('\n');
52
- let start = -1;
53
- for (let index = 0; index < lines.length; index += 1) {
54
- if (/^\s*-\s+id:\s*['"]?compaction-basic['"]?\s*$/.test(lines[index])) {
55
- start = index;
56
- break;
57
- }
58
- }
59
- if (start < 0) return { found: false };
169
+ const { lines } = splitText(text);
170
+ const entry = locateEntry(lines);
171
+ if (entry === null) return { found: false };
172
+
60
173
  const override = { found: true };
61
- for (let index = start + 1; index < lines.length; index += 1) {
62
- const line = lines[index];
63
- if (/^-\s/.test(line)) break; // 下一个顶层条目
64
- const auto = /^\s*auto:\s*(true|false)\s*(?:#.*)?$/.exec(line);
65
- if (auto !== null) override.autoEnabled = auto[1] === 'true';
66
- const ratio = /^\s*thresholdRatio:\s*([0-9.]+)\s*(?:#.*)?$/.exec(line);
67
- if (ratio !== null) {
68
- const value = Number(ratio[1]);
174
+ const config = locateConfig(lines, entry);
175
+ if (config === null || config.childIndent === null) return override;
176
+
177
+ const autoIndex = findDirectChild(lines, entry, config, AUTO_RE);
178
+ if (autoIndex >= 0) {
179
+ const match = AUTO_RE.exec(lines[autoIndex].trim());
180
+ if (match !== null) override.autoEnabled = match[1] === 'true';
181
+ }
182
+ const ratioIndex = findDirectChild(lines, entry, config, RATIO_RE);
183
+ if (ratioIndex >= 0) {
184
+ const match = RATIO_RE.exec(lines[ratioIndex].trim());
185
+ if (match !== null) {
186
+ const value = Number(match[1]);
69
187
  if (Number.isFinite(value) && value > 0 && value <= 1) override.thresholdRatio = value;
70
188
  }
71
189
  }
190
+ // 0.4 P2-3:headroomTokens 也是 config 直属子键(全局余量,默认 65536)。
191
+ const headroomIndex = findDirectChild(lines, entry, config, HEADROOM_RE);
192
+ if (headroomIndex >= 0) {
193
+ const match = HEADROOM_RE.exec(lines[headroomIndex].trim());
194
+ if (match !== null) {
195
+ const value = Number(match[1]);
196
+ if (Number.isInteger(value) && value >= 0) override.headroomTokens = value;
197
+ }
198
+ }
72
199
  return override;
73
200
  }
74
201
 
202
+ /**
203
+ * 0.4 P2-3:解析 `config.modelPolicies`(嵌套层,按 provider+model 精确匹配的路由级覆盖)。
204
+ *
205
+ * 支持 DSH Loader 的两种 YAML 写法(Win 真实配置用的是列表写法):
206
+ * modelPolicies:
207
+ * - provider: mac-unc
208
+ * model: qwen35-unc-64k
209
+ * headroomTokens: 4096
210
+ * 或映射写法(key 当 model):
211
+ * modelPolicies:
212
+ * mac-unc:
213
+ * thresholdRatio: 0.5
214
+ *
215
+ * 只收 `thresholdRatio`(0–1)与 `headroomTokens`(≥0);其它键忽略。
216
+ * @param {string} text
217
+ * @returns {Array<{provider:string|null, model:string|null, thresholdRatio?:number, headroomTokens?:number}>}
218
+ */
219
+ export function parseModelPolicies(text) {
220
+ if (typeof text !== 'string' || text.length === 0) return [];
221
+ const { lines } = splitText(text);
222
+ const entry = locateEntry(lines);
223
+ if (entry === null) return [];
224
+ const config = locateConfig(lines, entry);
225
+ if (config === null || config.childIndent === null) return [];
226
+ const start = findDirectChild(lines, entry, config, /^modelPolicies\s*:\s*(?:#.*)?$/);
227
+ if (start < 0) return [];
228
+ const blockIndent = indentOf(lines[start]);
229
+ const policies = [];
230
+ let current = null;
231
+ const flush = () => {
232
+ if (current === null) return;
233
+ if (current.thresholdRatio !== undefined || current.headroomTokens !== undefined) policies.push(current);
234
+ current = null;
235
+ };
236
+ for (let index = start + 1; index < entry.end; index += 1) {
237
+ const line = lines[index];
238
+ if (line.trim().length === 0 || /^\s*#/.test(line)) continue;
239
+ const indent = indentOf(line);
240
+ if (indent <= blockIndent) break;
241
+ const item = POLICY_ITEM_RE.exec(line);
242
+ if (item !== null && indent > blockIndent) {
243
+ // 列表项:- provider: x 或 - model: y(同缩进的新项)
244
+ if (item[1].length <= blockIndent + 2) { flush(); current = { provider: null, model: null }; }
245
+ const rest = item[2].trim();
246
+ const pm = POLICY_PROVIDER_RE.exec(rest);
247
+ const mm = POLICY_MODEL_RE.exec(rest);
248
+ if (current === null) current = { provider: null, model: null };
249
+ if (pm !== null) current.provider = pm[1];
250
+ else if (mm !== null) current.model = mm[1];
251
+ continue;
252
+ }
253
+ const trimmed = line.trim();
254
+ const ratioMatch = RATIO_RE.exec(trimmed);
255
+ const headMatch = HEADROOM_RE.exec(trimmed);
256
+ const providerMatch = POLICY_PROVIDER_RE.exec(trimmed);
257
+ const modelMatch = POLICY_MODEL_RE.exec(trimmed);
258
+ if (ratioMatch !== null || headMatch !== null || providerMatch !== null || modelMatch !== null) {
259
+ if (current === null) current = { provider: null, model: null };
260
+ if (ratioMatch !== null) {
261
+ const value = Number(ratioMatch[1]);
262
+ if (Number.isFinite(value) && value > 0 && value <= 1) current.thresholdRatio = value;
263
+ } else if (headMatch !== null) {
264
+ const value = Number(headMatch[1]);
265
+ if (Number.isInteger(value) && value >= 0) current.headroomTokens = value;
266
+ } else if (providerMatch !== null) current.provider = providerMatch[1];
267
+ else current.model = modelMatch[1];
268
+ continue;
269
+ }
270
+ // 映射写法:更深一层的 key 行当 model,其下缩进再深的键值由后续循环计入(current 不变)。
271
+ const keyMatch = /^([A-Za-z0-9._@/-]+)\s*:\s*(?:#.*)?$/.exec(trimmed);
272
+ if (keyMatch !== null && indent === blockIndent + 2 && current === null) {
273
+ current = { provider: null, model: keyMatch[1] };
274
+ }
275
+ }
276
+ flush();
277
+ return policies;
278
+ }
279
+
280
+ /**
281
+ * 0.4 P2-3:按路由取生效的 compaction-basic 策略(全局值 + 精确 provider/model 覆盖)。
282
+ * @param {{thresholdRatio:number, headroomTokens:number, modelPolicies?:Array}} nativeCfg readNativeCompaction() 结果
283
+ * @param {{provider:string, model:string}|null} route
284
+ * @returns {{thresholdRatio:number, headroomTokens:number, overridden:boolean}}
285
+ */
286
+ export function resolveNativePolicy(nativeCfg, route) {
287
+ const globalRatio = Number.isFinite(nativeCfg?.thresholdRatio) ? nativeCfg.thresholdRatio : NATIVE_DEFAULTS.thresholdRatio;
288
+ const globalHeadroom = Number.isFinite(nativeCfg?.headroomTokens) ? nativeCfg.headroomTokens : NATIVE_HEADROOM_DEFAULT;
289
+ if (route === null || route === undefined || typeof route.provider !== 'string' || typeof route.model !== 'string') {
290
+ return { thresholdRatio: globalRatio, headroomTokens: globalHeadroom, overridden: false };
291
+ }
292
+ const list = Array.isArray(nativeCfg?.modelPolicies) ? nativeCfg.modelPolicies : [];
293
+ const hit = list.find(
294
+ (policy) =>
295
+ policy !== null &&
296
+ typeof policy === 'object' &&
297
+ (policy.model === route.model || policy.model === `${route.provider}/${route.model}` || policy.model === null || policy.model === undefined) &&
298
+ (policy.provider === undefined || policy.provider === null || policy.provider === route.provider),
299
+ );
300
+ if (hit === undefined) return { thresholdRatio: globalRatio, headroomTokens: globalHeadroom, overridden: false };
301
+ return {
302
+ thresholdRatio: Number.isFinite(hit.thresholdRatio) ? hit.thresholdRatio : globalRatio,
303
+ headroomTokens: Number.isFinite(hit.headroomTokens) ? hit.headroomTokens : globalHeadroom,
304
+ overridden: true,
305
+ };
306
+ }
307
+
75
308
  /**
76
309
  * 读当前生效的原生压缩配置(用户覆盖 + 默认值)。带 mtime 缓存,避免每帧 stat 读文件。
310
+ *
311
+ * 0.3.6(BUG-012):`override` 只包含 config 直属子键(parse 的缩进作用域),
312
+ * 嵌套 modelPolicies 里的 thresholdRatio 不再影响全局线。
77
313
  * @param {string} [profileDir]
78
314
  * @returns {{autoEnabled:boolean, thresholdRatio:number, override:object, file:string}}
79
315
  */
@@ -93,6 +329,9 @@ export function readNativeCompaction(profileDir = resolveProfileDir()) {
93
329
  const value = {
94
330
  autoEnabled: override.autoEnabled ?? NATIVE_DEFAULTS.autoEnabled,
95
331
  thresholdRatio: override.thresholdRatio ?? NATIVE_DEFAULTS.thresholdRatio,
332
+ // 0.4 P2-3:真实触发线要用的余量与路由级覆盖(additive 字段,老调用方不受影响)。
333
+ headroomTokens: override.headroomTokens ?? NATIVE_HEADROOM_DEFAULT,
334
+ modelPolicies: parseModelPolicies(text),
96
335
  override,
97
336
  file,
98
337
  };
@@ -101,57 +340,55 @@ export function readNativeCompaction(profileDir = resolveProfileDir()) {
101
340
  return value;
102
341
  }
103
342
 
104
- /** 把 compaction-basic 条目改成指定 auto 值;返回新文本。纯函数,便于单测。 */
343
+ /**
344
+ * 把 compaction-basic 条目改成指定 auto 值;返回新文本。纯函数,便于单测。
345
+ *
346
+ * 写入口径与解析一致:只写 `config:` 的**直接子键**(不写进 modelPolicies 等嵌套层):
347
+ * - 已有直属 `auto:` → 原地改值(保留缩进;行尾注释与旧实现一样会被丢弃);
348
+ * - 没有直属 auto → 插到 config 直接子键区的第一行(缩进取已有子键缩进);
349
+ * - 条目没有 config: → 补 `config:` + 直属 auto;
350
+ * - 没有 compaction-basic 条目 → 追加完整条目(与旧行为一致)。
351
+ * CRLF 输入保持 CRLF。
352
+ */
105
353
  export function applyAutoOverride(text, enabled) {
106
354
  const source = typeof text === 'string' ? text : '';
107
- const lines = source.split('\n');
355
+ const { lines, eol } = splitText(source);
108
356
  const flag = enabled ? 'true' : 'false';
109
- let start = -1;
110
- for (let index = 0; index < lines.length; index += 1) {
111
- if (/^\s*-\s+id:\s*['"]?compaction-basic['"]?\s*$/.test(lines[index])) {
112
- start = index;
113
- break;
114
- }
357
+ const entry = locateEntry(lines);
358
+
359
+ if (entry === null) {
360
+ const base = source.length === 0 || /(?:\r?\n)$/.test(source) ? source : `${source}${eol}`;
361
+ const timestamp = new Date().toISOString().slice(0, 10);
362
+ return (
363
+ `${base}# 由 dsh-compact-relay 追加(${timestamp}):${enabled ? '恢复' : '关闭'} DSH 原生自动压缩(重启 DSH 生效)${eol}` +
364
+ `- id: ${NATIVE_PLUGIN_ID}${eol}` +
365
+ ` name: '${NATIVE_PLUGIN_NAME}'${eol}` +
366
+ ` config:${eol}` +
367
+ ` auto: ${flag}${eol}`
368
+ );
115
369
  }
116
370
 
117
- if (start >= 0) {
118
- // 已有条目:改 auto 行;没有就在 config: 下插入(缩进按 config 行 + 2)。
119
- let end = lines.length;
120
- for (let index = start + 1; index < lines.length; index += 1) {
121
- if (/^-\s/.test(lines[index])) {
122
- end = index;
123
- break;
124
- }
125
- }
126
- const block = lines.slice(start, end);
127
- let autoIndex = -1;
128
- let configIndex = -1;
129
- for (let offset = 0; offset < block.length; offset += 1) {
130
- if (/^\s*auto:\s*(true|false)\s*(?:#.*)?$/.test(block[offset])) autoIndex = offset;
131
- if (/^\s*config:\s*$/.test(block[offset])) configIndex = offset;
132
- }
371
+ const config = locateConfig(lines, entry);
372
+ if (config !== null) {
373
+ const autoIndex = findDirectChild(lines, entry, config, AUTO_RE);
133
374
  if (autoIndex >= 0) {
134
- const indent = /^(\s*)/.exec(block[autoIndex])[1];
135
- block[autoIndex] = `${indent}auto: ${flag}`;
136
- } else if (configIndex >= 0) {
137
- const indent = `${/^(\s*)/.exec(block[configIndex])[1]} `;
138
- block.splice(configIndex + 1, 0, `${indent}auto: ${flag}`);
375
+ lines[autoIndex] = `${' '.repeat(config.childIndent)}auto: ${flag}`;
139
376
  } else {
140
- block.splice(1, 0, ' config:', ` auto: ${flag}`);
377
+ const childIndent = config.childIndent === null ? config.configIndent + 2 : config.childIndent;
378
+ lines.splice(config.configIndex + 1, 0, `${' '.repeat(childIndent)}auto: ${flag}`);
141
379
  }
142
- return [...lines.slice(0, start), ...block, ...lines.slice(end)].join('\n');
380
+ return lines.join(eol);
143
381
  }
144
382
 
145
- // 没有条目:追加到末尾(顶层数组项)。
146
- const base = source.endsWith('\n') || source.length === 0 ? source : `${source}\n`;
147
- const timestamp = new Date().toISOString().slice(0, 10);
148
- return (
149
- `${base}# 由 dsh-compact-relay 追加(${timestamp}):${enabled ? '恢复' : '关闭'} DSH 原生自动压缩(重启 DSH 生效)\n` +
150
- `- id: ${NATIVE_PLUGIN_ID}\n` +
151
- ` name: '${NATIVE_PLUGIN_NAME}'\n` +
152
- ' config:\n' +
153
- ` auto: ${flag}\n`
383
+ // 条目存在但没有 config::补一个,auto 写在 config 的直接子键处(与解析口径一致)。
384
+ const configIndent = entry.indent + 2;
385
+ lines.splice(
386
+ entry.start + 1,
387
+ 0,
388
+ `${' '.repeat(configIndent)}config:`,
389
+ `${' '.repeat(configIndent + 2)}auto: ${flag}`,
154
390
  );
391
+ return lines.join(eol);
155
392
  }
156
393
 
157
394
  /**
@@ -7,12 +7,14 @@
7
7
  import { ratioOf } from '../core/policy.js';
8
8
  import { routeOf } from './route.js';
9
9
 
10
- /** provider/model → contextWindow 的进程内缓存(Promise,避免并发重复解析)。 */
10
+ /** provider/model → {window, defaultMaxTokens} 的进程内缓存(Promise,避免并发重复解析)。 */
11
11
  const windowCache = new Map();
12
12
 
13
- async function resolveWindow(ctx, session, agent, fallback) {
14
- const route = routeOf(ctx, session, agent);
15
- if (route === null) return fallback;
13
+ /**
14
+ * 解析路由模型的容量信息(contextWindow + 适配器默认输出上限)。
15
+ * 0.4 P2-3:输出预留要先看 requestHeader 的 maxTokens,其次才是这里的 defaultMaxTokens。
16
+ */
17
+ async function resolveModelMeta(ctx, route) {
16
18
  const key = `${route.provider}/${route.model}`;
17
19
  let pending = windowCache.get(key);
18
20
  if (pending === undefined) {
@@ -20,18 +22,68 @@ async function resolveWindow(ctx, session, agent, fallback) {
20
22
  try {
21
23
  const info = await ctx.llm.resolveModelInfo(route.provider, route.model);
22
24
  const window = info?.context?.contextWindow;
23
- return Number.isInteger(window) && window > 0 ? window : null;
25
+ const def = info?.defaultMaxTokens;
26
+ return {
27
+ window: Number.isInteger(window) && window > 0 ? window : null,
28
+ defaultMaxTokens: Number.isInteger(def) && def >= 0 ? def : null,
29
+ };
24
30
  } catch {
25
- return null;
31
+ return { window: null, defaultMaxTokens: null };
26
32
  }
27
33
  })();
28
34
  windowCache.set(key, pending);
29
35
  }
30
- const window = await pending;
31
- // 失败(null)不留在缓存里:适配器还没就绪时第一次解析失败,不能永久用 fallback
36
+ const meta = await pending;
37
+ // 失败不留在缓存里:适配器还没就绪时第一次解析失败,不能永久用 fallback
32
38
  // 窗口算比例(2026-10-19 debug:指示灯百分比长期低估)。
33
- if (!(Number.isInteger(window) && window > 0)) windowCache.delete(key);
34
- return window ?? fallback;
39
+ if (meta.window === null) windowCache.delete(key);
40
+ return meta;
41
+ }
42
+
43
+ async function resolveWindow(ctx, session, agent, fallback) {
44
+ const route = routeOf(ctx, session, agent);
45
+ if (route === null) return fallback;
46
+ const meta = await resolveModelMeta(ctx, route);
47
+ return meta.window ?? fallback;
48
+ }
49
+
50
+ /**
51
+ * 0.4 P2-3:解析「真实触发线」需要的路由预算。
52
+ * - contextWindow:路由模型窗口(失败回落测量窗口);
53
+ * - reservedCompletionTokens = session.requestHeader().config.maxTokens ?? 适配器 defaultMaxTokens ?? 0
54
+ * (与 dsh-compaction-basic 的 reservedCompletionTokens 同一口径)。
55
+ * @param {object} ctx
56
+ * @param {object} session
57
+ * @param {object} [agent]
58
+ * @param {number} [fallbackWindow]
59
+ * @returns {Promise<{route:object|null, contextWindow:number|null, reservedCompletionTokens:number, defaultMaxTokens:number|null, requestMaxTokens:number|null}>}
60
+ */
61
+ export async function routeBudgetOf(ctx, session, agent, fallbackWindow) {
62
+ const route = routeOf(ctx, session, agent);
63
+ let meta = { window: null, defaultMaxTokens: null };
64
+ if (route !== null) {
65
+ try {
66
+ meta = await resolveModelMeta(ctx, route);
67
+ } catch {
68
+ /* ignore */
69
+ }
70
+ }
71
+ let headerMax = null;
72
+ try {
73
+ const value = session?.requestHeader?.()?.config?.maxTokens;
74
+ if (Number.isInteger(value) && value >= 0) headerMax = value;
75
+ } catch {
76
+ /* 老宿主没有 requestHeader 就按未知处理 */
77
+ }
78
+ const reserved = headerMax ?? meta.defaultMaxTokens ?? 0;
79
+ const window = meta.window ?? (Number.isFinite(fallbackWindow) && fallbackWindow > 0 ? fallbackWindow : null);
80
+ return {
81
+ route,
82
+ contextWindow: window,
83
+ reservedCompletionTokens: reserved,
84
+ defaultMaxTokens: meta.defaultMaxTokens,
85
+ requestMaxTokens: headerMax,
86
+ };
35
87
  }
36
88
 
37
89
  /**
package/lib/dsh/relay.js CHANGED
@@ -9,21 +9,38 @@ import { RELAY_RECORD_TYPE } from '../core/relay.js';
9
9
 
10
10
  /**
11
11
  * 合法化一条 relay 记录;不合法返回 null。
12
+ *
13
+ * 0.4 P2-2(接力链元数据补全):canonical 字段为 `parentId` / `childId` / `ordinal` / `at` / `atIso`;
14
+ * 同时保留 0.3 的旧字段 `parentSessionId` / `index`(旧记录只读兼容,新写入两者都带)。
15
+ *
12
16
  * @param {unknown} data
13
- * @returns {{parentSessionId:string, childId:string, index:number, path?:string, planId?:string|null, at:number, title?:string, baseTitle?:string}|null}
17
+ * @returns {{parentId:string, childId:string, ordinal:number, at:number, atIso:string|null, parentSessionId:string, index:number, path?:string, planId?:string|null, title?:string, baseTitle?:string}|null}
14
18
  */
15
19
  export function normalizeRelayRecord(data) {
16
20
  if (data === null || typeof data !== 'object') return null;
17
21
  if (typeof data.childId !== 'string' || data.childId.length === 0) return null;
18
- const index = Number.isInteger(data.index) && data.index > 0 ? data.index : null;
19
- if (index === null) return null;
22
+ const ordinal = Number.isInteger(data.ordinal) && data.ordinal > 0
23
+ ? data.ordinal
24
+ : Number.isInteger(data.index) && data.index > 0
25
+ ? data.index
26
+ : null;
27
+ if (ordinal === null) return null;
28
+ const parentId = typeof data.parentId === 'string'
29
+ ? data.parentId
30
+ : typeof data.parentSessionId === 'string'
31
+ ? data.parentSessionId
32
+ : '';
20
33
  return {
21
- parentSessionId: typeof data.parentSessionId === 'string' ? data.parentSessionId : '',
34
+ parentId,
22
35
  childId: data.childId,
23
- index,
36
+ ordinal,
37
+ at: Number.isFinite(data.at) ? data.at : 0,
38
+ atIso: typeof data.atIso === 'string' ? data.atIso : null,
39
+ // 0.3 旧字段兼容(测试/旧读法仍按这些名字取)。
40
+ parentSessionId: parentId,
41
+ index: ordinal,
24
42
  ...(typeof data.path === 'string' ? { path: data.path } : {}),
25
43
  planId: typeof data.planId === 'string' ? data.planId : null,
26
- at: Number.isFinite(data.at) ? data.at : 0,
27
44
  ...(typeof data.title === 'string' ? { title: data.title } : {}),
28
45
  ...(typeof data.baseTitle === 'string' ? { baseTitle: data.baseTitle } : {}),
29
46
  };
@@ -8,6 +8,30 @@ import { latestCompressHint } from '../core/compress-hint.js';
8
8
  import { latestPinnedSeqs } from '../core/pins.js';
9
9
  import { scanCompactionLog } from './compaction-pair.js';
10
10
 
11
+ /**
12
+ * 0.4 BUG-013:checkpoint 判定的默认实现(漏传第二个参数时也不再静默失效)。
13
+ *
14
+ * 背景:0.3.1 起 `lastCheckpointSurfaceIdx/Seq` 的第二个参数 `isCheckpointSource`
15
+ * 在部分调用点(如 actions/state.js)被漏传。`isCheckpointSource(source)` 对
16
+ * undefined 会抛 TypeError,而函数内 try/catch 把它吞成「不是 checkpoint」→ 永远
17
+ * 返回 -1 → `compressHint` 永远算「有效」(压缩/交接后旧断点不消失、还会抑制 cue)。
18
+ *
19
+ * 这里给默认值补上 engine 的实现(dsh→dsh 同层,不产生环;engine 不 import 本文件)。
20
+ * 但 engine 是全插件唯一 import `@deepseek-ai/*` 的模块:纯 node 单测
21
+ * (test/smoke.mjs / state-passive.mjs / handoff.mjs)不注册包解析钩子、加载不了
22
+ * 这些包。所以用受保护的动态 import 取默认实现:
23
+ * - 宿主环境:正常拿到 engine.isCompactCheckpointSource;
24
+ * - 纯 node 单测:加载失败则保持 null(函数里显式传入的 fake engine 照常优先)。
25
+ */
26
+ let defaultIsCheckpointSource = null;
27
+ try {
28
+ const loaded = await import('./engine.js');
29
+ const candidate = loaded?.default?.isCompactCheckpointSource;
30
+ if (typeof candidate === 'function') defaultIsCheckpointSource = candidate;
31
+ } catch {
32
+ defaultIsCheckpointSource = null;
33
+ }
34
+
11
35
  /** @returns {number[]} 当前 surface 的 event seq(升序) */
12
36
  export function surfaceSeqs(session) {
13
37
  const nodes = session?.surface?.nodes;
@@ -68,9 +92,13 @@ export function countSummaries(session) {
68
92
  /**
69
93
  * 最后一次 checkpoint 消息在 surface 中的下标;没有返回 -1。
70
94
  * @param {object} session
71
- * @param {(source: unknown) => boolean} isCheckpointSource 通常传 engine.isCompactCheckpointSource
95
+ * @param {(source: unknown) => boolean} [isCheckpointSource] 省略时用 engine.isCompactCheckpointSource
96
+ * @returns {number}
72
97
  */
73
- export function lastCheckpointSurfaceIdx(session, isCheckpointSource) {
98
+ export function lastCheckpointSurfaceIdx(session, isCheckpointSource = defaultIsCheckpointSource) {
99
+ // 既没显式传、默认实现也没加载到(纯 node 单测):按「没有 checkpoint」处理,
100
+ // 不再让 TypeError 在循环里被静默吞掉。
101
+ if (typeof isCheckpointSource !== 'function') return -1;
74
102
  const seqs = surfaceSeqs(session);
75
103
  for (let index = seqs.length - 1; index >= 0; index -= 1) {
76
104
  const event = typeof session.eventAt === 'function' ? session.eventAt(seqs[index]) : undefined;
@@ -119,10 +147,10 @@ export function livePinnedSeqs(session) {
119
147
  * 按下标切会把这段旧尾部又压一遍(2026-10-19 真机踩过:33 条旧消息被误压)。
120
148
  *
121
149
  * @param {object} session
122
- * @param {(source: unknown) => boolean} isCheckpointSource
150
+ * @param {(source: unknown) => boolean} [isCheckpointSource] 省略时用 engine.isCompactCheckpointSource
123
151
  * @returns {number|undefined}
124
152
  */
125
- export function lastCheckpointSurfaceSeq(session, isCheckpointSource) {
153
+ export function lastCheckpointSurfaceSeq(session, isCheckpointSource = defaultIsCheckpointSource) {
126
154
  const index = lastCheckpointSurfaceIdx(session, isCheckpointSource);
127
155
  if (index < 0) return undefined;
128
156
  return surfaceSeqs(session)[index];
@@ -81,7 +81,7 @@ export function handoffTitlePlanOf(ctx, session) {
81
81
  if (record !== null) {
82
82
  const fallback = splitHandoffTitle(sourceTitleOf(ctx, session));
83
83
  const baseTitle = typeof record.baseTitle === 'string' && record.baseTitle.length > 0 ? record.baseTitle : fallback.baseTitle;
84
- const index = record.index + 1;
84
+ const index = (Number.isInteger(record.ordinal) ? record.ordinal : record.index) + 1;
85
85
  return { index, baseTitle, title: relayTitleOf(index, baseTitle), viaRecord: true };
86
86
  }
87
87
  const split = splitHandoffTitle(sourceTitleOf(ctx, session));
package/lib/index.js CHANGED
@@ -12,7 +12,9 @@ import { registerHttp } from './adapters/http.js';
12
12
  import { registerCompressCue } from './adapters/prompt.js';
13
13
  import { registerTool } from './adapters/tool.js';
14
14
  import { createSettingsStore } from './actions/settings.js';
15
+ import { createDiagCounters } from './dsh/diag-counters.js';
15
16
  import engine from './dsh/engine.js';
17
+ import { createLedgerStore } from './dsh/ledger.js';
16
18
  import { createStateHub } from './dsh/state-hub.js';
17
19
 
18
20
  export const name = 'compact-relay';
@@ -53,6 +55,10 @@ export function apply(ctx, config) {
53
55
  lock: createLock(),
54
56
  // M6:进行中动作登记表(HTTP cancel 用),与 lock 的分工见 actions/inflight.js。
55
57
  inflight: createInflight(),
58
+ // 0.4 P0-2:诊断计数/耗时/错误码(纯内存,重启清零;见 dsh/diag-counters.js)。
59
+ diag: createDiagCounters(),
60
+ // 0.4 P1-3:轻账本(~/.dsh/compact-relay-ledger.jsonl;写失败静默降级,不影响动作)。
61
+ ledger: createLedgerStore(),
56
62
  engine,
57
63
  ...(log === undefined ? {} : { log }),
58
64
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-compact-relay",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "DeepSeek Harness 上下文指示器插件:提供容量监控、压缩预览与确认、九段式交接单生成及消息锁定功能。 · Context indicator for DSH: preview-then-confirm compaction, 9-section session handoff, and pinned messages.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -53,7 +53,7 @@
53
53
  "node": ">=22"
54
54
  },
55
55
  "scripts": {
56
- "test": "node scripts/build-client.mjs --check && node --check lib/client.js && node test/smoke.mjs && node test/handoff.mjs && node test/plugin-load.mjs && node test/contract.mjs && node test/compact-log.mjs && node test/compress-cue.mjs && node test/state-passive.mjs && node test/markdown-lite.mjs",
56
+ "test": "node scripts/build-client.mjs --check && node --check lib/client.js && node test/smoke.mjs && node test/handoff.mjs && node test/plugin-load.mjs && node test/contract.mjs && node test/compact-log.mjs && node test/compress-cue.mjs && node test/state-passive.mjs && node test/native-compaction.mjs && node test/markdown-lite.mjs && node test/diagnostics.mjs && node test/pin-contract.mjs && node test/ledger.mjs && node test/handoff-p2.mjs && node test/native-line.mjs",
57
57
  "test:e2e": "node test/e2e/smoke.mjs",
58
58
  "test:e2e:settings": "node test/e2e/settings-ui.mjs",
59
59
  "test:e2e:compact": "node test/e2e/real-compact.mjs"