dsh-speak 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alan2Z
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # dsh-speak 🔊 — Voice announcements for AI coding harnesses
2
+
3
+ **English** · [中文](README.zh-CN.md)
4
+
5
+ Let your agent **tell you** when a long task is done — no more staring at the screen.
6
+
7
+ dsh-speak reads the final assistant reply aloud through Windows speech synthesis,
8
+ using natural voices (Windows 11 built-in, or [NaturalVoiceSAPIAdapter] on
9
+ Windows 10) with graceful fallback to stock voices. It was built for
10
+ [DeepSeek Harness](https://github.com/deepseek-ai/dsh)
11
+ and is structured so any harness can plug in.
12
+
13
+ > **Project status**: this project exists only to provide an **already-verified
14
+ > solution** for users who want their harness to speak. Barring unexpected
15
+ > circumstances, it will not be updated further.
16
+
17
+ ```
18
+ harness event (DSH session event / Claude Code Stop hook / anything)
19
+
20
+ ▼ adapters/… (harness-specific trigger: filter, throttle, cancel)
21
+ ▼ engine/speak.ps1 (harness-agnostic: clean text → Windows SAPI5)
22
+ ▼ 🔊 you hear the final reply
23
+ ```
24
+
25
+ ## Features
26
+
27
+ - **Automatic**: DSH web plugin watches the session event stream and announces the
28
+ final reply (skips reasoning/tool-call narration, merges multi-step messages).
29
+ - **Best-effort**: never throws, never blocks the harness, never breaks a session.
30
+ - **Natural voices**: prefers natural voices — Windows 11 built-in packs, or
31
+ voices registered via NaturalVoiceSAPIAdapter on Windows 10 (e.g. Xiaoxiao) —
32
+ and falls back to any installed voice.
33
+ - **Robust text cleaning**: strips markdown/URLs/emoji that make SAPI `Speak()`
34
+ silently fail, and guards the adapter's per-utterance character ceiling.
35
+ - **Portable engine**: any process can speak with one line:
36
+ `powershell -File speak.ps1 -Text "你好"`.
37
+
38
+ ## Prerequisites
39
+
40
+ - Windows 10 or 11, PowerShell (any recent version).
41
+ - Natural voices:
42
+ - **Windows 11**: natural voice packs are built into the system — no extra
43
+ installation. Enable/switch them in *Settings → Accessibility → Narrator* or
44
+ *Settings → Time & Language → Speech*.
45
+ - **Windows 10**: install
46
+ [NaturalVoiceSAPIAdapter](https://github.com/gexgd0419/NaturalVoiceSAPIAdapter)
47
+ and use its VoiceDownloader to download the natural voice pack(s) you want
48
+ (Chinese or any other language).
49
+ - Without natural voices, the engine falls back to a stock voice (e.g. Huihui).
50
+
51
+ ## Quick start — DSH
52
+
53
+ ### Option A — npm plugin (recommended)
54
+
55
+ ```powershell
56
+ # 1. install the plugin into your web profile (adds dsh-speak to
57
+ # ~/.dsh/profiles/web/package.json dependencies)
58
+ dsh plugin --profile web add dsh-speak
59
+
60
+ # 2. register it in ~/.dsh/profiles/web/cordis.patch.yml
61
+ # (for npm packages the bare package name is used — no file:/// URL needed):
62
+ # - insert:
63
+ # - id: speech-hook
64
+ # name: 'dsh-speak'
65
+
66
+ # 3. restart the DSH web app — replies are now announced automatically
67
+ ```
68
+
69
+ The engine ships inside the package (`node_modules/dsh-speak/engine/`), so no extra
70
+ copying is needed.
71
+
72
+ ### Option B — file install (no npm needed)
73
+
74
+ ```powershell
75
+ # 1. clone
76
+ git clone https://github.com/Alan2Z/dsh-speak.git
77
+ cd dsh-speak
78
+
79
+ # 2. one-command install: copies engine + plugin, registers in cordis.patch.yml
80
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File adapters\dsh\install.ps1
81
+
82
+ # 3. verify the engine speaks
83
+ powershell -NoProfile -ExecutionPolicy Bypass -File "$env:USERPROFILE\.dsh\hooks\speak.ps1" -Text "你好,语音播报已就绪。"
84
+
85
+ # 4. restart the DSH web app — replies are now announced automatically
86
+ ```
87
+
88
+ What the file installer did:
89
+
90
+ | file | destination |
91
+ | ---- | ----------- |
92
+ | `engine/*.ps1` | `%USERPROFILE%\.dsh\hooks\` |
93
+ | `adapters/dsh/speech-hook.js` | `%USERPROFILE%\.dsh\profiles\web\plugins\` |
94
+ | registration entry | appended to `%USERPROFILE%\.dsh\profiles\web\cordis.patch.yml` (backed up first) |
95
+
96
+ ## Quick start — Claude Code
97
+
98
+ Register the Stop hook in `~/.claude/settings.json`:
99
+
100
+ ```json
101
+ {
102
+ "hooks": {
103
+ "Stop": [
104
+ {
105
+ "hooks": [
106
+ {
107
+ "type": "command",
108
+ "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\\path\\to\\dsh-speak\\adapters\\claude-code\\stop-hook.ps1"
109
+ }
110
+ ]
111
+ }
112
+ ]
113
+ }
114
+ }
115
+ ```
116
+
117
+ ## Quick start — any other harness
118
+
119
+ Call the engine directly from your agent / wrapper / script:
120
+
121
+ ```powershell
122
+ # announce a one-liner
123
+ powershell -NoProfile -ExecutionPolicy Bypass -File engine\speak.ps1 -Text "构建完成"
124
+
125
+ # announce a long summary (from a file)
126
+ powershell -NoProfile -ExecutionPolicy Bypass -File engine\speech-summary.ps1 -Text "…"
127
+
128
+ # ask for user attention (blocking, for prompts/approvals)
129
+ powershell -NoProfile -ExecutionPolicy Bypass -File engine\speech-prompt.ps1 -Text "请做出选择"
130
+ ```
131
+
132
+ ## Configuration
133
+
134
+ Engine parameters (see [docs/DESIGN.md](docs/DESIGN.md#5-configuration-reference)):
135
+
136
+ ```powershell
137
+ speak.ps1 -Text "…" -Volume 50 -Rate 1 -MaxChars 300 -LongTextMessage "本次播报内容较长,请自行阅读。"
138
+ ```
139
+
140
+ DSH plugin environment variables:
141
+
142
+ | var | default | meaning |
143
+ | --- | ------- | ------- |
144
+ | `DSH_SPEAK_ENGINE` | `%USERPROFILE%\.dsh\hooks\speak.ps1` | engine path |
145
+ | `DSH_SPEAK_THROTTLE_MS` | `1500` | merge delay before announcing |
146
+
147
+ ## Troubleshooting
148
+
149
+ | symptom | cause | fix |
150
+ | ------- | ----- | --- |
151
+ | No sound at all, no error | no natural voice enabled/installed | Win11: enable a natural voice in *Settings → Narrator / Speech*; Win10: install NaturalVoiceSAPIAdapter + a voice pack. Test `speak.ps1` directly |
152
+ | Long replies never spoken | adapter per-`Speak` character ceiling | already guarded at 300 chars — lower `-MaxChars` if needed |
153
+ | Emoji-heavy text silent | SAPI fails silently on emoji | already stripped by the engine |
154
+ | Plugin not loading | raw Windows path as plugin name | use the `file:///C:/…` URL form (installer does this) |
155
+
156
+ Plugin diagnostics: `%TEMP%\dsh-speech-hook.log`.
157
+
158
+ ## Repository layout
159
+
160
+ ```
161
+ engine/ harness-agnostic speech engine (PowerShell + SAPI5)
162
+ speak.ps1 clean + speak (the only seam any adapter needs)
163
+ speech-prompt.ps1 blocking short announcement
164
+ speech-summary.ps1 blocking reply-summary announcement
165
+ adapters/
166
+ dsh/ DSH web plugin + one-command installer
167
+ speech-hook.js session-event trigger (throttle + tool-call cancel)
168
+ install.ps1 copies + registers + backs up
169
+ claude-code/
170
+ stop-hook.ps1 Claude Code Stop hook trigger
171
+ docs/
172
+ DESIGN.md full design rationale, pitfalls, extension guide
173
+ ```
174
+
175
+ ## Writing a new adapter
176
+
177
+ Three reference patterns exist: **event-stream** (DSH), **stop-hook** (Claude Code),
178
+ **agent-called** (`speech-summary.ps1` from a shell). In every case the adapter only
179
+ needs to: capture the *final reply text* → invoke the engine. See
180
+ [docs/DESIGN.md §7](docs/DESIGN.md#7-extending).
181
+
182
+ ## License
183
+
184
+ MIT — see [LICENSE](LICENSE).
185
+
186
+ [NaturalVoiceSAPIAdapter]: https://github.com/gexgd0419/NaturalVoiceSAPIAdapter
@@ -0,0 +1,177 @@
1
+ # dsh-speak 🔊 — 为 AI 编程 harness 提供语音播报
2
+
3
+ 让 Agent 在长任务完成时**开口告诉你**——不用再盯着屏幕等。
4
+
5
+ dsh-speak 通过 Windows 语音合成把 Agent 的最终回复朗读出来,优先使用自然语音
6
+ (Windows 11 内置,或 Windows 10 上经 [NaturalVoiceSAPIAdapter] 注册,如晓晓),
7
+ 没有时优雅回退到系统自带中文语音。本项目为
8
+ [DeepSeek Harness](https://github.com/deepseek-ai/dsh) 而生,但结构上
9
+ 任何 harness 都能接入。
10
+
11
+ > **项目定位**:本项目只是为了给想让 harness 开口说话的用户提供一种**已经验证过的方案**;
12
+ > 没有意外的话,后续不会再更新。
13
+
14
+ ```
15
+ harness 事件(DSH 会话事件 / Claude Code Stop hook / 任意方式)
16
+
17
+ ▼ adapters/… (harness 专属触发器:过滤、节流、取消)
18
+ ▼ engine/speak.ps1 (与 harness 无关:清洗文本 → Windows SAPI5)
19
+ ▼ 🔊 你听到最终回复
20
+ ```
21
+
22
+ ## 特性
23
+
24
+ - **全自动**:DSH web 插件监听会话事件流,自动播报最终回复
25
+ (跳过 reasoning/工具调用旁白,合并同一回复的多步消息)。
26
+ - **尽力而为**:绝不抛错、绝不阻塞 harness、绝不破坏会话。
27
+ - **自然语音**:优先使用自然语音——Windows 11 内置语音包,或 Windows 10 上经
28
+ NaturalVoiceSAPIAdapter 注册的语音(如晓晓),回退到任意已安装语音。
29
+ - **健壮的文本清洗**:去掉会让 SAPI `Speak()` 静默失败的 markdown/URL/emoji,
30
+ 并守卫适配器单次朗读的字数上限。
31
+ - **引擎可移植**:任意进程一行即可朗读:
32
+ `powershell -File speak.ps1 -Text "你好"`。
33
+
34
+ ## 前置条件
35
+
36
+ - Windows 10 或 11,任意较新的 PowerShell。
37
+ - 自然语音:
38
+ - **Windows 11**:系统已内置自然语音包,无需额外安装——在
39
+ *设置 → 辅助功能 → 讲述人* 或 *设置 → 时间和语言 → 语音* 中启用/切换即可。
40
+ - **Windows 10**:需要安装
41
+ [NaturalVoiceSAPIAdapter](https://github.com/gexgd0419/NaturalVoiceSAPIAdapter),
42
+ 并用它的 VoiceDownloader 手动下载你需要的中文或其他语言的自然语音包。
43
+ - 没有自然语音时,引擎回退到系统自带语音(如 Huihui)。
44
+
45
+ ## 快速开始 — DSH
46
+
47
+ ### 方式 A — npm 插件(推荐)
48
+
49
+ ```powershell
50
+ # 1. 把插件装进你的 web profile(会写入 ~/.dsh/profiles/web/package.json 的 dependencies)
51
+ dsh plugin --profile web add dsh-speak
52
+
53
+ # 2. 在 ~/.dsh/profiles/web/cordis.patch.yml 里注册(npm 包直接用包名,无需 file:/// URL):
54
+ # - insert:
55
+ # - id: speech-hook
56
+ # name: 'dsh-speak'
57
+
58
+ # 3. 重启 DSH web 应用 — 之后回复会被自动播报
59
+ ```
60
+
61
+ 引擎随包分发(`node_modules/dsh-speak/engine/`),无需额外拷贝。
62
+
63
+ ### 方式 B — 文件安装(不需要 npm)
64
+
65
+ ```powershell
66
+ # 1. 克隆
67
+ git clone https://github.com/Alan2Z/dsh-speak.git
68
+ cd dsh-speak
69
+
70
+ # 2. 一键安装:拷贝引擎 + 插件,并注册到 cordis.patch.yml
71
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File adapters\dsh\install.ps1
72
+
73
+ # 3. 验证引擎能出声
74
+ powershell -NoProfile -ExecutionPolicy Bypass -File "$env:USERPROFILE\.dsh\hooks\speak.ps1" -Text "你好,语音播报已就绪。"
75
+
76
+ # 4. 重启 DSH web 应用 — 之后回复会被自动播报
77
+ ```
78
+
79
+ 文件安装脚本做了这些事:
80
+
81
+ | 文件 | 目标位置 |
82
+ | ---- | -------- |
83
+ | `engine/*.ps1` | `%USERPROFILE%\.dsh\hooks\` |
84
+ | `adapters/dsh/speech-hook.js` | `%USERPROFILE%\.dsh\profiles\web\plugins\` |
85
+ | 注册条目 | 追加到 `%USERPROFILE%\.dsh\profiles\web\cordis.patch.yml`(先备份) |
86
+
87
+ ## 快速开始 — Claude Code
88
+
89
+ 在 `~/.claude/settings.json` 注册 Stop hook:
90
+
91
+ ```json
92
+ {
93
+ "hooks": {
94
+ "Stop": [
95
+ {
96
+ "hooks": [
97
+ {
98
+ "type": "command",
99
+ "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\\path\\to\\dsh-speak\\adapters\\claude-code\\stop-hook.ps1"
100
+ }
101
+ ]
102
+ }
103
+ ]
104
+ }
105
+ }
106
+ ```
107
+
108
+ ## 快速开始 — 其他任何 harness
109
+
110
+ 直接从你的 Agent / 包装脚本 / 工具里调用引擎:
111
+
112
+ ```powershell
113
+ # 播报一句话
114
+ powershell -NoProfile -ExecutionPolicy Bypass -File engine\speak.ps1 -Text "构建完成"
115
+
116
+ # 播报较长总结(阻塞,读完才返回)
117
+ powershell -NoProfile -ExecutionPolicy Bypass -File engine\speech-summary.ps1 -Text "…"
118
+
119
+ # 需要用户注意时(阻塞,适合提问/授权场景)
120
+ powershell -NoProfile -ExecutionPolicy Bypass -File engine\speech-prompt.ps1 -Text "请做出选择"
121
+ ```
122
+
123
+ ## 配置
124
+
125
+ 引擎参数(详见 [docs/DESIGN.zh-CN.md](docs/DESIGN.zh-CN.md#5-配置参考)):
126
+
127
+ ```powershell
128
+ speak.ps1 -Text "…" -Volume 50 -Rate 1 -MaxChars 300 -LongTextMessage "本次播报内容较长,请自行阅读。"
129
+ ```
130
+
131
+ DSH 插件环境变量:
132
+
133
+ | 变量 | 默认值 | 含义 |
134
+ | --- | ------- | ---- |
135
+ | `DSH_SPEAK_ENGINE` | `%USERPROFILE%\.dsh\hooks\speak.ps1` | 引擎路径 |
136
+ | `DSH_SPEAK_THROTTLE_MS` | `1500` | 播报前的合并延迟(毫秒) |
137
+
138
+ ## 排障
139
+
140
+ | 现象 | 原因 | 解决 |
141
+ | ---- | ---- | ---- |
142
+ | 完全没有声音、无报错 | 未启用/安装自然语音 | Win11:在 设置 → 讲述人/语音 中启用自然语音;Win10:安装 NaturalVoiceSAPIAdapter 并下载语音包。直接测 `speak.ps1` |
143
+ | 长回复从不播报 | 适配器单次 `Speak` 有字数上限 | 已默认在 300 字处守卫——必要时调低 `-MaxChars` |
144
+ | 含大量 emoji 的文本静默 | SAPI 遇到 emoji 会静默失败 | 引擎已自动剥离 |
145
+ | 插件加载失败 | 插件名用了 Windows 原始路径 | 改用 `file:///C:/…` URL 形式(安装脚本会自动处理) |
146
+
147
+ 插件诊断日志:`%TEMP%\dsh-speech-hook.log`
148
+
149
+ ## 仓库结构
150
+
151
+ ```
152
+ engine/ 与 harness 无关的语音引擎(PowerShell + SAPI5)
153
+ speak.ps1 清洗 + 朗读(适配层唯一需要打交道的接口)
154
+ speech-prompt.ps1 阻塞式短提示播报
155
+ speech-summary.ps1 阻塞式回复总结播报
156
+ adapters/
157
+ dsh/ DSH web 插件 + 一键安装脚本
158
+ speech-hook.js 会话事件触发器(节流 + 工具调用取消)
159
+ install.ps1 拷贝 + 注册 + 备份
160
+ claude-code/
161
+ stop-hook.ps1 Claude Code Stop hook 触发器
162
+ docs/
163
+ DESIGN.zh-CN.md 完整设计文档:设计取舍、踩坑记录、扩展指南
164
+ ```
165
+
166
+ ## 编写新适配器
167
+
168
+ 三种参考模式:**事件流**(DSH)、**Stop hook**(Claude Code)、**Agent 自调用**
169
+ (在 shell 里调 `speech-summary.ps1`)。无论哪种,适配器只需做一件事:
170
+ 拿到*最终回复文本* → 调用引擎。详见
171
+ [docs/DESIGN.zh-CN.md §7 扩展](docs/DESIGN.zh-CN.md#7-扩展)。
172
+
173
+ ## License
174
+
175
+ MIT — 见 [LICENSE](LICENSE)。
176
+
177
+ [NaturalVoiceSAPIAdapter]: https://github.com/gexgd0419/NaturalVoiceSAPIAdapter
@@ -0,0 +1,81 @@
1
+ # install.ps1 — one-command installer for the DSH adapter
2
+ # ========================================================
3
+ # 1. copies engine/*.ps1 to ~/.dsh/hooks/
4
+ # 2. copies speech-hook.js to ~/.dsh/profiles/web/plugins/
5
+ # 3. registers the plugin in ~/.dsh/profiles/web/cordis.patch.yml (backs it up first)
6
+ #
7
+ # Usage:
8
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File install.ps1
9
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File install.ps1 -DshHome C:\Users\you\.dsh -PluginsDir C:\Users\you\.dsh\profiles\web\plugins
10
+ #
11
+ # After installing, restart the DSH web app (the profile tree is composed at boot).
12
+
13
+ param(
14
+ [string]$DshHome = (Join-Path $env:USERPROFILE '.dsh'),
15
+ [string]$EngineDir = '',
16
+ [string]$PluginsDir = ''
17
+ )
18
+
19
+ $ErrorActionPreference = 'Stop'
20
+ $here = Split-Path -Parent $MyInvocation.MyCommand.Path
21
+ $repoRoot = Resolve-Path (Join-Path $here '..\..')
22
+
23
+ if (-not $EngineDir) { $EngineDir = Join-Path $DshHome 'hooks' }
24
+ if (-not $PluginsDir) { $PluginsDir = Join-Path $DshHome 'profiles\web\plugins' }
25
+ $cordisPatch = Join-Path $DshHome 'profiles\web\cordis.patch.yml'
26
+
27
+ # ---------- 1. engine ----------
28
+ Write-Host "==> Installing engine -> $EngineDir"
29
+ New-Item -ItemType Directory -Force -Path $EngineDir | Out-Null
30
+ Copy-Item -Force (Join-Path $repoRoot 'engine\*.ps1') $EngineDir
31
+ Write-Host " copied: $((Get-ChildItem (Join-Path $repoRoot 'engine\*.ps1')).Count) script(s)"
32
+
33
+ # ---------- 2. plugin ----------
34
+ Write-Host "==> Installing DSH plugin -> $PluginsDir"
35
+ New-Item -ItemType Directory -Force -Path $PluginsDir | Out-Null
36
+ Copy-Item -Force (Join-Path $here 'speech-hook.js') $PluginsDir
37
+ $pluginUrl = 'file:///' + ((Join-Path $PluginsDir 'speech-hook.js') -replace '\\', '/' -replace ' ', '%20')
38
+
39
+ # ---------- 3. register in cordis.patch.yml ----------
40
+ Write-Host "==> Registering plugin in cordis.patch.yml"
41
+ if (Test-Path $cordisPatch) {
42
+ $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)."
45
+ Write-Host ""
46
+ Write-Host "Done. Restart the DSH web app to pick up the plugin."
47
+ exit 0
48
+ }
49
+ # backup before modifying
50
+ $backup = "$cordisPatch.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
51
+ Copy-Item $cordisPatch $backup
52
+ Write-Host " backup -> $backup"
53
+ $block = @"
54
+
55
+ # speech-hook: auto voice-announce assistant replies (installed by dsh-speak)
56
+ - insert:
57
+ - id: speech-hook
58
+ name: '$pluginUrl'
59
+ "@
60
+ Add-Content -Path $cordisPatch -Value $block -Encoding UTF8
61
+ Write-Host " appended insert entry -> $cordisPatch"
62
+ } else {
63
+ New-Item -ItemType Directory -Force -Path (Split-Path $cordisPatch) | Out-Null
64
+ $content = @"
65
+ # dsh profile patch layer (created by dsh-speak installer)
66
+ # speech-hook: auto voice-announce assistant replies
67
+ - insert:
68
+ - id: speech-hook
69
+ name: '$pluginUrl'
70
+ "@
71
+ Set-Content -Path $cordisPatch -Value $content -Encoding UTF8
72
+ Write-Host " created -> $cordisPatch"
73
+ }
74
+
75
+ Write-Host ""
76
+ Write-Host "Installed. Next steps:"
77
+ Write-Host " 1. Restart the DSH web app (profile tree is composed at boot)."
78
+ Write-Host " 2. Verify voices: run"
79
+ Write-Host " powershell -NoProfile -ExecutionPolicy Bypass -File `"$EngineDir\speak.ps1`" -Text `"你好,语音播报已就绪。`""
80
+ Write-Host " 3. If no sound: install NaturalVoiceSAPIAdapter and register natural voices"
81
+ Write-Host " (see README.md -> Prerequisites)."
@@ -0,0 +1,133 @@
1
+ // speech-hook.js — DSH web adapter: auto voice-announce the final assistant reply
2
+ // ==============================================================================
3
+ // Listens to the session event stream (session/event), watches for
4
+ // assistant/message append events, extracts the final reply text, and hands it to
5
+ // engine/speak.ps1 through a hidden, non-blocking powershell process.
6
+ //
7
+ // Trigger semantics:
8
+ // * only events with a `text` block are announced (reasoning / tool_use blocks
9
+ // are skipped)
10
+ // * when a tool/call event arrives, that round's assistant text is treated as
11
+ // process narration, so any pending announcement is cancelled
12
+ // * a final reply with no following tool/call is announced after a throttle
13
+ // delay (merges multi-step messages from the same reply)
14
+ //
15
+ // Registration: add an insert entry in ~/.dsh/profiles/web/cordis.patch.yml —
16
+ // - insert:
17
+ // - id: speech-hook
18
+ // name: 'dsh-speak' # npm package (preferred)
19
+ // name: 'file:///C:/Users/<you>/.../speech-hook.js' # repo/file install
20
+ // (run adapters/dsh/install.ps1 to do this automatically for the file install)
21
+ //
22
+ // Configuration (environment variables, optional):
23
+ // DSH_SPEAK_ENGINE path to engine/speak.ps1
24
+ // (default: <package>/engine/speak.ps1, then
25
+ // %USERPROFILE%\.dsh\hooks\speak.ps1)
26
+ // DSH_SPEAK_THROTTLE_MS throttle delay before announcing (default: 1500)
27
+ 'use strict'
28
+ const { spawn } = require('child_process')
29
+ const fs = require('fs')
30
+ const os = require('os')
31
+ const path = require('path')
32
+
33
+ // diagnostic log (for troubleshooting; safe to remove once stable)
34
+ const LOG = path.join(os.tmpdir(), 'dsh-speech-hook.log')
35
+ function log(...args) {
36
+ try {
37
+ fs.appendFileSync(LOG, `[${new Date().toISOString()}] ${args.join(' ')}\n`)
38
+ } catch (e) { /* ignore */ }
39
+ }
40
+
41
+ const THROTTLE_MS = Number(process.env.DSH_SPEAK_THROTTLE_MS) || 1500
42
+
43
+ /**
44
+ * Locate engine/speak.ps1:
45
+ * 1. explicit DSH_SPEAK_ENGINE override
46
+ * 2. <this package>/engine/speak.ps1 — works both when running from a repo
47
+ * checkout and when installed into a profile's node_modules (npm install)
48
+ * 3. legacy file-copy location (~/.dsh/hooks/speak.ps1) from install.ps1
49
+ */
50
+ function resolveEngine() {
51
+ if (process.env.DSH_SPEAK_ENGINE) return process.env.DSH_SPEAK_ENGINE
52
+ const bundled = path.join(__dirname, '..', '..', 'engine', 'speak.ps1')
53
+ if (fs.existsSync(bundled)) return bundled
54
+ return path.join(process.env.USERPROFILE, '.dsh', 'hooks', 'speak.ps1')
55
+ }
56
+ const SPEAK_ENGINE = resolveEngine()
57
+
58
+ module.exports = {
59
+ apply(ctx) {
60
+ log('plugin apply 执行(加载成功); engine=', SPEAK_ENGINE, '; throttle=', THROTTLE_MS)
61
+ let timer = null
62
+ let pendingText = ''
63
+
64
+ /** cancel a pending announcement (called when a tool-call round arrives) */
65
+ function cancelPending() {
66
+ if (timer) { clearTimeout(timer); timer = null }
67
+ pendingText = ''
68
+ }
69
+
70
+ function speak(text) {
71
+ log('speak 调用, 文本长度:', text ? text.length : 0)
72
+ if (!text || !text.trim()) return
73
+ const tmp = path.join(os.tmpdir(), `dsh-speech-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`)
74
+ try {
75
+ fs.writeFileSync(tmp, text, 'utf8')
76
+ } catch (e) {
77
+ log('写临时文件失败:', e.message)
78
+ return
79
+ }
80
+ const ps = spawn('powershell.exe',
81
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', SPEAK_ENGINE, '-File', tmp],
82
+ { windowsHide: true, stdio: 'ignore' })
83
+ log('spawn powershell 已发起')
84
+ ps.on('exit', (code) => { log('播报进程退出 code=', code); try { fs.unlinkSync(tmp) } catch (e) { /* 清理 */ } })
85
+ ps.on('error', (e) => { log('播报进程 error:', e.message); try { fs.unlinkSync(tmp) } catch (e2) { /* 清理 */ } })
86
+ }
87
+
88
+ ctx.on('session/event', (session, event) => {
89
+ try {
90
+ const type = event && event.type
91
+ // noise filter: assistant/chunk (streaming chunks) is not recorded
92
+ if (type !== 'assistant/chunk') {
93
+ log('事件 type=', type, 'surfaceOp=', event && event.surfaceOp, 'seq=', event && event.seq)
94
+ }
95
+ // tool-call round: cancel pending announcement (that round's assistant
96
+ // text is process narration, not the final reply)
97
+ if (type === 'tool/call') {
98
+ cancelPending()
99
+ return
100
+ }
101
+ if (!event || type !== 'assistant/message') return
102
+ if (event.surfaceOp && event.surfaceOp !== 'append') return
103
+ // the message object lives at event.data.message (event.data wraps { turn, step, message })
104
+ const msg = event.data && (event.data.message || event.data)
105
+ if (!msg) return
106
+ let text = ''
107
+ const c = msg.content
108
+ if (typeof c === 'string') {
109
+ text = c
110
+ } else if (Array.isArray(c)) {
111
+ // only text blocks: reasoning / tool_use blocks are not announced
112
+ text = c
113
+ .filter(b => b && b.type === 'text' && typeof b.text === 'string')
114
+ .map(b => b.text)
115
+ .join('')
116
+ }
117
+ if (!text.trim()) return
118
+ log('缓存待播报文本长度:', text.length, '前 60:', text.slice(0, 60))
119
+ pendingText = text
120
+ if (timer) clearTimeout(timer)
121
+ // throttle: merge multi-step messages of one reply; a tool/call in
122
+ // between cancels the announcement
123
+ timer = setTimeout(() => {
124
+ speak(pendingText)
125
+ pendingText = ''
126
+ timer = null
127
+ }, THROTTLE_MS)
128
+ } catch (e) {
129
+ log('事件处理异常:', e.message)
130
+ }
131
+ })
132
+ },
133
+ }
package/docs/DESIGN.md ADDED
@@ -0,0 +1,245 @@
1
+ # DESIGN.md — dsh-speak: voice announcements for AI coding harnesses
2
+
3
+ English · [中文](DESIGN.zh-CN.md)
4
+
5
+ Status: **draft** — this document describes the current (proven) local implementation
6
+ and the target generic structure of this repository. It is the reference for the
7
+ README.
8
+
9
+ ---
10
+
11
+ ## 1. Why
12
+
13
+ Agentic coding tools run long tasks (builds, tests, migrations, batch edits) while
14
+ you work on something else. When a reply finally lands you have to keep checking
15
+ the screen. **dsh-speak** reads the final reply aloud through Windows speech
16
+ synthesis so you know *without looking* that a long task finished — and what its
17
+ outcome was.
18
+
19
+ The original implementation was built and proven in a local DSH (DeepSeek Harness)
20
+ setup. This repository generalizes that working implementation into:
21
+
22
+ - a **harness-agnostic engine** (PowerShell + Windows SAPI5) that any process can call,
23
+ - **adapter layers** that turn harness-specific events into engine calls
24
+ (DSH session events, Claude Code Stop hooks, ...).
25
+
26
+ ## 2. Goals / non-goals
27
+
28
+ Goals:
29
+
30
+ - One-command install for DSH users (engine + plugin + registration).
31
+ - Engine callable from any harness via a trivial command line.
32
+ - Best-effort speech: never throws, never blocks a harness, never breaks a session.
33
+ - Natural-sounding voices: Windows 11 built-in natural voice packs, or
34
+ NaturalVoiceSAPIAdapter on Windows 10; graceful fallback to stock voices.
35
+
36
+ Non-goals (for now):
37
+
38
+ - Cross-platform engines (macOS/Linux TTS). Windows-only by design.
39
+ - In-repo packaging of NaturalVoiceSAPIAdapter (Windows 10 only) or voice data —
40
+ they are prerequisites, not bundled.
41
+ - Streaming/queued playback, per-voice audio files, non-Chinese voice curation.
42
+
43
+ ## 3. Architecture
44
+
45
+ ```
46
+ +--------------------------------------------------------------+
47
+ | harness |
48
+ | (DSH web app | Claude Code | anything with a shell) |
49
+ +--------+-----------------------------+-----------------------+
50
+ | |
51
+ | session events | Stop hook JSON (stdin)
52
+ v v
53
+ +------------------+ +--------------------------+
54
+ | adapters/dsh/ | | adapters/claude-code/ |
55
+ | speech-hook.js | | stop-hook.ps1 |
56
+ | (event filter, | | (transcript extraction) |
57
+ | throttle, | +------------+-------------+
58
+ | cancel) | |
59
+ +--------+---------+ |
60
+ | text | text
61
+ v v
62
+ +---------------------------------------------------------------+
63
+ | engine/speak.ps1 (harness-agnostic) |
64
+ | text -> clean (markdown/emoji/length) -> SAPI5 Speak() |
65
+ +---------------------------------------------------------------+
66
+ |
67
+ v
68
+ Windows SAPI5 (System.Speech) — voices:
69
+ * preferred: a natural voice — Windows 11 built-in pack, or one
70
+ registered by NaturalVoiceSAPIAdapter on Windows 10
71
+ (e.g. "Microsoft Xiaoxiao")
72
+ * fallback: any zh voice (e.g. "Microsoft Huihui")
73
+ ```
74
+
75
+ ### 3.1 Engine — `engine/speak.ps1`
76
+
77
+ The only file a new adapter needs. Two input modes: `-Text "..."` inline, or
78
+ `-File C:\path\msg.txt` (UTF-8). Also `-Volume`, `-Rate`, `-MaxChars`,
79
+ `-LongTextMessage` (see §5).
80
+
81
+ Processing pipeline (in order):
82
+
83
+ 1. **Read** text (file read is always UTF-8).
84
+ 2. **Strip markdown** — code blocks, inline code, links, bare URLs, emphasis chars.
85
+ 3. **Strip emoji / non-printable** — keep CJK, CJK punctuation, full-width ranges,
86
+ ASCII printable (regex `[^一-龥 -〿＀-￯ - -~]`).
87
+ 4. **Collapse whitespace.**
88
+ 5. **Length guard** — if cleaned text exceeds `MaxChars` (default 300), replace with
89
+ `LongTextMessage` (default: `本次播报内容较长,请自行阅读。`).
90
+ 6. **Speak** — `System.Speech.Synthesis.SpeechSynthesizer`, volume/rate applied,
91
+ best zh natural voice selected, then `Speak()`.
92
+
93
+ Engine contract for adapters:
94
+
95
+ - exit 0 always; never writes to stdout/stderr on failure paths;
96
+ - synchronous (returns when the utterance finishes, or immediately on any failure);
97
+ - safe to call from a sandboxed process *provided* the caller does not need to nest
98
+ another `powershell.exe` inside a harness sandbox (see §6.3).
99
+
100
+ ### 3.2 DSH adapter — `adapters/dsh/speech-hook.js`
101
+
102
+ A DSH web-profile plugin (Cordis plugin) registered via `cordis.patch.yml`. DSH has
103
+ no "reply finished" hook, so the plugin observes the session event stream:
104
+
105
+ - listens to `session/event`;
106
+ - filters `assistant/message` events with `surfaceOp == 'append'`;
107
+ - extracts only `text` content blocks (reasoning / tool_use blocks are skipped);
108
+ - buffers the text and starts a throttle timer (default 1500 ms) to merge
109
+ multi-step messages of one reply;
110
+ - a `tool/call` event **cancels** the pending announcement — that round's assistant
111
+ text is process narration, not the final reply;
112
+ - on fire: writes the text to a temp file and `spawn`s
113
+ `powershell.exe -File <engine> -File <tmp>` with `windowsHide` + `stdio: 'ignore'`
114
+ so the harness is never blocked; the temp file is deleted on exit.
115
+
116
+ Registration snippet (also automated by `install.ps1`):
117
+
118
+ ```yaml
119
+ # ~/.dsh/profiles/web/cordis.patch.yml
120
+ - insert:
121
+ - id: speech-hook
122
+ name: 'file:///C:/Users/<you>/.dsh/profiles/web/plugins/speech-hook.js'
123
+ ```
124
+
125
+ > Node's ESM loader does not accept Windows absolute paths as plugin names — the
126
+ > `file:///C:/...` URL form is required.
127
+
128
+ ### 3.3 Claude Code adapter — `adapters/claude-code/stop-hook.ps1`
129
+
130
+ Claude Code *does* have a Stop hook. The hook JSON (with `transcript_path`) arrives
131
+ on stdin; the script scans the transcript backwards for the last assistant message
132
+ that contains text (the final entry is often a pure tool call), writes it to a temp
133
+ file and launches the engine in its own hidden powershell process, so the hook
134
+ returns immediately. (Async spawning is safe here — the nested-spawn restriction in
135
+ §6.3 is specific to DSH's sandbox.)
136
+
137
+ ## 4. Event-flow truth table (DSH)
138
+
139
+ | assistant round contains | announced? |
140
+ | ------------------------------- | ----------- |
141
+ | final text reply, no tool call | ✅ after throttle |
142
+ | text + tool/call(s) | ❌ (cancelled — narration) |
143
+ | reasoning only, no text | ❌ (no text block) |
144
+ | streaming chunks | ❌ (filtered) |
145
+
146
+ ## 5. Configuration reference
147
+
148
+ ### Engine (`speak.ps1` parameters)
149
+
150
+ | param | default | meaning |
151
+ | ----------------- | --------------------------- | ---------------------------------------- |
152
+ | `-Text` | `''` | inline text (used when `-File` is empty) |
153
+ | `-File` | `''` | UTF-8 file to read |
154
+ | `-Volume` | `50` | 0–100 |
155
+ | `-Rate` | `1` | speech rate (SAPI scale) |
156
+ | `-MaxChars` | `300` | beyond this, replaced by `LongTextMessage` |
157
+ | `-LongTextMessage`| `本次播报内容较长,请自行阅读。` | spoken instead of over-long text |
158
+
159
+ ### DSH plugin (environment variables)
160
+
161
+ | var | default | meaning |
162
+ | -------------------- | ---------------------------------------- | ------------------------------ |
163
+ | `DSH_SPEAK_ENGINE` | `%USERPROFILE%\.dsh\hooks\speak.ps1` | engine path |
164
+ | `DSH_SPEAK_THROTTLE_MS` | `1500` | merge delay before announcing |
165
+
166
+ ## 6. Pitfalls (hard-won; do not "fix" casually)
167
+
168
+ | # | pitfall | symptom | fix / rule |
169
+ |---|---------|---------|------------|
170
+ | 6.1 | Emoji / surrogate pairs reach `Speak()` | **silent** — no audio, no error | strip non-CJK/ASCII before speaking (engine step 3) |
171
+ | 6.2 | Text longer than the adapter's per-`Speak` ceiling (~375–470 chars) | **silent** — the whole utterance is dropped, not truncated | length guard at 300 chars (engine step 5) |
172
+ | 6.3 | Nested `Start-Process powershell` inside a DSH-sandboxed process | silent failure, no exception | keep the DSH chain synchronous at the adapter boundary (spawn once from the plugin; `speech-summary.ps1` calls `speak.ps1` synchronously) |
173
+ | 6.4 | Plugin name with a raw Windows path in `cordis.patch.yml` | plugin fails to load | `file:///C:/...` URL form |
174
+ | 6.5 | Matching adapter voices by name only | falls back to robotic stock voice | match `Name + Description` against `Natural\|Online` |
175
+ | 6.6 | Reading/writing speech text as ANSI | mojibake or empty speech | always UTF-8 (`[System.IO.File]::ReadAllText(..., UTF8)`) |
176
+
177
+ ## 7. Extending
178
+
179
+ ### New engine backend
180
+ The engine is the single seam for TTS backends. A future `speak-edge.ps1` could
181
+ wrap `edge-tts`, or a `speak-piper.ps1` a local offline model — same parameter
182
+ contract, same cleaning pipeline, swap the `Speak()` step. Adapters never change.
183
+
184
+ ### New harness adapter
185
+ Implement: *capture the final reply text → call the engine*. DSH (event stream),
186
+ Claude Code (Stop hook), and any shell-based harness (`speech-summary.ps1` called
187
+ by the agent) are the three reference patterns.
188
+
189
+ ## 8. Scope
190
+
191
+ This project is intentionally **not** a living product. It documents one proven way
192
+ to give a harness a voice: a small engine + the two adapter patterns (event-stream
193
+ and stop-hook) that worked. If you need more (voice management UI, more backends,
194
+ cross-platform), treat the engine as the seam and build on top — this repository
195
+ stays as a minimal, self-contained reference implementation.
196
+
197
+ ## 9. Publishing as an npm plugin (appendix)
198
+
199
+ The DSH plugin mechanism is Cordis-based, and the official install path for
200
+ out-of-tree plugins is `dsh plugin --profile web add <package>` (pnpm-managed
201
+ dependencies in the profile). This repository is prepared for that path:
202
+
203
+ ### Package layout
204
+
205
+ - `package.json` — `name: dsh-speak`, `main: adapters/dsh/speech-hook.js`,
206
+ `files` whitelists exactly what ships (plugin, `engine/*.ps1`, `install.ps1`,
207
+ docs, license). `prepublishOnly` runs `node --check` on the plugin.
208
+ - The plugin entry is the same CJS module (`module.exports = { apply(ctx) }`)
209
+ already used by the file install — no code change is needed to publish.
210
+
211
+ ### Engine resolution (npm vs file install)
212
+
213
+ `speech-hook.js` locates `engine/speak.ps1` in this order:
214
+
215
+ 1. `DSH_SPEAK_ENGINE` environment override;
216
+ 2. `<package>/engine/speak.ps1` resolved relative to the plugin file — covers
217
+ both a repo checkout and `node_modules/dsh-speak/` after `npm install`;
218
+ 3. legacy `%USERPROFILE%\.dsh\hooks\speak.ps1` (the file-install location).
219
+
220
+ Because the engine rides inside the npm package, `dsh plugin --profile web add
221
+ dsh-speak` alone is sufficient — no separate copying step.
222
+
223
+ ### Publish steps (maintainer)
224
+
225
+ ```powershell
226
+ npm login --registry=https://registry.npmjs.org # official registry, 2FA required
227
+ npm publish # publishConfig.registry pins the official registry
228
+ # bump "version" in package.json before every subsequent publish
229
+ ```
230
+
231
+ > China note: if your global `.npmrc` points at a mirror (`registry.npmmirror.com`
232
+ > etc.), `npm login`/`npm publish` would target the mirror, which does **not**
233
+ > accept publishes. The package's `publishConfig.registry` pins publishing to the
234
+ > official registry; just make sure the login used the official registry too.
235
+
236
+ ### Install steps (DSH user)
237
+
238
+ ```powershell
239
+ dsh plugin --profile web add dsh-speak
240
+ # then register in ~/.dsh/profiles/web/cordis.patch.yml:
241
+ # - insert:
242
+ # - id: speech-hook
243
+ # name: 'dsh-speak'
244
+ # restart the DSH web app
245
+ ```
@@ -0,0 +1,232 @@
1
+ # DESIGN.zh-CN.md — dsh-speak:为 AI 编程 harness 提供语音播报
2
+
3
+ 状态:**草稿** — 本文档描述当前(已验证的)本地实现与本文档仓库的目标通用结构,
4
+ 是 README 的参考依据。
5
+
6
+ (英文版:docs/DESIGN.md)
7
+
8
+ ---
9
+
10
+ ## 1. 为什么
11
+
12
+ Agent 工具会跑长任务(构建、测试、迁移、批量修改),而你正在忙别的。回复终于落地时,
13
+ 你不得不反复看屏幕。**dsh-speak** 通过 Windows 语音合成把最终回复读出来,让你
14
+ *不用看屏幕* 就知道长任务完成了——以及结果是什么。
15
+
16
+ 最初实现是在本地 DSH(DeepSeek Harness)环境里搭建并验证过的。本仓库把这份可用的
17
+ 实现通用化为:
18
+
19
+ - **与 harness 无关的引擎**(PowerShell + Windows SAPI5),任意进程都可调用;
20
+ - **适配层**,把 harness 专属事件转成引擎调用(DSH 会话事件、Claude Code Stop hook…)。
21
+
22
+ ## 2. 目标 / 非目标
23
+
24
+ 目标:
25
+
26
+ - DSH 用户一键安装(引擎 + 插件 + 注册)。
27
+ - 引擎可通过一行命令行从任意 harness 调用。
28
+ - 尽力而为的播报:绝不抛错、绝不阻塞 harness、绝不破坏会话。
29
+ - 自然语音:Windows 11 内置自然语音包,或 Windows 10 上经 NaturalVoiceSAPIAdapter
30
+ 注册;优雅回退到系统自带语音。
31
+
32
+ 非目标(当前阶段):
33
+
34
+ - 跨平台引擎(macOS/Linux TTS)。设计上仅限 Windows。
35
+ - 在仓库内打包 NaturalVoiceSAPIAdapter(仅 Windows 10 需要)或语音数据——
36
+ 它们是前置依赖,不打进仓库。
37
+ - 流式/队列播放、按音色输出音频文件、非中文音色管理。
38
+
39
+ ## 3. 架构
40
+
41
+ ```
42
+ +--------------------------------------------------------------+
43
+ | harness |
44
+ | (DSH web 应用 | Claude Code | 任何有 shell 的东西) |
45
+ +--------+-----------------------------+-----------------------+
46
+ | |
47
+ | 会话事件 | Stop hook JSON (stdin)
48
+ v v
49
+ +------------------+ +--------------------------+
50
+ | adapters/dsh/ | | adapters/claude-code/ |
51
+ | speech-hook.js | | stop-hook.ps1 |
52
+ | (事件过滤、 | | (transcript 提取) |
53
+ | 节流、取消) | +------------+-------------+
54
+ +--------+---------+ |
55
+ | 文本 | 文本
56
+ v v
57
+ +---------------------------------------------------------------+
58
+ | engine/speak.ps1 (与 harness 无关) |
59
+ | 文本 -> 清洗(markdown/emoji/长度) -> SAPI5 Speak() |
60
+ +---------------------------------------------------------------+
61
+ |
62
+ v
63
+ Windows SAPI5 (System.Speech) — 音色:
64
+ * 优先:自然语音 — Windows 11 内置语音包,或 Windows 10 上经
65
+ NaturalVoiceSAPIAdapter 注册(如 "Microsoft Xiaoxiao")
66
+ * 回退:任意 zh 语音(如 "Microsoft Huihui")
67
+ ```
68
+
69
+ ### 3.1 引擎 — `engine/speak.ps1`
70
+
71
+ 新适配器唯一需要打交道的文件。两种输入模式:`-Text "..."` 直接传入,或
72
+ `-File C:\path\msg.txt`(UTF-8)。另有 `-Volume`、`-Rate`、`-MaxChars`、
73
+ `-LongTextMessage`(见 §5)。
74
+
75
+ 处理管线(按顺序):
76
+
77
+ 1. **读取**文本(读文件一律 UTF-8)。
78
+ 2. **剥离 markdown** — 代码块、行内代码、链接、裸 URL、强调符号。
79
+ 3. **剥离 emoji / 不可打印字符** — 只保留中文汉字、中文标点、全角区间、
80
+ ASCII 可打印(正则 `[^一-龥 -〿＀-￯ - -~]`)。
81
+ 4. **压缩空白。**
82
+ 5. **长度守卫** — 清洗后文本超过 `MaxChars`(默认 300)时,替换为
83
+ `LongTextMessage`(默认:`本次播报内容较长,请自行阅读。`)。
84
+ 6. **朗读** — `System.Speech.Synthesis.SpeechSynthesizer`,应用音量/语速,
85
+ 选择最佳 zh 自然语音,然后 `Speak()`。
86
+
87
+ 引擎对适配器的契约:
88
+
89
+ - 总是以 0 退出;失败路径不向 stdout/stderr 写内容;
90
+ - 同步(读完整个句子才返回,或任何失败时立即返回);
91
+ - 沙箱进程可安全调用,*前提* 是调用方不需要在 harness 沙箱内再嵌套一个
92
+ `powershell.exe`(见 §6.3)。
93
+
94
+ ### 3.2 DSH 适配层 — `adapters/dsh/speech-hook.js`
95
+
96
+ 一个 DSH web 配置插件(Cordis 插件),通过 `cordis.patch.yml` 注册。DSH 没有
97
+ "回复完成" hook,所以插件观察会话事件流:
98
+
99
+ - 监听 `session/event`;
100
+ - 过滤 `assistant/message` 且 `surfaceOp == 'append'` 的事件;
101
+ - 只提取 `text` 内容块(reasoning / tool_use 块跳过);
102
+ - 缓冲文本并启动节流定时器(默认 1500 ms)以合并同一回复的多步消息;
103
+ - `tool/call` 事件会**取消**待播报——该轮 assistant 文本是过程旁白,不是最终回复;
104
+ - 触发时:把文本写入临时文件,`spawn` 出
105
+ `powershell.exe -File <engine> -File <tmp>`,带 `windowsHide` + `stdio: 'ignore'`,
106
+ 绝不阻塞 harness;退出后删除临时文件。
107
+
108
+ 注册片段(`install.ps1` 也会自动完成):
109
+
110
+ ```yaml
111
+ # ~/.dsh/profiles/web/cordis.patch.yml
112
+ - insert:
113
+ - id: speech-hook
114
+ name: 'file:///C:/Users/<you>/.dsh/profiles/web/plugins/speech-hook.js'
115
+ ```
116
+
117
+ > Node 的 ESM 加载器不接受 Windows 绝对路径作为插件名——必须用
118
+ > `file:///C:/...` URL 形式。
119
+
120
+ ### 3.3 Claude Code 适配层 — `adapters/claude-code/stop-hook.ps1`
121
+
122
+ Claude Code *确实*有 Stop hook。hook JSON(含 `transcript_path`)从 stdin 传入;
123
+ 脚本从后往前扫描 transcript,找最后一条含文本的 assistant 消息(末尾常常是纯
124
+ 工具调用),写入临时文件后在自己独立的隐藏 powershell 进程里启动引擎,hook 立即
125
+ 返回。(此处异步 spawn 是安全的——§6.3 的嵌套限制仅存在于 DSH 沙箱内。)
126
+
127
+ ## 4. 事件流真值表(DSH)
128
+
129
+ | assistant 轮次包含 | 是否播报 |
130
+ | --------------------------------- | -------- |
131
+ | 最终文本回复,无工具调用 | ✅ 节流后播报 |
132
+ | 文本 + tool/call(s) | ❌(取消——旁白) |
133
+ | 只有 reasoning,无文本 | ❌(无 text 块) |
134
+ | 流式分块 | ❌(被过滤) |
135
+
136
+ ## 5. 配置参考
137
+
138
+ ### 引擎(`speak.ps1` 参数)
139
+
140
+ | 参数 | 默认值 | 含义 |
141
+ | ---- | ------ | ---- |
142
+ | `-Text` | `''` | 内联文本(`-File` 为空时使用) |
143
+ | `-File` | `''` | 要读取的 UTF-8 文件 |
144
+ | `-Volume` | `50` | 0–100 |
145
+ | `-Rate` | `1` | 语速(SAPI 刻度) |
146
+ | `-MaxChars` | `300` | 超过此长度时替换为 `LongTextMessage` |
147
+ | `-LongTextMessage` | `本次播报内容较长,请自行阅读。` | 超长文本时改念这句 |
148
+
149
+ ### DSH 插件(环境变量)
150
+
151
+ | 变量 | 默认值 | 含义 |
152
+ | ---- | ------ | ---- |
153
+ | `DSH_SPEAK_ENGINE` | `%USERPROFILE%\.dsh\hooks\speak.ps1` | 引擎路径 |
154
+ | `DSH_SPEAK_THROTTLE_MS` | `1500` | 播报前的合并延迟(毫秒) |
155
+
156
+ ## 6. 踩坑记录(来之不易;不要随意"修复")
157
+
158
+ | # | 坑 | 现象 | 修复/规则 |
159
+ |---|-----|------|-----------|
160
+ | 6.1 | emoji / 代理对进入 `Speak()` | **静默**——没声音也没报错 | 朗读前剥离非 CJK/ASCII 字符(引擎第 3 步) |
161
+ | 6.2 | 文本超过适配器单次 `Speak` 上限(约 375–470 字) | **静默**——整段被丢弃,而不是截断 | 300 字长度守卫(引擎第 5 步) |
162
+ | 6.3 | 在 DSH 沙箱进程内嵌套 `Start-Process powershell` | 静默失败,无异常 | DSH 链路在适配器边界保持同步(插件只 spawn 一次;`speech-summary.ps1` 同步调用 `speak.ps1`) |
163
+ | 6.4 | `cordis.patch.yml` 里插件名用 Windows 原始路径 | 插件加载失败 | 用 `file:///C:/...` URL 形式 |
164
+ | 6.5 | 只按名字匹配适配器音色 | 回退到机械感的系统语音 | 用 `Name + Description` 匹配 `Natural\|Online` |
165
+ | 6.6 | 用 ANSI 读写播报文本 | 乱码或完全无声 | 一律 UTF-8(`[System.IO.File]::ReadAllText(..., UTF8)`) |
166
+
167
+ ## 7. 扩展
168
+
169
+ ### 新的引擎后端
170
+ 引擎是 TTS 后端的唯一接缝。未来可以加 `speak-edge.ps1`(封装 `edge-tts`)或
171
+ `speak-piper.ps1`(本地离线模型)——同样的参数契约、同样的清洗管线,只换
172
+ `Speak()` 这一步。适配层永远不用改。
173
+
174
+ ### 新的 harness 适配层
175
+ 实现思路:*捕获最终回复文本 → 调用引擎*。DSH(事件流)、Claude Code(Stop
176
+ hook)、任意 shell harness(Agent 自己调 `speech-summary.ps1`)就是三种参考范式。
177
+
178
+ ## 8. 项目定位
179
+
180
+ 本项目**刻意不是**一个持续迭代的产品。它记录了一条被验证过的、让 harness
181
+ 开口说话的实现路径:一个小引擎 + 两种可复用的适配范式(事件流 / Stop hook)。
182
+ 如果你需要更多(音色管理界面、更多后端、跨平台),把引擎当作接缝在其上扩展——
183
+ 本仓库保持为最小、自包含的参考实现。
184
+
185
+ ## 9. 发布为 npm 插件(附录)
186
+
187
+ DSH 的插件机制基于 Cordis,官方安装树外插件的路径是
188
+ `dsh plugin --profile web add <包名>`(由 pnpm 管理 profile 依赖)。本仓库已为
189
+ 该路径做好准备:
190
+
191
+ ### 包结构
192
+
193
+ - `package.json` — `name: dsh-speak`,`main: adapters/dsh/speech-hook.js`,
194
+ `files` 白名单精确列出发布内容(插件、`engine/*.ps1`、`install.ps1`、文档、
195
+ LICENSE)。`prepublishOnly` 会对插件跑 `node --check`。
196
+ - 插件入口就是文件安装已用的同一个 CJS 模块(`module.exports = { apply(ctx) }`)
197
+ ——发布**不需要改任何代码**。
198
+
199
+ ### 引擎解析(npm 安装 vs 文件安装)
200
+
201
+ `speech-hook.js` 按以下顺序定位 `engine/speak.ps1`:
202
+
203
+ 1. `DSH_SPEAK_ENGINE` 环境变量覆盖;
204
+ 2. 相对插件文件解析 `<包>/engine/speak.ps1`——同时覆盖仓库检出和
205
+ `npm install` 后的 `node_modules/dsh-speak/`;
206
+ 3. 旧的 `%USERPROFILE%\.dsh\hooks\speak.ps1`(文件安装的位置)。
207
+
208
+ 因为引擎随 npm 包分发,用户只需 `dsh plugin --profile web add dsh-speak`
209
+ 一条命令,无需额外拷贝。
210
+
211
+ ### 发布步骤(维护者)
212
+
213
+ ```powershell
214
+ npm login --registry=https://registry.npmjs.org # 官方源,npm 强制要求 2FA
215
+ npm publish # publishConfig.registry 已锁定官方源
216
+ # 后续每次发布前先在 package.json 里 bump "version"
217
+ ```
218
+
219
+ > 中国区注意:如果你的全局 `.npmrc` 指向镜像(如 `registry.npmmirror.com`),
220
+ > `npm login`/`npm publish` 会打到镜像站——镜像**不接受发布**。本包的
221
+ > `publishConfig.registry` 已把发布锁定到官方源;登录时也要用官方源。
222
+
223
+ ### 安装步骤(DSH 用户)
224
+
225
+ ```powershell
226
+ dsh plugin --profile web add dsh-speak
227
+ # 然后在 ~/.dsh/profiles/web/cordis.patch.yml 注册:
228
+ # - insert:
229
+ # - id: speech-hook
230
+ # name: 'dsh-speak'
231
+ # 重启 DSH web 应用
232
+ ```
@@ -0,0 +1,76 @@
1
+ # speak.ps1 — Harness-agnostic speech engine (Windows SAPI5 + NaturalVoiceSAPIAdapter)
2
+ # ====================================================================================
3
+ # Reads text (inline or from a UTF-8 file), cleans it for speech synthesis, and reads
4
+ # it aloud through Windows SAPI5, preferring natural voices registered by
5
+ # NaturalVoiceSAPIAdapter (https://github.com/gexgd0419/NaturalVoiceSAPIAdapter).
6
+ #
7
+ # This script knows NOTHING about any harness (DSH, Claude Code, ...). Any process
8
+ # can call it:
9
+ #
10
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speak.ps1 -Text "hello"
11
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speak.ps1 -File C:\tmp\msg.txt
12
+ #
13
+ # It is best-effort by design: it never throws, never blocks the caller for longer
14
+ # than the utterance itself, and exits 0 even if something failed.
15
+ #
16
+ # Design notes (see docs/DESIGN.md for full rationale):
17
+ # * Markdown symbols, URLs and emoji are stripped before speaking — SAPI5 Speak()
18
+ # silently fails (produces no audio, no error) when it hits emoji/surrogates.
19
+ # * NaturalVoiceSAPIAdapter has a per-Speak character ceiling (~375-470 chars);
20
+ # beyond that it silently speaks nothing. Text longer than $MaxChars is replaced
21
+ # with $LongTextMessage instead.
22
+ # * Adapter-registered voices often have plain names ("Microsoft Xiaoxiao") that do
23
+ # not contain the word "Natural", so matching checks Name + Description.
24
+ # ====================================================================================
25
+
26
+ param(
27
+ [string]$Text = '',
28
+ [string]$File = '',
29
+ [int]$Volume = 50,
30
+ [int]$Rate = 1,
31
+ [int]$MaxChars = 300,
32
+ [string]$LongTextMessage = '本次播报内容较长,请自行阅读。'
33
+ )
34
+
35
+ # ---------- input: pick text source ----------
36
+ if ($File) {
37
+ if (-not (Test-Path $File)) { exit 0 }
38
+ $text = [System.IO.File]::ReadAllText($File, [System.Text.Encoding]::UTF8)
39
+ } else {
40
+ $text = [string]$Text
41
+ }
42
+ if (-not $text -or -not $text.Trim()) { exit 0 }
43
+
44
+ # ---------- clean: markdown -> plain speech text ----------
45
+ # code blocks, inline code, markdown links, bare URLs, emphasis/marker chars
46
+ $text = $text -replace '```[\s\S]*?```', ' '
47
+ $text = $text -replace '`[^`]*`', ' '
48
+ $text = $text -replace '\[([^\]]*)\]\([^\)]*\)', '$1'
49
+ $text = $text -replace 'https?://\S+', ' '
50
+ $text = $text -replace '[-#*_~|>+]+', ' '
51
+ # emoji / special symbols (Speak() fails silently on them): keep CJK, CJK punct,
52
+ # full-width ranges, ASCII printable
53
+ $text = [regex]::Replace($text, '[^一-龥 -〿＀-￯ - -~]', '')
54
+ $text = $text -replace '\s+', ' '
55
+ $text = $text.Trim()
56
+
57
+ # ---------- length guard: adapter per-Speak ceiling ----------
58
+ if ($text.Length -gt $MaxChars) { $text = $LongTextMessage }
59
+
60
+ # ---------- speak ----------
61
+ Add-Type -AssemblyName System.Speech
62
+ $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
63
+ $synth.Volume = $Volume
64
+
65
+ # prefer a zh natural voice (NaturalVoiceSAPIAdapter-registered), fall back to any zh
66
+ $voices = $synth.GetInstalledVoices()
67
+ $voice = $voices | Where-Object {
68
+ $_.VoiceInfo.Culture.Name -like 'zh*' -and
69
+ ($_.VoiceInfo.Name + ' ' + $_.VoiceInfo.Description) -match 'Natural|Online'
70
+ } | Select-Object -First 1
71
+ if (-not $voice) { $voice = $voices | Where-Object { $_.VoiceInfo.Culture.Name -like 'zh*' } | Select-Object -First 1 }
72
+ if ($voice) { $synth.SelectVoice($voice.VoiceInfo.Name) }
73
+
74
+ $synth.Rate = $Rate
75
+ $synth.Speak($text)
76
+ exit 0
@@ -0,0 +1,22 @@
1
+ # speech-prompt.ps1 — Short prompt announcement (synchronous, blocking)
2
+ # Use when a harness/agent needs the user's attention (a question, an approval
3
+ # request). Reads the text through engine/speak.ps1 and waits until it finishes,
4
+ # so the caller knows the announcement was actually spoken.
5
+ #
6
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speech-prompt.ps1 -Text "请做出选择"
7
+
8
+ param([string]$Text = '请做出选择')
9
+
10
+ if (-not $Text) { exit 0 }
11
+
12
+ $speak = Join-Path $PSScriptRoot 'speak.ps1'
13
+ if (-not (Test-Path $speak)) { exit 0 }
14
+
15
+ $tmp = Join-Path $env:TEMP ('speech-prompt-' + [guid]::NewGuid().ToString('N') + '.txt')
16
+ try {
17
+ [System.IO.File]::WriteAllText($tmp, $Text, [System.Text.UTF8Encoding]::new($false))
18
+ & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $speak -File $tmp
19
+ exit $LASTEXITCODE
20
+ } finally {
21
+ if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
22
+ }
@@ -0,0 +1,27 @@
1
+ # speech-summary.ps1 — Reply summary announcement (synchronous, blocking)
2
+ # For harnesses with no "reply finished" event (e.g. DSH has no Stop hook): the
3
+ # agent calls this at the end of its final reply.
4
+ #
5
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speech-summary.ps1 -Text "总结文本"
6
+ #
7
+ # NOTE: keep this SYNCHRONOUS. An earlier version spawned the inner powershell
8
+ # asynchronously with Start-Process; DSH's sandbox blocks nested sub-process
9
+ # spawning, so it silently produced no audio. The synchronous call chain
10
+ # (summary -> speak.ps1) is the reliable path (cost: caller waits for the
11
+ # utterance to finish).
12
+
13
+ param([string]$Text = '')
14
+
15
+ if (-not $Text) { exit 0 }
16
+
17
+ $speak = Join-Path $PSScriptRoot 'speak.ps1'
18
+ if (-not (Test-Path $speak)) { exit 0 }
19
+
20
+ $tmp = Join-Path $env:TEMP ('speech-summary-' + [guid]::NewGuid().ToString('N') + '.txt')
21
+ try {
22
+ [System.IO.File]::WriteAllText($tmp, $Text, [System.Text.UTF8Encoding]::new($false))
23
+ & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $speak -File $tmp
24
+ exit $LASTEXITCODE
25
+ } finally {
26
+ if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
27
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "dsh-speak",
3
+ "version": "1.0.0",
4
+ "description": "Make your AI harness speak — verified voice announcements for DSH and other AI coding harnesses (Windows SAPI5 + natural voices)",
5
+ "main": "adapters/dsh/speech-hook.js",
6
+ "files": [
7
+ "adapters/dsh/speech-hook.js",
8
+ "adapters/dsh/install.ps1",
9
+ "engine/",
10
+ "docs/",
11
+ "README.md",
12
+ "README.zh-CN.md",
13
+ "LICENSE"
14
+ ],
15
+ "keywords": [
16
+ "dsh",
17
+ "dsh-plugin",
18
+ "deepseek-harness",
19
+ "tts",
20
+ "text-to-speech",
21
+ "voice",
22
+ "speech",
23
+ "voice-announcement",
24
+ "sapi5",
25
+ "windows",
26
+ "powershell"
27
+ ],
28
+ "scripts": {
29
+ "prepublishOnly": "node --check adapters/dsh/speech-hook.js"
30
+ },
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/Alan2Z/dsh-speak.git"
37
+ },
38
+ "author": "Alan2Z",
39
+ "license": "MIT",
40
+ "publishConfig": {
41
+ "registry": "https://registry.npmjs.org"
42
+ }
43
+ }