dsh-dupguard 1.0.0 → 1.0.2

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
@@ -2,6 +2,36 @@
2
2
 
3
3
  本文件遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [1.0.2] - 2026-08-27
6
+
7
+ ### Fixed
8
+
9
+ - 修复 Markdown 表格误截停:新增 `ignoredChars` 白名单(默认 `['-', '|']`),
10
+ 表格分隔行(如 `|---|---|`)与长分隔线不再被误判为复读;夹带白名单字符的真实复读
11
+ (如 `-ab-` ×10)仍会被识别。测试套件新增 4 项,双入口各 20 项。
12
+
13
+ ### Changed
14
+
15
+ - 验证与 DSH `0.1.1-rc.1` 兼容:`llm/stream` 事件签名、`StreamChunk` 协议、适配器关闭语义
16
+ (`consumer.abort`)、`llm-invariant` 校验、`BlockAssembler`、agent-loop 流消费、
17
+ `CordisInspectRegistryService`(`providers` 字段与 `register` 语义)均无变化,插件无需改动;
18
+ `dsh-agent-presets` 的 standing-mount 多代并存缺陷 `0.1.1-rc.1` 仍未修复,兼容补丁
19
+ (`fixStandingMountConflict`)仍然必要(文档表述更新为"≤ 0.1.1-rc.1")。
20
+ - 验证与 DSH `0.1.0-rc.7` 兼容:`llm/stream` 事件签名、`StreamChunk` 协议、适配器关闭语义
21
+ (`consumer.abort`)、`llm-invariant` 校验、`BlockAssembler`、agent-loop 流消费、
22
+ `CordisInspectRegistryService`(`providers` 字段与 `register` 语义)均与 rc.6 一致,
23
+ 插件无需改动;`dsh-agent-presets` 的 standing-mount 多代并存缺陷 rc.7 仍未修复,
24
+ 兼容补丁(`fixStandingMountConflict`)仍然必要(文档表述由"≤ rc.6"更新为"≤ rc.7")。
25
+
26
+ ## [1.0.1] - 2026-08-17
27
+
28
+ ### Added
29
+
30
+ - DSH ≤ rc.6 兼容补丁(`fixStandingMountConflict`,默认开启):幂等化 `cordisInspect.register`,
31
+ 修复 preset standing-mount 多代并存导致的 "Host Cordis inspect provider ... is already registered"
32
+ (截停后模型操作报 resume failed、必须重启 DSH 才能恢复)问题。
33
+ - 测试套件新增第 16 项(幂等补丁行为),双入口各 16 项。
34
+
5
35
  ## [1.0.0] - 2026-08-16
6
36
 
7
37
  ### Added
package/README.md CHANGED
@@ -21,8 +21,11 @@ When triggered, the already-generated text is committed as a normal assistant me
21
21
  - **多种复读形态**:单字符循环、词语循环、带空格/换行分隔的复读均能识别(默认去空白后检测)。
22
22
  - **真正的服务端停止**:提前关闭流迭代 → 适配器 `consumer.abort()` → 中断 HTTP 连接,模型在服务端停止生成。
23
23
  - **安全停止**:绝不 `abort()` agent 步骤信号;补发协议合规的 `block-end` + `finish(stop)`,消息正常提交。
24
+ - **Markdown 表格友好**:默认忽略连字符与竖线(`ignoredChars` 白名单,可配置),表格分隔行与长分隔线不会被误判为复读。
24
25
  - **零依赖 / 零配置**:纯 JavaScript,无运行时依赖;默认配置开箱即用。
25
26
  - **双入口交付**:动态插件(`plugin/host.js`)+ npm 组合挂载(`lib/index.js`),行为一致、CI 防漂移。
27
+ - **内置 DSH 兼容补丁**(`fixStandingMountConflict`,默认开启):幂等化 `cordisInspect.register`,
28
+ 修复 DSH ≤ 0.1.1-rc.1 的 preset standing-mount 多代并存冲突(见下文"已知限制")。
26
29
 
27
30
  ---
28
31
 
@@ -39,16 +42,53 @@ When triggered, the already-generated text is committed as a normal assistant me
39
42
 
40
43
  Paste the entire content of [`plugin/host.js`](plugin/host.js) as `code.host` in `cordis_define`, then activate the returned `packageId` with `cordis_run`.
41
44
 
42
- ### 方式二:npm 安装 + 组合挂载(常驻)/ npm + composition (persistent)
45
+ ### 方式二:npm 安装 + 组合挂载(常驻,随 DSH 启动)/ npm + composition (persistent)
46
+
47
+ 插件已发布到 npm:[`dsh-dupguard`](https://www.npmjs.com/package/dsh-dupguard)。
48
+
49
+ **1. 在 DSH profile 目录安装依赖**(例如 web GUI 的 `$DSH_HOME/profiles/web`):
43
50
 
44
51
  ```bash
45
- npm install dsh-dupguard
52
+ pnpm add dsh-dupguard # 或 npm install dsh-dupguard
46
53
  ```
47
54
 
48
- 然后在你的 preset 组合文件(cordis.yml)中加入本插件的行并重启 DSH。具体挂载写法以官方发布指南为准:
49
- [deepseek-ai/deepseek-harness · docs/user/develop/basic/publish.zh.md](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/basic/publish.zh.md)(npm 生态与发布流程)。
55
+ **2. 在 profile 的用户补丁层 `cordis.patch.yml` 插入组合行**:
50
56
 
51
- Then add the plugin row to your preset composition (cordis.yml) and restart DSH; follow the official [publish guide](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/develop/basic/publish.md) for the exact mounting syntax.
57
+ ```yaml
58
+ # $DSH_HOME/profiles/web/cordis.patch.yml
59
+ - insert:
60
+ - id: dupguard
61
+ name: dsh-dupguard
62
+ ```
63
+
64
+ - 用户补丁层在 bundle 层之后、`--patch` 之前应用;运行中的 DSH 通过 `watchUserPatches`
65
+ **热重载**它——保存即生效,无需重启;加载失败会事务性回滚,不影响应用。
66
+ - 验证:`dsh --profile web --dump-config` 末尾应出现 `dupguard` 行;loader 日志会出现
67
+ `apply plugin dupguard`。
68
+ - **撤销**:删掉该 `insert` 项即可热卸载。
69
+
70
+ **本机当前状态**:`profiles/web` 的组合行仍以 `file:` URL 直连仓库 `lib/index.js`
71
+ (发布前的过渡形态)。切换到 npm 包只需两步:在 `$DSH_HOME/profiles/web` 下执行
72
+ `pnpm add dsh-dupguard`,再把 `cordis.patch.yml` 中该行的 `name` 改为 `dsh-dupguard`。
73
+
74
+ **本地开发替代**:未发布/调试时,`name` 也可直接用 `file:` URL 指向仓库内的
75
+ [`lib/index.js`](lib/index.js)(CJS 导出 `{ name, apply }`,与 loader 的
76
+ `unwrapExports` 兼容,零构建)。
77
+
78
+ The plugin is published on npm as [`dsh-dupguard`](https://www.npmjs.com/package/dsh-dupguard).
79
+ Install it inside the DSH profile (e.g. `pnpm add dsh-dupguard` under
80
+ `$DSH_HOME/profiles/web`), then insert the row `{ id: dupguard, name: dsh-dupguard }` into the
81
+ profile's user patch layer `cordis.patch.yml`. The running DSH hot-reloads that file
82
+ (`watchUserPatches`) — no restart needed; a failed reload rolls back transactionally. Verify with
83
+ `dsh --profile web --dump-config` or the loader log line `apply plugin dupguard`; remove the
84
+ `insert` entry to uninstall.
85
+
86
+ **Current state on this machine**: the `profiles/web` row still points at the repo's
87
+ `lib/index.js` via a `file:` URL (pre-publish transitional form). To switch to the npm package,
88
+ run `pnpm add dsh-dupguard` in `$DSH_HOME/profiles/web` and change that row's `name` to
89
+ `dsh-dupguard`. The `file:` URL form also remains handy for local development against
90
+ [`lib/index.js`](lib/index.js) (CJS, `module.exports = { name, apply }`, compatible with the
91
+ loader's `unwrapExports`, no build step).
52
92
 
53
93
  ---
54
94
 
@@ -65,8 +105,10 @@ Edit the `CONFIG` block at the top of `plugin/host.js` / `lib/index.js` (both en
65
105
  | `maxUnitLength` | `80` | 最大重复单元长度 / maximum repeating-unit length |
66
106
  | `detectionWindow` | `8192` | 检测滚动窗口(字符,去空白后)/ rolling detection window in chars (after whitespace removal) |
67
107
  | `stripWhitespace` | `true` | 检测前移除空白/换行,识别带分隔符的复读 / strip whitespace so `"x x x"` and `"x\nx\nx"` are caught |
108
+ | `ignoredChars` | `['-', '\|']` | 检测时忽略的字符白名单:Markdown 表格分隔行(连字符与竖线)不参与重复统计;需更严格检测时置 `[]` / whitelist of characters ignored during detection: Markdown table separators don't count as repetition; set `[]` for stricter detection |
68
109
  | `monitorReasoning` | `false` | 是否检测思考文本 / also guard reasoning (thinking) text — off by default, high false-positive risk |
69
110
  | `monitorToolArguments` | `false` | 是否检测工具调用参数 / also guard tool-call JSON args — off by default (base64/JSON repeats are common) |
111
+ | `fixStandingMountConflict` | `true` | DSH ≤ 0.1.1-rc.1 兼容补丁:幂等化 `cordisInspect.register`,修复 preset standing-mount 多代并存冲突 / idempotent `cordisInspect.register` patch for the DSH ≤ 0.1.1-rc.1 standing-mount conflict |
70
112
 
71
113
  ---
72
114
 
@@ -120,8 +162,9 @@ agent-loop commits the partial text as a normal assistant message.
120
162
  | 前缀后循环 / loop after prefix | `好的,下面开始回答:` + `循环` ×10 |
121
163
 
122
164
  **不会触发 / Won't trigger**:正常文本中的高频词(检测只针对**连续**重复)、重复 9 次及以下、
123
- reasoning 与工具参数(默认关闭)。/ high-frequency words in normal prose (consecutive runs only),
124
- ≤9 repeats, reasoning and tool args (off by default).
165
+ reasoning 与工具参数(默认关闭)、Markdown 表格分隔行与长分隔线(连字符与竖线在白名单中,默认忽略)。
166
+ / high-frequency words in normal prose (consecutive runs only), ≤9 repeats, reasoning and tool args
167
+ (off by default), Markdown table separator rows and horizontal rules (whitelisted by default).
125
168
 
126
169
  ---
127
170
 
@@ -134,7 +177,8 @@ reasoning 与工具参数(默认关闭)。/ high-frequency words in normal p
134
177
  ├── lib/
135
178
  │ └── index.js # npm/组合常驻形式(package.json main 入口)
136
179
  ├── tests/
137
- │ └── detector.test.js # 端到端测试:15 项 × 2 入口(防漂移)
180
+ │ ├── detector.test.js # 端到端测试:15 项 × 2 入口(防漂移)
181
+ │ └── experiment-cancel.mjs # 诊断实验(不进 CI):验证截停不阻塞于底层流取消
138
182
  ├── .github/workflows/ci.yml # GitHub Actions:Node 18/20/22
139
183
  ├── package.json
140
184
  ├── CHANGELOG.md
@@ -164,6 +208,37 @@ Node 18/20/22.
164
208
  - 服务端停止依赖适配器在流关闭时中止底层请求的语义(已验证 `dsh-llm-deepseek`;自定义适配器需自查)。
165
209
  - 阈值语义为 `>= threshold`:第 10 次重复出现时即停止。
166
210
 
211
+ ### DSH 运行期间编辑 preset 后的 standing-mount 冲突(DSH ≤ 0.1.1-rc.1 缺陷,本插件已内置补丁)
212
+
213
+ **现象**:对某个会话执行模型选择等操作时报
214
+ `resume failed ... preset ... failed to mount ... Host Cordis inspect provider "Service" is already registered`,
215
+ 此后该错误持续出现,只有**重启 DSH** 才能恢复。
216
+
217
+ **机制**:preset 以 standing mount 方式**每 preset 挂载一次**并常驻;当 preset 的 composition 文件在
218
+ DSH 运行期间被编辑过(mtime/size 变化),下一次对"无活跃 agent 的会话"的操作(模型切换、打开历史会话等)
219
+ 会**新建一代 standing mount**,而**旧代从不销毁**(DSH 注释明示 "a superseded one is never disposed
220
+ while the process lives")。`tool-cordis` 在每次挂载时向**进程全局**的 `cordisInspect` 注册表注册
221
+ `Service`/`Event`/`Builtin`/`Tool` 四个 provider,新旧两代并存即冲突;失败的新代回滚、旧代残留,
222
+ 重试永远重复冲突——这正是报错后"必须重启才能恢复"的原因。
223
+
224
+ **本插件的修复(默认开启)**:`apply` 时把 `cordisInspect.register` **幂等化**——同 id 已有注册时
225
+ 共享既有注册并返回 no-op disposer,多代并存不再冲突。补丁进程内常驻(卸载本插件后仍生效,
226
+ 重启后由本插件重新安装;HMR 重载不会叠加)。依赖 `cordisInspect.providers` 为可读 Map
227
+ (rc.6 / rc.7 / 0.1.1-rc.1 实测如此);DSH 升级修复后可将 `CONFIG.fixStandingMountConflict` 置为 `false` 关闭。
228
+
229
+ **仍建议的操作纪律**:运行期间编辑已挂载 preset 后重启 DSH(补丁消除的是报错,旧代残留的
230
+ 组合仍占用资源,这是 DSH 的既有行为);根治仍待上游修复。
231
+
232
+ If you edit a mounted preset's `agent.cordis.yml` while DSH is running, the next session resume
233
+ (triggered e.g. by the model picker on a session whose agent is gone) mounts a NEW standing-mount
234
+ generation of that preset while the old generation is never disposed — `tool-cordis` then registers
235
+ its process-global Host inspect providers (`Service` …) twice and every retry fails with
236
+ `Host Cordis inspect provider "Service" is already registered` until DSH restarts. **This plugin
237
+ patches it by default**: `cordisInspect.register` is made idempotent (a same-id registration shares
238
+ the existing one and gets a no-op disposer), so coexisting generations no longer collide. The patch
239
+ is process-resident (survives plugin unload, reinstalled on restart; HMR reload does not stack it);
240
+ set `CONFIG.fixStandingMountConflict` to `false` once a fixed DSH ships.
241
+
167
242
  ## License
168
243
 
169
244
  [MIT](LICENSE)
package/lib/index.js CHANGED
@@ -28,11 +28,24 @@ const CONFIG = {
28
28
  // 检测前先移除所有空白字符(含换行):
29
29
  // 让 "重复 重复 重复"、"重复\n重复\n重复" 这类带分隔符的复读也能被识别。
30
30
  stripWhitespace: true,
31
+ // 检测时忽略的字符(白名单):Markdown 表格的分隔行由连字符与竖线组成
32
+ // (如 "|---|---|"),正常表格输出会大量连续出现,不应视为复读。
33
+ // 默认忽略连字符与竖线;需要更严格的检测时可改为空数组 []。
34
+ ignoredChars: ['-', '|'],
31
35
  // 是否同时检测思考(reasoning)文本。默认关闭:思考内容不可见,且正常思考文本
32
36
  // 更可能出现连续重复片段,误伤风险高。
33
37
  monitorReasoning: false,
34
38
  // 是否同时检测工具调用参数(JSON 片段)。默认关闭:JSON / base64 中重复字符很常见。
35
39
  monitorToolArguments: false,
40
+ // DSH ≤ 0.1.1-rc.1 兼容补丁(默认开启;实测 0.1.1-rc.1 仍未修复):preset 的 standing mount
41
+ // 在 composition 文件变化后
42
+ // 会新建一代而旧代永不销毁,tool-cordis 每次挂载都向进程全局的 cordisInspect 注册表
43
+ // 注册 Service/Event/Builtin/Tool provider,两代并存即抛
44
+ // "Host Cordis inspect provider ... is already registered"(表现为:截停后模型操作报
45
+ // resume failed,且必须重启 DSH 才能恢复)。开启后本插件把 cordisInspect.register
46
+ // 幂等化(同 id 已注册时共享注册),消除多代冲突。补丁进程内常驻(卸载本插件后
47
+ // 依然生效,重启 DSH 后由本插件重新安装);DSH 升级修复后可关闭。
48
+ fixStandingMountConflict: true,
36
49
  }
37
50
 
38
51
  /**
@@ -68,6 +81,24 @@ function stripWhitespace(text) {
68
81
  return text.replace(/\s+/g, '')
69
82
  }
70
83
 
84
+ /** 移除白名单字符(与 CONFIG.ignoredChars 配合)。 */
85
+ function stripIgnoredChars(text, ignored) {
86
+ if (ignored.length === 0) return text
87
+ let out = ''
88
+ for (let i = 0; i < text.length; i++) {
89
+ const ch = text[i]
90
+ if (ignored.indexOf(ch) === -1) out += ch
91
+ }
92
+ return out
93
+ }
94
+
95
+ /** 供检测使用的增量清洗:去空白 + 移除白名单字符。 */
96
+ function sanitizeDelta(delta, config) {
97
+ let piece = config.stripWhitespace ? stripWhitespace(delta) : delta
98
+ if (config.ignoredChars.length > 0) piece = stripIgnoredChars(piece, config.ignoredChars)
99
+ return piece
100
+ }
101
+
71
102
  /**
72
103
  * 为一次 llm/stream 调用创建守卫。
73
104
  * 每次模型调用都会新建一份状态,互不干扰。
@@ -99,7 +130,7 @@ function createStreamGuard(options) {
99
130
  /** 把一段文本增量喂给检测缓冲,返回命中结果(null 表示未命中)。 */
100
131
  function feedText(b, delta) {
101
132
  b.text += delta
102
- const piece = CONFIG.stripWhitespace ? stripWhitespace(delta) : delta
133
+ const piece = sanitizeDelta(delta, CONFIG)
103
134
  b.stripped = (b.stripped + piece).slice(-CONFIG.detectionWindow)
104
135
  return findRepeatedTail(b.stripped, CONFIG.threshold, CONFIG.minUnitLength, CONFIG.maxUnitLength)
105
136
  }
@@ -132,7 +163,8 @@ function createStreamGuard(options) {
132
163
  if (chunk.name !== undefined) b.toolCallName = chunk.name
133
164
  b.toolCallArguments += chunk.argumentsDelta
134
165
  if (CONFIG.monitorToolArguments) {
135
- b.stripped = b.toolCallArguments.slice(-CONFIG.detectionWindow)
166
+ const piece = sanitizeDelta(chunk.argumentsDelta, CONFIG)
167
+ b.stripped = (b.stripped + piece).slice(-CONFIG.detectionWindow)
136
168
  const hit = findRepeatedTail(b.stripped, CONFIG.threshold, CONFIG.minUnitLength, CONFIG.maxUnitLength)
137
169
  if (hit !== null) stopped = hit
138
170
  }
@@ -205,7 +237,39 @@ function createStreamGuard(options) {
205
237
  return guarded
206
238
  }
207
239
 
240
+ /**
241
+ * DSH ≤ 0.1.1-rc.1 standing-mount 兼容补丁:把 cordisInspect.register 幂等化。
242
+ *
243
+ * 背景:preset 的 standing mount 在 composition 文件变化后新建一代、旧代永不销毁;
244
+ * tool-cordis 每次挂载都向进程全局的 cordisInspect 注册 Service/Event/Builtin/Tool
245
+ * provider,两代并存即抛 "already registered"。幂等化后同 id 的后续注册共享已有
246
+ * 注册(返回 no-op disposer),多代并存不再冲突。
247
+ *
248
+ * 说明:补丁有意不做撤销(常驻进程,重启后由本插件重新安装);依赖
249
+ * cordisInspect.providers 为可读 Map(实测 rc.6 / rc.7 / 0.1.1-rc.1 如此)。
250
+ * DSH 升级修复后可将 CONFIG.fixStandingMountConflict 置为 false。
251
+ */
252
+ function installStandingMountPatch(ctx) {
253
+ const inspect = ctx.get('cordisInspect')
254
+ if (inspect === undefined || typeof inspect.register !== 'function') return
255
+ if (inspect.register.__dupguardIdempotent === true) return
256
+ const original = inspect.register.bind(inspect)
257
+ const patched = (registration) => {
258
+ const id = registration && typeof registration === 'object' ? registration.manifest?.id : undefined
259
+ if (typeof id === 'string' && inspect.providers !== undefined && inspect.providers.has(id)) {
260
+ // 同 id 已有注册(旧代仍活着):共享它,返回 no-op disposer,
261
+ // 新代卸载时不得注销共享注册。
262
+ return () => {}
263
+ }
264
+ return original(registration)
265
+ }
266
+ patched.__dupguardIdempotent = true
267
+ inspect.register = patched
268
+ console.log('[dupguard] cordisInspect.register 已幂等化(DSH standing-mount 多代并存兼容补丁)')
269
+ }
270
+
208
271
  function apply(ctx) {
272
+ if (CONFIG.fixStandingMountConflict) installStandingMountPatch(ctx)
209
273
  // llm/stream:包裹每次流式模型调用的瀑布事件。
210
274
  // 监听器返回包装后的 AsyncIterable,即成为本次调用对消费方可见的流。
211
275
  // (与 @deepseek-ai/dsh-llm 的 invariant、dsh-session-checkpoint-policy 同款接入方式)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dupguard",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Real-time repetition guard for DeepSeek Harness (DSH): stops model generation when the same string repeats >=10 times in the streamed output. 实时检测 DSH 大模型流式输出中的重复内容,同一字符串重复十次以上立即停止生成。",
5
5
  "keywords": [
6
6
  "dsh-plugin",
package/plugin/host.js CHANGED
@@ -25,11 +25,24 @@ const CONFIG = {
25
25
  // 检测前先移除所有空白字符(含换行):
26
26
  // 让 "重复 重复 重复"、"重复\n重复\n重复" 这类带分隔符的复读也能被识别。
27
27
  stripWhitespace: true,
28
+ // 检测时忽略的字符(白名单):Markdown 表格的分隔行由连字符与竖线组成
29
+ // (如 "|---|---|"),正常表格输出会大量连续出现,不应视为复读。
30
+ // 默认忽略连字符与竖线;需要更严格的检测时可改为空数组 []。
31
+ ignoredChars: ['-', '|'],
28
32
  // 是否同时检测思考(reasoning)文本。默认关闭:思考内容不可见,且正常思考文本
29
33
  // 更可能出现连续重复片段,误伤风险高。
30
34
  monitorReasoning: false,
31
35
  // 是否同时检测工具调用参数(JSON 片段)。默认关闭:JSON / base64 中重复字符很常见。
32
36
  monitorToolArguments: false,
37
+ // DSH ≤ 0.1.1-rc.1 兼容补丁(默认开启;实测 0.1.1-rc.1 仍未修复):preset 的 standing mount
38
+ // 在 composition 文件变化后
39
+ // 会新建一代而旧代永不销毁,tool-cordis 每次挂载都向进程全局的 cordisInspect 注册表
40
+ // 注册 Service/Event/Builtin/Tool provider,两代并存即抛
41
+ // "Host Cordis inspect provider ... is already registered"(表现为:截停后模型操作报
42
+ // resume failed,且必须重启 DSH 才能恢复)。开启后本插件把 cordisInspect.register
43
+ // 幂等化(同 id 已注册时共享注册),消除多代冲突。补丁进程内常驻(卸载本插件后
44
+ // 依然生效,重启 DSH 后由本插件重新安装);DSH 升级修复后可关闭。
45
+ fixStandingMountConflict: true,
33
46
  }
34
47
 
35
48
  /**
@@ -65,6 +78,24 @@ function stripWhitespace(text) {
65
78
  return text.replace(/\s+/g, '')
66
79
  }
67
80
 
81
+ /** 移除白名单字符(与 CONFIG.ignoredChars 配合)。 */
82
+ function stripIgnoredChars(text, ignored) {
83
+ if (ignored.length === 0) return text
84
+ let out = ''
85
+ for (let i = 0; i < text.length; i++) {
86
+ const ch = text[i]
87
+ if (ignored.indexOf(ch) === -1) out += ch
88
+ }
89
+ return out
90
+ }
91
+
92
+ /** 供检测使用的增量清洗:去空白 + 移除白名单字符。 */
93
+ function sanitizeDelta(delta, config) {
94
+ let piece = config.stripWhitespace ? stripWhitespace(delta) : delta
95
+ if (config.ignoredChars.length > 0) piece = stripIgnoredChars(piece, config.ignoredChars)
96
+ return piece
97
+ }
98
+
68
99
  /**
69
100
  * 为一次 llm/stream 调用创建守卫。
70
101
  * 每次模型调用都会新建一份状态,互不干扰。
@@ -96,7 +127,7 @@ function createStreamGuard(options) {
96
127
  /** 把一段文本增量喂给检测缓冲,返回命中结果(null 表示未命中)。 */
97
128
  function feedText(b, delta) {
98
129
  b.text += delta
99
- const piece = CONFIG.stripWhitespace ? stripWhitespace(delta) : delta
130
+ const piece = sanitizeDelta(delta, CONFIG)
100
131
  b.stripped = (b.stripped + piece).slice(-CONFIG.detectionWindow)
101
132
  return findRepeatedTail(b.stripped, CONFIG.threshold, CONFIG.minUnitLength, CONFIG.maxUnitLength)
102
133
  }
@@ -129,7 +160,8 @@ function createStreamGuard(options) {
129
160
  if (chunk.name !== undefined) b.toolCallName = chunk.name
130
161
  b.toolCallArguments += chunk.argumentsDelta
131
162
  if (CONFIG.monitorToolArguments) {
132
- b.stripped = b.toolCallArguments.slice(-CONFIG.detectionWindow)
163
+ const piece = sanitizeDelta(chunk.argumentsDelta, CONFIG)
164
+ b.stripped = (b.stripped + piece).slice(-CONFIG.detectionWindow)
133
165
  const hit = findRepeatedTail(b.stripped, CONFIG.threshold, CONFIG.minUnitLength, CONFIG.maxUnitLength)
134
166
  if (hit !== null) stopped = hit
135
167
  }
@@ -202,8 +234,40 @@ function createStreamGuard(options) {
202
234
  return guarded
203
235
  }
204
236
 
237
+ /**
238
+ * DSH ≤ 0.1.1-rc.1 standing-mount 兼容补丁:把 cordisInspect.register 幂等化。
239
+ *
240
+ * 背景:preset 的 standing mount 在 composition 文件变化后新建一代、旧代永不销毁;
241
+ * tool-cordis 每次挂载都向进程全局的 cordisInspect 注册 Service/Event/Builtin/Tool
242
+ * provider,两代并存即抛 "already registered"。幂等化后同 id 的后续注册共享已有
243
+ * 注册(返回 no-op disposer),多代并存不再冲突。
244
+ *
245
+ * 说明:补丁有意不做撤销(常驻进程,重启后由本插件重新安装);依赖
246
+ * cordisInspect.providers 为可读 Map(实测 rc.6 / rc.7 / 0.1.1-rc.1 如此)。
247
+ * DSH 升级修复后可将 CONFIG.fixStandingMountConflict 置为 false。
248
+ */
249
+ function installStandingMountPatch(ctx) {
250
+ const inspect = ctx.get('cordisInspect')
251
+ if (inspect === undefined || typeof inspect.register !== 'function') return
252
+ if (inspect.register.__dupguardIdempotent === true) return
253
+ const original = inspect.register.bind(inspect)
254
+ const patched = (registration) => {
255
+ const id = registration && typeof registration === 'object' ? registration.manifest?.id : undefined
256
+ if (typeof id === 'string' && inspect.providers !== undefined && inspect.providers.has(id)) {
257
+ // 同 id 已有注册(旧代仍活着):共享它,返回 no-op disposer,
258
+ // 新代卸载时不得注销共享注册。
259
+ return () => {}
260
+ }
261
+ return original(registration)
262
+ }
263
+ patched.__dupguardIdempotent = true
264
+ inspect.register = patched
265
+ console.log('[dupguard] cordisInspect.register 已幂等化(DSH standing-mount 多代并存兼容补丁)')
266
+ }
267
+
205
268
  return {
206
269
  apply(ctx) {
270
+ if (CONFIG.fixStandingMountConflict) installStandingMountPatch(ctx)
207
271
  // llm/stream:包裹每次流式模型调用的瀑布事件。
208
272
  // 监听器返回包装后的 AsyncIterable,即成为本次调用对消费方可见的流。
209
273
  // (与 @deepseek-ai/dsh-llm 的 invariant、dsh-session-checkpoint-policy 同款接入方式)