dsh-context-pruner 0.0.0-stage → 0.1.1

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/README.md CHANGED
@@ -1,3 +1,785 @@
1
- # Temporary Holding Version
1
+ # dsh-context-pruner
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 一个 DSH 插件:**看清模型当前的上下文,并精确压缩你指定的那几轮**。
4
+
5
+ | 入口 | 作用 | 写入? |
6
+ |---|---|---|
7
+ | `/prune` | 按 token 从大到小列出最占地方的轮次(含 seq,可直接拿去 drop) | 否 |
8
+ | `/prune list [n\|a-b\|all\|seq a-b]` | 按时间顺序读轮次内容 | 否 |
9
+ | `/prune drop <a>-<b>` | **把你指定的 a-b 轮压成一条纠正摘要**,其余原样保留 | **是** |
10
+ | `/prune drop seq <a>-<b>` | 同上,但用 surface seq 指定(面板走这条,无歧义) | **是** |
11
+ | 右侧栏 **Context** 面板 | 图形化入口(见下) | 见下 |
12
+
13
+ ---
14
+
15
+ ## 图形入口(右侧栏 Context 面板)
16
+
17
+ 在右侧栏(与「工作区文件」「新建终端」并列)新增 **Context** 入口,点开后是完整的选择界面:
18
+
19
+ ![右侧栏里的 Context 面板](step2-panel.png)
20
+
21
+ | 功能 | 说明 |
22
+ |---|---|
23
+ | 轮次列表 | 每行显示轮次编号、token 大小、表面 seq、内容预览 |
24
+ | 复选框 | 每行一个,自由勾选任意几轮 |
25
+ | 实时统计 | 选中后显示 `frees ~X tok` |
26
+ | Prune 按钮 | 未选中时禁用;选中后显示 `Prune N`,点击执行 |
27
+ | Clear | 清空选择 |
28
+
29
+ **打开方式**:展开右侧栏 → 点 **Context**。
30
+
31
+ ### 五个曾让面板出错的问题(已修)
32
+
33
+ 这些只有拿**真实会话**验证才发现,因此记下来。
34
+
35
+ **0. 裁错位置:「轮次编号」在两边含义不同(最严重)**
36
+
37
+ 你选第 9-11 轮,实际被压掉的是**另外的跨度**。原因是两边各自定义"第几轮":
38
+
39
+ | 侧 | 编号规则 | 真实会话实测 |
40
+ |---|---|---|
41
+ | `lib/inspect.js`(host 命令) | **每条 user 消息**都开一轮 | **69 轮** |
42
+ | `lib/client.js`(面板) | 只有**真人消息**才开一轮 | **66 轮** |
43
+
44
+ 同一编号在两边的差异:
45
+
46
+ ```
47
+ # 9 | host seq 608 | client seq 742
48
+ #10 | host seq 609 | client seq 764
49
+ #11 | host seq 709 | client seq 820
50
+ ```
51
+
52
+ 面板发 `drop 9-11`,host 按**自己的 69 轮编号**解析 → **裁的是完全不同的区间,且 UI 毫无提示**。
53
+
54
+ **修法**:面板改发 **surface seq 范围**,不再发轮次编号。
55
+
56
+ ```
57
+ /prune drop seq 6050-6947 --yes
58
+ ```
59
+
60
+ seq 是持久标识,两边含义一致。`drop` 现在同时接受两种形式:
61
+
62
+ ```
63
+ /prune drop 3-7 # 轮次(给人手敲)
64
+ /prune drop seq 6050-6947 # surface seq(给程序调用,无歧义)
65
+ ```
66
+
67
+ **还有一个相关的 bug**:选中状态原本按**列表位置**记录,而列表在加载更早分页或发生压缩后会重新编号,于是"选中的那一行"变成另一轮。实测到 `#15 seq 9426` 却发出 `drop seq 8055-8310`(不含 9426)。现在选中状态按 **startSeq** 记录,跨重新编号保持稳定。
68
+
69
+ **1.「压缩完了怎么还在」——压缩其实成功了,是显示没跟上**
70
+
71
+ 这是最容易误判的一条。真实日志显示压缩**完全成功**(193 个节点、70066 tokens 被替换成一条摘要),但面板看起来毫无变化,于是读成"没生效"。
72
+
73
+ 三个独立原因叠加:
74
+
75
+ | # | 原因 | 修法 |
76
+ |---|---|---|
77
+ | a | 面板只在 `openState` 变化时重读,而**压缩不改变 `openState`** | 订阅 `eventSource`,用它的 `revision` 驱动重渲染 |
78
+ | b | 检查点的样板文块被当成了预览内容 | 见下方「检查点的真实结构」 |
79
+ | c | 命令回显 `/prune drop 6-7 --yes` 被算作一轮对话 | 只排除**命令形状**的行(见下) |
80
+
81
+ > ⚠️ 关于 c 的一个更正:查过 `dsh-commands` 后确认,提交 `/xxx` 并**不会**变成聊天消息——composer 把它交给 `adjudicate`,命令域记录的是 `command/run` **生命周期事件**,不是 `user/message`。所以这条排除规则属于**纵深防御**,不是主路径。
82
+ >
83
+ > 但它**必须写窄**:早先的版本排除所有以 `/` 开头的消息,于是把「粘贴了一个绝对路径」的真实提问也吞掉了(`/tmp cleanup needed, why?`、`/home/user/x is missing`、`/usr/bin/env not found`),这些轮次被静默并进上一轮,编号整体错位——正是这个插件要防的那类 bug。现在只排除**命令形状**(`/名字`,且名字在已知命令表里,或整行只有 `/名字`)。
84
+
85
+ **检查点的真实结构**(对照上游 `dsh-compaction-basic` 的 `frameSummary()` 确认):
86
+
87
+ ```js
88
+ // 三个 text block。注意:开标签与样板文在**同一个 block** 里
89
+ const CHECKPOINT_PREAMBLE = "This is an automatically generated checkpoint condensing...";
90
+ frameSummary(summary) => [
91
+ { type: "text", text: `${CHECKPOINT_PREAMBLE}\n\n<compacted-summary>` }, // block 0:样板 + 开标签
92
+ ...summary, // block 1:正文
93
+ { type: "text", text: "</compacted-summary>" } // block 2:闭标签
94
+ ]
95
+ ```
96
+
97
+ > ⚠️ **这条曾经写错过。** 早先的版本断言「开标签在 block[1] 开头,不在 block[0] 结尾」,据此认为在 block[0] 里搜标签是徒劳的。实际上上游把样板文和开标签拼进了**同一个** block,所以在 block[0] 里搜标签是能搜到的——真正的错误在于**搜到之后把整个样板文当成了正文**。
98
+ >
99
+ > 教训不是「标签在哪」,而是**样板文与正文可能同块**:因此剥离必须按整个 content 数组做(样板按自身特征串切掉、标签就地删除、空块丢弃),而不能逐块判断。现在的实现三种布局都正确,`test/rounds.mjs` 逐条覆盖。
100
+
101
+ **1. 列表不全:`eventSource` 是「窗口」而非全部历史**
102
+
103
+ `SessionBinding.eventSource` 的文档写的是 *"Contiguous event window reserved for Conversation assembly"*——它是**一段连续窗口**,长会话打开时只有尾部,`hasMore` 表示前面还有。
104
+
105
+ 只读这个窗口的后果:一个 **28 轮的会话只显示 4 轮**(也就是窗口里那几轮)。
106
+
107
+ 修法是调用 `ISession.loadOlder()` 往前分页(每页至少 50 条消息),循环到 `hasMore` 清除为止。
108
+
109
+ 另外加了**显式警告**:窗口未加载完时面板会写「Showing the newest part of this session only」,避免把残缺列表当成完整列表。
110
+
111
+ **2. `background job ...` 看不懂:机器通知被当成了对话轮次**
112
+
113
+ 这是最容易误判的一点。真实日志统计(31 条 `user/message`):
114
+
115
+ | 来源 | 数量 |
116
+ |---|---|
117
+ | **HUMAN(真实对话)** | **16** |
118
+ | `background job ...` 后台任务报告 | 12 |
119
+ | runtime-context 注入 | 1 |
120
+ | system-reminder | 1 |
121
+ | model-change 通知 | 1 |
122
+
123
+ **harness 会把后台任务完成报告等机器通知,以 `user/message` 的形式注入。** 它们和真实用户消息共用同一个 surface 类型,所以按「user 消息 = 一轮」分组的后果是:
124
+
125
+ - 面板出现 `background job pwsh-20 (pwsh: Start-Sleep ...)` 这种毫无意义的行
126
+ - **真实的 16 轮对话被切碎成 30 段**
127
+
128
+ 修法是加 `isHumanTurn` 判别:只有真正的人类消息才开启新轮次,机器通知**折叠进它所属的轮次**并在计数里体现(`(+2 notices)`)。
129
+
130
+ 修复后:**30 rounds → 16 rounds**,与日志里的 16 条人类消息完全吻合,且 `rounds still previewing scaffolding: 0`。
131
+
132
+ **但它当时只修了面板一半——host 侧一直没修(第二轮 review 才发现)**
133
+
134
+ 第一轮只给 `lib/client.js` 加了分类器,`lib/inspect.js` 的 `groupRounds` 仍然是「每条 user 消息开一轮」。后果不是「两边差一个常数」,而是**编号分段错位**:多出来的 opener 夹在人类轮次之间,所以 host 的第 9 轮和真实第 9 轮不是同一轮。
135
+
136
+ 在 README 自己统计的那个会话上,`/prune` 和 `/prune list` 会打印 **31 轮**,而真相是 16 轮。于是:
137
+
138
+ - `/prune` 的「最占地方的轮次」编号,和面板里看到的任何东西都对不上;
139
+ - README 原先教的 `/prune list 0-3` → `/prune drop 3-7`,`drop` 走的是**同一套 host 编号**,所以手敲的区间会落在**错误的跨度**上,而预览用的是同一套错误编号,**看起来自洽,无从察觉**;
140
+ - 每有一个后台任务结束,编号就会整体平移——和面板当初修掉的是同一类 bug。
141
+
142
+ **根因**:同一条规则被两边各写了一次。所以这次不是「再补一个分类器」,而是把它抽成 `lib/rounds.js` 作为唯一定义,host 直接 import;面板因为不能 import host 模块,仍保留一份副本——但由 `test/rounds.mjs` 把 bundle 里的函数**整段取出来执行**,用同一张表跑两边,逐条比对结论。
143
+
144
+ 顺带确认了「机器通知」有**结构化标记**,不必靠文本前缀猜:
145
+
146
+ | 注入 | `source` |
147
+ |---|---|
148
+ | 后台任务报告 | `{ kind: "tool-jobs", form: "notice" }` |
149
+ | runtime context | `{ kind: "runtime-context", form: "snapshot" }` |
150
+ | `<system-reminder>` / skill 正文 | `{ kind: "agent-instructions", form: "instructions" }` |
151
+ | 队友消息 | `{ kind: "agent-message" }` |
152
+ | `@file` 展开 | `{ kind: "session-reference" }` |
153
+ | 模型切换通知 | `{ kind: "model-selection" }` |
154
+
155
+ `session.deriveEventMessage()` 对 `user/message` 原样返回 `event.data`,所以 `source` 在 host 侧直接可达。**优先用 `source`,文本前缀只作兜底**——正则会在上游改写措辞时静默失效(`/^Current runtime context\./` 尤其危险)。
156
+
157
+ > 另有一个只有真实会话才暴露的细节:会话常常**以 runtime context 开头**。若让它在人类消息之前先开一轮,那一轮既凭空多出来,又和后面的轮次割裂。正确做法是把它「挂起」,等第一条人类消息到来时并入该轮——这样 `startSeq` 仍指向通知节点,跨度完整、可被 drop。
158
+
159
+ ### 面板的多出来一轮:同一个 bug 的第二次发作(第三轮 review 才发现)
160
+
161
+ 第二轮把分类器抽成了 `lib/rounds.js` 并让 host 用上,但**面板的「开轮」判断没跟着改**:
162
+
163
+ | 侧 | 何时开新轮 |
164
+ |---|---|
165
+ | `lib/inspect.js`(host) | 人类消息;人类消息之前的节点**先挂起** |
166
+ | `lib/client.js`(面板,修复前) | **任何第一个节点**立刻开轮 |
167
+
168
+ 差别只在「人类消息之前的节点」,而**真实会话必然命中**——`SystemPromptProjection` 会把渲染后的系统提示提交为 **surface node 0**,runtime-context 快照常常跟在后面。实测:
169
+
170
+ ```
171
+ surface = [system, runtime-context, Q0, A0, Q1, A1]
172
+ host = 2 轮(round 0 = seq 0-3,标题 Q0)
173
+ 面板 = 3 轮(round 0 = seq 0-1,标题是系统提示!) ← 后面每个编号都错一位
174
+ ```
175
+
176
+ 这正是本轮 review 的 CRITICAL 项,也和 README 早先记录的「两边从一开始就对不上」是同一个症状。
177
+
178
+ **为什么之前的测试没抓到**:新加的 1365 形状性质测试只跑了 host 的 `groupRounds`,**一次都没碰面板**;而 `test/preview-rules.mjs` 是用**正则匹配源码文本**,天然无法发现两边行为不一致。
179
+
180
+ **修法**:面板镜像 host 的挂起逻辑;并新增 `test/rounds.mjs` 里一条**真正的一致性测试**——把面板的 `readRounds` 从 bundle 里整段取出**执行**,让它和 host 的 `groupRounds` 跑**同一个 session**,逐轮比对轮数、`startSeq`/`endSeq`、tokens 和标题。
181
+
182
+ > 教训:**「两边各实现一份」光靠比对标量布尔值是不够的。** 只有当两个实现跑同一份输入、产出同一份结构时,一致性才是被证明的。
183
+
184
+ **3. `tool/result` 读错了载荷路径**
185
+
186
+ 真实事件结构(从解压日志确认):
187
+
188
+ ```js
189
+ // user/message:载荷是平的
190
+ { type: "user/message", data: { role: "user", content: [...] } }
191
+
192
+ // assistant/message、tool/result、system/message:嵌在 data.message 下
193
+ { type: "tool/result", data: { turn, step, message: { role: "tool", content: [...] } } }
194
+ ```
195
+
196
+ 我原本只对 `assistant/message` 做了嵌套解包,**漏了 `tool/result`**——于是找不到 `content`,预览退化成原始脚手架文本。
197
+
198
+ 修复后工具行显示**工具名 + 目标**(如 `[pwsh: Get-Process -Name node]`),空内容也有明确标签(`(tool output)` / `(tool error)` / `(assistant called a tool)`)。
199
+
200
+ ### 怎么自己核对面板数字
201
+
202
+ 面板显示的轮次数应当等于日志里的**人类** `user/message` 数。两个脚本可以直接查:
203
+
204
+ ```powershell
205
+ # 事件类型直方图 + 表层事件列表
206
+ node test/inspect-session.mjs "$env:USERPROFILE\.dsh\sessions\<workspace>\<session>\session.v4.jsonl.zstd"
207
+
208
+ # 按来源分类所有 user/message,并列出真实的人类轮次
209
+ node test/classify-user-messages.mjs "$env:USERPROFILE\.dsh\sessions\<workspace>\<session>\session.v4.jsonl.zstd"
210
+
211
+ # 按面板的分组规则回放,核对轮次数
212
+ node test/verify-grouping.mjs "$env:USERPROFILE\.dsh\sessions\<workspace>\<session>\session.v4.jsonl.zstd"
213
+ ```
214
+
215
+ > 注意:会话日志是多帧 zstd,`zstdDecompressSync` 只解第一帧(只有 header)。脚本按魔数 `28 B5 2F FD` 分帧后逐帧解压。
216
+
217
+ ### 它如何执行裁剪(重要:两步操作)
218
+
219
+ **面板不能在浏览器里自己执行斜杠命令。** 这是实测结论,不是设计偏好:
220
+
221
+ | 尝试 | 结果 |
222
+ |---|---|
223
+ | `conversation.sendSession(session, "/prune drop 6-7 --yes", [], "queue")` | ❌ 文本被当作**普通 prompt** 发给模型,模型回复「I don't have a `/prune` command」 |
224
+ | `inputActions.setDraft(line)` + `inputActions.submit()` | ❌ `setDraft` 生效(输入框确实出现命令),但 `submit()` **不产生任何 `command/run` 事件**,会话日志里 `compaction/*` 为 0 |
225
+
226
+ 原因:斜杠命令的分发由**输入框自己的 Enter 事务**拥有(`SubmitMachine.submit()` 走 command claim 与 adjudication)。从外部插件驱动它没有成功路径,而且 `submit()` 返回 `void`,失败是静默的。
227
+
228
+ 所以面板的工作方式是**准备好命令,由你按回车执行**:
229
+
230
+ 1. 在面板勾选轮次 → 点 **Prepare prune**
231
+ 2. 输入框被填入 `/prune drop 3-5 --yes`
232
+ 3. **你看一眼确认,按回车**
233
+
234
+ 这样做的好处是:命令永远由框架自己的事务执行,且**你在执行前有一次确认机会**——考虑到裁剪不可逆,这个确认本身就是有价值的。
235
+
236
+ > 面板**刻意不调用 `submit()`**:静默地把命令当 prompt 发出去比不动作更糟——它会消耗一次模型调用,还给你一个困惑的回复。
237
+
238
+ ### 支持不连续的轮次选择
239
+
240
+ 可以任意勾选,包括跳着选。比如选 **6、9、10、11**(跳过 7、8):
241
+
242
+ ```
243
+ /prune drop seq 2745-3009, seq 4312-6875 --yes
244
+ ```
245
+
246
+ 面板把选中轮次按**连续段**拆分,每段一个 seq 区间,用逗号连成**一条命令**:
247
+
248
+ | 选择 | 生成 |
249
+ |---|---|
250
+ | 6、9、10、11 | `seq A-B, seq C-D`(两段) |
251
+ | 3、4、5 | `seq A-B`(一段) |
252
+ | 1、3 | `seq A-A, seq B-B`(两段) |
253
+
254
+ **关键保证:中间的间隔不会被吞掉。** 如果直接把 `6-11` 当成一个区间,第 7、8 轮(你没选)也会被压掉——这正是分段的意义。测试 `test/multi-range.mjs` 专门验证了这一点。
255
+
256
+ > 早期版本只把**第一段**填进输入框,其余要你手动依次执行;现在一条命令覆盖全部选中轮次。
257
+
258
+ **手动用法**:host 命令同样接受多段,逗号分隔:
259
+
260
+ ```
261
+ /prune drop 6-6, 9-11 # 轮次形式
262
+ /prune drop seq 6050-6947, 9000-9100 # seq 形式
263
+ ```
264
+
265
+ 重叠的区间会被拒绝,且**所有区间在任何写入前统一校验平衡性**,所以一个坏区间不会留下半完成的结果。
266
+
267
+ ### 客户端插件的构建方式
268
+
269
+ DSH 的客户端插件是**编译好的 React bundle**(`dsh-client-ui-trajectory` 那份有 41 万字符),由 rolldown 产出 `window.__ModuleLoader__.load({ id, factory })` 包装。
270
+
271
+ 本插件的 `lib/client.js` **手写**这个格式,不需要构建步骤——依据是 `dsh-client-ui-brand-official`(2KB)就是一个完整的同款范例。好处是维护者读到的源码就是浏览器实际运行的产物。
272
+
273
+ ### 两个 inject 含义不同(踩过的坑)
274
+
275
+ 这是最容易搞错的地方,两者**语义完全不同**:
276
+
277
+ | 位置 | 含义 | 值 |
278
+ |---|---|---|
279
+ | `package.json` → `dsh.client.inject` | **包名**(模块图依赖边) | `@deepseek-ai/dsh-client-ui-slots` … |
280
+ | `lib/client.js` → `exports.inject` | **Cordis 服务名** | `slots`、`sidebarRightTabs`、`sessions` |
281
+
282
+ 我一开始在 `exports.inject` 里写了包名,结果浏览器控制台直接报:
283
+
284
+ ```
285
+ Error: web boot: 1 entry did not activate
286
+ dsh-context-pruner: pending (waiting for services:
287
+ @deepseek-ai/dsh-client-ui-slots, @deepseek-ai/dsh-client-ui-sidebar-right)
288
+ ```
289
+
290
+ Cordis 会一直等这两个**不存在的服务名**,插件永远处于 `pending`。改成服务名后,控制台归零。
291
+
292
+ **这个错误只有真实浏览器能发现** —— Node 侧测试、启动日志、`--dump-config` 全都是干净的。
293
+
294
+ ### 为什么入口要走 guide
295
+
296
+ 注册 tab **类型**不等于打开 tab:侧边栏 tab 条会一直是空的,直到有东西调用 `openTab(kind)`。guide 页(右侧栏空状态)是 sidebar 自己的「打开一个页面」入口,所以在那儿登记一条是最自然的可达路径,不需要新建命令或改 shell。
297
+
298
+ ### 最隐蔽的坑:JSX 自动运行时是两参数
299
+
300
+ **症状**:tab 能打开、控制台**零错误**、组件日志证明**已挂载且 props 正确**,但面板**完全空白**。
301
+
302
+ **原因**:自动运行时的签名是 `jsx(type, props)` —— **没有第三个参数**。
303
+
304
+ ```js
305
+ // ❌ 第三个参数被静默丢弃,元素照常创建,只是内容不见了
306
+ h("div", { style: {...} }, props.children)
307
+ jsxs("div", { children: [...] }, "pruner-root")
308
+
309
+ // ✅ children / key 必须在 props 对象里
310
+ h("div", { style: {...}, children: props.children })
311
+ jsxs("div", { children: [...], key: "pruner-root" })
312
+ ```
313
+
314
+ **「静默丢弃」是它最难查的地方**:不报错、不警告,DOM 里出现一个 0×0 的空元素。所以我前几轮排查一直在追错方向——以为是「没渲染」,实际是「渲染了但内容被丢掉」。
315
+
316
+ 为此加了 `test/audit-jsx.mjs`,扫描 `lib/client.js` 里所有三参数的 `h/jsx/jsxs` 调用并接入 `npm test`。它已进入测试套件,这类 bug 不会再回来。
317
+
318
+ ### 另外两个已修的注入问题
319
+
320
+ 1. **`inject` 必须是工厂函数,不能是对象**
321
+ 框架调用方式是 `inject(sessionId, actions)`,返回值展开成组件 props。传对象会抛 `TypeError: inject is not a function`,同样表现为面板空白。
322
+
323
+ 2. **guide entry 的 id 要与 tab type 的 id 区分**
324
+ 照抄 `sidebar-files` 的写法(它的 guide id 是 `"workspace"`,type id 是包名),两者相同会让注册表认为是重复 id。
325
+
326
+ ---
327
+
328
+ ## 它解决什么问题
329
+
330
+ 典型场景:你问了一个问题 A,和模型来回讨论了 10 轮,最后发现之前的答案是错的、正确答案是 Y。此时那 10 轮试错过程还留在上下文里,持续误导模型。
331
+
332
+ 两步解决:
333
+
334
+ ```
335
+ /prune → 「重的是第 1、2 轮,加起来 3.2k」
336
+ /prune list 0-3 → 「这两轮是调刷新时机的试错——确实白费了」
337
+ /prune drop 0-3 → 预览将被压缩的范围
338
+ /prune drop 0-3 --yes → 执行
339
+ ```
340
+
341
+ **未指定的轮次完全不动**——这是 `drop` 的核心保证,有专门的测试守着(见下文)。
342
+
343
+ ```
344
+ /prune list
345
+ ```
346
+
347
+ 输出按对话轮次分组,每轮显示开头提问、助手回应、token 大小和可寻址的轮次编号:
348
+
349
+ ```
350
+ Newest 5 of 5 rounds (~2.2k tokens shown)
351
+ round size spans what was said
352
+ 0 440 tok #0-1 我提个问题A:这个函数为什么返回 undefined?
353
+ └ 看了一下,是因为异步没 await
354
+ 1 440 tok #2-3 我试了加 await,还是 undefined
355
+ └ 那可能是 return 漏了
356
+ 2 440 tok #4-5 加了 return,还是不行
357
+ └ 让我看看具体代码
358
+ 3 440 tok #6-7 是这里:const x = fetch().then(r=>r.json())
359
+ └ 这个 then 没 return,所以是 undefined
360
+ 4 440 tok #8-9 改成 return fetch() 好了
361
+ └ 对,这就是根因——实际答案是 Y
362
+ ```
363
+
364
+ 这样你能一眼看出:轮次 0-3 是白费的探索,轮次 4 才是真正的结论。
365
+
366
+ 长会话默认只显示最新 15 轮,可用 `list all` 或 `list 0-20` 查看其它区间。
367
+
368
+ ### 一次失败的初版(记录下来避免重犯)
369
+
370
+ 第一版输出了 466 个节点的 `seq / tokens / 占比`:
371
+
372
+ ```
373
+ # 0 seq 1817 ~ 2077 tok 1%
374
+ # 456 seq 2123 ~ 150 tok 0%
375
+ ```
376
+
377
+ 数字全对,但**毫无用处**:占比在几百个节点上全部舍入成 0%,而且没有一行说明节点里是什么。教训是——**统计正确 ≠ 对人有用**。现在的版本以"人能不能据此做决定"为验收标准,见 `test/render.mjs`。
378
+
379
+ ---
380
+
381
+ ## 兼容的 DSH 版本
382
+
383
+ 本插件**只声明它真正 import 的**宿主包,并跟随宿主轮次版本(当前 `0.2.1-rc.2`):
384
+
385
+ | peer | 用途 |
386
+ |---|---|
387
+ | `@deepseek-ai/dsh-compaction-basic` | 继承 `BasicCompactionEngine` 做纠错摘要 |
388
+ | `@deepseek-ai/dsh-compaction` | `ManualCompactionError`、`CompactionId`、工具配对校验 |
389
+ | `@deepseek-ai/dsh-llm` | `BlockAssembler`、`createUserMessage` 等 |
390
+ | `@deepseek-ai/dsh-commands` | `CommandDefinitionId` |
391
+ | `@deepseek-ai/dsh-util-values` | `deepFreeze` |
392
+
393
+ > **为什么必须跟着升**:DSH 启动时会用 `evaluatePluginCompatibility()` 校验插件声明的
394
+ > `engines.dsh` 或 dsh peer 区间,**不满足就拒绝加载**(报「不兼容…可能导致崩溃或数据丢失」)。
395
+ > 所以插件 `version` 与 peer 版本都要随宿主升级同步递增,否则每次 DSH 小版本升级都会
396
+ > 让插件变成「异常」状态。改完 peer 后记得同时把本包的 `version` 递增(当前 `0.1.1`),
397
+ > 否则 npm/pnpm 看到的仍是旧 manifest。
398
+
399
+ ---
400
+
401
+ ## 命令
402
+
403
+ 两个命令看**同一批数据**,只是排列方式和详细度不同:
404
+
405
+ | 输入 | 排序 | 显示 | 回答的问题 |
406
+ |---|---|---|---|
407
+ | `/prune` | **按 token 从大到小** | 只有你的提问 | 「**哪里最占地方?**」 |
408
+ | `/prune list` | **按时间顺序** | 提问 + 助手回应 | 「**那里到底聊了什么?**」 |
409
+
410
+ ### `/prune` —— 定位「该动谁」
411
+
412
+ 按 token 大小降序排列,最大的排最前。用途是找出**砍哪里收益最大**:
413
+
414
+ ```
415
+ Context at a glance
416
+ 24 nodes in 12 rounds, ~9800 tokens (request pressure ~5500)
417
+
418
+ Biggest rounds (largest first, not in time order) — these 8 of 12 rounds hold ~7.4k tok (76% of the total):
419
+ round 2 2.4k tok seq 140 用户提问第 2 轮
420
+ round 1 1.4k tok seq 82 用户提问第 1 轮
421
+ round 0 600 tok seq 31 用户提问第 0 轮
422
+ ...
423
+
424
+ Reading this: the rounds at the top use the most context, so removing
425
+ them frees the most. Rounds near the bottom barely matter.
426
+ ```
427
+
428
+ > 每行都带 `seq`,所以 `seq 140` 可以直接抄进 `drop seq 140-…`,不必再跑一次 `/prune list` 找编号——`/prune` 单独就是可行动的。
429
+
430
+ > ⚠️ 注意这个列表**不是时间顺序**——第 2 轮排在最前面是因为它最占地方,不是因为它最早发生。标题里写明了这一点。
431
+ >
432
+ > 当会话较短、所有轮次都能列出时,标题会写 `all N shown`,不再报百分比("占总数 100%" 是废话)。
433
+
434
+ ### `/prune list` —— 看清「是什么」
435
+
436
+ 按时间顺序,每轮显示提问与助手回应:
437
+
438
+ | 输入 | 行为 |
439
+ |---|---|
440
+ | `/prune list` | 最新 15 轮(含内容预览)。**不写入任何东西。** |
441
+ | `/prune list 30` | 最新 30 轮(**注意:是"最新30轮",不是"第30轮"**)。 |
442
+ | `/prune list 10-25` | 第 10 到 25 轮。 |
443
+ | `/prune list seq 6050-6947` | 与该 surface seq 跨度**重叠**的轮次(与 `drop seq` 配对,可先读后裁)。 |
444
+ | `/prune list all` | 全部轮次。 |
445
+
446
+ `list 0` 会被拒绝并说明原因,而不是悄悄当成「最新 1 轮」。
447
+
448
+ 「轮次」= 从一个**人类**消息开始,到下一个人类消息之前。这是人做判断的自然单位("那轮我们试错了"),也是让几百个节点变得可读的关键。机器通知(后台任务报告、runtime context、system-reminder 等)**不开新一轮**,而是折叠进它所属的轮次,并在行尾标注 `(+2 notices)`。
449
+
450
+ ### `/prune drop <a>-<b>` —— 真正裁掉指定轮次
451
+
452
+ **这是唯一的写入操作。** 它把指定的 a-b 轮替换成**一条纠正检查点**,范围之外的轮次**一字不动**。
453
+
454
+ ```
455
+ /prune drop 3-7
456
+ ```
457
+ 先预览(不改任何东西):
458
+ ```
459
+ Would compact rounds 3-7 — 1 span, 5 rounds, 61 nodes, ~12.4k tok:
460
+
461
+ span 1 seq 2745..3009 ~12.4k tok
462
+ round 3 1.2k tok 调刷新时机的第一次尝试
463
+ round 4 800 tok 还是不行 (+1 notice)
464
+ ...
465
+
466
+ inside: 21 user, 18 assistant, 22 tool
467
+ 1 of these nodes are harness notices, not conversation.
468
+ largest single node: seq 5120 (~3.8k tok) — [pwsh: Get-Process -Name node]
469
+
470
+ BECOMES: 1 checkpoint replacing those 61 nodes.
471
+ The surface goes from 41 rounds to 37, and ~54.2k tok stay as they are.
472
+
473
+ The checkpoints are CORRECTIVE: each marks the dropped reasoning as
474
+ "Retired Exploration — DO NOT TRUST" and asserts an Authoritative Conclusion.
475
+ Whatever the summarizer concludes is what the model believes afterwards.
476
+ This cannot be undone: the session log keeps the originals for replay, but the
477
+ conversation itself cannot get them back.
478
+
479
+ To proceed: /prune drop seq 2745-3009 --yes
480
+ ```
481
+
482
+ 预览刻意**不复述列表里已经给过的 opener**,而是回答「我到底要丢掉什么」:
483
+
484
+ | 行 | 回答的问题 |
485
+ |---|---|
486
+ | `span N seq a..b ~X tok` | 每个跨度各占多少 |
487
+ | 每个受影响的轮次 | 哪些轮次会被替换(用**人类消息**作标题,不是机器通知) |
488
+ | `inside: 21 user, 18 assistant, 22 tool` | 一个 96 字符的 opener 背后可能是 3.8k 的工具链 |
489
+ | `largest single node` | **一眼判断「这是不是我说的那段试错」最省力的线索** |
490
+ | `N of these nodes are harness notices` | 这一跨度里有几成是脚手架而非对话 |
491
+ | `BECOMES:` / `goes from 41 rounds to 37` | 操作之后 surface 长什么样 |
492
+ | `CORRECTIVE:` 段 | 检查点会**断言一个结论**——这才是真正的风险 |
493
+
494
+ **如果跨度没有对齐到轮次边界**(只有 `seq` 形式能做到),预览会先给 WARNING:
495
+
496
+ ```
497
+ WARNING: span 1 cuts through a conversation round rather than covering whole rounds.
498
+ Part of a round would be replaced while the rest stays, so the model would see
499
+ an answer with its question missing (or the reverse). Prefer whole rounds.
500
+ ```
501
+
502
+ 此时**不再给出轮次数**,因为切半的跨度只退休节点、不退休轮次,硬算会算出「轮次变多了」——而裁剪永远不可能增加轮次。
503
+
504
+ ```
505
+ /prune drop 3-7 --yes
506
+ ```
507
+ 执行:
508
+ ```
509
+ Compacted rounds 3-7 (1 checkpoint, 61 nodes, ~12.4k tokens). Everything outside those spans is unchanged.
510
+ ```
511
+
512
+ **为什么要 `--yes`**:这是不可逆的——被压掉的轮次**模型再也看不到了**(session log 里还留着,可回放,但对话本身拿不回来)。所以先给你看清楚会被替换掉什么,再要求明确确认。
513
+
514
+ `--yes` 是**旗标**而不是位置参数:出现在哪里都认(`drop 3-7 --yes`、`drop --yes 3-7`、`drop 1-1 --yes, 3-3` 都可以),重复写也无害。预览里的 `To proceed:` 一行由**解析后的跨度重新生成**,所以它永远是规范形式、永远可以原样粘回去执行。
515
+
516
+ **摘要不是普通总结**,而是"纠错检查点":
517
+
518
+ ```markdown
519
+ ## Original Request and Intent
520
+ ## Retired Exploration — DO NOT TRUST
521
+ ## Authoritative Conclusion
522
+ ## Carried-Forward Facts
523
+ ## Next Step
524
+ ```
525
+
526
+ 关键是指令里明确要求:错误结论**只能**记为「被否决的方向」,不得当作 finding 复述。这样模型就不会继续沿着错路走。
527
+
528
+ **边界拒绝**:如果 a-b 的边界正好切断了「助手发起工具调用 → 工具返回」这一对,`drop` 会拒绝并给出可操作提示,而不是默默把配对切断。
529
+
530
+ ### 关于框架样板文:保持与上游一致(有意为之)
531
+
532
+ 替换消息的外层包裹照抄上游 `CHECKPOINT_PREAMBLE`:
533
+
534
+ ```
535
+ This is a correction checkpoint replacing an earlier span of the conversation to
536
+ free up context. Treat the captured context as established background and build
537
+ on it without restating it. Continue the task directly from the messages that
538
+ follow, without acknowledging this checkpoint.
539
+ ```
540
+
541
+ **这段措辞存在一个已知的语义张力**:它说 `Treat the captured context as established background and build on it`,但我们压掉的恰恰是**错误**的内容。字面上看,让模型把错误当作"established background"去 build on,与纠错目的相反。
542
+
543
+ 权衡后选择**不改**:
544
+
545
+ - 这套措辞是模型在原生 `/compact` 路径上已经适配过的,改动会引入未知的表述风险;
546
+ - 其中 `without acknowledging this checkpoint` 有实际价值——避免模型在你面前讨论上下文管理;
547
+ - 纠错的语义已经由**摘要正文**的结构承担(`Retired Exploration — DO NOT TRUST`),不依赖外层样板。
548
+
549
+ **但样板文必须从列表预览里剔除**:它固定 285 字符、每条 checkpoint 都一样,留在预览里会让所有摘要节点显示同一句英文。见 `test/checkpoint-preview.mjs`。
550
+
551
+ 样板文代价约 **72 tok / 条**。相对它替换掉的内容(通常是数千至数万 tok),可以忽略。
552
+
553
+ ---
554
+
555
+ ## 安装
556
+
557
+ 本插件**不修改 dsh 安装目录里的任何文件**。安装只影响你的 profile 目录。
558
+
559
+ ```powershell
560
+ dsh plugin --profile web add G:\Projects\dsh-context-pruner
561
+ ```
562
+
563
+ `dsh plugin add` 会把包 pnpm 安装到 `~/.dsh/profiles/web/node_modules/`,并把 `dsh-context-pruner` 追加到该 profile `package.json` 的 `dsh.profile.bundles` 列表。本包的 `cordis.patch.yml` 提供挂载行。
564
+
565
+ 卸载:
566
+
567
+ ```powershell
568
+ dsh plugin --profile web remove dsh-context-pruner
569
+ ```
570
+
571
+ ---
572
+
573
+ ## 开发
574
+
575
+ ```powershell
576
+ npm test
577
+ ```
578
+
579
+ 十二套测试,共 146 项:
580
+
581
+ | 套件 | 覆盖 | 项数 |
582
+ |---|---|---|
583
+ | `test/smoke.mjs` | 纠正式 backend 的 `summarize()` 契约 | 12 |
584
+ | `test/render.mjs` | 在**真实 Session**上验证输出可读性 | 17 |
585
+ | `test/checkpoint-preview.mjs` | **摘要节点在列表里的预览** | 6 |
586
+ | `test/compact.mjs` | **区域压缩事务**:括号顺序、边界校验、并发防护 | 18 |
587
+ | `test/drop.mjs` | `/prune drop` 端到端:**未指定轮次完整性** | 17 |
588
+ | `test/drop-arguments.mjs` | `drop` 的参数解析与**预览准确性** | 23 |
589
+ | `test/drop-span.mjs` | 轮次/seq 两种跨度形式的解析 | 7 |
590
+ | `test/multi-range.mjs` | **不连续选择**:间隔轮次不被吞掉 | 6 |
591
+ | `test/rounds.mjs` | **host/client 跑同一 session 的一致性** + 全形状性质检查 | 17 |
592
+ | `test/live-load.mjs` | 在**真实 Cordis Context** 上装载 | 4 |
593
+ | `test/audit-jsx.mjs` | 客户端 bundle 的 JSX 参数个数 | — |
594
+ | `test/preview-rules.mjs` | 客户端预览规则 | 19 |
595
+
596
+ > `test/drop.mjs` 里最关键的一条:`drop --yes` 之后逐个检查范围外的每个节点**仍在 surface 上且顺序不变**。这是「未指定对话不要压缩」这条需求的直接回归防护。
597
+
598
+ > `test/rounds.mjs` 是**唯一一个同时跑 host 与 panel 两份实现**的套件:它把 `lib/client.js` 里的分类器整段取出来执行,再用同一张表跑 `lib/rounds.js`,两边结论必须逐条相同。加这个套件的原因很直接——README 里记录的一连串 bug(轮次编号、预览、载荷路径)全都是「同一条规则被两边各实现一次」造成的,而当时**没有任何测试构造过含机器通知的会话**,所以 31 轮 vs 16 轮一直没被发现。
599
+
600
+ > `test/checkpoint-preview.mjs` 守住一个真实踩过的坑:摘要消息的框架样板文有 285 字符,如果不去掉,列表里每条 checkpoint 都显示同一句英文样板,**读者永远看不到摘要讲了什么**。而框架跨越多个 content block(开头块、正文、结尾标签块),所以剥离要按**整个 content 数组**做,而不是逐块做。
601
+
602
+ > `test/render.mjs` 用 `Session.create()` + 真实消息工厂构造会话,断言的是**输出对人有意义**(内容预览出现、轮次分组正确、200 轮时输出仍然简短、标题写明了排序方式),而不是内部数据结构。这是针对初版"数字全对但看不懂"的直接回归防护。
603
+
604
+ 测试通过 junction link 引用 dsh 自带的 peer 包(`node_modules/@deepseek-ai/*`),不联网安装依赖。
605
+
606
+ 这些链接是**只读脚手架**——它们指向 dsh 安装目录,不复制、不修改 dsh 的任何文件。重建方式(PowerShell):
607
+
608
+ ```powershell
609
+ $src = "$env:APPDATA\npm\node_modules\@deepseek-ai\dsh\node_modules\@deepseek-ai"
610
+ $dst = "G:\Projects\dsh-context-pruner\node_modules\@deepseek-ai"
611
+ foreach ($p in @("dsh-llm","dsh-util-values","dsh-compaction","dsh-compaction-basic",
612
+ "dsh-session","dsh-agent","dsh-commands","cordis","schemastery")) {
613
+ cmd /c mklink /J "$dst\$p" "$src\$p"
614
+ }
615
+ ```
616
+
617
+ ---
618
+
619
+ ## 架构发现(重要)
620
+
621
+ 开发过程中确认了 DSH compaction 的几个关键事实,记录下来避免重复踩坑。
622
+
623
+ ### 1. 压缩引擎只有一个子类钩子
624
+
625
+ `BasicCompactionEngine` 的文档明确写着 `summarize()` 是 **sole subclass customization hook**。压力策略、保留策略、范围选择、shrink 校验、token 计费全部留在父类,以保证每次定价都走同一个 `ctx.tokenMeter`。
626
+
627
+ `SummaryResult` 支持两种形态,其中 **unmarked 变体**(`llmStreamCall?: never`)正是给模板/远程摘要器用的:
628
+
629
+ ```ts
630
+ type SummaryResult = { summary, provider, model, ... } & (
631
+ | { rawOutput: ContentBlock[]; llmStreamCall: true } // 走 ctx.llm.stream()
632
+ | { rawOutput?: ContentBlock[]; llmStreamCall?: never } // 模板/远程摘要器
633
+ );
634
+ ```
635
+
636
+ ### 2. `compactRegion` 与 `compactNow` 的能力不重叠(本插件自实现事务的原因)
637
+
638
+ DSH 的压缩 seam 只有两个入口,而它们各自缺一半能力:
639
+
640
+ | 入口 | 能指定范围? | 空闲时可调用? |
641
+ |---|---|---|
642
+ | `compactRegion(start, end)` | ✅ 调用方指定 | ❌ 要求 turn 开着(`owner: 'current-turn'`) |
643
+ | `compactNow()` | ❌ 内部用 `selectCompactableRange()` 自选 | ✅ 走 `agent.runMaintenance()` |
644
+
645
+ 实测证据(`dsh-compaction-basic/lib/index.js`):
646
+
647
+ ```js
648
+ // compactRegion 的第 460 行
649
+ if (entryState.openTurn === null)
650
+ throw new Error("compactRegion: no open turn — automatic compaction events must be enclosed in a turn");
651
+
652
+ // compactNow 的第 994 行:owner 硬编码为 null,范围由引擎内部决定
653
+ return await compactSurfaceRegion(..., range.start, range.end, agent, { owner: null, ... }, ...);
654
+ ```
655
+
656
+ 而 `runMaintenance()` 的文档明确写着它跑的是 **non-turn** 任务,所以也没法靠它伪造出一个 turn 来喂给 `compactRegion`。
657
+
658
+ **结论**:没有现成路径能同时做到「调用方指定范围」+「空闲安全」。所以 `lib/compact.js` **自己实现了手动版区域事务**——完整复刻上游的括号锁定、边界校验、变更检测、shrink 校验与单次 surface 替换。
659
+
660
+ 这不是轻率的决定。事务里有三个不变量必须自己保证,`test/compact.mjs` 逐条验证:
661
+
662
+ 1. **锁**:`compaction/start` 必须在任何异步操作前同步写入,失败时恰好关闭一次(故意留下可检测的孤儿锁,而不是假报成功)。
663
+ 2. **平衡**:替换区间的两端不能切断「assistant 工具调用 → tool/result」配对。
664
+ 3. **收缩**:摘要必须真的比被替换的内容小,否则拒绝。
665
+
666
+ 另外自己实现还带来一个上游没有的防护:**并发替换检测**。如果摘要生成期间有别的替换落在目标区间之前,区间位置会整体平移——而两个端点 seq 仍然找得到,所以「按 seq 重新解析」这种检查会漏掉它。这里改成比较**绝对位置窗口**,见 `lib/compact.js` 第 6 步。
667
+
668
+ ### 3. compaction 不在 host plane,而在每个 agent preset 内
669
+
670
+ `dsh-web-app` 的 patch 显式禁掉了 host 层的 compaction 行,并留有注释说明「只有读 token meter 的 compaction backend 会搬走」:
671
+
672
+ ```yaml
673
+ # dsh-web-app/cordis.patch.yml:509
674
+ - id: compaction-basic
675
+ disabled: true
676
+ ```
677
+
678
+ 真正生效的挂载点位于 preset 的嵌套 group 里(`dsh-web-app/presets/standard.patch.yml:62`):
679
+
680
+ ```yaml
681
+ - id: compaction
682
+ name: cordis:group
683
+ group: true
684
+ isolate:
685
+ compaction: true
686
+ toolResultPruner: true
687
+ config:
688
+ - id: compaction-basic
689
+ name: '@deepseek-ai/dsh-compaction-basic'
690
+ ```
691
+
692
+ 这意味着:
693
+
694
+ - 替换 `ctx.compaction` 服务必须**改 dsh 安装目录里的 preset 文件**,会与后续 dsh 升级冲突。**本插件刻意不做这件事。**
695
+ - 若要把整棵 preset 快照进 profile patch,`!!js process.platform === 'win32'` 会被序列化成**普通字符串**(已实测)。字符串是 truthy,会直接禁掉 Windows 上的 `tool-pwsh`——**这是个真实的破坏性陷阱**。
696
+
697
+ **推论(踩过一次坑)**:`compaction` 只在 preset 内部可用,host plane 上没有。所以本插件曾经把 `compaction` 写进 `inject`,结果启动时报:
698
+
699
+ ```
700
+ context-pruner (dsh-context-pruner): pending (waiting for service: compaction)
701
+ ```
702
+
703
+ 插件永远等不到这个服务,连带把只读的 `/prune list` 一起废掉。现在 `inject` 只声明 host plane 上确实存在的 `commands` 和 `tokenMeter`;`compaction`、`llm`、`sessions` 全部改为**调用时 `ctx.get()` 解析**,缺失时给出明确错误而不是静默挂起。
704
+
705
+ ### 4. `seq` 不是 surface 位置
706
+
707
+ `CompactionResult.shadowedRange` 的注释明确警告:
708
+
709
+ > A surface-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at an older range's position, `start` can be **GREATER** than `end`.
710
+
711
+ 只要做过一次压缩,surface 上的 seq 就不单调了。**权威的替换集合永远是 `shadowedSeqs`**,不是自己推算的区间。`/prune list` 因此同时显示 `#N`(位置,权威)和 `seq`(日志序号,仅供追溯)。
712
+
713
+ ---
714
+
715
+ ## 纠正式摘要 backend
716
+
717
+ `lib/backend.js` 提供「纠错检查点」摘要,是 `drop` 真正使用的摘要器:
718
+
719
+ - 继承 `BasicCompactionEngine`,只覆盖 `summarize()`(上游文档称其为 *sole subclass customization hook*)
720
+ - 用「纠正检查点」指令替换原版的中性摘要指令
721
+ - 要求输出结构为 `## Original Request and Intent` / `## Retired Exploration — DO NOT TRUST` / `## Authoritative Conclusion` / `## Carried-Forward Facts` / `## Next Step`
722
+ - 明确要求把错误结论**只**记为「被否决的方向」,不得当作 finding 复述
723
+ - 12 项测试全通过,包括图像输出拒绝、token 截断、provider 失败、空摘要等边界
724
+
725
+ ### 它是怎么被调用的
726
+
727
+ **没有**把它挂载成 `ctx.compaction`。原因见上文第 3 点:那需要改 dsh 安装目录里的 preset 文件。
728
+
729
+ `summarize()` 只读 `this.ctx` 和 `this.config`,所以可以把它作用在一个普通对象上:
730
+
731
+ ```js
732
+ CorrectiveCompactionEngine.prototype.summarize.call({ ctx: { llm }, config }, input, agent, signal)
733
+ ```
734
+
735
+ 这样 `drop` 用的是纠正式摘要,而**完全不需要接管压缩服务**——profile 里原有的 `/compact` 和自动压缩行为一点没变。
736
+
737
+ ---
738
+
739
+ ## 文件布局
740
+
741
+ ```
742
+ dsh-context-pruner/
743
+ ├── package.json # dsh.bundle.patch 指向 cordis.patch.yml
744
+ ├── cordis.patch.yml # bundle patch:插入 context-pruner 行
745
+ ├── lib/
746
+ │ ├── index.js # /prune 命令(list / drop)
747
+ │ ├── inspect.js # surface → 可读轮次
748
+ │ ├── rounds.js # 「什么算一轮」的唯一定义(host/client 共用)
749
+ │ ├── compact.js # 手动版区域压缩事务(自实现)
750
+ │ ├── backend.js # 纠正式摘要 backend
751
+ │ └── client.js # 右侧栏 Context 面板(手写 bundle)
752
+ └── test/
753
+ ├── run.mjs # 统一入口(十二套)
754
+ ├── harness.js # 测试脚手架
755
+ ├── smoke.mjs # backend 契约
756
+ ├── render.mjs # 真实 Session 上的输出可读性
757
+ ├── checkpoint-preview.mjs # 摘要节点的预览
758
+ ├── compact.mjs # 区域压缩事务
759
+ ├── drop.mjs # drop 端到端
760
+ ├── drop-arguments.mjs # drop 参数与预览准确性
761
+ ├── drop-span.mjs # 跨度形式解析
762
+ ├── multi-range.mjs # 不连续选择
763
+ ├── rounds.mjs # host/client 轮次判定一致性
764
+ ├── live-load.mjs # 真实 Cordis 装载
765
+ ├── audit-jsx.mjs # JSX 参数个数审计
766
+ ├── preview-rules.mjs # 面板预览规则
767
+ ├── inspect-session.mjs # ┐ 排查真实会话日志的脚本
768
+ ├── classify-user-messages.mjs # │ (非测试,手动运行)
769
+ ├── verify-grouping.mjs # ┘
770
+ └── verify_*.py # 早期浏览器验证脚本
771
+ ```
772
+
773
+ ---
774
+
775
+ ## 兼容性
776
+
777
+ - 针对 `dsh 0.2.0-rc.2` 开发与验证
778
+ - `peerDependencies` 精确锁定 `0.2.0-rc.2`,DSH 安装时会校验 peer 范围
779
+ - 依赖 `ctx.tokenMeter.measure(session)` 返回的 `TokenMeasurement`(`nodes[].seq` / `.tokens`、`surfaceTokens`、`totalTokens`)
780
+
781
+ ---
782
+
783
+ ## License
784
+
785
+ MIT