dsh-speak 1.4.0 → 1.5.0

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
@@ -6,6 +6,8 @@
6
6
 
7
7
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
8
8
 
9
+ [![npm version](https://img.shields.io/npm/v/dsh-speak)](https://www.npmjs.com/package/dsh-speak)
10
+
9
11
  Let your agent **tell you** when a long task is done — no more staring at the screen.
10
12
 
11
13
  dsh-speak reads the final assistant reply aloud through system speech synthesis —
@@ -62,6 +64,11 @@ harness event (DSH session event / Claude Code Stop hook / anything)
62
64
 
63
65
  - **Automatic**: DSH web plugin watches the session event stream and announces the
64
66
  final reply (skips reasoning/tool-call narration, merges multi-step messages).
67
+ - **Gets your attention**: announces approval requests (hears "需要你的审批" when
68
+ the agent is waiting on you) and questions the agent asks via `ask_user_question`.
69
+ - **Bundle auto-registration** (1.3.0): declare the package in `dsh.profile.bundles`
70
+ and the plugin registers itself via the bundled `cordis.patch.yml` — no manual
71
+ patch entry needed.
65
72
  - **Best-effort**: never throws, never blocks the harness, never breaks a session.
66
73
  - **Natural voices**: Windows prefers natural voices — Windows 11 built-in packs,
67
74
  or voices registered via NaturalVoiceSAPIAdapter on Windows 10 (e.g. Xiaoxiao);
@@ -240,12 +247,27 @@ Engine parameters (see [docs/DESIGN.md](docs/DESIGN.md#5-configuration-reference
240
247
  speak.ps1 -Text "…" -Volume 50 -Rate 1 -MaxChars 300 -LongTextMessage "本次播报内容较长,请自行阅读。"
241
248
  ```
242
249
 
243
- DSH plugin environment variables:
250
+ DSH plugin configuration — **prefer the profile patch `config` block** (visible in
251
+ `dsh --dump-config`, per-profile, survives npm updates):
252
+
253
+ ```yaml
254
+ # ~/.dsh/profiles/web/cordis.patch.yml
255
+ - insert:
256
+ - id: speech-hook
257
+ name: 'dsh-speak'
258
+ config:
259
+ throttleMs: 1500 # merge delay before announcing (ms)
260
+ engine: '' # engine path override; '' = auto-resolve
261
+ announceApprovals: true # speak approval requests
262
+ announceQuestions: true # speak ask_user_question content
263
+ stripApprovalPrefix: true # strip the "escalate sandbox to ...: " prefix
264
+ longTextMode: message # message | heading (speak largest md heading)
265
+ maxChars: 300 # engine per-utterance ceiling
266
+ volume: 50 # Windows only
267
+ rate: 0 # 0 = engine default (Windows SAPI scale / macOS wpm)
268
+ ```
244
269
 
245
- | var | default | meaning |
246
- | --- | ------- | ------- |
247
- | `DSH_SPEAK_ENGINE` | empty (auto-resolved) | engine path override; otherwise resolved as `<package>/engine/<platform script>` → `~/.dsh/hooks/<platform script>` (Windows `speak.ps1` / macOS `speak.sh`) |
248
- | `DSH_SPEAK_THROTTLE_MS` | `1500` | merge delay before announcing |
270
+ See [docs/CUSTOMIZATION.md](docs/CUSTOMIZATION.md) for the full customization guide.
249
271
 
250
272
  ## Customizing (survives npm updates)
251
273
 
@@ -259,20 +281,24 @@ You can tune behavior without forking, and your changes **survive `npm update`**
259
281
  Copy-Item "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-speak\engine\speak.ps1" "$env:USERPROFILE\.dsh\hooks\my-speak.ps1"
260
282
  # macOS
261
283
  cp ~/.dsh/profiles/web/node_modules/dsh-speak/engine/speak.sh ~/.dsh/hooks/my-speak.sh
262
- # edit my-speak.ps1 / my-speak.sh to taste, then point the plugin at it:
263
- setx DSH_SPEAK_ENGINE "$env:USERPROFILE\.dsh\hooks\my-speak.ps1" # Windows
264
- echo 'export DSH_SPEAK_ENGINE=~/.dsh/hooks/my-speak.sh' >> ~/.zshrc # macOS
265
284
  ```
266
285
 
267
- The plugin resolves the engine as `DSH_SPEAK_ENGINE` package engine `~/.dsh/hooks/`,
268
- so your copy wins. `npm update` only touches the package — your engine stays.
286
+ Then point the plugin at your copy in the `config` block:
287
+
288
+ ```yaml
289
+ - insert:
290
+ - id: speech-hook
291
+ name: 'dsh-speak'
292
+ config:
293
+ engine: 'C:/Users/<you>/.dsh/hooks/my-speak.ps1' # or ~/.dsh/hooks/my-speak.sh on macOS
294
+ ```
269
295
 
270
- 2. **Environment variables** (no code changes): `DSH_SPEAK_ENGINE` (engine path),
271
- `DSH_SPEAK_THROTTLE_MS` (announcement merge delay).
296
+ The plugin resolves the engine as `config.engine` → package engine → `~/.dsh/hooks/`,
297
+ so your copy wins. `npm update` only touches the package — your engine stays.
272
298
 
273
- 3. **Edit the file inside `node_modules`** — works, but the next `npm update` overwrites it.
299
+ 2. **Edit the file inside `node_modules`** — works, but the next `npm update` overwrites it.
274
300
 
275
- 4. **Fork the repo** — full control, publish your own package if you want.
301
+ 3. **Fork the repo** — full control, publish your own package if you want.
276
302
 
277
303
  ## Troubleshooting
278
304
 
package/README.zh-CN.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
6
6
 
7
+ [![npm version](https://img.shields.io/npm/v/dsh-speak)](https://www.npmjs.com/package/dsh-speak)
8
+
7
9
  让 Agent 在长任务完成时**开口告诉你**——不用再盯着屏幕等。
8
10
 
9
11
  dsh-speak 通过系统语音合成把 Agent 的最终回复朗读出来——Windows 上优先使用自然
@@ -58,6 +60,10 @@ harness 事件(DSH 会话事件 / Claude Code Stop hook / 任意方式)
58
60
 
59
61
  - **全自动**:DSH web 插件监听会话事件流,自动播报最终回复
60
62
  (跳过 reasoning/工具调用旁白,合并同一回复的多步消息)。
63
+ - **提醒你**:审批请求(Agent 等你操作时会播"需要你的审批")和 Agent 通过
64
+ `ask_user_question` 提出的问题都会播报。
65
+ - **Bundle 自动注册**(1.3.0):把包声明进 `dsh.profile.bundles`,插件通过包内
66
+ 自带的 `cordis.patch.yml` 自动注册,无需手动写 patch 条目。
61
67
  - **尽力而为**:绝不抛错、绝不阻塞 harness、绝不破坏会话。
62
68
  - **自然语音**:Windows 优先使用自然语音——Windows 11 内置语音包,或 Windows 10
63
69
  上经 NaturalVoiceSAPIAdapter 注册的语音(如晓晓);macOS 使用系统朗读声音
@@ -225,12 +231,26 @@ powershell -NoProfile -ExecutionPolicy Bypass -File engine\speech-prompt.ps1 -Te
225
231
  speak.ps1 -Text "…" -Volume 50 -Rate 1 -MaxChars 300 -LongTextMessage "本次播报内容较长,请自行阅读。"
226
232
  ```
227
233
 
228
- DSH 插件环境变量:
234
+ DSH 插件配置——**优先用 profile patch 的 `config` 块**(`dsh --dump-config` 可见、按 profile 隔离、升级不丢):
235
+
236
+ ```yaml
237
+ # ~/.dsh/profiles/web/cordis.patch.yml
238
+ - insert:
239
+ - id: speech-hook
240
+ name: 'dsh-speak'
241
+ config:
242
+ throttleMs: 1500 # 播报前的合并延迟(毫秒)
243
+ engine: '' # 引擎路径覆盖;'' = 自动解析
244
+ announceApprovals: true # 播报审批请求
245
+ announceQuestions: true # 播报 ask_user_question 提问内容
246
+ stripApprovalPrefix: true # 剥离审批原因里的 "escalate sandbox to ...: " 前缀
247
+ longTextMode: message # message | heading(念最大字号 markdown 标题)
248
+ maxChars: 300 # 引擎单次朗读字数上限
249
+ volume: 50 # 仅 Windows
250
+ rate: 0 # 0 = 引擎默认(Windows SAPI 刻度 / macOS wpm)
251
+ ```
229
252
 
230
- | 变量 | 默认值 | 含义 |
231
- | --- | ------- | ---- |
232
- | `DSH_SPEAK_ENGINE` | 空(自动解析) | 引擎路径覆盖;否则按"包内 `engine/<平台脚本>` → `~/.dsh/hooks/<平台脚本>`"顺序解析(Windows `speak.ps1` / macOS `speak.sh`) |
233
- | `DSH_SPEAK_THROTTLE_MS` | `1500` | 播报前的合并延迟(毫秒) |
253
+ 完整自定义指南见 [docs/CUSTOMIZATION.md](docs/CUSTOMIZATION.zh-CN.md)。
234
254
 
235
255
  ## 自定义(升级不丢)
236
256
 
@@ -243,19 +263,24 @@ DSH 插件环境变量:
243
263
  Copy-Item "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-speak\engine\speak.ps1" "$env:USERPROFILE\.dsh\hooks\my-speak.ps1"
244
264
  # macOS
245
265
  cp ~/.dsh/profiles/web/node_modules/dsh-speak/engine/speak.sh ~/.dsh/hooks/my-speak.sh
246
- # 按喜好编辑 my-speak.ps1 / my-speak.sh,然后让插件指向它:
247
- setx DSH_SPEAK_ENGINE "$env:USERPROFILE\.dsh\hooks\my-speak.ps1" # Windows
248
- echo 'export DSH_SPEAK_ENGINE=~/.dsh/hooks/my-speak.sh' >> ~/.zshrc # macOS
249
266
  ```
250
267
 
251
- 插件按 `DSH_SPEAK_ENGINE` → 包内引擎 → `~/.dsh/hooks/` 的顺序解析引擎,所以你的副本
252
- 优先生效;`npm update` 只动包本身,你的引擎安然无恙。
268
+ 然后在 config 块里指向你的副本:
269
+
270
+ ```yaml
271
+ - insert:
272
+ - id: speech-hook
273
+ name: 'dsh-speak'
274
+ config:
275
+ engine: 'C:/Users/<你>/.dsh/hooks/my-speak.ps1' # macOS 用 ~/.dsh/hooks/my-speak.sh
276
+ ```
253
277
 
254
- 2. **环境变量**(零改代码):`DSH_SPEAK_ENGINE`(引擎路径)、`DSH_SPEAK_THROTTLE_MS`(播报合并延迟)。
278
+ 插件按 `config.engine` → 包内引擎 → `~/.dsh/hooks/` 的顺序解析引擎,所以你的副本
279
+ 优先生效;`npm update` 只动包本身,你的引擎安然无恙。
255
280
 
256
- 3. **直接改 `node_modules` 里的文件**——能改,但下次 `npm update` 会被覆盖。
281
+ 2. **直接改 `node_modules` 里的文件**——能改,但下次 `npm update` 会被覆盖。
257
282
 
258
- 4. **fork 仓库**——完全掌控,想发自己的包也行。
283
+ 3. **fork 仓库**——完全掌控,想发自己的包也行。
259
284
 
260
285
  ## 排障
261
286
 
@@ -8,12 +8,11 @@
8
8
  // Trigger semantics:
9
9
  // * only events with a `text` block are announced (reasoning / tool_use blocks
10
10
  // are skipped)
11
- // * when a tool/call event arrives, that round's assistant text is treated as
12
- // process narration, so any pending announcement is cancelled EXCEPT a
13
- // call to `ask_user_question`, which is a question for the user and keeps
14
- // the pending text so it is announced
15
- // * `approval/asked` is announced immediately (approval reason, or a fixed
16
- // prompt) since approvals are time-sensitive
11
+ // * a tool/call to `ask_user_question` announces the parsed question
12
+ // (title + single/multi + options); other tool calls cancel the pending
13
+ // announcement (that round's assistant text is process narration)
14
+ // * `approval/asked` is announced immediately (reason with the fixed English
15
+ // template prefix stripped, or a fixed prompt)
17
16
  // * a final reply with no following tool/call is announced after a throttle
18
17
  // delay (merges multi-step messages from the same reply)
19
18
  //
@@ -24,11 +23,20 @@
24
23
  // name: 'file:///C:/Users/<your-username>/.../speech-hook.js' # repo/file install (replace <your-username>)
25
24
  // (run adapters/dsh/install.ps1 to do this automatically for the file install)
26
25
  //
27
- // Configuration (environment variables, optional):
28
- // DSH_SPEAK_ENGINE path to the engine script (speak.ps1 / speak.sh)
29
- // (default: <package>/engine/<platform script>, then
30
- // ~/.dsh/hooks/<platform script>)
31
- // DSH_SPEAK_THROTTLE_MS throttle delay before announcing (default: 1500)
26
+ // Configuration — prefer the profile patch `config` block (see docs/CUSTOMIZATION.md):
27
+ // - insert:
28
+ // - id: speech-hook
29
+ // name: 'dsh-speak'
30
+ // config:
31
+ // throttleMs: 1500 # merge delay before announcing (ms)
32
+ // engine: '' # engine path override; '' = auto-resolve
33
+ // announceApprovals: true # speak approval requests
34
+ // announceQuestions: true # speak ask_user_question content
35
+ // stripApprovalPrefix: true # strip "escalate sandbox to ...: " prefix
36
+ // longTextMode: message # message | heading (speak largest md heading)
37
+ // maxChars: 300 # engine per-utterance ceiling
38
+ // volume: 50 # Windows only
39
+ // rate: 0 # 0 = engine default (Windows SAPI scale / macOS wpm)
32
40
  'use strict'
33
41
  const { spawn } = require('child_process')
34
42
  const fs = require('fs')
@@ -43,27 +51,40 @@ function log(...args) {
43
51
  } catch (e) { /* ignore */ }
44
52
  }
45
53
 
46
- const THROTTLE_MS = Number(process.env.DSH_SPEAK_THROTTLE_MS) || 1500
47
54
  const ENGINE_NAME = process.platform === 'darwin' ? 'speak.sh' : 'speak.ps1'
48
55
 
49
56
  /**
50
57
  * Locate the engine script:
51
- * 1. explicit DSH_SPEAK_ENGINE override
58
+ * 1. explicit override (config `engine`)
52
59
  * 2. <this package>/engine/<speak.ps1|speak.sh> — works both when running from
53
60
  * a repo checkout and when installed into a profile's node_modules
54
61
  * 3. legacy file-copy location (~/.dsh/hooks/<speak.ps1|speak.sh>)
55
62
  */
56
- function resolveEngine() {
57
- if (process.env.DSH_SPEAK_ENGINE) return process.env.DSH_SPEAK_ENGINE
63
+ function resolveEngine(override) {
64
+ if (override) return override
58
65
  const bundled = path.join(__dirname, '..', '..', 'engine', ENGINE_NAME)
59
66
  if (fs.existsSync(bundled)) return bundled
60
67
  return path.join(os.homedir(), '.dsh', 'hooks', ENGINE_NAME)
61
68
  }
62
- const SPEAK_ENGINE = resolveEngine()
63
69
 
64
70
  module.exports = {
65
- apply(ctx) {
66
- log('plugin apply 执行(加载成功); engine=', SPEAK_ENGINE, '; throttle=', THROTTLE_MS)
71
+ apply(ctx, config) {
72
+ config = config || {}
73
+ // resolved settings: config > default
74
+ const cfg = {
75
+ throttleMs: Number(config.throttleMs != null ? config.throttleMs : 1500) || 1500,
76
+ engine: resolveEngine(config.engine || ''),
77
+ announceApprovals: config.announceApprovals !== false,
78
+ announceQuestions: config.announceQuestions !== false,
79
+ stripApprovalPrefix: config.stripApprovalPrefix !== false,
80
+ longTextMode: config.longTextMode || 'message',
81
+ maxChars: Number(config.maxChars != null ? config.maxChars : 300) || 300,
82
+ volume: Number(config.volume != null ? config.volume : 50) || 50,
83
+ rate: Number(config.rate != null ? config.rate : 0) || 0,
84
+ }
85
+ log('plugin apply 执行(加载成功); engine=', cfg.engine, '; throttle=', cfg.throttleMs,
86
+ '; longTextMode=', cfg.longTextMode, '; maxChars=', cfg.maxChars)
87
+
67
88
  let timer = null
68
89
  let pendingText = ''
69
90
 
@@ -86,11 +107,18 @@ module.exports = {
86
107
  let ps
87
108
  if (process.platform === 'darwin') {
88
109
  // macOS: run the say-based engine through bash
89
- ps = spawn('/bin/bash', [SPEAK_ENGINE, '-f', tmp], { stdio: 'ignore' })
90
- log('spawn bash (macOS engine) 已发起')
110
+ const args = ['-f', tmp, '-m', String(cfg.maxChars), '-M', cfg.longTextMode]
111
+ if (cfg.rate > 0) args.push('-r', String(cfg.rate))
112
+ ps = spawn('/bin/bash', [cfg.engine].concat(args), { stdio: 'ignore' })
113
+ log('spawn bash (macOS engine) 已发起:', args.join(' '))
91
114
  } else {
92
115
  ps = spawn('powershell.exe',
93
- ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', SPEAK_ENGINE, '-File', tmp],
116
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cfg.engine,
117
+ '-File', tmp,
118
+ '-Volume', String(cfg.volume),
119
+ '-Rate', String(cfg.rate > 0 ? cfg.rate : 1),
120
+ '-MaxChars', String(cfg.maxChars),
121
+ '-LongTextMode', cfg.longTextMode],
94
122
  { windowsHide: true, stdio: 'ignore' })
95
123
  log('spawn powershell 已发起')
96
124
  }
@@ -105,14 +133,31 @@ module.exports = {
105
133
  if (type !== 'assistant/chunk') {
106
134
  log('事件 type=', type, 'surfaceOp=', event && event.surfaceOp, 'seq=', event && event.seq)
107
135
  }
108
- // tool-call round: a call to ask_user_question is a question to the
109
- // user keep the pending text so it gets announced (the user should
110
- // hear the question); any other tool call cancels the pending
111
- // announcement (that round's assistant text is process narration)
136
+ // tool-call round: a call to ask_user_question announces the parsed
137
+ // question (title + mode + options); any other tool call cancels the
138
+ // pending announcement (that round's assistant text is narration)
112
139
  if (type === 'tool/call') {
113
140
  const toolName = event.data && event.data.name
114
- if (toolName === 'ask_user_question') {
115
- log('提问工具调用(ask_user_question)— 保留待播报文本')
141
+ if (toolName === 'ask_user_question' && cfg.announceQuestions) {
142
+ let spoken = ''
143
+ try {
144
+ const args = JSON.parse((event.data && event.data.arguments) || '{}')
145
+ const qs = Array.isArray(args.questions) ? args.questions : []
146
+ spoken = qs.map((q) => {
147
+ const mode = q.multi_select ? '多选' : '单选'
148
+ const labels = Array.isArray(q.options)
149
+ ? q.options.map((o) => o.label).filter(Boolean).join('、')
150
+ : ''
151
+ return (q.question || '') + '(' + mode + ')' + (labels ? ',选项:' + labels : '')
152
+ }).filter(Boolean).join(';')
153
+ } catch (e) { /* arguments 解析失败则回退原逻辑 */ }
154
+ if (spoken) {
155
+ cancelPending()
156
+ log('提问播报:', spoken.slice(0, 120))
157
+ speak(spoken)
158
+ } else {
159
+ log('提问工具调用(ask_user_question)— 保留待播报文本')
160
+ }
116
161
  return
117
162
  }
118
163
  cancelPending()
@@ -120,10 +165,15 @@ module.exports = {
120
165
  }
121
166
  // approval requested: announce it right away (time-sensitive), using
122
167
  // the approval reason if present
123
- if (type === 'approval/asked') {
168
+ if (type === 'approval/asked' && cfg.announceApprovals) {
124
169
  cancelPending()
125
- const reason = event.data && event.data.reason
126
- const text = reason && reason.trim() ? reason : '需要你的审批,请查看界面。'
170
+ let reason = (event.data && event.data.reason) || ''
171
+ if (cfg.stripApprovalPrefix) {
172
+ // strip the fixed English template prefix (e.g. "escalate sandbox
173
+ // to danger-full-access: "), keep the human explanation
174
+ reason = reason.replace(/^escalate sandbox to danger-full-access\s*:\s*/i, '').trim()
175
+ }
176
+ const text = reason || '需要你的审批,请查看界面。'
127
177
  log('审批请求,播报:', text.slice(0, 60))
128
178
  speak(text)
129
179
  return
@@ -154,7 +204,7 @@ module.exports = {
154
204
  speak(pendingText)
155
205
  pendingText = ''
156
206
  timer = null
157
- }, THROTTLE_MS)
207
+ }, cfg.throttleMs)
158
208
  } catch (e) {
159
209
  log('事件处理异常:', e.message)
160
210
  }
@@ -0,0 +1,89 @@
1
+ # CUSTOMIZATION.md — customizing dsh-speak
2
+
3
+ (中文版:docs/CUSTOMIZATION.zh-CN.md)
4
+
5
+ dsh-speak is deliberately small, but it exposes three levels of customization:
6
+ **configuration** (no code), **engine override** (copy & edit), and **extension**
7
+ (new backends / adapters). Everything below survives `npm update`.
8
+
9
+ ---
10
+
11
+ ## 1. Configuration (recommended)
12
+
13
+ Set options in the profile patch `config` block — visible in
14
+ `dsh --dump-config`, per-profile, and never overwritten by npm:
15
+
16
+ ```yaml
17
+ # ~/.dsh/profiles/web/cordis.patch.yml
18
+ - insert:
19
+ - id: speech-hook
20
+ name: 'dsh-speak'
21
+ config:
22
+ throttleMs: 1500 # merge delay before announcing (ms)
23
+ engine: '' # engine path override; '' = auto-resolve
24
+ announceApprovals: true # speak approval requests
25
+ announceQuestions: true # speak ask_user_question content
26
+ stripApprovalPrefix: true # strip "escalate sandbox to ...: " prefix
27
+ longTextMode: message # message | heading (speak largest md heading)
28
+ maxChars: 300 # engine per-utterance ceiling
29
+ volume: 50 # Windows only
30
+ rate: 0 # 0 = engine default (Windows SAPI scale / macOS wpm)
31
+ ```
32
+
33
+ ### Option reference
34
+
35
+ | option | default | effect |
36
+ | ------ | ------- | ------ |
37
+ | `throttleMs` | `1500` | how long a reply's text waits before being announced (merges multi-step messages) |
38
+ | `engine` | `''` | explicit engine script path; `''` auto-resolves: `<package>/engine/<platform>` → `~/.dsh/hooks/<platform>` |
39
+ | `announceApprovals` | `true` | announce `approval/asked` events (reason, or the fixed prompt) |
40
+ | `announceQuestions` | `true` | announce `ask_user_question` calls as "question(单选/多选),选项:…" |
41
+ | `stripApprovalPrefix` | `true` | strip the fixed English template prefix (`escalate sandbox to danger-full-access: `) from approval reasons, keeping the human explanation |
42
+ | `longTextMode` | `message` | `message` = fixed prompt for over-long text; `heading` = speak the largest markdown heading instead (see below) |
43
+ | `maxChars` | `300` | engine per-utterance ceiling (SAPI/NVSAPIAdapter fails silently beyond ~375-470) |
44
+ | `volume` | `50` | Windows only (0-100); macOS volume follows the system |
45
+ | `rate` | `0` | `0` = engine default (Windows SAPI scale, e.g. 1; macOS words-per-minute, e.g. 175) |
46
+
47
+ ### Long-text modes
48
+
49
+ When cleaned text exceeds `maxChars`:
50
+
51
+ - **`message`** (default): speak `LongTextMessage` (`本次播报内容较长,请自行阅读。`, overridable via engine param `-LongTextMessage` / `-l`).
52
+ - **`heading`**: pick the *largest* markdown heading in the raw text — fewest `#` wins, tie → first; if there is no heading line, the first non-empty line is used. The chosen candidate is still cleaned and subject to the `maxChars` ceiling, falling back to the message if it is itself too long.
53
+
54
+ ---
55
+
56
+ ## 2. Engine override (copy & edit)
57
+
58
+ To change what is actually spoken (voice selection, cleaning rules, defaults),
59
+ copy the engine out of the package and point the plugin at your copy:
60
+
61
+ ```powershell
62
+ # Windows
63
+ Copy-Item "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-speak\engine\speak.ps1" "$env:USERPROFILE\.dsh\hooks\my-speak.ps1"
64
+ # macOS
65
+ cp ~/.dsh/profiles/web/node_modules/dsh-speak/engine/speak.sh ~/.dsh/hooks/my-speak.sh
66
+ ```
67
+
68
+ Then set the engine path in config:
69
+
70
+ ```yaml
71
+ config:
72
+ engine: 'C:/Users/<you>/.dsh/hooks/my-speak.ps1' # or ~/.dsh/hooks/my-speak.sh on macOS
73
+ ```
74
+
75
+ `npm update` never touches your copy.
76
+
77
+ ---
78
+
79
+ ## 3. Extension
80
+
81
+ - **New engine backend**: the engine is the single seam. A new backend
82
+ (`speak-edge.ps1` for edge-tts, `speak-piper.ps1` for a local model, ...) keeps
83
+ the same parameter contract and cleaning pipeline — adapters never change.
84
+ See [DESIGN.md §7](DESIGN.md#7-extending).
85
+ - **New harness adapter**: capture the final reply text → call the engine. The
86
+ DSH (event stream), Claude Code (Stop hook), and agent-called
87
+ (`speech-summary.ps1`) patterns are the three references.
88
+ - **Your own published variant**: fork this repo, adjust, publish your own npm
89
+ package — the `dsh.bundle` manifest already makes it `dsh plugin add`-installable.
@@ -0,0 +1,88 @@
1
+ # CUSTOMIZATION.zh-CN.md — dsh-speak 自定义指南
2
+
3
+ (English: docs/CUSTOMIZATION.md)
4
+
5
+ dsh-speak 刻意保持小巧,但提供三层自定义:**配置**(不改代码)、**引擎覆盖**(复制后改)、
6
+ **扩展**(新后端 / 新适配器)。以下所有方式都不会被 `npm update` 覆盖。
7
+
8
+ ---
9
+
10
+ ## 一、配置(推荐)
11
+
12
+ 在 profile patch 的 `config` 块里设置——`dsh --dump-config` 可见、按 profile 隔离、
13
+ npm 更新永不覆盖:
14
+
15
+ ```yaml
16
+ # ~/.dsh/profiles/web/cordis.patch.yml
17
+ - insert:
18
+ - id: speech-hook
19
+ name: 'dsh-speak'
20
+ config:
21
+ throttleMs: 1500 # 播报前的合并延迟(毫秒)
22
+ engine: '' # 引擎路径覆盖;'' = 自动解析
23
+ announceApprovals: true # 播报审批请求
24
+ announceQuestions: true # 播报 ask_user_question 提问内容
25
+ stripApprovalPrefix: true # 剥离审批原因里的 "escalate sandbox to ...: " 前缀
26
+ longTextMode: message # message | heading(念最大字号 markdown 标题)
27
+ maxChars: 300 # 引擎单次朗读字数上限
28
+ volume: 50 # 仅 Windows
29
+ rate: 0 # 0 = 引擎默认(Windows SAPI 刻度 / macOS wpm)
30
+ ```
31
+
32
+ ### 选项说明
33
+
34
+ | 选项 | 默认值 | 效果 |
35
+ | ---- | ------ | ---- |
36
+ | `throttleMs` | `1500` | 回复文本等待多久才播报(合并同一回复的多步消息) |
37
+ | `engine` | `''` | 显式引擎脚本路径;`''` 自动解析:包内 `engine/<平台>` → `~/.dsh/hooks/<平台>` |
38
+ | `announceApprovals` | `true` | 播报 `approval/asked` 事件(审批原因,或固定提示语) |
39
+ | `announceQuestions` | `true` | 把 `ask_user_question` 调用播报成"问题(单选/多选),选项:…" |
40
+ | `stripApprovalPrefix` | `true` | 剥离审批原因里的固定英文模板前缀(`escalate sandbox to danger-full-access: `),保留中文说明 |
41
+ | `longTextMode` | `message` | `message` = 超长念固定提示语;`heading` = 改念最大字号 markdown 标题(规则见下) |
42
+ | `maxChars` | `300` | 引擎单次朗读上限(SAPI/NVSAPIAdapter 超过约 375-470 字会静默失败) |
43
+ | `volume` | `50` | 仅 Windows(0-100);macOS 音量跟随系统 |
44
+ | `rate` | `0` | `0` = 引擎默认(Windows SAPI 刻度如 1;macOS words-per-minute 如 175) |
45
+
46
+ ### 超长文本模式
47
+
48
+ 清洗后文本超过 `maxChars` 时:
49
+
50
+ - **`message`**(默认):念 `LongTextMessage`(`本次播报内容较长,请自行阅读。`,
51
+ 可用引擎参数 `-LongTextMessage` / `-l` 覆盖)。
52
+ - **`heading`**:在原始文本里挑**最大字号**的 markdown 标题——`#` 数量最少者优先,
53
+ 并列取第一个;没有标题行则取第一个非空行。选中的候选仍会清洗并受 `maxChars`
54
+ 上限约束,若其本身仍超长则回退提示语。
55
+
56
+ ---
57
+
58
+ ## 二、引擎覆盖(复制后改)
59
+
60
+ 想改"实际念出来的内容"(音色选择、清洗规则、默认参数),把引擎复制出包并指向你的副本:
61
+
62
+ ```powershell
63
+ # Windows
64
+ Copy-Item "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-speak\engine\speak.ps1" "$env:USERPROFILE\.dsh\hooks\my-speak.ps1"
65
+ # macOS
66
+ cp ~/.dsh/profiles/web/node_modules/dsh-speak/engine/speak.sh ~/.dsh/hooks/my-speak.sh
67
+ ```
68
+
69
+ 然后在 config 里设引擎路径:
70
+
71
+ ```yaml
72
+ config:
73
+ engine: 'C:/Users/<你>/.dsh/hooks/my-speak.ps1' # macOS 用 ~/.dsh/hooks/my-speak.sh
74
+ ```
75
+
76
+ `npm update` 永远不会碰你的副本。
77
+
78
+ ---
79
+
80
+ ## 三、扩展
81
+
82
+ - **新引擎后端**:引擎是唯一接缝。新后端(`speak-edge.ps1` 封装 edge-tts、
83
+ `speak-piper.ps1` 接本地模型……)保持同样的参数契约与清洗管线——适配层不用改。
84
+ 见 [DESIGN.zh-CN.md §7](DESIGN.zh-CN.md#7-扩展)。
85
+ - **新 harness 适配层**:拿到最终回复文本 → 调引擎。DSH(事件流)、Claude Code
86
+ (Stop hook)、Agent 自调用(`speech-summary.ps1`)是三种参考范式。
87
+ - **发布自己的变体**:fork 本仓库、按需调整、发布自己的 npm 包——`dsh.bundle`
88
+ manifest 已让它天然支持 `dsh plugin add` 安装。
package/docs/DESIGN.md CHANGED
@@ -168,13 +168,24 @@ returns immediately. (Async spawning is safe here — the nested-spawn restricti
168
168
  | `-Rate` | `1` | speech rate (SAPI scale) |
169
169
  | `-MaxChars` | `300` | beyond this, replaced by `LongTextMessage` |
170
170
  | `-LongTextMessage`| `本次播报内容较长,请自行阅读。` | spoken instead of over-long text |
171
+ | `-LongTextMode` | `message` | `message` (fixed prompt) \| `heading` (speak the largest markdown heading) |
171
172
 
172
- ### DSH plugin (environment variables)
173
+ ### DSH plugin (profile `config` — recommended; env vars are legacy fallbacks)
173
174
 
174
- | var | default | meaning |
175
- | -------------------- | ---------------------------------------- | ------------------------------ |
176
- | `DSH_SPEAK_ENGINE` | empty (auto-resolved) | engine path override; otherwise resolved as `<package>/engine/<platform script>` → `~/.dsh/hooks/<platform script>` (Windows: `speak.ps1`, macOS: `speak.sh`) |
177
- | `DSH_SPEAK_THROTTLE_MS` | `1500` | merge delay before announcing |
175
+ ```yaml
176
+ config:
177
+ throttleMs: 1500
178
+ engine: '' # '' = auto-resolve
179
+ announceApprovals: true
180
+ announceQuestions: true
181
+ stripApprovalPrefix: true
182
+ longTextMode: message # message | heading
183
+ maxChars: 300
184
+ volume: 50 # Windows only
185
+ rate: 0 # 0 = engine default
186
+ ```
187
+
188
+ Full guide: docs/CUSTOMIZATION.md.
178
189
 
179
190
  ## 6. Pitfalls (hard-won; do not "fix" casually)
180
191
 
@@ -227,7 +238,7 @@ dependencies in the profile). This repository is prepared for that path:
227
238
 
228
239
  `speech-hook.js` locates `engine/speak.ps1` in this order:
229
240
 
230
- 1. `DSH_SPEAK_ENGINE` environment override;
241
+ 1. `config.engine` override;
231
242
  2. `<package>/engine/speak.ps1` resolved relative to the plugin file — covers
232
243
  both a repo checkout and `node_modules/dsh-speak/` after `npm install`;
233
244
  3. legacy `%USERPROFILE%\.dsh\hooks\speak.ps1` (the file-install location).
@@ -155,13 +155,24 @@ Claude Code *确实*有 Stop hook。hook JSON(含 `transcript_path`)从 stdi
155
155
  | `-Rate` | `1` | 语速(SAPI 刻度) |
156
156
  | `-MaxChars` | `300` | 超过此长度时替换为 `LongTextMessage` |
157
157
  | `-LongTextMessage` | `本次播报内容较长,请自行阅读。` | 超长文本时改念这句 |
158
+ | `-LongTextMode` | `message` | `message`(固定提示语)\| `heading`(念最大字号 markdown 标题) |
158
159
 
159
- ### DSH 插件(环境变量)
160
+ ### DSH 插件(profile `config` —— 推荐)
160
161
 
161
- | 变量 | 默认值 | 含义 |
162
- | ---- | ------ | ---- |
163
- | `DSH_SPEAK_ENGINE` | 空(自动解析) | 引擎路径覆盖;否则按"包内 `engine/<平台脚本>` → `~/.dsh/hooks/<平台脚本>`"顺序解析(Windows 为 `speak.ps1`,macOS 为 `speak.sh`) |
164
- | `DSH_SPEAK_THROTTLE_MS` | `1500` | 播报前的合并延迟(毫秒) |
162
+ ```yaml
163
+ config:
164
+ throttleMs: 1500
165
+ engine: '' # '' = 自动解析
166
+ announceApprovals: true
167
+ announceQuestions: true
168
+ stripApprovalPrefix: true
169
+ longTextMode: message # message | heading
170
+ maxChars: 300
171
+ volume: 50 # 仅 Windows
172
+ rate: 0 # 0 = 引擎默认
173
+ ```
174
+
175
+ 完整指南:docs/CUSTOMIZATION.zh-CN.md。
165
176
 
166
177
  ## 6. 踩坑记录(来之不易;不要随意"修复")
167
178
 
@@ -212,7 +223,7 @@ DSH 的插件机制基于 Cordis,官方安装树外插件的路径是
212
223
 
213
224
  `speech-hook.js` 按以下顺序定位 `engine/speak.ps1`:
214
225
 
215
- 1. `DSH_SPEAK_ENGINE` 环境变量覆盖;
226
+ 1. `config.engine` 覆盖;
216
227
  2. 相对插件文件解析 `<包>/engine/speak.ps1`——同时覆盖仓库检出和
217
228
  `npm install` 后的 `node_modules/dsh-speak/`;
218
229
  3. 旧的 `%USERPROFILE%\.dsh\hooks\speak.ps1`(文件安装的位置)。
package/engine/speak.ps1 CHANGED
@@ -29,7 +29,9 @@ param(
29
29
  [int]$Volume = 50,
30
30
  [int]$Rate = 1,
31
31
  [int]$MaxChars = 300,
32
- [string]$LongTextMessage = '本次播报内容较长,请自行阅读。'
32
+ [string]$LongTextMessage = '本次播报内容较长,请自行阅读。',
33
+ [ValidateSet('message', 'heading')]
34
+ [string]$LongTextMode = 'message'
33
35
  )
34
36
 
35
37
  # ---------- input: pick text source ----------
@@ -41,6 +43,29 @@ if ($File) {
41
43
  }
42
44
  if (-not $text -or -not $text.Trim()) { exit 0 }
43
45
 
46
+ # ---------- length guard: adapter per-Speak ceiling ----------
47
+ # 'message': fixed prompt. 'heading': speak the largest markdown heading instead
48
+ # (fewest '#' wins, tie -> first; no heading -> first non-empty line; the
49
+ # cleaned candidate is still subject to the ceiling below).
50
+ if ($text.Length -gt $MaxChars -and $LongTextMode -eq 'heading') {
51
+ $candidate = ''
52
+ $bestLevel = 7
53
+ $firstNonEmpty = ''
54
+ foreach ($line in ($text -split "`n")) {
55
+ if ($line -match '^\s*#{1,6}\s+') {
56
+ $level = ([regex]::Match($line, '^(\s*)(#+)')).Groups[2].Value.Length
57
+ if ($level -lt $bestLevel) {
58
+ $bestLevel = $level
59
+ $candidate = $line -replace '^\s*#+\s*', ''
60
+ }
61
+ } elseif (-not $firstNonEmpty -and $line.Trim()) {
62
+ $firstNonEmpty = $line
63
+ }
64
+ }
65
+ if (-not $candidate) { $candidate = $firstNonEmpty }
66
+ if ($candidate) { $text = $candidate }
67
+ }
68
+
44
69
  # ---------- clean: markdown -> plain speech text ----------
45
70
  # code blocks, inline code, markdown links, bare URLs, emphasis/marker chars
46
71
  $text = $text -replace '```[\s\S]*?```', ' '
@@ -54,7 +79,7 @@ $text = [regex]::Replace($text, '[^一-龥 -〿＀-￯ - -~]', '')
54
79
  $text = $text -replace '\s+', ' '
55
80
  $text = $text.Trim()
56
81
 
57
- # ---------- length guard: adapter per-Speak ceiling ----------
82
+ # ---------- final ceiling (also catches over-long heading candidates) ----------
58
83
  if ($text.Length -gt $MaxChars) { $text = $LongTextMessage }
59
84
 
60
85
  # ---------- speak ----------
package/engine/speak.sh CHANGED
@@ -37,13 +37,14 @@ VOICE=""
37
37
  RATE=175 # words per minute (say default)
38
38
  MAX_CHARS=300
39
39
  LONG_MSG="本次播报内容较长,请自行阅读。"
40
+ LONG_MODE="message" # message | heading
40
41
 
41
42
  usage() {
42
- echo "usage: speak.sh [-t text | -f file] [-v voice] [-r wpm] [-m maxchars] [-l longmsg]" >&2
43
+ echo "usage: speak.sh [-t text | -f file] [-v voice] [-r wpm] [-m maxchars] [-l longmsg] [-M message|heading]" >&2
43
44
  exit 1
44
45
  }
45
46
 
46
- while getopts "t:f:v:r:m:l:h" opt; do
47
+ while getopts "t:f:v:r:m:l:M:h" opt; do
47
48
  case "$opt" in
48
49
  t) TEXT="$OPTARG" ;;
49
50
  f) FILE="$OPTARG" ;;
@@ -51,6 +52,7 @@ while getopts "t:f:v:r:m:l:h" opt; do
51
52
  r) RATE="$OPTARG" ;;
52
53
  m) MAX_CHARS="$OPTARG" ;;
53
54
  l) LONG_MSG="$OPTARG" ;;
55
+ M) LONG_MODE="$OPTARG" ;;
54
56
  h) usage ;;
55
57
  *) usage ;;
56
58
  esac
@@ -63,6 +65,26 @@ if [ -n "$FILE" ]; then
63
65
  fi
64
66
  if [ -z "$TEXT" ]; then exit 0; fi
65
67
 
68
+ # ---------- length guard / long-text mode ----------
69
+ # 'message': fixed prompt. 'heading': speak the largest markdown heading instead
70
+ # (fewest '#' wins, tie -> first; no heading -> first non-empty line; the
71
+ # cleaned candidate is still subject to the ceiling below).
72
+ if [ "${#TEXT}" -gt "$MAX_CHARS" ] && [ "$LONG_MODE" = "heading" ]; then
73
+ TEXT=$(printf '%s' "$TEXT" | /usr/bin/perl -CSD -e '
74
+ my $best = 7; my $cand = ""; my $first = "";
75
+ while (<STDIN>) {
76
+ if (/^[ \t]*(\#{1,6})[ \t]+(.*)$/) {
77
+ my $n = length($1);
78
+ if ($n < $best) { $best = $n; $cand = $2; }
79
+ } elsif ($first eq "" && /\S/) {
80
+ $first = $_;
81
+ }
82
+ }
83
+ $cand = $first if $cand eq "";
84
+ print $cand;
85
+ ')
86
+ fi
87
+
66
88
  # ---------- clean (mirrors speak.ps1) ----------
67
89
  TEXT=$(printf '%s' "$TEXT" | /usr/bin/perl -CSD -pe '
68
90
  s/```[\s\S]*?```/ /g; # code blocks
@@ -75,7 +97,7 @@ TEXT=$(printf '%s' "$TEXT" | /usr/bin/perl -CSD -pe '
75
97
  ')
76
98
  TEXT=$(printf '%s' "$TEXT" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
77
99
 
78
- # ---------- length guard ----------
100
+ # ---------- final ceiling (also catches over-long heading candidates) ----------
79
101
  if [ "${#TEXT}" -gt "$MAX_CHARS" ]; then
80
102
  TEXT="$LONG_MSG"
81
103
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-speak",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Make your AI harness speak — verified voice announcements for DSH and other AI coding harnesses (Windows SAPI5 + macOS system voices)",
5
5
  "main": "adapters/dsh/speech-hook.js",
6
6
  "files": [