dsh-speak 1.4.0 → 1.7.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.
@@ -1,7 +1,6 @@
1
1
  # DESIGN.zh-CN.md — dsh-speak:为 AI 编程 harness 提供语音播报
2
2
 
3
- 状态:**草稿** — 本文档描述当前(已验证的)本地实现与本文档仓库的目标通用结构,
4
- 是 README 的参考依据。
3
+ 状态:**维护中** — 本文档描述当前实现与仓库结构,是 README 的参考依据。
5
4
 
6
5
  (英文版:docs/DESIGN.md)
7
6
 
@@ -32,11 +31,12 @@ Agent 工具会跑长任务(构建、测试、迁移、批量修改),而
32
31
 
33
32
  非目标(当前阶段):
34
33
 
35
- - Windows 引擎(`speak.ps1`)+ macOS 引擎(`speak.sh`,系统自带 `say`)均已
36
- **正式支持**(macOS 自 1.2.0 起随 npm 包分发)。不支持 Linux/无头 TTS。
34
+ - 不支持 Linux/无头 TTS(Windows 用 `speak.ps1` + SAPI5;macOS 用 `speak.sh`
35
+ + 系统 `say`,macOS 引擎自 1.2.0 起随 npm 包分发)。
37
36
  - 在仓库内打包 NaturalVoiceSAPIAdapter(仅 Windows 10 需要)或语音数据——
38
37
  它们是前置依赖,不打进仓库。
39
- - 流式/队列播放、按音色输出音频文件、非中文音色管理。
38
+ - 按音色输出音频文件、非中文音色管理(语音**播放队列**已在 1.7.0 实现——
39
+ 见 §3.2 的 host FIFO 队列)。
40
40
 
41
41
  ## 3. 架构
42
42
 
@@ -106,13 +106,29 @@ Agent 工具会跑长任务(构建、测试、迁移、批量修改),而
106
106
  - 监听 `session/event`;
107
107
  - 过滤 `assistant/message` 且 `surfaceOp == 'append'` 的事件;
108
108
  - 只提取 `text` 内容块(reasoning / tool_use 块跳过);
109
- - 缓冲文本并启动节流定时器(默认 1500 ms)以合并同一回复的多步消息;
110
- - `tool/call` 事件会**取消**待播报——该轮 assistant 文本是过程旁白,不是最终回复;
111
- - 触发时:把文本写入临时文件,`spawn` 出
112
- `powershell.exe -File <engine> -File <tmp>`,带 `windowsHide` + `stdio: 'ignore'`,
113
- 绝不阻塞 harness;退出后删除临时文件。
114
-
115
- 注册片段(`install.ps1` 也会自动完成):
109
+ - 默认模式:缓冲文本并启动节流定时器(默认 1500 ms)以合并同一回复的多步消息,
110
+ `tool/call` 事件会**取消**待播报(该轮 assistant 文本是过程旁白),但
111
+ **`turn/end` 会在回合结束时兜底补播最终回复**(带工具调用的回复也能听到);
112
+ - **host 语音队列**(1.7.0,源自 PR #2):所有播报(最终回复、审批、提问、
113
+ 可选事件、手动重播)统一进入 FIFO 队列,**同一时间只运行一个语音进程**,
114
+ 读完自动播下一条;`/dsh-speak/control` POST 路由(play/stop/status)和
115
+ `/dsh-speak/ws` WebSocket 广播权威播放状态(正在读哪条、队列长度)。
116
+ - **最终回复重播**(1.7.0):回合尾部(最终回复)操作栏的 🔊 按钮调 control
117
+ 路由重播**该条最终回复**;语音执行完全由 host 拥有(浏览器关掉也继续)。
118
+ - **`queueAllMessages` 开关**(1.7.0,默认关):关 = 默认节流最终回复 + 可选
119
+ 事件;开 = 每条 assistant 消息立即入队朗读(中间消息也读)。
120
+ - **可选事件播报**(1.6.0,默认全关):`turn/end`、`command/done`、
121
+ `goal/change`、`tool/result`(出错时)、`todo/write` 各自独立开关(见 §5)。
122
+ - **settings namespace 注册**(1.6.0):apply 后在一个 timer tick 里调用
123
+ `installSettingsSection(ctx, 'dsh-speak', schema, patchConfig, hooks)`,配置
124
+ 解析为 schema 默认 → patch `config` → UI 用户设置三层。`onChange` 时通过
125
+ `settingsSource()` 重新解析 cfg(注意:`installSettingsSection` 只在
126
+ attach/detach 时调 `setSource`,变更时需自己在 `onChange` 里重读 source)。
127
+ 若宿主没有 settings 服务(dsh < 0.1.0-rc.7 或未挂载 provider),注册静默
128
+ 跳过,插件完全按 patch `config` 工作——向后兼容。
129
+
130
+ 注册片段(`install.ps1` 也会自动完成;npm 安装用裸包名 `'dsh-speak'` 即可,
131
+ 这是文件安装方式用的路径):
116
132
 
117
133
  ```yaml
118
134
  # ~/.dsh/profiles/web/cordis.patch.yml
@@ -125,6 +141,29 @@ Agent 工具会跑长任务(构建、测试、迁移、批量修改),而
125
141
  > Node 的 ESM 加载器不接受 Windows 绝对路径作为插件名——必须用
126
142
  > `file:///C:/...` URL 形式。
127
143
 
144
+ ### 3.4 DSH 浏览器端 — `client/client.js`
145
+
146
+ 一个 DSH client bundle(`window.__ModuleLoader__.load({ id: 'dsh-speak',
147
+ factory })`),注册两条 UI:
148
+
149
+ - **回合尾部的 Speak 按钮**(1.7.0,源自 PR #2):注册进
150
+ `conversation.chat.assistant-actions` slot(该回合最终回复的操作栏)。点击 🔊
151
+ 调 `/dsh-speak/control` 重播该条最终回复,再点停止,点另一条切换;按钮状态
152
+ (播放中/暂停)由 `/dsh-speak/ws` WebSocket 的 host 权威状态推导(session +
153
+ turn 身份匹配)。
154
+ - **设置 → dsh-speak 设置独立设置页**(1.7.0):注册进 `settings.section` slot。
155
+ 用 `@deepseek-ai/dsh-client-ui-primitives` 的 Button/DisclosureRow/Input
156
+ 绘制(Toggle/Options/SettingInput 组件),所有配置项(总开关、自动朗读、
157
+ queueAllMessages、Markdown 清洗、代码块、maxChars、longTextMode、固定提示语、
158
+ 审批/提问、5 类可选事件)都通过 `settingsScope.bind({ namespace: 'dsh-speak' })`
159
+ 读写。
160
+
161
+ - 包通过 `package.json` 的 `dsh.client: { platform: 'web' }` +
162
+ `exports['./client']` 声明浏览器端;DSH 的 client-modules 扫描到后自动加载。
163
+ - **刻意手写、零构建**:只用平台 seed 模块 + 官方 primitives(bundle-purity
164
+ gate 允许用 primitives,禁止 import 官方包内部组件),与构建出来的 bundle
165
+ 契约一致。
166
+
128
167
  ### 3.3 Claude Code 适配层 — `adapters/claude-code/stop-hook.ps1`
129
168
 
130
169
  Claude Code *确实*有 Stop hook。hook JSON(含 `transcript_path`)从 stdin 传入;
@@ -137,11 +176,19 @@ Claude Code *确实*有 Stop hook。hook JSON(含 `transcript_path`)从 stdi
137
176
  | assistant 轮次 / 事件 | 是否播报 |
138
177
  | -------------------------------- | -------- |
139
178
  | 最终文本回复,无工具调用 | ✅ 节流后播报 |
140
- | 文本 + tool/call(s) | ❌(取消——旁白) |
141
- | 文本 + `ask_user_question` 调用 | ✅ 保留提问文本并播报 |
179
+ | 文本 + tool/call(s) | 🟡 节流被取消(中间轮次);**回合结束时兜底补播最终回复** |
180
+ | 文本 + `ask_user_question` 调用 | ✅ 每个问题单独播报:"问题N"(多问题时)+ "选项N"序号,问题间停顿 `questionGapMs` |
142
181
  | `approval/asked`(审批请求) | ✅ 立即播报(审批原因,否则固定提示语) |
143
182
  | 只有 reasoning,无文本 | ❌(无 text 块) |
144
183
  | 流式分块 | ❌(被过滤) |
184
+ | `turn/end`(回合结束) | 🟡 默认关;开则播报"第 N 轮对话完成/中断/异常结束" |
185
+ | `command/done`(命令完成) | 🟡 默认关;开则播报"命令执行完成/失败" |
186
+ | `goal/change`(目标变更) | 🟡 默认关;开则播报"已创建目标/目标已完成…(前 40 字)" |
187
+ | `tool/result`(工具结果) | 🟡 默认关;开则仅当带 `error` 或 `isError` 内容块时播报"工具调用出错"(英文详情/技术 code 截掉,只保留中文详情) |
188
+ | `todo/write`(待办更新) | 🟡 默认关;开则播报"待办已更新:n/m 完成" |
189
+
190
+ | `assistant/message`(queueAllMessages 开)| ✅ 每条立即入队(中间消息也读) |
191
+ | 手动重播(每条消息 🔊 按钮) | ✅ 清队列 → 停当前 → 读该回合 |
145
192
 
146
193
  ## 5. 配置参考
147
194
 
@@ -153,15 +200,52 @@ Claude Code *确实*有 Stop hook。hook JSON(含 `transcript_path`)从 stdi
153
200
  | `-File` | `''` | 要读取的 UTF-8 文件 |
154
201
  | `-Volume` | `50` | 0–100 |
155
202
  | `-Rate` | `1` | 语速(SAPI 刻度) |
156
- | `-MaxChars` | `300` | 超过此长度时替换为 `LongTextMessage` |
203
+ | `-MaxChars` | 平台相关 | 超过此长度时替换为 `LongTextMessage`(macOS 默认 0 = 不限) |
157
204
  | `-LongTextMessage` | `本次播报内容较长,请自行阅读。` | 超长文本时改念这句 |
205
+ | `-LongTextMode` | `message` | `message`(固定提示语)\| `heading`(念最大字号 markdown 标题) |
206
+ | `-CleanMarkdownFormatting` | `true` | Markdown 转自然语音(保留链接文字去 URL) |
207
+ | `-ReadInlineCode` | `true` | 朗读行内代码(去掉反引号) |
208
+ | `-CodeBlocks` | `smart` | `all` \| `smart` \| `replace`(围栏代码块) |
209
+ | `-CodeBlockMaxChars` | `300` | `smart` 模式的代码块字数上限 |
210
+ | `-CodeBlockReplacementText` | `You can see the code in our history.` | `replace` 时的替代文本 |
158
211
 
159
- ### DSH 插件(环境变量)
212
+ ### DSH 插件(profile `config`;1.7.0 起同样可在 Web UI 的 dsh-speak 设置页里改)
160
213
 
161
- | 变量 | 默认值 | 含义 |
162
- | ---- | ------ | ---- |
163
- | `DSH_SPEAK_ENGINE` | 空(自动解析) | 引擎路径覆盖;否则按"包内 `engine/<平台脚本>` → `~/.dsh/hooks/<平台脚本>`"顺序解析(Windows 为 `speak.ps1`,macOS 为 `speak.sh`) |
164
- | `DSH_SPEAK_THROTTLE_MS` | `1500` | 播报前的合并延迟(毫秒) |
214
+ ```yaml
215
+ config:
216
+ enabled: true # 总开关
217
+ automaticSpeech: true # 自动朗读最终回复
218
+ queueAllMessages: false # true = 所有 assistant 消息立即入队
219
+ replayFullRead: false # true = 手动重播跳过超长文本截断,完整朗读
220
+ cleanMarkdownFormatting: true
221
+ readInlineCode: true
222
+ codeBlocks: smart # all | smart | replace
223
+ codeBlockMaxChars: 300
224
+ codeBlockReplacementText: 'You can see the code in our history.'
225
+ throttleMs: 1500
226
+ engine: '' # '' = 自动解析
227
+ announceApprovals: true
228
+ announceQuestions: true
229
+ stripApprovalPrefix: true
230
+ questionGapMs: 2000 # 多个提问播报之间的停顿(毫秒)
231
+ longTextMode: message # message | heading
232
+ longTextMessage: '本次播报内容较长,请自行阅读。'
233
+ maxChars: 300 # macOS 默认 0 = 不限
234
+ volume: 50 # 仅 Windows
235
+ rate: 0 # 0 = 引擎默认
236
+ # —— 可选事件播报(默认全关)——
237
+ announceTurnEnd: false # turn/end
238
+ announceCommandDone: false # command/done
239
+ announceGoalChange: false # goal/change
240
+ announceToolErrors: false # tool/result 带 error 或 isError 块时
241
+ announceTodoWrite: false # todo/write
242
+ ```
243
+
244
+ 配置解析顺序:schema 默认值 → patch `config`(base)→ UI 用户设置(user 层)。
245
+ 浏览器端 dsh-speak 设置页(`client/client.js`)与 patch YAML 读写同一个 settings
246
+ 文档。平台差异:`maxChars` macOS 默认 0(`say` 无上限)、Windows 默认 300。
247
+
248
+ 完整配置指南见 README 的"配置"一节。
165
249
 
166
250
  ## 6. 踩坑记录(来之不易;不要随意"修复")
167
251
 
@@ -189,10 +273,9 @@ hook)、任意 shell harness(Agent 自己调 `speech-summary.ps1`)就是
189
273
 
190
274
  ## 8. 项目定位
191
275
 
192
- 本项目**刻意不是**一个持续迭代的产品。它记录了一条被验证过的、让 harness
193
- 开口说话的实现路径:一个小引擎 + 两种可复用的适配范式(事件流 / Stop hook)。
194
- 如果你需要更多(音色管理界面、更多后端、跨平台),把引擎当作接缝在其上扩展——
195
- 本仓库保持为最小、自包含的参考实现。
276
+ 本项目保持**小而自包含**的设计:一个小引擎 + 两种可复用的适配范式(事件流 /
277
+ Stop hook),并**持续维护**。需要更多能力(音色管理界面、更多后端、跨平台)时,
278
+ 把引擎当作接缝在其上扩展。
196
279
 
197
280
  ## 9. 发布为 npm 插件(附录)
198
281
 
@@ -212,7 +295,7 @@ DSH 的插件机制基于 Cordis,官方安装树外插件的路径是
212
295
 
213
296
  `speech-hook.js` 按以下顺序定位 `engine/speak.ps1`:
214
297
 
215
- 1. `DSH_SPEAK_ENGINE` 环境变量覆盖;
298
+ 1. `config.engine` 覆盖;
216
299
  2. 相对插件文件解析 `<包>/engine/speak.ps1`——同时覆盖仓库检出和
217
300
  `npm install` 后的 `node_modules/dsh-speak/`;
218
301
  3. 旧的 `%USERPROFILE%\.dsh\hooks\speak.ps1`(文件安装的位置)。
package/engine/speak.ps1 CHANGED
@@ -29,9 +29,27 @@ 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',
35
+ # 命令行参数一律是字符串(-File 模式不做类型转换),这里用 [string] 接收,
36
+ # 在脚本内部再转布尔,兼容 '1'/'0'/'true'/'True'/'yes'/'on' 等写法。
37
+ [string]$CleanMarkdownFormatting = 'true',
38
+ [string]$ReadInlineCode = 'true',
39
+ [ValidateSet('all', 'smart', 'replace')]
40
+ [string]$CodeBlocks = 'smart',
41
+ [int]$CodeBlockMaxChars = 300,
42
+ [string]$CodeBlockReplacementText = 'You can see the code in our history.',
43
+ # 手动重播完整朗读:跳过超长文本的 heading/message 截断,分段完整朗读
44
+ [string]$FullRead = '0'
33
45
  )
34
46
 
47
+ $cleanMarkdown = $CleanMarkdownFormatting -in @('1', 'true', 'yes', 'on')
48
+ $readInlineCode = $ReadInlineCode -in @('1', 'true', 'yes', 'on')
49
+ # 注意:PowerShell 变量大小写不敏感,内部变量名不能与参数名仅差大小写
50
+ # (曾用 $fullRead 导致自赋值污染参数 $FullRead,使 -not 判断失效)
51
+ $fullReadMode = $FullRead -in @('1', 'true', 'yes', 'on')
52
+
35
53
  # ---------- input: pick text source ----------
36
54
  if ($File) {
37
55
  if (-not (Test-Path $File)) { exit 0 }
@@ -41,21 +59,52 @@ if ($File) {
41
59
  }
42
60
  if (-not $text -or -not $text.Trim()) { exit 0 }
43
61
 
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, '[^一-龥 -〿＀-￯ - -~]', '')
62
+ # ---------- length guard: adapter per-Speak ceiling ----------
63
+ # 'message': fixed prompt. 'heading': speak the largest markdown heading instead
64
+ # (fewest '#' wins, tie -> first; no heading -> first non-empty line; the
65
+ # cleaned candidate is still subject to the ceiling below). FullRead 手动重播
66
+ # 跳过该守卫(见文件底部"完整朗读"分支)。
67
+ if (-not $fullReadMode -and $text.Length -gt $MaxChars -and $LongTextMode -eq 'heading') {
68
+ $candidate = ''
69
+ $bestLevel = 7
70
+ $firstNonEmpty = ''
71
+ foreach ($line in ($text -split "`n")) {
72
+ if ($line -match '^\s*#{1,6}\s+') {
73
+ $level = ([regex]::Match($line, '^(\s*)(#+)')).Groups[2].Value.Length
74
+ if ($level -lt $bestLevel) {
75
+ $bestLevel = $level
76
+ $candidate = $line -replace '^\s*#+\s*', ''
77
+ }
78
+ } elseif (-not $firstNonEmpty -and $line.Trim()) {
79
+ $firstNonEmpty = $line
80
+ }
81
+ }
82
+ if (-not $candidate) { $candidate = $firstNonEmpty }
83
+ if ($candidate) { $text = $candidate }
84
+ }
85
+
86
+ # ---------- clean: Markdown -> natural speech text ----------
87
+ if ($cleanMarkdown) {
88
+ $text = [regex]::Replace($text, '```[^\n]*\n?([\s\S]*?)```', {
89
+ param($match)
90
+ $code = $match.Groups[1].Value
91
+ if ($CodeBlocks -eq 'all' -or ($CodeBlocks -eq 'smart' -and $code.Length -le $CodeBlockMaxChars)) { return " $code " }
92
+ return " $CodeBlockReplacementText "
93
+ })
94
+ if ($readInlineCode) { $text = $text -replace '`([^`]*)`', '$1' } else { $text = $text -replace '`[^`]*`', ' ' }
95
+ $text = $text -replace '\[([^\]]*)\]\([^\)]*\)', '$1'
96
+ $text = $text -replace 'https?://\S+', ' '
97
+ $text = $text -replace '(?m)^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+|>\s?)', ' '
98
+ $text = $text -replace '(\*\*|__|~~)(.*?)\1', '$2'
99
+ $text = $text -replace '[*_~]+', ''
100
+ }
101
+ # Keep all Unicode letters, including Portuguese accents; remove unsafe symbols.
102
+ $text = [regex]::Replace($text, '[^\p{L}\p{N}一-龥 -〿＀-￯ - -~]', '')
54
103
  $text = $text -replace '\s+', ' '
55
104
  $text = $text.Trim()
56
105
 
57
- # ---------- length guard: adapter per-Speak ceiling ----------
58
- if ($text.Length -gt $MaxChars) { $text = $LongTextMessage }
106
+ # ---------- final ceiling (also catches over-long heading candidates) ----------
107
+ if (-not $fullReadMode -and $text.Length -gt $MaxChars) { $text = $LongTextMessage }
59
108
 
60
109
  # ---------- speak ----------
61
110
  Add-Type -AssemblyName System.Speech
@@ -72,5 +121,30 @@ if (-not $voice) { $voice = $voices | Where-Object { $_.VoiceInfo.Culture.Name -
72
121
  if ($voice) { $synth.SelectVoice($voice.VoiceInfo.Name) }
73
122
 
74
123
  $synth.Rate = $Rate
75
- $synth.Speak($text)
124
+
125
+ # ---------- 完整朗读(FullRead,手动重播) ----------
126
+ # Windows SAPI 单次 Speak 有约 375-470 字上限,超长会静默失败;因此按句末
127
+ # 标点切成不超过 450 字的段,逐段朗读(自动播报不经过这里,走上面的守卫)。
128
+ $SPEAK_CHUNK = 400
129
+ if ($fullReadMode -and $text.Length -gt $SPEAK_CHUNK) {
130
+ $parts = [regex]::Split($text, '(?<=[。!?;.!?;])')
131
+ $chunk = ''
132
+ foreach ($part in $parts) {
133
+ if ($part.Length -eq 0) { continue }
134
+ if ($chunk.Length + $part.Length -gt $SPEAK_CHUNK) {
135
+ if ($chunk) { $synth.Speak($chunk); $chunk = '' }
136
+ # 单段仍超长:硬切
137
+ while ($part.Length -gt $SPEAK_CHUNK) {
138
+ $synth.Speak($part.Substring(0, $SPEAK_CHUNK))
139
+ $part = $part.Substring($SPEAK_CHUNK)
140
+ }
141
+ $chunk = $part
142
+ } else {
143
+ $chunk += $part
144
+ }
145
+ }
146
+ if ($chunk) { $synth.Speak($chunk) }
147
+ } else {
148
+ $synth.Speak($text)
149
+ }
76
150
  exit 0
package/engine/speak.sh CHANGED
@@ -1,49 +1,32 @@
1
1
  #!/usr/bin/env bash
2
2
  # speak.sh — macOS speech engine (uses the built-in `say` command)
3
- # ==============================================================================
4
- # The macOS counterpart of engine/speak.ps1. Reads text (inline or UTF-8 file),
5
- # cleans it for speech synthesis, and reads it aloud via the system `say`
6
- # command. Any process can call it:
7
- #
8
- # ./speak.sh -t "你好,构建完成"
9
- # ./speak.sh -f /tmp/msg.txt -v Eddy -r 200
10
- #
11
- # Best-effort by design: never throws, exits 0 even if something failed.
12
- #
13
- # Voice selection:
14
- # * no -v (default): follow the system voice — on recent macOS this is the
15
- # Siri voice chosen in Settings > Siri > Voice (e.g. "声音 1"), on older
16
- # versions the Spoken Content voice. This is the least surprising default.
17
- # * -v <name>: force a voice by name (e.g. Eddy, Flo, Tingting — see
18
- # `say -v '?'`).
19
- # * NOTE: the Siri voices ("声音 1-4") are NOT exposed to `say` — they do not
20
- # appear in `say -v '?'` and cannot be selected by name; they only work as
21
- # the system default.
22
- #
23
- # Notes:
24
- # * LC_ALL is pinned to a UTF-8 locale so the perl cleaning pipeline and bash
25
- # character counting behave identically regardless of the caller's locale
26
- # (a C/POSIX locale would silently strip all CJK text).
27
- # * The cleaning pipeline mirrors speak.ps1 (markdown/URL/emoji stripped).
28
- # * `say` has no volume flag — volume is controlled by the system output.
29
- # * Length guard: text over $MAX_CHARS is replaced with $LONG_MSG.
30
- # ==============================================================================
3
+ # ==================================================================
4
+ # Reads text (inline or UTF-8 file), optionally converts Markdown into natural
5
+ # speech text, and reads it aloud with the system voice. A zero max length means
6
+ # unlimited text, which is safe for macOS `say`.
31
7
 
32
8
  export LC_ALL="${LC_ALL:-en_US.UTF-8}"
33
9
 
34
10
  TEXT=""
35
11
  FILE=""
36
12
  VOICE=""
37
- RATE=175 # words per minute (say default)
38
- MAX_CHARS=300
13
+ RATE=175
14
+ MAX_CHARS=0
39
15
  LONG_MSG="本次播报内容较长,请自行阅读。"
16
+ LONG_MODE="message"
17
+ CLEAN_MARKDOWN=1
18
+ READ_INLINE_CODE=1
19
+ CODE_BLOCKS="smart"
20
+ CODE_BLOCK_MAX_CHARS=300
21
+ CODE_BLOCK_REPLACEMENT="You can see the code in our history."
22
+ FULL_READ=0
40
23
 
41
24
  usage() {
42
- echo "usage: speak.sh [-t text | -f file] [-v voice] [-r wpm] [-m maxchars] [-l longmsg]" >&2
25
+ echo "usage: speak.sh [-t text | -f file] [-v voice] [-r wpm] [-m maxchars] [-l longmsg] [-M message|heading] [-C 0|1] [-I 0|1] [-B all|smart|replace] [-K codechars] [-R replacement] [-F]" >&2
43
26
  exit 1
44
27
  }
45
28
 
46
- while getopts "t:f:v:r:m:l:h" opt; do
29
+ while getopts "t:f:v:r:m:l:M:C:I:B:K:R:Fh" opt; do
47
30
  case "$opt" in
48
31
  t) TEXT="$OPTARG" ;;
49
32
  f) FILE="$OPTARG" ;;
@@ -51,39 +34,70 @@ while getopts "t:f:v:r:m:l:h" opt; do
51
34
  r) RATE="$OPTARG" ;;
52
35
  m) MAX_CHARS="$OPTARG" ;;
53
36
  l) LONG_MSG="$OPTARG" ;;
37
+ M) LONG_MODE="$OPTARG" ;;
38
+ C) CLEAN_MARKDOWN="$OPTARG" ;;
39
+ I) READ_INLINE_CODE="$OPTARG" ;;
40
+ B) CODE_BLOCKS="$OPTARG" ;;
41
+ K) CODE_BLOCK_MAX_CHARS="$OPTARG" ;;
42
+ R) CODE_BLOCK_REPLACEMENT="$OPTARG" ;;
43
+ F) FULL_READ=1 ;;
54
44
  h) usage ;;
55
45
  *) usage ;;
56
46
  esac
57
47
  done
58
48
 
59
- # ---------- input ----------
60
49
  if [ -n "$FILE" ]; then
61
50
  [ -f "$FILE" ] || exit 0
62
51
  TEXT=$(/usr/bin/perl -CSD -e 'print <>' "$FILE")
63
52
  fi
64
- if [ -z "$TEXT" ]; then exit 0; fi
53
+ [ -n "$TEXT" ] || exit 0
65
54
 
66
- # ---------- clean (mirrors speak.ps1) ----------
55
+ # `heading` is meaningful only when a positive long-text ceiling is configured.
56
+ # -F (full read, manual replay) skips this guard entirely.
57
+ if [ "$FULL_READ" != "1" ] && [ "$MAX_CHARS" -gt 0 ] && [ "${#TEXT}" -gt "$MAX_CHARS" ] && [ "$LONG_MODE" = "heading" ]; then
58
+ TEXT=$(printf '%s' "$TEXT" | /usr/bin/perl -CSD -e '
59
+ my $best = 7; my $cand = ""; my $first = "";
60
+ while (<STDIN>) {
61
+ if (/^[ \t]*(\#{1,6})[ \t]+(.*)$/) { my $n = length($1); if ($n < $best) { $best = $n; $cand = $2; } }
62
+ elsif ($first eq "" && /\S/) { $first = $_; }
63
+ }
64
+ print($cand eq "" ? $first : $cand);
65
+ ')
66
+ fi
67
+
68
+ if [ "$CLEAN_MARKDOWN" = "1" ]; then
69
+ export DSH_SPEAK_CODE_BLOCKS="$CODE_BLOCKS"
70
+ export DSH_SPEAK_CODE_BLOCK_MAX_CHARS="$CODE_BLOCK_MAX_CHARS"
71
+ export DSH_SPEAK_CODE_BLOCK_REPLACEMENT="$CODE_BLOCK_REPLACEMENT"
72
+ export DSH_SPEAK_READ_INLINE_CODE="$READ_INLINE_CODE"
73
+ TEXT=$(printf '%s' "$TEXT" | /usr/bin/perl -CSD -0pe '
74
+ my $mode = $ENV{DSH_SPEAK_CODE_BLOCKS} || "smart";
75
+ my $limit = $ENV{DSH_SPEAK_CODE_BLOCK_MAX_CHARS} || 300;
76
+ my $replacement = $ENV{DSH_SPEAK_CODE_BLOCK_REPLACEMENT} || "";
77
+ s{```[^\n]*\n?(.*?)```}{
78
+ my $code = $1;
79
+ $mode eq "all" || ($mode eq "smart" && length($code) <= $limit) ? " $code " : " $replacement ";
80
+ }gse;
81
+ if (($ENV{DSH_SPEAK_READ_INLINE_CODE} || "1") eq "1") { s/`([^`]*)`/$1/g; }
82
+ else { s/`[^`]*`/ /g; }
83
+ s/\[([^\]]*)\]\([^\)]*\)/$1/g;
84
+ s|https?://\S+| |g;
85
+ s/^\s{0,3}(?:\#{1,6}\s+|[-*+]\s+|\d+[.)]\s+|>\s?)/ /gm;
86
+ s/(\*\*|__|~~)(.*?)\1/$2/g;
87
+ s/[\*_~]+//g;
88
+ ')
89
+ fi
90
+
91
+ # Retain all letters (including Portuguese accents) and numbers, while removing
92
+ # emoji/symbols that make the native synthesizer unreliable.
67
93
  TEXT=$(printf '%s' "$TEXT" | /usr/bin/perl -CSD -pe '
68
- s/```[\s\S]*?```/ /g; # code blocks
69
- s/`[^`]*`/ /g; # inline code
70
- s/\[([^\]]*)\]\([^\)]*\)/$1/g; # markdown links
71
- s|https?://\S+| |g; # bare URLs
72
- s/[-#*_~|>+]+/ /g; # emphasis / marker chars
73
- s/[^\p{Han}\x{3000}-\x{303F}\x{FF00}-\x{FFEF}\x{2000}-\x{206F}\x{20}-\x{7E}]//g; # emoji / specials
74
- s/\s+/ /g; # collapse whitespace
94
+ s/[^\p{L}\p{N}\p{Han}\x{3000}-\x{303F}\x{FF00}-\x{FFEF}\x{2000}-\x{206F}\x{20}-\x{7E}]//g;
95
+ s/\s+/ /g;
75
96
  ')
76
97
  TEXT=$(printf '%s' "$TEXT" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
77
98
 
78
- # ---------- length guard ----------
79
- if [ "${#TEXT}" -gt "$MAX_CHARS" ]; then
80
- TEXT="$LONG_MSG"
81
- fi
99
+ if [ "$FULL_READ" != "1" ] && [ "$MAX_CHARS" -gt 0 ] && [ "${#TEXT}" -gt "$MAX_CHARS" ]; then TEXT="$LONG_MSG"; fi
100
+ [ -n "$TEXT" ] || exit 0
82
101
 
83
- # ---------- speak (no -v => system default voice) ----------
84
- if [ -n "$VOICE" ]; then
85
- say -v "$VOICE" -r "$RATE" "$TEXT"
86
- else
87
- say -r "$RATE" "$TEXT"
88
- fi
102
+ if [ -n "$VOICE" ]; then say -v "$VOICE" -r "$RATE" "$TEXT"; else say -r "$RATE" "$TEXT"; fi
89
103
  exit 0
package/package.json CHANGED
@@ -1,12 +1,19 @@
1
1
  {
2
2
  "name": "dsh-speak",
3
- "version": "1.4.0",
4
- "description": "Make your AI harness speak — verified voice announcements for DSH and other AI coding harnesses (Windows SAPI5 + macOS system voices)",
3
+ "version": "1.7.0",
4
+ "description": "Make your AI harness speak — voice announcements for DSH and other AI coding harnesses (Windows SAPI5 + macOS system voices)",
5
5
  "main": "adapters/dsh/speech-hook.js",
6
+ "exports": {
7
+ ".": "./adapters/dsh/speech-hook.js",
8
+ "./client": "./client/client.js",
9
+ "./cordis.patch.yml": "./cordis.patch.yml",
10
+ "./package.json": "./package.json"
11
+ },
6
12
  "files": [
7
13
  "adapters/dsh/speech-hook.js",
8
14
  "adapters/dsh/install.ps1",
9
15
  "cordis.patch.yml",
16
+ "client/",
10
17
  "engine/",
11
18
  "docs/",
12
19
  "README.md",
@@ -16,6 +23,17 @@
16
23
  "dsh": {
17
24
  "bundle": {
18
25
  "patch": "./cordis.patch.yml"
26
+ },
27
+ "client": {
28
+ "platform": "web",
29
+ "inject": [
30
+ "@deepseek-ai/dsh-client-ui-conversation",
31
+ "@deepseek-ai/dsh-client-ui-settings",
32
+ "@deepseek-ai/dsh-client-runtime"
33
+ ],
34
+ "external": [
35
+ "@deepseek-ai/dsh-client-ui-primitives"
36
+ ]
19
37
  }
20
38
  },
21
39
  "keywords": [
@@ -35,7 +53,10 @@
35
53
  "say"
36
54
  ],
37
55
  "scripts": {
38
- "prepublishOnly": "node --check adapters/dsh/speech-hook.js"
56
+ "prepublishOnly": "node --check adapters/dsh/speech-hook.js && node --check client/client.js"
57
+ },
58
+ "dependencies": {
59
+ "ws": "^8.18.0"
39
60
  },
40
61
  "engines": {
41
62
  "node": ">=18"
@@ -46,6 +67,18 @@
46
67
  },
47
68
  "author": "Alan2Z",
48
69
  "license": "MIT",
70
+ "peerDependencies": {
71
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
72
+ "@deepseek-ai/schemastery": "^3.18.1"
73
+ },
74
+ "peerDependenciesMeta": {
75
+ "@deepseek-ai/dsh-settings": {
76
+ "optional": true
77
+ },
78
+ "@deepseek-ai/schemastery": {
79
+ "optional": true
80
+ }
81
+ },
49
82
  "publishConfig": {
50
83
  "registry": "https://registry.npmjs.org"
51
84
  }