dsh-speak 1.8.1 → 1.8.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/README.md CHANGED
@@ -93,19 +93,28 @@ macOS:
93
93
 
94
94
  DSH web app:
95
95
 
96
- - Tested against **DSH 0.1.5-rc.1**. Two host/client APIs changed after 0.1.1, both
97
- handled here (1.8.0):
98
- - `@deepseek-ai/dsh-settings` deleted the `installSettingsSection` /
99
- `settingsNamespace` helpers — the plugin now registers its namespace through
100
- the `settings` **service**. On those older releases the plugin aborted the
101
- host boot (`settingsNamespace is not a function`); a missing settings provider
102
- now just leaves the composed patch `config` in force.
103
- - the Session snapshot stopped carrying Conversation target data — the 🔊 button
104
- resolves the clicked message through the Chat target hook `useChat`.
96
+ - Tested against **DSH 0.1.7-rc.2**. Host/client APIs changed twice since 0.1.1,
97
+ both handled here:
98
+ - **0.1.7 replaced the settings provider API with Config projection**: the
99
+ settings service now reads each active Loader entry's own exported `Config`
100
+ schema and projects its *volatile* fields into the settings UI
101
+ (`ctx.settings.describe()` on the host, `ctx.configForms` in the browser).
102
+ `settings.register(namespace, schema, { base })` — the 1.6.0–1.8.x wiring —
103
+ is gone, and the browser service `settingsScope` was replaced by
104
+ `configForms`. This plugin therefore exports its schema as `Config` and the
105
+ settings namespace is its **entry id** (`dsh-speak`; a leftover
106
+ `speech-hook` row from 1.8.x is still bound, see below).
107
+ - **0.1.2** stopped putting Conversation target data into the Session snapshot
108
+ (the 🔊 button resolves the clicked message through the Chat target hook
109
+ `useChat`), and deleted `@deepseek-ai/dsh-settings`'s `installSettingsSection`
110
+ / `settingsNamespace` helpers.
105
111
  - The host floor lives where dsh-market reads it: `engines.dsh` in `package.json`
106
- (`>=0.1.5-rc.1`). The catalog card and its "compatible with current DSH" filter
112
+ (`>=0.1.7-rc.2`). The catalog card and its "compatible with current DSH" filter
107
113
  read exactly that field, so the floor moves only after a release has been
108
114
  verified against the new host.
115
+ - 1.8.2 requires the settings model introduced in 0.1.7: on an older host the
116
+ browser half would wait forever for the `configForms` service. Use 1.8.1 for
117
+ DSH ≤ 0.1.6.
109
118
 
110
119
  ## Install & quick start
111
120
 
@@ -113,18 +122,35 @@ DSH web app:
113
122
 
114
123
  ```powershell
115
124
  # 1. install the plugin into your web profile (adds dsh-speak to
116
- # ~/.dsh/profiles/web/package.json dependencies)
125
+ # ~/.dsh/profiles/web/package.json dependencies AND to dsh.profile.bundles)
117
126
  dsh plugin --profile web add dsh-speak
118
127
 
119
- # 2. register it in ~/.dsh/profiles/web/cordis.patch.yml
120
- # (for npm packages the bare package name is used — no file:/// URL needed):
121
- # - insert:
122
- # - id: speech-hook
123
- # name: 'dsh-speak'
128
+ # 2. that is all — the package ships its own bundle patch, which registers the
129
+ # `dsh-speak` entry. Do NOT also hand-write an `insert:` row for it:
130
+ # registering the same entry twice makes the id non-unique, and DSH's config
131
+ # editor then rejects every settings write with
132
+ # `settings/rejected: Configuration for "dsh-speak" is overridden by a home
133
+ # patch or command-line overlay` (speech keeps working, the settings page
134
+ # silently bounces).
135
+ #
136
+ # To pin options in YAML anyway, add ONLY this top-level row (the settings page
137
+ # edits it in place; `config` on a top-level row is the shape the editor
138
+ # supports — see "DSH plugin config"):
139
+ # - id: dsh-speak
140
+ # name: 'dsh-speak'
141
+ # config: {}
142
+ #
143
+ # The entry id IS the settings namespace on DSH >= 0.1.7: your options are
144
+ # stored under that key in this same file. A row left over from 1.8.x
145
+ # (id: speech-hook) keeps working — the browser half binds either id.
124
146
 
125
147
  # 3. restart the DSH web app — replies are now announced automatically
126
148
  ```
127
149
 
150
+ > Installing from the in-app marketplace is the same path: it adds the package to
151
+ > `dsh.profile.bundles`. Only the manual installs below need hand-written rows.
152
+ > **One registration path per profile** — never both.
153
+
128
154
  > **No pnpm?** `dsh plugin` forwards to pnpm, which is not installed on every
129
155
  > machine. The exact same install can be done with npm directly:
130
156
  >
@@ -137,6 +163,10 @@ dsh plugin --profile web add dsh-speak
137
163
  > ```bash
138
164
  > npm install --prefix "$HOME/.dsh/profiles/web" dsh-speak
139
165
  > ```
166
+ >
167
+ > An `npm install` alone does **not** register the plugin: add either the bundle
168
+ > name to `~/.dsh/profiles/web/package.json` → `dsh.profile.bundles`, or the two
169
+ > rows from the file-install section — not both.
140
170
 
141
171
  The engine ships inside the package (`node_modules/dsh-speak/engine/`), so no extra
142
172
  copying is needed.
@@ -182,8 +212,11 @@ npm install --prefix "$HOME/.dsh/profiles/web" dsh-speak
182
212
 
183
213
  # 2. register in ~/.dsh/profiles/web/cordis.patch.yml (bare package name — no file:/// URL):
184
214
  # - insert:
185
- # - id: speech-hook
215
+ # - id: dsh-speak
186
216
  # name: 'dsh-speak'
217
+ # - id: dsh-speak
218
+ # name: 'dsh-speak'
219
+ # config: {}
187
220
 
188
221
  # 3. no restart needed — the patch watcher hot-reloads; replies are announced
189
222
  # after the throttle (~1.5 s); tool-calling replies are announced at turn end
@@ -267,8 +300,8 @@ speak.ps1 -Text "…" -Volume 50 -Rate 1 -MaxChars 300 -LongTextMessage "本次
267
300
 
268
301
  ### DSH plugin config
269
302
 
270
- **Either way works, and they stay in sync** (both write the same settings
271
- document):
303
+ **Either way works, and they stay in sync** (both write the same profile patch
304
+ document — the settings service persists UI edits into the row it read them from):
272
305
 
273
306
  1. **Web UI (1.7.0, recommended)**: a dedicated Settings → dsh-speak settings
274
307
  page. Every option is editable and saved there (visible in `dsh --dump-config`,
@@ -277,41 +310,71 @@ document):
277
310
 
278
311
  ```yaml
279
312
  # ~/.dsh/profiles/web/cordis.patch.yml
313
+ # TWO rows, and the shape matters: `insert` provides the entry, the TOP-LEVEL row
314
+ # carries the config the settings page edits. DSH's config editor rewrites a
315
+ # config in place only on a top-level row; a config nested inside the insert row
316
+ # (what 1.8.x profiles and the 1.8.2 notes first showed) makes the UI accept an
317
+ # edit, apply it live, and then silently roll it back on disk — the value reverts
318
+ # on the next boot.
280
319
  - insert:
281
- - id: speech-hook
320
+ - id: dsh-speak # the entry id IS the settings namespace (0.1.7+)
282
321
  name: 'dsh-speak'
283
- config:
284
- enabled: true # master switch: false silences everything
285
- automaticSpeech: true # auto-speak final replies
286
- queueAllMessages: false # true = enqueue every assistant message as it arrives
287
- replayFullRead: false # true = manual replay skips the long-text truncation, reads everything
288
- cleanMarkdownFormatting: true # convert Markdown to natural speech
289
- readInlineCode: true # read inline code without backticks
290
- codeBlocks: smart # all | smart | replace (fenced code blocks)
291
- codeBlockMaxChars: 300 # smart-mode code block character limit
292
- codeBlockReplacementText: 'You can see the code in our history.' # replace-mode text
293
- throttleMs: 1500 # merge delay before announcing (ms)
294
- engine: '' # engine path override; '' = auto-resolve
295
- announceApprovals: true # speak approval requests
296
- announceQuestions: true # speak ask_user_question content
297
- stripApprovalPrefix: true # strip "escalate sandbox to ...: " prefix
298
- questionGapMs: 2000 # pause between multiple question announcements (ms)
299
- longTextMode: message # message | heading (speak largest md heading)
300
- longTextMessage: '本次播报内容较长,请自行阅读。' # fixed prompt for message mode
301
- maxChars: 300 # per-utterance ceiling (macOS default 0 = unlimited)
302
- volume: 50 # Windows only
303
- rate: 0 # 0 = engine default (Windows SAPI scale / macOS wpm)
304
- # —— optional event announcements (1.6.0, all off by default) ——
305
- announceTurnEnd: false # turn/end — "第 N 轮对话完成"
306
- announceCommandDone: false # command/done — command finished/failed
307
- announceGoalChange: false # goal/change — goal created/updated/completed
308
- announceToolErrors: false # tool/result error — announce (english dropped)
309
- announceTodoWrite: false # todo/write — todo list updated
322
+ - id: dsh-speak
323
+ name: 'dsh-speak'
324
+ config:
325
+ enabled: true # master switch: false silences everything
326
+ automaticSpeech: true # auto-speak final replies
327
+ queueAllMessages: false # true = enqueue every assistant message as it arrives
328
+ replayFullRead: false # true = manual replay skips the long-text truncation, reads everything
329
+ cleanMarkdownFormatting: true # convert Markdown to natural speech
330
+ readInlineCode: true # read inline code without backticks
331
+ codeBlocks: smart # all | smart | replace (fenced code blocks)
332
+ codeBlockMaxChars: 300 # smart-mode code block character limit
333
+ codeBlockReplacementText: 'You can see the code in our history.' # replace-mode text
334
+ throttleMs: 1500 # merge delay before announcing (ms)
335
+ engine: '' # engine path override; '' = auto-resolve
336
+ announceApprovals: true # speak approval requests
337
+ announceQuestions: true # speak ask_user_question content
338
+ stripApprovalPrefix: true # strip "escalate sandbox to ...: " prefix
339
+ questionGapMs: 2000 # pause between multiple question announcements (ms)
340
+ longTextMode: message # message | heading (speak largest md heading)
341
+ longTextMessage: '本次播报内容较长,请自行阅读。' # fixed prompt for message mode
342
+ maxChars: 300 # per-utterance ceiling (macOS default 0 = unlimited)
343
+ volume: 50 # Windows only
344
+ rate: 0 # 0 = engine default (Windows SAPI scale / macOS wpm)
345
+ # —— optional event announcements (1.6.0, all off by default) ——
346
+ announceTurnEnd: false # turn/end — "第 N 轮对话完成"
347
+ announceCommandDone: false # command/done — command finished/failed
348
+ announceGoalChange: false # goal/change — goal created/updated/completed
349
+ announceToolErrors: false # tool/result error — announce (english dropped)
350
+ announceTodoWrite: false # todo/write — todo list updated
310
351
  ```
311
352
 
312
353
  > Resolution order: schema default → patch `config` → UI user settings. Fields
313
354
  > written in YAML show up in the UI too. Platform note: `maxChars` defaults to
314
355
  > 0 on macOS (`say` has no ceiling) and 300 on Windows (SAPI safe limit).
356
+ >
357
+ > Values outside the ranges in the table (`volume` 0-100, Windows `rate` -10..10)
358
+ > are clamped before they reach the engine — SAPI throws on an out-of-range
359
+ > `Volume`/`Rate`, which surfaces as silence and nothing else. A clamped value is
360
+ > logged as `settings 值超出范围,已钳制: <field> <given> -> <used>`. An explicit
361
+ > `0` is always honored (`volume: 0` silences, `maxChars: 0` means unlimited,
362
+ > `throttleMs: 0` announces without merging).
363
+
364
+ > **Keep the config on the top-level row.** It is not a style preference: with the
365
+ > `config:` block nested inside the `insert` row, the settings page still renders
366
+ > and the running plugin still obeys an edit, but DSH's config editor cannot rewrite
367
+ > that row — it appends a new top-level row and then rolls the write back, so the
368
+ > value silently reverts the next time dsh starts. Check with
369
+ > `python scripts/settings-ui-check.py`, which asserts the write lands in the patch.
370
+
371
+ > **Upgrading from 1.8.x:** edit the options through the settings page, or move
372
+ > your old `config:` block onto the new top-level row (see the shape above). Before
373
+ > 0.1.7 the options lived in a `dsh-speak:` section of `~/.dsh/settings.yaml`; that
374
+ > file was migrated to `settings.yaml.imported` by DSH, and a section whose name
375
+ > matched no Loader entry (1.8.x registered the namespace in code, so `dsh-speak:`
376
+ > matched nothing) was left behind. If you had custom values, copy them into that
377
+ > `config:` block — the entry id (`dsh-speak`) is now the key DSH looks for.
315
378
 
316
379
  #### Option reference
317
380
 
@@ -391,11 +454,11 @@ You can tune behavior without forking, and your changes **survive `npm update`**
391
454
  > run `node scripts/test-engine-static.js`.
392
455
 
393
456
  ```yaml
394
- - insert:
395
- - id: speech-hook
396
- name: 'dsh-speak'
397
- config:
398
- engine: 'C:/Users/<you>/.dsh/hooks/my-speak.ps1' # or ~/.dsh/hooks/my-speak.sh on macOS
457
+ # edit the top-level row (NOT the insert row — see "DSH plugin config")
458
+ - id: dsh-speak
459
+ name: 'dsh-speak'
460
+ config:
461
+ engine: 'C:/Users/<you>/.dsh/hooks/my-speak.ps1' # or ~/.dsh/hooks/my-speak.sh on macOS
399
462
  ```
400
463
 
401
464
  The plugin resolves the engine as `config.engine` → package engine → `~/.dsh/hooks/`,
@@ -415,6 +478,10 @@ You can tune behavior without forking, and your changes **survive `npm update`**
415
478
  | `工具调用出错:Error: cannot read …` spoken | the "is this Chinese?" detail filter only checked for the presence of a CJK character, so a Chinese directory name inside an English error passed it (1.8.0 regression) | fixed in 1.8.0 — the detail now needs more Chinese characters than Latin letters |
416
479
  | Emoji-heavy text silent | SAPI fails silently on emoji | already stripped by the engine |
417
480
  | Plugin not loading | raw Windows path as plugin name | use the `file:///C:/…` URL form (installer does this) |
481
+ | Settings page missing after upgrade to 1.8.2 | profile patch row `disabled: true`, or the entry id is neither `dsh-speak` nor the legacy `speech-hook` | enable the row and use one of those two ids as its `id` |
482
+ | Settings page renders but every change bounces back | DSH < 0.1.7 (the `configForms` service replaced `settingsScope`) | update DSH, or stay on dsh-speak 1.8.1 |
483
+ | Every change bounces back on DSH 0.1.7+, and the plugin log says `已有实例在运行` | the entry is registered **twice** — e.g. the package is in `dsh.profile.bundles` *and* a hand-written `insert:` row exists. The id stops being unique, and the config editor rejects each write (`settings/rejected: Configuration for "dsh-speak" is overridden by a home patch or command-line overlay`); speech keeps working, which is what makes it confusing | keep exactly one registration path (see Option A), then restart |
484
+ | A setting applies immediately but is back to the old value after a restart | the `config:` block sits INSIDE the profile patch's `insert:` row — DSH's config editor rewrites config in place only on a top-level row, and silently rolls the nested write back (it still answers `ok: true`) | split it into the two-row shape from [DSH plugin config](#dsh-plugin-config); `python scripts/settings-ui-check.py` asserts the write lands |
418
485
  | macOS: voice suddenly became "婷婷" | opening the "Spoken Content / Siri Voice" pane drifted the system voice | re-pick via Settings → Accessibility → Spoken Content → System Voice → ⓘ entry |
419
486
  | macOS: no log at `/tmp` | `os.tmpdir()` is `/var/folders/.../T`, not `/tmp` | log is at `$TMPDIR/dsh-speech-hook.log` |
420
487
 
@@ -429,7 +496,7 @@ engine/ harness-agnostic speech engine (PowerShell + SAPI5 / ba
429
496
  speech-summary.ps1 blocking reply-summary announcement
430
497
  adapters/
431
498
  dsh/ DSH web plugin + one-command installer
432
- speech-hook.js session-event trigger (throttle/cancel + optional events + FIFO speech queue + WebSocket + settings registration)
499
+ speech-hook.js session-event trigger (throttle/cancel + optional events + FIFO speech queue + WebSocket + Config-projected settings form)
433
500
  install.ps1 copies + registers + backs up
434
501
  claude-code/
435
502
  stop-hook.ps1 Claude Code Stop hook trigger
@@ -442,7 +509,7 @@ scripts/ tests + manual dev helpers (not shipped in the npm pack
442
509
  test-engine-longtext.js long-text guard contract for BOTH engines (speak.ps1 -DryRun / speak.sh's perl)
443
510
  test-speech-hook.js host plugin: event triggers, queue, tool-error detail filter
444
511
  test-client-bundle.js browser bundle: slot registration + component rendering
445
- test-settings-integration.js settings-service wiring + removed-API guard
512
+ test-settings-integration.js Config-projection wiring (volatile refs + live writes) + removed-API guard
446
513
  session-log-dump.js read a DSH session log (manual: what text reached the engine)
447
514
  settings-ui-check.py Playwright UI check (manual: needs a running, authenticated dsh)
448
515
  dsh-events-check.py Playwright disclosure check (manual)
package/README.zh-CN.md CHANGED
@@ -82,35 +82,53 @@ harness 事件(DSH 会话事件 / Claude Code Stop hook / 任意方式)
82
82
 
83
83
  ### DSH 版本
84
84
 
85
- - 已在 **DSH 0.1.5-rc.1** 上验证。0.1.1 之后有两处 host/客户端 API 变更,本插件
86
- 1.8.0 均已适配:
87
- - `@deepseek-ai/dsh-settings` 删除了 `installSettingsSection` /
88
- `settingsNamespace` 两个辅助导出——插件改为通过 `settings` **服务**注册
89
- namespace(旧版本上原实现会让宿主启动直接崩掉:
90
- `settingsNamespace is not a function`)。没有 settings provider 时,插件照旧
91
- 按 patch `config` 工作。
92
- - Session snapshot 不再携带会话视图(Conversation target)数据——🔊 按钮改为
93
- 通过 Chat 目标的 hook `useChat` 取被点击消息的文本。
85
+ - 已在 **DSH 0.1.7-rc.2** 上验证。0.1.1 之后有两轮 host/客户端 API 变更,本插件均已适配:
86
+ - **0.1.7 把 settings provider API 换成了 Config 投影**:settings 服务改为读取每个
87
+ 已激活 Loader 条目自己导出的 `Config` schema,把其中的 *volatile* 字段投影成设置
88
+ 界面(宿主侧 `ctx.settings.describe()`,浏览器侧 `ctx.configForms`)。1.6.0–1.8.x
89
+ 使用的 `settings.register(namespace, schema, { base })` 已删除,浏览器服务
90
+ `settingsScope` 也被 `configForms` 取代。因此本插件把 schema 作为 `Config` 导出,
91
+ 而设置 namespace 就是它的**条目 id**(`dsh-speak`;1.8.x 遗留的 `speech-hook`
92
+ 条目 id 依然可以绑定,见下)。
93
+ - **0.1.2** 起 Session snapshot 不再携带会话视图(Conversation target)数据——
94
+ 🔊 按钮改为通过 Chat 目标的 hook `useChat` 取被点击消息的文本;同一版本还删除了
95
+ `@deepseek-ai/dsh-settings` 的 `installSettingsSection` / `settingsNamespace`。
94
96
  - 宿主要求声明在 dsh-market 实际读取的位置:`package.json` 的 `engines.dsh`
95
- (`>=0.1.5-rc.1`)。市场卡片与「适配当前 DSH」筛选读的正是这个字段,因此只有在
97
+ (`>=0.1.7-rc.2`)。市场卡片与「适配当前 DSH」筛选读的正是这个字段,因此只有在
96
98
  某个宿主版本上实测通过后,这个下限才会移动。
99
+ - 1.8.2 依赖 0.1.7 引入的设置模型:在更老的宿主上浏览器半侧会一直等待
100
+ `configForms` 服务。DSH ≤ 0.1.6 请用 1.8.1。
97
101
 
98
102
  ## 安装与快速开始
99
103
 
100
104
  ### DSH — 方式 A:npm 插件(推荐)
101
105
 
102
106
  ```powershell
103
- # 1. 把插件装进你的 web profile(会写入 ~/.dsh/profiles/web/package.json 的 dependencies)
107
+ # 1. 把插件装进你的 web profile(会写入 ~/.dsh/profiles/web/package.json 的
108
+ # dependencies **和** dsh.profile.bundles)
104
109
  dsh plugin --profile web add dsh-speak
105
110
 
106
- # 2. 在 ~/.dsh/profiles/web/cordis.patch.yml 里注册(npm 包直接用包名,无需 file:/// URL):
107
- # - insert:
108
- # - id: speech-hook
109
- # name: 'dsh-speak'
111
+ # 2. 到此为止——包自带 bundle patch,条目(id: dsh-speak)由它注册。
112
+ # 不要再手写一行 insert:同一个条目注册两次会让 id 不唯一,DSH 的 config-editor
113
+ # 随后会拒绝每一次设置写入:
114
+ # settings/rejected: Configuration for "dsh-speak" is overridden by a home patch or command-line overlay
115
+ # (语音照常工作,只有设置页静默弹回——所以特别难查。)
116
+ #
117
+ # 如果仍想在 YAML 里固定选项,只加这一行**顶层行**(设置页就地改写它;
118
+ # config 放顶层行才是编辑器支持的形状,见「DSH 插件配置」):
119
+ # - id: dsh-speak
120
+ # name: 'dsh-speak'
121
+ # config: {}
122
+ #
123
+ # 0.1.7 起条目 id 就是设置 namespace:你的选项会以这个 key 存在同一个文件里。
124
+ # 1.8.x 遗留的 id(speech-hook)继续可用——浏览器半侧两个 id 都认。
110
125
 
111
126
  # 3. 重启 DSH web 应用 — 之后回复会被自动播报
112
127
  ```
113
128
 
129
+ > 在应用内插件市场安装是**同一条路径**(会把包加进 `dsh.profile.bundles`);
130
+ > 只有下面两种手动安装才需要手写行。**一个 profile 只能有一种注册方式**,绝不能两种都有。
131
+
114
132
  > **没有 pnpm?** `dsh plugin` 内部转发给 pnpm,并非所有机器都装了。可以用 npm
115
133
  > 直接完成同样的安装:
116
134
  >
@@ -123,6 +141,10 @@ dsh plugin --profile web add dsh-speak
123
141
  > ```bash
124
142
  > npm install --prefix "$HOME/.dsh/profiles/web" dsh-speak
125
143
  > ```
144
+ >
145
+ > 单纯 `npm install` **不会**注册插件:要么把包名加进
146
+ > `~/.dsh/profiles/web/package.json` 的 `dsh.profile.bundles`,要么用文件安装那两行
147
+ > ——不要两者都做。
126
148
 
127
149
  引擎随包分发(`node_modules/dsh-speak/engine/`),无需额外拷贝。
128
150
 
@@ -166,8 +188,11 @@ npm install --prefix "$HOME/.dsh/profiles/web" dsh-speak
166
188
 
167
189
  # 2. 在 ~/.dsh/profiles/web/cordis.patch.yml 末尾注册(裸包名即可,无需 file:/// URL):
168
190
  # - insert:
169
- # - id: speech-hook
191
+ # - id: dsh-speak
170
192
  # name: 'dsh-speak'
193
+ # - id: dsh-speak
194
+ # name: 'dsh-speak'
195
+ # config: {}
171
196
 
172
197
  # 3. 无需重启——patch 监视器会热更新;回复在节流后(约 1.5 秒)自动播报;
173
198
  # 带工具调用的回复会在回合结束时补播最终回复
@@ -247,7 +272,8 @@ speak.ps1 -Text "…" -Volume 50 -Rate 1 -MaxChars 300 -LongTextMessage "本次
247
272
 
248
273
  ### DSH 插件配置
249
274
 
250
- **两种改法,任选其一**(改 UI 或改 YAML 都写进同一个 settings 文档,彼此同步):
275
+ **两种改法,任选其一**(改 UI 或改 YAML 都落进同一份 profile patch——设置服务把 UI 的
276
+ 修改写回它读到的那一行,彼此同步):
251
277
 
252
278
  1. **Web UI(1.7.0,推荐)**:设置 → dsh-speak 设置独立设置页。所有配置项都能直接改并
253
279
  保存(`dsh --dump-config` 可见、按 profile 隔离、升级不丢)。
@@ -255,41 +281,63 @@ speak.ps1 -Text "…" -Volume 50 -Rate 1 -MaxChars 300 -LongTextMessage "本次
255
281
 
256
282
  ```yaml
257
283
  # ~/.dsh/profiles/web/cordis.patch.yml
284
+ # 必须写成两行:insert 行负责“提供条目”,顶层行承载设置页要改写的 config。
285
+ # DSH 的 config-editor 只会就地改写顶层行;config 嵌在 insert 里时,UI 会接受修改、
286
+ # 运行中的插件也会立刻生效,但这次写入随后会被回滚——下次启动 dsh 时值就悄悄变回去了。
258
287
  - insert:
259
- - id: speech-hook
288
+ - id: dsh-speak # 0.1.7 起条目 id 就是设置 namespace
260
289
  name: 'dsh-speak'
261
- config:
262
- enabled: true # 总开关:false 时完全不播报
263
- automaticSpeech: true # 自动朗读最终回复
264
- queueAllMessages: false # true = 所有 assistant 消息立即入队朗读(中间消息也读)
265
- replayFullRead: false # true = 手动重播跳过超长文本截断,完整朗读
266
- cleanMarkdownFormatting: true # Markdown 转自然语音
267
- readInlineCode: true # 朗读行内代码(去掉反引号)
268
- codeBlocks: smart # all | smart | replace(围栏代码块)
269
- codeBlockMaxChars: 300 # smart 模式下的代码块字数上限
270
- codeBlockReplacementText: 'You can see the code in our history.' # replace 时的替代文本
271
- throttleMs: 1500 # 播报前的合并延迟(毫秒)
272
- engine: '' # 引擎路径覆盖;'' = 自动解析
273
- announceApprovals: true # 播报审批请求
274
- announceQuestions: true # 播报 ask_user_question 提问内容
275
- stripApprovalPrefix: true # 剥离审批原因里的 "escalate sandbox to ...: " 前缀
276
- questionGapMs: 2000 # 多个提问播报之间的停顿(毫秒)
277
- longTextMode: message # message | heading(念最大字号 markdown 标题)
278
- longTextMessage: '本次播报内容较长,请自行阅读。' # message 模式下的固定提示语
279
- maxChars: 300 # 引擎单次朗读字数上限(macOS 默认 0 = 不限)
280
- volume: 50 # 仅 Windows
281
- rate: 0 # 0 = 引擎默认(Windows SAPI 刻度 / macOS wpm)
282
- # —— 可选事件播报(1.6.0,默认全关)——
283
- announceTurnEnd: false # 回合结束("第 N 轮对话完成")
284
- announceCommandDone: false # 命令完成/失败(command/done)
285
- announceGoalChange: false # 目标创建/更新/完成(goal/change)
286
- announceToolErrors: false # 工具调用出错时播报(英文详情截掉,tool/result)
287
- announceTodoWrite: false # 待办列表更新(todo/write)
290
+ - id: dsh-speak
291
+ name: 'dsh-speak'
292
+ config:
293
+ enabled: true # 总开关:false 时完全不播报
294
+ automaticSpeech: true # 自动朗读最终回复
295
+ queueAllMessages: false # true = 所有 assistant 消息立即入队朗读(中间消息也读)
296
+ replayFullRead: false # true = 手动重播跳过超长文本截断,完整朗读
297
+ cleanMarkdownFormatting: true # Markdown 转自然语音
298
+ readInlineCode: true # 朗读行内代码(去掉反引号)
299
+ codeBlocks: smart # all | smart | replace(围栏代码块)
300
+ codeBlockMaxChars: 300 # smart 模式下的代码块字数上限
301
+ codeBlockReplacementText: 'You can see the code in our history.' # replace 时的替代文本
302
+ throttleMs: 1500 # 播报前的合并延迟(毫秒)
303
+ engine: '' # 引擎路径覆盖;'' = 自动解析
304
+ announceApprovals: true # 播报审批请求
305
+ announceQuestions: true # 播报 ask_user_question 提问内容
306
+ stripApprovalPrefix: true # 剥离审批原因里的 "escalate sandbox to ...: " 前缀
307
+ questionGapMs: 2000 # 多个提问播报之间的停顿(毫秒)
308
+ longTextMode: message # message | heading(念最大字号 markdown 标题)
309
+ longTextMessage: '本次播报内容较长,请自行阅读。' # message 模式下的固定提示语
310
+ maxChars: 300 # 引擎单次朗读字数上限(macOS 默认 0 = 不限)
311
+ volume: 50 # 仅 Windows
312
+ rate: 0 # 0 = 引擎默认(Windows SAPI 刻度 / macOS wpm)
313
+ # —— 可选事件播报(1.6.0,默认全关)——
314
+ announceTurnEnd: false # 回合结束("第 N 轮对话完成")
315
+ announceCommandDone: false # 命令完成/失败(command/done)
316
+ announceGoalChange: false # 目标创建/更新/完成(goal/change)
317
+ announceToolErrors: false # 工具调用出错时播报(英文详情截掉,tool/result)
318
+ announceTodoWrite: false # 待办列表更新(todo/write)
288
319
  ```
289
320
 
290
321
  > 解析顺序:schema 默认值 → patch `config` → UI 用户设置。写进 YAML 的字段
291
322
  > 同样出现在 UI 中。平台差异:`maxChars` 在 macOS 默认 0(`say` 无上限),
292
323
  > Windows 默认 300(SAPI 安全上限)。
324
+ >
325
+ > 超出上表范围的取值(`volume` 0-100、Windows `rate` -10..10)会在送进引擎前被**钳制**
326
+ > ——SAPI 遇到越界的 `Volume`/`Rate` 会抛异常,表现就是"没声音且没有任何提示"。被钳制
327
+ > 时会记一行日志:`settings 值超出范围,已钳制: <字段> <给出值> -> <实际值>`。
328
+ > 显式写 `0` 一律尊重原意(`volume: 0` 静音、`maxChars: 0` 不限长、`throttleMs: 0` 不合并)。
329
+
330
+ > **`config` 必须写在顶层行上。** 这不是风格问题:如果把 `config:` 嵌在 `insert`
331
+ > 行里,设置页照样能显示、改完运行中的插件也立刻生效,但 DSH 的 config-editor
332
+ > 无法就地改写那一行——它会追加一个顶层行再把写入回滚,于是下次启动 dsh 时值会
333
+ > 悄悄变回去。可以用 `python scripts/settings-ui-check.py` 验证(它会断言写入真的
334
+ > 落进了 profile patch)。
335
+
336
+ > **从 1.8.x 升级:** 直接在设置页里改,或把旧的 `config:` 块挪到新的顶层行上(形状见上)。
337
+ > 0.1.7 之前选项存在 `~/.dsh/settings.yaml` 的 `dsh-speak:` 段里;该文件已被 DSH 迁移为
338
+ > `settings.yaml.imported`,而段名对不上任何 Loader 条目的段(1.8.x 是在代码里注册
339
+ > namespace,所以 `dsh-speak:` 谁也匹配不到)会被留在原地。如果你改过默认值,把那些值
340
+ > 抄进上面那个 `config:` 块即可——现在 DSH 找的 key 就是条目 id(`dsh-speak`)。
293
341
 
294
342
  #### 选项说明
295
343
 
@@ -362,11 +410,11 @@ speak.ps1 -Text "…" -Volume 50 -Rate 1 -MaxChars 300 -LongTextMessage "本次
362
410
  > 或跑 `node scripts/test-engine-static.js`。
363
411
 
364
412
  ```yaml
365
- - insert:
366
- - id: speech-hook
367
- name: 'dsh-speak'
368
- config:
369
- engine: 'C:/Users/<你>/.dsh/hooks/my-speak.ps1' # macOS 用 ~/.dsh/hooks/my-speak.sh
413
+ # 改的是顶层行(不是 insert 行——见「DSH 插件配置」)
414
+ - id: dsh-speak
415
+ name: 'dsh-speak'
416
+ config:
417
+ engine: 'C:/Users/<你>/.dsh/hooks/my-speak.ps1' # macOS 用 ~/.dsh/hooks/my-speak.sh
370
418
  ```
371
419
 
372
420
  插件按 `config.engine` → 包内引擎 → `~/.dsh/hooks/` 的顺序解析引擎,所以你的副本
@@ -386,6 +434,10 @@ speak.ps1 -Text "…" -Volume 50 -Rate 1 -MaxChars 300 -LongTextMessage "本次
386
434
  | 听到 `工具调用出错:Error: cannot read …` | "是否中文"的详情判据只检查"含有汉字",英文报错里夹着中文目录名就能骗过它(1.8.0 引入的回归) | 1.8.0 已修——详情需满足"汉字数量多于拉丁字母数量" |
387
435
  | 含大量 emoji 的文本静默 | SAPI 遇到 emoji 会静默失败 | 引擎已自动剥离 |
388
436
  | 插件加载失败 | 插件名用了 Windows 原始路径 | 改用 `file:///C:/…` URL 形式(安装脚本会自动处理) |
437
+ | 升级到 1.8.2 后设置页不出现 | profile patch 那行写着 `disabled: true`,或条目 id 既不是 `dsh-speak` 也不是旧 id `speech-hook` | 去掉 `disabled`,并把 `id` 改成两者之一 |
438
+ | 设置页能开、但每次改动都被弹回 | DSH < 0.1.7(`configForms` 已取代 `settingsScope`) | 升级 DSH,或继续用 dsh-speak 1.8.1 |
439
+ | DSH 0.1.7+ 上每次改动都被弹回,且插件日志出现 `已有实例在运行` | 条目被注册了**两次**(例如包既在 `dsh.profile.bundles` 里、又手写了一行 `insert:`):id 不再唯一,config-editor 会拒绝每次写入(`settings/rejected: Configuration for "dsh-speak" is overridden by a home patch or command-line overlay`);语音照常工作,所以特别难查 | 只保留一种注册方式(见「方式 A」),然后重启 |
440
+ | 改完立刻生效,但重启后变回旧值 | `config:` 块嵌在 profile patch 的 `insert:` 行里——DSH 的 config-editor 只会就地改写**顶层**行,嵌套写入会被静默回滚(仍然回 `ok: true`) | 按「DSH 插件配置」把 `config` 放到顶层行;`python scripts/settings-ui-check.py` 会断言写入真的落盘 |
389
441
  | macOS:音色突然变成"婷婷" | 打开过"朗读内容 / Siri 声音"设置面板导致系统朗读声音漂移 | 系统设置 → 辅助功能 → 阅读与朗读 → 系统声音 → ⓘ 入口重新选择 |
390
442
  | macOS:在 `/tmp` 找不到日志 | `os.tmpdir()` 是 `/var/folders/.../T`,不是 `/tmp` | 日志在 `$TMPDIR/dsh-speech-hook.log` |
391
443
 
@@ -400,7 +452,7 @@ engine/ 与 harness 无关的语音引擎(PowerShell + SAPI5
400
452
  speech-summary.ps1 阻塞式回复总结播报
401
453
  adapters/
402
454
  dsh/ DSH web 插件 + 一键安装脚本
403
- speech-hook.js 会话事件触发器(节流/取消 + 可选事件 + FIFO 语音队列 + WebSocket + settings 注册)
455
+ speech-hook.js 会话事件触发器(节流/取消 + 可选事件 + FIFO 语音队列 + WebSocket + Config 投影的设置表单)
404
456
  install.ps1 拷贝 + 注册 + 备份
405
457
  claude-code/
406
458
  stop-hook.ps1 Claude Code Stop hook 触发器
@@ -413,7 +465,7 @@ scripts/ 测试 + 手动开发辅助脚本(不随 npm 包发
413
465
  test-engine-longtext.js 两个引擎的长文守卫契约(speak.ps1 -DryRun / speak.sh 的 perl)
414
466
  test-speech-hook.js 宿主插件:事件触发、队列、工具出错详情过滤
415
467
  test-client-bundle.js 浏览器 bundle:slot 注册 + 组件渲染
416
- test-settings-integration.js settings 服务接线 + 已删除 API 的回归守卫
468
+ test-settings-integration.js Config 投影接线(volatile 引用 + 实时写入)+ 已删除 API 回归守卫
417
469
  session-log-dump.js 读取 DSH 会话日志(手动:看引擎究竟收到了什么文本)
418
470
  settings-ui-check.py Playwright UI 检查(手动:需要运行中且已鉴权的 dsh)
419
471
  dsh-events-check.py Playwright 折叠行检查(手动)
@@ -37,11 +37,28 @@ Copy-Item -Force (Join-Path $here 'speech-hook.js') $PluginsDir
37
37
  $pluginUrl = 'file:///' + ((Join-Path $PluginsDir 'speech-hook.js') -replace '\\', '/' -replace ' ', '%20')
38
38
 
39
39
  # ---------- 3. register in cordis.patch.yml ----------
40
+ # The entry id doubles as the settings namespace on DSH >= 0.1.7 (the settings
41
+ # service projects each entry's own Config), so the installer uses the id this
42
+ # package documents: dsh-speak. A profile still carrying the pre-1.8.2 row
43
+ # (id: speech-hook) is left alone — the browser half binds either id — so a
44
+ # re-run never registers the plugin twice.
45
+ #
46
+ # Two rows, deliberately: `insert` provides the entry, and the TOP-LEVEL row is the
47
+ # one the settings page persists into. DSH's config editor rewrites a `config` in
48
+ # place only on a top-level row; a `config` nested inside the insert is accepted by
49
+ # the UI, applied to the running plugin, and then silently rolled back on disk.
40
50
  Write-Host "==> Registering plugin in cordis.patch.yml"
41
51
  if (Test-Path $cordisPatch) {
42
52
  $existing = Get-Content $cordisPatch -Raw -Encoding UTF8
43
- if ($existing -match '(?m)^\s*- id:\s*speech-hook\b') {
44
- Write-Host " speech-hook already registered — skipping (nothing to do)."
53
+ $registered = $existing -match '(?m)^\s*- id:\s*dsh-speak\b'
54
+ $legacy = $existing -match '(?m)^\s*- id:\s*speech-hook\b'
55
+ if ($registered -or $legacy) {
56
+ if ($legacy -and -not $registered) {
57
+ Write-Host " an older 'id: speech-hook' row already registers dsh-speak — skipping."
58
+ Write-Host " (optional) rename that row's id to dsh-speak so your saved options live under the documented key."
59
+ } else {
60
+ Write-Host " dsh-speak already registered — skipping (nothing to do)."
61
+ }
45
62
  Write-Host ""
46
63
  Write-Host "Done. Restart the DSH web app to pick up the plugin."
47
64
  exit 0
@@ -52,21 +69,29 @@ if (Test-Path $cordisPatch) {
52
69
  Write-Host " backup -> $backup"
53
70
  $block = @"
54
71
 
55
- # speech-hook: auto voice-announce assistant replies (installed by dsh-speak)
72
+ # dsh-speak: auto voice-announce assistant replies (installed by dsh-speak)
73
+ # insert = the entry itself; the top-level row is what the settings page edits.
56
74
  - insert:
57
- - id: speech-hook
75
+ - id: dsh-speak
58
76
  name: '$pluginUrl'
77
+ - id: dsh-speak
78
+ name: '$pluginUrl'
79
+ config: {}
59
80
  "@
60
81
  Add-Content -Path $cordisPatch -Value $block -Encoding UTF8
61
- Write-Host " appended insert entry -> $cordisPatch"
82
+ Write-Host " appended insert + settings entry -> $cordisPatch"
62
83
  } else {
63
84
  New-Item -ItemType Directory -Force -Path (Split-Path $cordisPatch) | Out-Null
64
85
  $content = @"
65
86
  # dsh profile patch layer (created by dsh-speak installer)
66
- # speech-hook: auto voice-announce assistant replies
87
+ # dsh-speak: auto voice-announce assistant replies
88
+ # insert = the entry itself; the top-level row is what the settings page edits.
67
89
  - insert:
68
- - id: speech-hook
90
+ - id: dsh-speak
69
91
  name: '$pluginUrl'
92
+ - id: dsh-speak
93
+ name: '$pluginUrl'
94
+ config: {}
70
95
  "@
71
96
  Set-Content -Path $cordisPatch -Value $content -Encoding UTF8
72
97
  Write-Host " created -> $cordisPatch"