dsh-compact-relay 0.3.4 → 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,25 @@
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
+
17
+ ## 0.3.5(2026-10-24,压缩按钮误禁修复)
18
+
19
+ - **问题(外部用户报告,0.3.3 / Windows;0.3.4 源码同样存在)**:会话用量 53.5%、插件建议压缩,但指示灯菜单「压缩旧会话」灰置,提示「暂无可压缩内容(会话太短,或近期会话需保留原文)」——把「没有活的 agent」误当成「没有可压缩内容」。
20
+ - **根因**:`readState` 的 target 允许退化成「有内容、但还没有活 agent」的 passivized live session(压力/比例能测出来),但 `compactRange` 只在有活 agent 时计算 → 客户端收到 `null` 后禁用按钮并显示通用「暂无可压内容」文案。
21
+ - **修复(additive,HTTP 契约不破坏)**:`lib/actions/state.js` 对 target(含 passivized live session)也计算 `compactRange`(纯显示信息;真正执行/preview 仍要求活 agent);新增字段 `compactRangeReason`,仅当范围存在且无活 agent 时为 `'no_agent'`。
22
+ - **客户端**:菜单「压缩旧会话」在 `reason === 'no_agent'` 时仍禁用(预览需要活 agent),但文案改为「**会话未激活:发一条消息唤醒 agent 后可压 N 条 · 约 X tokens**」(含实际数字;禁用态悬停 `title` 可读);`rangeInfo === null` 与正常可压的既有文案不变。
23
+ - **测试**:新增 `test/state-passive.mjs`(无活 agent 的 live session 仍算出范围 + `reason='no_agent'`;有活 agent `reason=null`;空会话/无会话不误报;既有 state 字段不丢),并入 `npm test`。
24
+ - 测试基线:smoke 53/53、handoff 35/35、plugin-load 14/14、contract 17/17、compact-log PASS、compress-cue PASS、state-passive PASS、markdown-lite PASS;client bundle `--check` 通过(148857 字符)。
25
+
7
26
  ## 0.3.4(2026-10-24,判断机制修复 + 小 bug)
8
27
 
9
28
  ### 判断机制(BUG-010:适合压缩的节点几乎不触发)
package/ROADMAP.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 压缩接力 · 目标与路线图
2
2
 
3
- > 版本基准:0.3.4(2026-10-24)|状态:0.3 已发布(npm + GitHub),0.3.4 发布中
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)
@@ -143,6 +143,8 @@
143
143
  - ✅ 0.3.1 发布包已就绪:版本号 / CHANGELOG / README 安装段 / .gitignore / npm `files` 收窄 / 文件权限归一;测试基线 smoke 50、handoff 35、plugin-load 13、contract 17、compact-log PASS。
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
+ - ✅ 0.3.5 补丁:修复「无活 agent 时压缩按钮误禁 + 文案误导」(state 增加 `compactRangeReason='no_agent'`;客户端提示「会话未激活:发一条消息唤醒 agent 后可压 N 条」)。
147
+ - 🔧 0.3.6 修复中(BUG-012):原生压缩线解析只认 `config:` 直接子键(按缩进判断),修复 `modelPolicies` 嵌套 `thresholdRatio` 被误报为全局线(Windows 真实会话误显示 50%);真实触发线按模型 route 计算留 0.4。
146
148
  - ✅ 真实压缩 e2e:`test/e2e/real-compact.mjs`(真实模型 + 真实压缩事务 + `maxSummaries=1` 合并旧摘要分支)6 项断言通过。
147
149
  - ✅ P3-01~14(13 修/1 确认无需修)+ BUG-004 + P2-T1 已收口。
148
150
  - ⏳ 市场收录 PR:仓库满 1 天(2026-10-21 19:09 后)提,yml 与说明已备。
@@ -66,10 +66,15 @@ export async function readState(deps, { sessionId, agent } = {}) {
66
66
  const advice = adviceFor({ pressure: measured.tokens, summaries, thresholds: thresholdLines });
67
67
 
68
68
  // 当前能不能精压(菜单里显示「可压 N 条 / 没有可压范围」,点击前就有明确反馈)。
69
- // 只对活着的 agent 算;passivized 的会话没有 agent,点了也压不了,直接 null。
69
+ //
70
+ // 0.3.5(外部用户报的「压缩按钮灰置」):有 live session、但还没有活 agent
71
+ // (target 是 passivized 的 live session)时也要算范围 —— selectCompactableRange
72
+ // 只需要 session/nodes/engine/cfg,是纯显示信息;真实执行仍要求活 agent(preview
73
+ // 需要 agent 去跑选段/事务)。否则会把「会话未激活」误显示成「没有可压缩内容」。
74
+ const hasLiveAgent = picked !== undefined && picked !== null;
70
75
  let compactRange = null;
71
- if (picked !== undefined && picked !== null) {
72
- const live = picked.session ?? null;
76
+ if (target !== null) {
77
+ const live = target.session ?? null;
73
78
  if (live !== null) {
74
79
  compactRange = selectCompactableRange({
75
80
  session: live,
@@ -84,6 +89,9 @@ export async function readState(deps, { sessionId, agent } = {}) {
84
89
  : { nodes: compactRange.shadowedSeqs.length, tokens: compactRange.tokenCount, startSeq: compactRange.startSeq, endSeq: compactRange.endSeq };
85
90
  }
86
91
  }
92
+ // 范围算出来了但没有活 agent:客户端据此区分「会话未激活(发一条消息唤醒 agent)」
93
+ // 和「真的没有可压缩内容」。additive 字段,不破坏既有 HTTP 契约。
94
+ const compactRangeReason = compactRange !== null && !hasLiveAgent ? 'no_agent' : null;
87
95
 
88
96
  // M6:钉住集合(菜单显示「清除钉住(N 条)」,也能在无 agent 时读 live session)。
89
97
  const pinnedSeqs = target === null ? [] : livePinnedSeqs(target.session);
@@ -119,6 +127,8 @@ export async function readState(deps, { sessionId, agent } = {}) {
119
127
  level: advice === 'none' ? 'idle' : advice,
120
128
  pins: { count: pinnedSeqs.length, seqs: pinnedSeqs.slice(-20), marks: pinMarks },
121
129
  compactRange,
130
+ // 0.3.5:compactRange 非 null 但无活 agent 时为 'no_agent',其它情况 null。
131
+ compactRangeReason,
122
132
  busy: deps.lock.isBusy(target?.id),
123
133
  thresholds: thresholdLines,
124
134
  // A8:两套派生色板由 host 算好下发,浏览器半只按明暗挑一套(见 core/colors.js)。
package/lib/client.js CHANGED
@@ -1014,6 +1014,9 @@ window.__ModuleLoader__.load({
1014
1014
  startSeq: data.compactRange.startSeq ?? null,
1015
1015
  endSeq: data.compactRange.endSeq ?? null,
1016
1016
  },
1017
+ // 0.3.5:范围存在但会话没有活 agent —— 菜单据此显示「会话未激活…」而不是
1018
+ // 「暂无可压缩内容」(applyState 只保留白名单字段,新字段必须显式透传)。
1019
+ compactRangeReason: data.compactRangeReason === 'no_agent' ? 'no_agent' : null,
1017
1020
  });
1018
1021
  }, []);
1019
1022
 
@@ -1855,7 +1858,8 @@ window.__ModuleLoader__.load({
1855
1858
  role: 'menuitem',
1856
1859
  'data-cr-item': action,
1857
1860
  disabled,
1858
- title: oneLine ? (meta.note ?? sub) : undefined,
1861
+ // 禁用态的两行条目也把说明挂到 title:鼠标悬停能读到完整原因(含数字)。
1862
+ title: meta.title ?? (oneLine || disabled ? (meta.note ?? sub) : undefined),
1859
1863
  onClick: () => {
1860
1864
  if (disabled) return;
1861
1865
  // M6:压缩项先走预览(meta.onClick),其余动作直接执行。
@@ -1912,6 +1916,12 @@ window.__ModuleLoader__.load({
1912
1916
 
1913
1917
  // undefined=宿主没上报(老版本/未知);null=明确没有可压范围;object=可压多少。
1914
1918
  const rangeInfo = state.compactRange;
1919
+ // 0.3.5:有可压范围、但会话还没有活 agent(passivized live session)——preview 需要
1920
+ // 活 agent,所以按钮仍禁用,但文案要说清「发一条消息唤醒 agent」,不能再报「暂无可压缩内容」。
1921
+ const passiveRangeNote =
1922
+ rangeInfo !== null && rangeInfo !== undefined && state.compactRangeReason === 'no_agent'
1923
+ ? `会话未激活:发一条消息唤醒 agent 后可压 ${rangeInfo.nodes} 条 · 约 ${fmt(rangeInfo.tokens)} tokens`
1924
+ : null;
1915
1925
  const panel = open
1916
1926
  ? h(
1917
1927
  'div',
@@ -1953,9 +1963,11 @@ window.__ModuleLoader__.load({
1953
1963
  ? `暂无可压内容:有 ${pins.count} 条消息被锁定保护(先点「解锁消息」)`
1954
1964
  : '暂无可压缩内容(会话太短,或近期会话需保留原文)',
1955
1965
  }
1956
- : rangeInfo !== undefined
1957
- ? { note: `可压 ${rangeInfo.nodes} 条 · 约 ${fmt(rangeInfo.tokens)} tokens · 点击先看压缩预估` }
1958
- : { note: '点击先看压缩预估(范围/容量)' }),
1966
+ : passiveRangeNote !== null
1967
+ ? { disabled: true, title: passiveRangeNote, note: passiveRangeNote }
1968
+ : rangeInfo !== undefined
1969
+ ? { note: `可压 ${rangeInfo.nodes} 条 · 约 ${fmt(rangeInfo.tokens)} tokens · 点击先看压缩预估` }
1970
+ : { note: '点击先看压缩预估(范围/容量)' }),
1959
1971
  onClick: () => {
1960
1972
  void startPreview();
1961
1973
  },
@@ -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.4",
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/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"