dsh-compact-relay 0.3.5 → 0.3.6

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 CHANGED
@@ -4,6 +4,16 @@
4
4
 
5
5
  (暂无新改动)
6
6
 
7
+ ## 0.3.6(2026-10-24,原生压缩线解析作用域修复)
8
+
9
+ - **问题(BUG-012,外部用户报告,Windows / DSH 0.2.1-rc.2,真实会话 `session-1f90f5ee`,2026-10-23)**:指示灯「原生自动压缩线」(紫色刻度)从默认 80% 误显示成 **50%**。用户配置的 `compaction-basic` 块里只有 `config.modelPolicies`(`mac-unc` / `qwen35-unc-64k`)写了 `thresholdRatio: 0.5`,并没有改全局线。
10
+ - **根因**:`lib/dsh/native-compaction.js` 的 `parseCompactionOverride` 只按「下一个顶层 `-` 条目」截断,不区分 YAML 缩进层级 —— `config.modelPolicies[*].thresholdRatio` 被当成整个插件的全局线。
11
+ - **修复**:按**缩进**定位 `compaction-basic` 条目 → 它的 `config:` 直接子键,只认这一层的 `auto` / `thresholdRatio`;更深嵌套层(`modelPolicies[*]`、多层 map/list)一律忽略。`applyAutoOverride` 写入口径同步(只写 config 直接子键,不写进 `modelPolicies`)。
12
+ - **边界**:没有 `config:` / config 为空 → `found=true` 但无覆盖,读侧回落默认值(80%);多个 `compaction-basic` 条目取第一个;CRLF 文件解析与 apply 后都保持 CRLF;`#` 注释与引号值兼容现有写法。
13
+ - **范围**:本次只修解析作用域;「按当前模型 route 算真实触发线(deepseek-flash 67.8% / mac-unc 75%,而非窗口比例 80%)」留 0.4。
14
+ - **测试**:新增 `test/native-compaction.mjs`(① config 直属阈值生效 ② 嵌套 modelPolicies 被忽略并回落 0.8 ③ 嵌套+直属取直属 ④ 多层嵌套 ⑤ 无 config / 无条目 ⑥ CRLF ⑦ apply 后回读且不破坏 modelPolicies ⑧ 引号/注释),并入 `npm test`;修复前该测试在嵌套用例上失败(把 0.6 误读成全局线)。
15
+ - 测试基线:smoke 53/53、handoff 35/35、plugin-load 14/14、contract 17/17、compact-log PASS、compress-cue PASS、state-passive PASS、native-compaction PASS、markdown-lite PASS;client bundle `--check` 通过(148857 字符,本补丁不改 client)。
16
+
7
17
  ## 0.3.5(2026-10-24,压缩按钮误禁修复)
8
18
 
9
19
  - **问题(外部用户报告,0.3.3 / Windows;0.3.4 源码同样存在)**:会话用量 53.5%、插件建议压缩,但指示灯菜单「压缩旧会话」灰置,提示「暂无可压缩内容(会话太短,或近期会话需保留原文)」——把「没有活的 agent」误当成「没有可压缩内容」。
package/ROADMAP.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 压缩接力 · 目标与路线图
2
2
 
3
- > 版本基准:0.3.5(2026-10-24)|状态:0.3 已发布(npm + GitHub),0.3.5 补丁发布中
3
+ > 版本基准:0.3.6(2026-10-24)|状态:0.3 已发布(npm + GitHub),0.3.5 已发布、0.3.6 发布中
4
4
  > 维护方式:每次发布前更新"当前状态"与勾选进度;方向变化必须同时改本文件和 memory 项目页。
5
5
 
6
6
  ## 一、最终目标(North Star)
@@ -144,6 +144,7 @@
144
144
  - ✅ 0.3.1 已发布:npm `dsh-compact-relay@0.3.1` + GitHub tag/Release;0.3.2 补设置页链接与联系方式;0.3.3 完成 Windows 适配(Win11 + Node 24.22 全测试通过)。
145
145
  - ✅ 0.3.4 判断机制修复(BUG-010:`mark_compress_point` + systemPrompt 可压缩区间提示 + 状态缓存;427 会话数据佐证旧工具 0 调用)+ BUG-005/BUG-006 小 bug;npm latest 待本次发布。
146
146
  - ✅ 0.3.5 补丁:修复「无活 agent 时压缩按钮误禁 + 文案误导」(state 增加 `compactRangeReason='no_agent'`;客户端提示「会话未激活:发一条消息唤醒 agent 后可压 N 条」)。
147
+ - 🔧 0.3.6 修复中(BUG-012):原生压缩线解析只认 `config:` 直接子键(按缩进判断),修复 `modelPolicies` 嵌套 `thresholdRatio` 被误报为全局线(Windows 真实会话误显示 50%);真实触发线按模型 route 计算留 0.4。
147
148
  - ✅ 真实压缩 e2e:`test/e2e/real-compact.mjs`(真实模型 + 真实压缩事务 + `maxSummaries=1` 合并旧摘要分支)6 项断言通过。
148
149
  - ✅ P3-01~14(13 修/1 确认无需修)+ BUG-004 + P2-T1 已收口。
149
150
  - ⏳ 市场收录 PR:仓库满 1 天(2026-10-21 19:09 后)提,yml 与说明已备。
@@ -41,31 +41,139 @@ export function resolvePatchFile(profileDir = resolveProfileDir()) {
41
41
  return path.join(profileDir, 'cordis.patch.yml');
42
42
  }
43
43
 
44
+ /* ------------------------------------------------------------------ *
45
+ * YAML 缩进层级工具(0.3.6 / BUG-012)
46
+ *
47
+ * 为什么不用「正则扫全文件」:cordis.patch.yml 是 YAML,
48
+ * compaction-basic 的配置结构是
49
+ *
50
+ * - id: compaction-basic
51
+ * config:
52
+ * auto: false
53
+ * thresholdRatio: 0.7
54
+ * modelPolicies: ← 嵌套层,不是这个插件的「全局线」
55
+ * mac-unc:
56
+ * thresholdRatio: 0.5
57
+ *
58
+ * 旧实现只按「下一个顶层 `-` 条目」截断,会把 modelPolicies 里的 0.5 当成全局
59
+ * thresholdRatio(Windows 真实会话 session-1f90f5ee,2026-10-23:指示灯误报 50%)。
60
+ * 现在统一按**缩进**定位三层:插件条目 → 它的 `config:` 直接子键 → 只认这一层;
61
+ * 更深的嵌套层(modelPolicies[*] 等)一律忽略。parse / apply 共用同一套定位。
62
+ * ------------------------------------------------------------------ */
63
+
64
+ const ENTRY_ID_RE = /^(\s*)-\s+id:\s*['"]?compaction-basic['"]?\s*(?:#.*)?$/;
65
+ const CONFIG_RE = /^(\s*)config\s*:\s*(?:#.*)?$/;
66
+ const AUTO_RE = /^auto\s*:\s*(true|false)\s*(?:#.*)?$/;
67
+ const RATIO_RE = /^thresholdRatio\s*:\s*['"]?([0-9.]+)['"]?\s*(?:#.*)?$/;
68
+
69
+ /** 按行拆文本,保留原换行风格(CRLF 文件 apply 后仍是 CRLF)。 */
70
+ function splitText(text) {
71
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
72
+ return { lines: text.split(/\r?\n/), eol };
73
+ }
74
+
75
+ function indentOf(line) {
76
+ return /^(\s*)/.exec(line)[1].length;
77
+ }
78
+
79
+ /**
80
+ * 找第一个 compaction-basic 条目,返回 `{start, end, indent}`(end 为开区间)。
81
+ * 条目范围到「同缩进或更浅的下一个 - 列表项」为止;嵌套列表项缩进更深,不算结束。
82
+ */
83
+ function locateEntry(lines) {
84
+ for (let index = 0; index < lines.length; index += 1) {
85
+ const match = ENTRY_ID_RE.exec(lines[index]);
86
+ if (match === null) continue;
87
+ const indent = match[1].length;
88
+ let entryEnd = lines.length;
89
+ for (let next = index + 1; next < lines.length; next += 1) {
90
+ const line = lines[next];
91
+ if (line.trim().length === 0) continue;
92
+ if (/^\s*-\s/.test(line) && indentOf(line) <= indent) {
93
+ entryEnd = next;
94
+ break;
95
+ }
96
+ }
97
+ return { start: index, end: entryEnd, indent };
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * 在条目范围内找 `config:`(必须是条目的直接子键),并确定其直接子键缩进。
104
+ * @returns {{configIndex:number, configIndent:number, childIndent:number|null}|null}
105
+ * null = 条目里没有 config:;childIndent=null = config: 是空的/列表(没有直接子键)。
106
+ */
107
+ function locateConfig(lines, entry) {
108
+ for (let index = entry.start + 1; index < entry.end; index += 1) {
109
+ const match = CONFIG_RE.exec(lines[index]);
110
+ if (match === null) continue;
111
+ const configIndent = match[1].length;
112
+ if (configIndent <= entry.indent) continue; // 必须是条目的直接子键,不是更浅层级
113
+ let childIndent = null;
114
+ for (let next = index + 1; next < entry.end; next += 1) {
115
+ const line = lines[next];
116
+ if (line.trim().length === 0 || /^\s*#/.test(line)) continue;
117
+ const indent = indentOf(line);
118
+ if (indent <= configIndent) break; // config 结束(回到同级/更浅)
119
+ if (/^\s*-\s/.test(line)) break; // config 是列表,不是键值映射
120
+ childIndent = indent;
121
+ break;
122
+ }
123
+ return { configIndex: index, configIndent, childIndent };
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * 在 config 的**直接子键**里找匹配的行号;缩进更深(嵌套层)的行不认。
130
+ * @returns {number} 行号;没有返回 -1
131
+ */
132
+ function findDirectChild(lines, entry, config, keyRe) {
133
+ if (config === null || config.childIndent === null) return -1;
134
+ for (let index = config.configIndex + 1; index < entry.end; index += 1) {
135
+ const line = lines[index];
136
+ if (line.trim().length === 0 || /^\s*#/.test(line)) continue;
137
+ const indent = indentOf(line);
138
+ if (indent < config.childIndent) break; // 出了 config 块
139
+ if (indent !== config.childIndent) continue; // 更深 = 嵌套层,忽略
140
+ if (/^\s*-\s/.test(line)) continue;
141
+ if (keyRe.test(line.trim())) return index;
142
+ }
143
+ return -1;
144
+ }
145
+
44
146
  /**
45
147
  * 解析 patch 文本里 compaction-basic 条目的覆盖值。
148
+ *
149
+ * 只读 `config:` 的**直接子键** `auto` / `thresholdRatio`:
150
+ * - `config.modelPolicies[*]` 或任意更深嵌套层里的同名键不算全局线(BUG-012);
151
+ * - 没有 `config:` / config 为空 → `{found:true}`(found 只表示条目存在,读侧回落默认值);
152
+ * - 多个 compaction-basic 条目时取第一个。
153
+ *
46
154
  * @param {string} text
47
155
  * @returns {{found:boolean, autoEnabled?:boolean, thresholdRatio?:number}}
48
156
  */
49
157
  export function parseCompactionOverride(text) {
50
158
  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 };
159
+ const { lines } = splitText(text);
160
+ const entry = locateEntry(lines);
161
+ if (entry === null) return { found: false };
162
+
60
163
  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]);
164
+ const config = locateConfig(lines, entry);
165
+ if (config === null || config.childIndent === null) return override;
166
+
167
+ const autoIndex = findDirectChild(lines, entry, config, AUTO_RE);
168
+ if (autoIndex >= 0) {
169
+ const match = AUTO_RE.exec(lines[autoIndex].trim());
170
+ if (match !== null) override.autoEnabled = match[1] === 'true';
171
+ }
172
+ const ratioIndex = findDirectChild(lines, entry, config, RATIO_RE);
173
+ if (ratioIndex >= 0) {
174
+ const match = RATIO_RE.exec(lines[ratioIndex].trim());
175
+ if (match !== null) {
176
+ const value = Number(match[1]);
69
177
  if (Number.isFinite(value) && value > 0 && value <= 1) override.thresholdRatio = value;
70
178
  }
71
179
  }
@@ -74,6 +182,9 @@ export function parseCompactionOverride(text) {
74
182
 
75
183
  /**
76
184
  * 读当前生效的原生压缩配置(用户覆盖 + 默认值)。带 mtime 缓存,避免每帧 stat 读文件。
185
+ *
186
+ * 0.3.6(BUG-012):`override` 只包含 config 直属子键(parse 的缩进作用域),
187
+ * 嵌套 modelPolicies 里的 thresholdRatio 不再影响全局线。
77
188
  * @param {string} [profileDir]
78
189
  * @returns {{autoEnabled:boolean, thresholdRatio:number, override:object, file:string}}
79
190
  */
@@ -101,57 +212,55 @@ export function readNativeCompaction(profileDir = resolveProfileDir()) {
101
212
  return value;
102
213
  }
103
214
 
104
- /** 把 compaction-basic 条目改成指定 auto 值;返回新文本。纯函数,便于单测。 */
215
+ /**
216
+ * 把 compaction-basic 条目改成指定 auto 值;返回新文本。纯函数,便于单测。
217
+ *
218
+ * 写入口径与解析一致:只写 `config:` 的**直接子键**(不写进 modelPolicies 等嵌套层):
219
+ * - 已有直属 `auto:` → 原地改值(保留缩进;行尾注释与旧实现一样会被丢弃);
220
+ * - 没有直属 auto → 插到 config 直接子键区的第一行(缩进取已有子键缩进);
221
+ * - 条目没有 config: → 补 `config:` + 直属 auto;
222
+ * - 没有 compaction-basic 条目 → 追加完整条目(与旧行为一致)。
223
+ * CRLF 输入保持 CRLF。
224
+ */
105
225
  export function applyAutoOverride(text, enabled) {
106
226
  const source = typeof text === 'string' ? text : '';
107
- const lines = source.split('\n');
227
+ const { lines, eol } = splitText(source);
108
228
  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
- }
229
+ const entry = locateEntry(lines);
230
+
231
+ if (entry === null) {
232
+ const base = source.length === 0 || /(?:\r?\n)$/.test(source) ? source : `${source}${eol}`;
233
+ const timestamp = new Date().toISOString().slice(0, 10);
234
+ return (
235
+ `${base}# 由 dsh-compact-relay 追加(${timestamp}):${enabled ? '恢复' : '关闭'} DSH 原生自动压缩(重启 DSH 生效)${eol}` +
236
+ `- id: ${NATIVE_PLUGIN_ID}${eol}` +
237
+ ` name: '${NATIVE_PLUGIN_NAME}'${eol}` +
238
+ ` config:${eol}` +
239
+ ` auto: ${flag}${eol}`
240
+ );
115
241
  }
116
242
 
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
- }
243
+ const config = locateConfig(lines, entry);
244
+ if (config !== null) {
245
+ const autoIndex = findDirectChild(lines, entry, config, AUTO_RE);
133
246
  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}`);
247
+ lines[autoIndex] = `${' '.repeat(config.childIndent)}auto: ${flag}`;
139
248
  } else {
140
- block.splice(1, 0, ' config:', ` auto: ${flag}`);
249
+ const childIndent = config.childIndent === null ? config.configIndent + 2 : config.childIndent;
250
+ lines.splice(config.configIndex + 1, 0, `${' '.repeat(childIndent)}auto: ${flag}`);
141
251
  }
142
- return [...lines.slice(0, start), ...block, ...lines.slice(end)].join('\n');
252
+ return lines.join(eol);
143
253
  }
144
254
 
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`
255
+ // 条目存在但没有 config::补一个,auto 写在 config 的直接子键处(与解析口径一致)。
256
+ const configIndent = entry.indent + 2;
257
+ lines.splice(
258
+ entry.start + 1,
259
+ 0,
260
+ `${' '.repeat(configIndent)}config:`,
261
+ `${' '.repeat(configIndent + 2)}auto: ${flag}`,
154
262
  );
263
+ return lines.join(eol);
155
264
  }
156
265
 
157
266
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-compact-relay",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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",
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"