# dsh-speak 🔊 — 为 AI 编程 harness 提供语音播报

**中文** · [English](README.md)

![鲸鱼娘大喇叭](鲸鱼娘大喇叭.png)

[![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)

[![npm version](https://img.shields.io/npm/v/dsh-speak)](https://www.npmjs.com/package/dsh-speak)

让 Agent 在长任务完成时**开口告诉你**——不用再盯着屏幕等。

dsh-speak 通过系统语音合成把 Agent 的最终回复朗读出来，用的是**操作系统当前的嗓音**——
Windows 上即默认 SAPI 嗓音（Windows 11 自带自然语音包，或 Windows 10 上经
[NaturalVoiceSAPIAdapter] 注册），macOS 上即系统朗读嗓音（`say`，可含 Siri 音色）。
本项目为 [DeepSeek Harness](https://github.com/deepseek-ai/dsh) 而生，但结构上
任何 harness 都能接入。

## 特性

- **全自动**：DSH web 插件监听会话事件流，自动播报最终回复
  （跳过 reasoning/工具调用旁白，合并同一回复的多步消息）。
- **提醒你**：审批请求（Agent 等你操作时会播"需要你的审批"）和 Agent 通过
  `ask_user_question` 提出的问题都会播报。
- **最终回复重播**（1.7.0）：每条最终回复（回合尾部）操作栏有 🔊 按钮——点击
  重播该条回复、再点停止、点另一条切换。语音执行完全由 DSH host 拥有（浏览器
  关掉也继续读）。
- **host 语音队列**（1.7.0）：同一时间只运行一个语音进程，队列自动串行；
  WebSocket 实时同步"正在读哪条、队列长度"到 UI。
- **多事件可选播报**（1.6.0）：回合结束、命令完成、目标变更、工具出错、
  待办更新等事件都可选播报，各自独立开/关（默认关）。
- **可视化配置**（1.7.0）：设置 → dsh-speak 设置独立设置页，所有配置项（总开关、
  自动朗读、Markdown 清洗、代码块、事件开关、固定提示语…）直接改，无需手写
  YAML。
- **总开关**（1.6.0）：一键静音所有播报。
- **Bundle 自动注册**（1.3.0）：把包声明进 `dsh.profile.bundles`，插件通过包内
  自带的 `cordis.patch.yml` 自动注册，无需手动写 patch 条目。
- **尽力而为**：绝不抛错、绝不阻塞 harness、绝不破坏会话。
- **跟随系统嗓音**：引擎不挑、也不覆盖嗓音，直接用**系统默认**——Windows 是默认
  SAPI 嗓音（控制面板「语音」或 设置 → 时间和语言 → 语音 里换），macOS 是系统朗读
  嗓音（系统设置 → 辅助功能 → 朗读内容 → 系统嗓音，可含 Siri）。换嗓音请改系统设置。
- **健壮的文本清洗**：去掉会让语音合成静默失败的 markdown/URL/emoji，
  并守卫适配器单次朗读的字数上限。
- **引擎可移植**：任意进程一行即可朗读：
  Windows `powershell -File speak.ps1 -Text "你好"` / macOS `./speak.sh -t "你好"`。

## 工作原理

```
harness 事件（DSH 会话事件 / Claude Code Stop hook / 任意方式）
      │
      ▼  adapters/…  （harness 专属触发器：过滤、节流、取消）
      ▼  engine/speak.ps1 / speak.sh  （与 harness 无关：清洗文本 → SAPI5 / say）
      ▼  🔊 你听到最终回复
```

适配器负责把 harness 专属事件转成引擎调用；引擎负责清洗文本并朗读，
与 harness 完全解耦。完整设计见 [docs/DESIGN.zh-CN.md](docs/DESIGN.zh-CN.md)。

## 前置条件

### Windows

- Windows 10 或 11，任意较新的 PowerShell。
- 引擎使用 **Windows 默认 SAPI 嗓音**。换嗓音在「控制面板 → 语音」（或 设置 →
  时间和语言 → 语音）里改；插件不会覆盖系统设置。
- 想听自然语音：Windows 11（21H2–23H2）已内置语音包；**Windows 11 24H2/25H2** 与
  **Windows 10** 需先装
  [NaturalVoiceSAPIAdapter](https://github.com/gexgd0419/NaturalVoiceSAPIAdapter)，
  用它的 VoiceDownloader 下载语音包，再把该嗓音设为系统默认。系统默认是机械音，
  听到的就是机械音。

### macOS 要求

- macOS（Apple Silicon / Intel 均可），系统自带 `say` 命令，**无需安装任何软件**。
- 中文音色与 Siri 音色的选择入口/坑见 [macOS](#macos) 一节。

### DSH 版本

- 已在 **DSH 0.2.1-rc.4** 上验证。0.1.1 之后有两轮 host/客户端 API 变更，本插件均已适配：
  - **0.1.7 把 settings provider API 换成了 Config 投影**：settings 服务改为读取每个
    已激活 Loader 条目自己导出的 `Config` schema，把其中的 *volatile* 字段投影成设置
    界面（宿主侧 `ctx.settings.describe()`，浏览器侧 `ctx.configForms`）。1.6.0–1.8.x
    使用的 `settings.register(namespace, schema, { base })` 已删除，浏览器服务
    `settingsScope` 也被 `configForms` 取代。因此本插件把 schema 作为 `Config` 导出，
    而设置 namespace 就是它的**条目 id**（`dsh-speak`；1.8.x 遗留的 `speech-hook`
    条目 id 依然可以绑定，见下）。
  - **0.1.2** 起 Session snapshot 不再携带会话视图（Conversation target）数据——
    🔊 按钮改为通过 Chat 目标的 hook `useChat` 取被点击消息的文本；同一版本还删除了
    `@deepseek-ai/dsh-settings` 的 `installSettingsSection` / `settingsNamespace`。
  - **0.2.x**（已实测到 0.2.1-rc.4）没有插件 API 破坏性变更，插件仍只依赖上面这些接口
    和 `engines.dsh`。
- 宿主要求声明在 dsh-market 实际读取的位置：`package.json` 的 `engines.dsh`
  （`>=0.1.7-rc.2`）。市场卡片与「适配当前 DSH」筛选读的正是这个字段，因此只有在
  某个宿主版本上实测通过后，这个下限才会移动。
- dsh-speak 1.8.2+ 依赖 0.1.7 引入的设置模型：在更老的宿主上浏览器半侧会一直等待
  `configForms` 服务。DSH ≤ 0.1.6 请用 1.8.1。

## 安装与快速开始

### DSH — 方式 A：npm 插件（推荐）

```powershell
# 1. 把插件装进你的 web profile（会写入 ~/.dsh/profiles/web/package.json 的
#    dependencies **和** dsh.profile.bundles）
dsh plugin --profile web add dsh-speak

# 2. 到此为止——包自带 bundle patch，条目（id: dsh-speak）由它注册。
#    不要再手写一行 insert：同一个条目注册两次会让 id 不唯一，DSH 的 config-editor
#    随后会拒绝每一次设置写入：
#      settings/rejected: Configuration for "dsh-speak" is overridden by a home patch or command-line overlay
#    （语音照常工作，只有设置页静默弹回——所以特别难查。）
#
#    如果仍想在 YAML 里固定选项，只加这一行**顶层行**（设置页就地改写它；
#    config 放顶层行才是编辑器支持的形状，见「DSH 插件配置」）：
#    - id: dsh-speak
#      name: 'dsh-speak'
#      config: {}
#
#    0.1.7 起条目 id 就是设置 namespace：你的选项会以这个 key 存在同一个文件里。
#    1.8.x 遗留的 id（speech-hook）继续可用——浏览器半侧两个 id 都认。

# 3. 重启 DSH web 应用 — 之后回复会被自动播报
```

> 在应用内插件市场安装是**同一条路径**（会把包加进 `dsh.profile.bundles`）；
> 只有下面两种手动安装才需要手写行。**一个 profile 只能有一种注册方式**，绝不能两种都有。

> **没有 pnpm？** `dsh plugin` 内部转发给 pnpm，并非所有机器都装了。可以用 npm
> 直接完成同样的安装：
>
> ```powershell
> npm install --prefix "$env:USERPROFILE\.dsh\profiles\web" dsh-speak
> ```
>
> macOS（bash）：
>
> ```bash
> npm install --prefix "$HOME/.dsh/profiles/web" dsh-speak
> ```
>
> 单纯 `npm install` **不会**注册插件：要么把包名加进
> `~/.dsh/profiles/web/package.json` 的 `dsh.profile.bundles`，要么用文件安装那两行
> ——不要两者都做。

引擎随包分发（`node_modules/dsh-speak/engine/`），无需额外拷贝。

> **想让 Agent 帮你装？** 把本仓库地址（`https://github.com/Alan2Z/dsh-speak`）
> 丢给你的 DSH 会话，让它照着这份 README 安装即可——它读的就是你正在看的这份文档。
> 只需要同意它对 `~/.dsh`（工作区外）的写入审批。

### DSH — 方式 B：文件安装（不需要 npm）

```powershell
# 1. 克隆
git clone https://github.com/Alan2Z/dsh-speak.git
cd dsh-speak

# 2. 一键安装：拷贝引擎 + 插件，并注册到 cordis.patch.yml
powershell.exe -NoProfile -ExecutionPolicy Bypass -File adapters\dsh\install.ps1

# 3. 验证引擎能出声
powershell -NoProfile -ExecutionPolicy Bypass -File "$env:USERPROFILE\.dsh\hooks\speak.ps1" -Text "你好，语音播报已就绪。"

# 4. 重启 DSH web 应用 — 之后回复会被自动播报
```

文件安装脚本做了这些事：

| 文件 | 目标位置 |
| ---- | -------- |
| `engine/*.ps1` | `%USERPROFILE%\.dsh\hooks\` |
| `adapters/dsh/speech-hook.js` | `%USERPROFILE%\.dsh\profiles\web\plugins\` |
| 注册条目 | 追加到 `%USERPROFILE%\.dsh\profiles\web\cordis.patch.yml`（先备份） |

### macOS

同一套适配层原生支持 macOS——插件自动检测平台，改调 `engine/speak.sh`
（系统自带的 `say` 命令）而不是 `speak.ps1`。**自 1.2.0 起 macOS 引擎随 npm 包
正式分发**，无需安装任何额外软件。

```bash
# 1. 装进你的 web profile（没有 pnpm 也能装——dsh plugin 才依赖 pnpm）
npm install --prefix "$HOME/.dsh/profiles/web" dsh-speak

# 2. 在 ~/.dsh/profiles/web/cordis.patch.yml 末尾注册（裸包名即可，无需 file:/// URL）：
#    - insert:
#        - id: dsh-speak
#          name: 'dsh-speak'
#    - id: dsh-speak
#      name: 'dsh-speak'
#      config: {}

# 3. 无需重启——patch 监视器会热更新；回复在节流后（约 1.5 秒）自动播报；
#    带工具调用的回复会在回合结束时补播最终回复
```

> 装过 pnpm 也可以 `dsh plugin --profile web add dsh-speak`，效果相同。

#### 音色（重要，有两个坑）

- 引擎**跟随系统朗读声音**（系统设置 → 辅助功能 → 朗读内容 → 系统朗读声音），
  从不覆盖它。**macOS 26** 上该选择框旁有个 **ⓘ 圆圈图标**，点开才是完整音色
  列表——普通下拉框里**没有 Siri 自然音色**；可在 ⓘ 列表里选"普通话 Siri
  声音1（男声）"等。
- **Siri 声音**（设置 → Siri → 声音）与系统朗读声音是**两个独立设置**；Siri
  音色不暴露给 `say -v '?'`，无法按名选择，只能作为系统默认生效。
- ⚠️ **坑 1（实测复现）**：打开"朗读内容 / Siri 声音"设置面板（**哪怕不改任何
  选项**）会把系统朗读声音漂移/重置成经典音色"婷婷(Tingting)"——音色突然变了
  就回到 ⓘ 入口重新选择。
- ⚠️ **坑 2**：日志在 `$TMPDIR/dsh-speech-hook.log`（`os.tmpdir()`，**不是**
  `/tmp`）。
- `say` 没有音量参数——音量跟随系统输出音量。

#### 单独测试引擎（不装 DSH 也行）

```bash
curl -sfL -o ~/speak.sh "https://cdn.jsdelivr.net/gh/Alan2Z/dsh-speak@main/engine/speak.sh"
chmod +x ~/speak.sh
~/speak.sh -t "你好，Mac 版语音播报测试"
~/speak.sh -t "测试" -r 200                       # 语速更快，仍用系统声音
```

### Claude Code

在 `~/.claude/settings.json` 注册 Stop hook：

```json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\\path\\to\\dsh-speak\\adapters\\claude-code\\stop-hook.ps1"
          }
        ]
      }
    ]
  }
}
```

### 其他任何 harness

直接从你的 Agent / 包装脚本 / 工具里调用引擎：

```powershell
# 播报一句话
powershell -NoProfile -ExecutionPolicy Bypass -File engine\speak.ps1 -Text "构建完成"

# 播报较长总结（阻塞，读完才返回）
powershell -NoProfile -ExecutionPolicy Bypass -File engine\speech-summary.ps1 -Text "…"

# 需要用户注意时（阻塞，适合提问/授权场景）
powershell -NoProfile -ExecutionPolicy Bypass -File engine\speech-prompt.ps1 -Text "请做出选择"
```

## 配置

### 引擎参数

详见 [docs/DESIGN.zh-CN.md §5 配置参考](docs/DESIGN.zh-CN.md#5-配置参考)：

```powershell
speak.ps1 -Text "…" -Volume 50 -Rate 1 -MaxChars 300 -LongTextMessage "本次播报内容较长，请自行阅读。"
```

### DSH 插件配置

**两种改法，任选其一**（改 UI 或改 YAML 都落进同一份 profile patch——设置服务把 UI 的
修改写回它读到的那一行，彼此同步）：

1. **Web UI（1.7.0，推荐）**：设置 → dsh-speak 设置独立设置页。所有配置项都能直接改并
   保存（`dsh --dump-config` 可见、按 profile 隔离、升级不丢）。
2. **profile patch 的 `config` 块**（等效）：

```yaml
# ~/.dsh/profiles/web/cordis.patch.yml
# 必须写成两行：insert 行负责“提供条目”，顶层行承载设置页要改写的 config。
# DSH 的 config-editor 只会就地改写顶层行；config 嵌在 insert 里时，UI 会接受修改、
# 运行中的插件也会立刻生效，但这次写入随后会被回滚——下次启动 dsh 时值就悄悄变回去了。
- insert:
    - id: dsh-speak           # 0.1.7 起条目 id 就是设置 namespace
      name: 'dsh-speak'
- id: dsh-speak
  name: 'dsh-speak'
  config:
    enabled: true           # 总开关：false 时完全不播报
    automaticSpeech: true   # 自动朗读最终回复
    queueAllMessages: false # true = 所有 assistant 消息立即入队朗读（中间消息也读）
    replayFullRead: false   # true = 手动重播跳过超长文本截断，完整朗读
    cleanMarkdownFormatting: true # Markdown 转自然语音
    readInlineCode: true    # 朗读行内代码（去掉反引号）
    codeBlocks: smart       # all | smart | replace（围栏代码块）
    codeBlockMaxChars: 300  # smart 模式下的代码块字数上限
    codeBlockReplacementText: 'You can see the code in our history.' # replace 时的替代文本
    throttleMs: 1500        # 播报前的合并延迟（毫秒）
    engine: ''              # 引擎路径覆盖；'' = 自动解析
    announceApprovals: true # 播报审批请求
    announceQuestions: true # 播报 ask_user_question 提问内容
    stripApprovalPrefix: true  # 剥离审批原因里的 "escalate sandbox to ...: " 前缀
    questionGapMs: 2000      # 多个提问播报之间的停顿（毫秒）
    longTextMode: message   # message | heading（念最大字号 markdown 标题）
    longTextMessage: '本次播报内容较长，请自行阅读。' # message 模式下的固定提示语
    maxChars: 300           # 引擎单次朗读字数上限（macOS 默认 0 = 不限）
    volume: 50              # 仅 Windows
    rate: 0                 # 0 = 引擎默认（Windows SAPI 刻度 / macOS wpm）
    # —— 可选事件播报（1.6.0，默认全关）——
    announceTurnEnd: false     # 回合结束（"第 N 轮对话完成"）
    speakTurnEndSummary: false # 完成播报带一句话摘要
    announceCommandDone: false # 命令完成/失败（command/done）
    announceGoalChange: false  # 目标创建/更新/完成（goal/change）
    announceToolErrors: false  # 工具调用出错时播报（英文详情截掉，tool/result）
    announceTodoWrite: false   # 待办列表更新（todo/write）
```

> 解析顺序：schema 默认值 → patch `config` → UI 用户设置。写进 YAML 的字段
> 同样出现在 UI 中。平台差异：`maxChars` 在 macOS 默认 0（`say` 无上限），
> Windows 默认 300（SAPI 安全上限）。
>
> 超出上表范围的取值（`volume` 0-100、Windows `rate` -10..10）会在送进引擎前被**钳制**
> ——SAPI 遇到越界的 `Volume`/`Rate` 会抛异常，表现就是"没声音且没有任何提示"。被钳制
> 时会记一行日志：`settings 值超出范围，已钳制: <字段> <给出值> -> <实际值>`。
> 显式写 `0` 一律尊重原意（`volume: 0` 静音、`maxChars: 0` 不限长、`throttleMs: 0` 不合并）。

> **`config` 必须写在顶层行上。** 这不是风格问题：如果把 `config:` 嵌在 `insert`
> 行里，设置页照样能显示、改完运行中的插件也立刻生效，但 DSH 的 config-editor
> 无法就地改写那一行——它会追加一个顶层行再把写入回滚，于是下次启动 dsh 时值会
> 悄悄变回去。可以用 `python scripts/settings-ui-check.py` 验证（它会断言写入真的
> 落进了 profile patch）。

> **从 1.8.x 升级：** 直接在设置页里改，或把旧的 `config:` 块挪到新的顶层行上（形状见上）。
> 0.1.7 之前选项存在 `~/.dsh/settings.yaml` 的 `dsh-speak:` 段里；该文件已被 DSH 迁移为
> `settings.yaml.imported`，而段名对不上任何 Loader 条目的段（1.8.x 是在代码里注册
> namespace，所以 `dsh-speak:` 谁也匹配不到）会被留在原地。如果你改过默认值，把那些值
> 抄进上面那个 `config:` 块即可——现在 DSH 找的 key 就是条目 id（`dsh-speak`）。

#### 选项说明

| 选项 | 默认值 | 效果 |
| ---- | ------ | ---- |
| `enabled` | `true` | **总开关**：关闭后所有播报都不触发（最终回复/审批/提问/可选事件/重播） |
| `automaticSpeech` | `true` | 自动朗读最终回复；手动重播始终可用 |
| `queueAllMessages` | `false` | `true` 时每条 assistant 消息立即入队朗读（中间消息也读，FIFO）；默认只读节流后的最终回复 |
| `replayFullRead` | `false` | `true` 时手动重播跳过超长文本的标题截断（`longTextMode: heading`），完整分段朗读 |
| `cleanMarkdownFormatting` | `true` | 把 Markdown 转成自然语音文本（链接保留文字去 URL、标题/强调符号清理） |
| `readInlineCode` | `true` | 朗读行内代码（去掉反引号标记） |
| `codeBlocks` | `smart` | 围栏代码块处理：`all` 全读 / `smart`（≤`codeBlockMaxChars` 才读）/ `replace` 用替代文本 |
| `codeBlockMaxChars` | `300` | `smart` 模式下的代码块字数上限 |
| `codeBlockReplacementText` | `You can see the code in our history.` | `replace` 模式（或超限的 `smart`）下朗读的替代文本 |
| `throttleMs` | `1500` | 回复文本等待多久才播报（合并同一回复的多步消息） |
| `engine` | `''` | 显式引擎脚本路径；`''` 自动解析：包内 `engine/<平台>` → `~/.dsh/hooks/<平台>` |
| `announceApprovals` | `true` | 播报 `approval/asked` 事件（审批原因，或固定提示语） |
| `announceQuestions` | `true` | 播报 `ask_user_question`：每个问题单独朗读，带"问题N"序号（多问题时）与"选项N"序号（与 UI 编号一致）；多个问题之间停顿 `questionGapMs` |
| `questionGapMs` | `2000` | 多个提问播报之间的停顿（毫秒），0 = 不停顿 |
| `stripApprovalPrefix` | `true` | 剥离审批原因里的固定英文模板前缀（`escalate sandbox to danger-full-access: `），保留中文说明 |
| `longTextMode` | `message` | `message` = 超长念固定提示语；`heading` = 改念最大字号 markdown 标题（规则见下） |
| `longTextMessage` | `本次播报内容较长，请自行阅读。` | `message` 模式下超长文本改念的固定提示语（UI 可编辑） |
| `maxChars` | 平台相关 | 引擎单次朗读上限。**macOS 默认 0（`say` 无上限）；Windows 默认 300**（SAPI 超过约 375-470 字会静默失败） |
| `volume` | `50` | 仅 Windows（0-100）；macOS 音量跟随系统 |
| `rate` | `0` | 语速：Windows SAPI 刻度（-10 到 10，0 = 正常，推荐 0 / 稍快 1-3）；macOS words-per-minute（默认 175，稍快 200） |
| `announceTurnEnd` | `false` | 回合结束时播报"第 N 轮对话完成/中断/异常结束"（`turn/end`） |
| `speakTurnEndSummary` | `false` | 完成播报带一句话摘要："第 N 轮对话完成：<摘要>"（剥 markdown、最多 60 字，硬截断时补省略号；节流路径刚念过正文时跳过；`queueAllMessages` 模式下照常追加） |
| `announceCommandDone` | `false` | 命令执行完成/失败时播报（`command/done`） |
| `announceGoalChange` | `false` | 目标创建/更新/完成/暂停/恢复时播报（`goal/change`，含目标标题前 40 字） |
| `announceToolErrors` | `false` | 工具调用返回错误时播报"工具调用出错"（英文错误详情/技术 code 截掉，只保留中文详情）。触发条件：`tool/result` 带 `error`（结构化失败身份）或结果块 `isError === true`。注意 **shell 命令非零退出不算**——pwsh/bash 把 `exit code: N` 当结果数据上报（dsh 明文如此设计），只有基础设施失败（spawn 错误、abort）和 fs 这类结构化失败才置 `isError` |
| `announceTodoWrite` | `false` | agent 更新待办列表时播报"待办已更新：n/m 完成"（`todo/write`） |

#### 超长文本模式

清洗后文本超过 `maxChars` 时：

- **`message`**（默认）：念 `longTextMessage`（`本次播报内容较长，请自行阅读。`，
  可在 UI 或 YAML 里编辑）。
- **`heading`**：在原始文本里挑**最大字号**的 markdown 标题——`#` 数量最少者优先，
  并列取第一个。**整段没有任何标题时**改念"有头有尾的开头"：取开头 `maxChars`
  长度的窗口并回退到窗口内最后一个句末标点；若这样会砍掉半个窗口以上则保留整窗。
  句末标点中英双语识别：全角 `。！？；` 与 `…` 无条件算；半角 `.!?;` 只在后面跟
  空白、右引号/右括号时才算，落在窗口最后一位时会**多读一位**判断——所以英文
  「句号+空格」在边缘照样算，而 `Version 0.1.` 这种小数点不算。（1.8.0 之前这里只念
  第一个非空行，听感上就是"从第二行开始不念了"。）选中的候选仍会清洗并受 `maxChars`
  上限约束，若其本身仍超长则回退提示语。

完整架构与设计取舍见 [docs/DESIGN.zh-CN.md](docs/DESIGN.zh-CN.md)。

## 自定义（升级不丢）

想调行为又不想 fork，而且改完**不会被 `npm update` 覆盖**：

1. **把引擎复制出来改**（推荐——默认参数都在这：音量、语速、字数上限、超长提示语）：

   ```powershell
   # Windows
   Copy-Item "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-speak\engine\speak.ps1" "$env:USERPROFILE\.dsh\hooks\my-speak.ps1"
   # macOS
   cp ~/.dsh/profiles/web/node_modules/dsh-speak/engine/speak.sh ~/.dsh/hooks/my-speak.sh
   ```

   然后在 config 块里指向你的副本：

   > **Windows：务必保住文件的 UTF-8 BOM。** `speak.ps1` 是 UTF-8 脚本，而 Windows
   > PowerShell 5.1 只能靠开头那三个字节 `EF BB BF` 知道这一点；编辑器保存时若把它
   > 丢掉，系统会改用 ANSI 代码页解码，脚本里的中文会变乱码——症状是**静默无声或
   > 修剪错乱，且不报错**。为此仓库里的脚本已把**逻辑部分全部写成纯 ASCII**，所以
   > 丢 BOM 只会让中文注释和默认提示语变乱码。改完可以用
   > `Get-Content -Encoding Byte -TotalCount 3 你的-speak.ps1` 检查（应为 `239 187 191`），
   > 或跑 `node scripts/test-engine-static.js`。

   ```yaml
   # 改的是顶层行（不是 insert 行——见「DSH 插件配置」）
   - id: dsh-speak
     name: 'dsh-speak'
     config:
       engine: 'C:/Users/<你>/.dsh/hooks/my-speak.ps1'   # macOS 用 ~/.dsh/hooks/my-speak.sh
   ```

   插件按 `config.engine` → 包内引擎 → `~/.dsh/hooks/` 的顺序解析引擎，所以你的副本
   优先生效；`npm update` 只动包本身，你的引擎安然无恙。

2. **直接改 `node_modules` 里的文件**——能改，但下次 `npm update` 会被覆盖。

3. **fork 仓库**——完全掌控，想发自己的包也行。

## 排障

| 现象 | 原因 | 解决 |
| ---- | ---- | ---- |
| 完全没有声音、无报错 | 没有可用的 SAPI 嗓音，或系统默认嗓音缺失 | 打开「控制面板 → 语音」选一个已安装的嗓音；Win10 / Win11 24H2+ 需安装 NaturalVoiceSAPIAdapter 并下载语音包。直接测 `speak.ps1` |
| 长回复从不播报 | 适配器单次 `Speak` 有字数上限 | 已默认在 300 字处守卫——必要时调低 `-MaxChars` |
| 念到第二行就停/像是被切断 | `longTextMode: heading` 下，文本超过 `maxChars` 且整段没有 markdown 标题时，旧版引擎只念第一个非空行（1.8.0 之前） | 1.8.0 已修（改念"有头有尾的开头"）；想换策略可用 `message` 模式或调高 `maxChars` |
| 听到 `工具调用出错：Error: cannot read …` | "是否中文"的详情判据只检查"含有汉字"，英文报错里夹着中文目录名就能骗过它（1.8.0 引入的回归） | 1.8.0 已修——详情需满足"汉字数量多于拉丁字母数量" |
| 含大量 emoji 的文本静默 | SAPI 遇到 emoji 会静默失败 | 引擎已自动剥离 |
| 插件加载失败 | 插件名用了 Windows 原始路径 | 改用 `file:///C:/…` URL 形式（安装脚本会自动处理） |
| 升级到 1.8.x 后设置页不出现 | profile patch 那行写着 `disabled: true`，或条目 id 既不是 `dsh-speak` 也不是旧 id `speech-hook` | 去掉 `disabled`，并把 `id` 改成两者之一 |
| 设置页能开、但每次改动都被弹回 | DSH < 0.1.7（`configForms` 已取代 `settingsScope`） | 升级 DSH，或继续用 dsh-speak 1.8.1 |
| DSH 0.1.7+ 上每次改动都被弹回，且插件日志出现 `已有实例在运行` | 条目被注册了**两次**（例如包既在 `dsh.profile.bundles` 里、又手写了一行 `insert:`）：id 不再唯一，config-editor 会拒绝每次写入（`settings/rejected: Configuration for "dsh-speak" is overridden by a home patch or command-line overlay`）；语音照常工作，所以特别难查 | 只保留一种注册方式（见「方式 A」），然后重启 |
| 改完立刻生效，但重启后变回旧值 | `config:` 块嵌在 profile patch 的 `insert:` 行里——DSH 的 config-editor 只会就地改写**顶层**行，嵌套写入会被静默回滚（仍然回 `ok: true`） | 按「DSH 插件配置」把 `config` 放到顶层行；`python scripts/settings-ui-check.py` 会断言写入真的落盘 |
| macOS：音色突然变成"婷婷" | 打开过"朗读内容 / Siri 声音"设置面板导致系统朗读声音漂移 | 系统设置 → 辅助功能 → 阅读与朗读 → 系统声音 → ⓘ 入口重新选择 |
| macOS：在 `/tmp` 找不到日志 | `os.tmpdir()` 是 `/var/folders/.../T`，不是 `/tmp` | 日志在 `$TMPDIR/dsh-speech-hook.log` |

插件诊断日志：Windows `%TEMP%\dsh-speech-hook.log`；macOS `$TMPDIR/dsh-speech-hook.log`

## 仓库结构

```
engine/                  与 harness 无关的语音引擎（PowerShell + SAPI5 / bash + say）
  speak.ps1 / speak.sh   清洗 + 朗读（适配层唯一需要打交道的接口）
  speech-prompt.ps1      阻塞式短提示播报
  speech-summary.ps1     阻塞式回复总结播报
adapters/
  dsh/                   DSH web 插件 + 一键安装脚本
    speech-hook.js       会话事件触发器（节流/取消 + 可选事件 + FIFO 语音队列 + WebSocket + Config 投影的设置表单）
    install.ps1          拷贝 + 注册 + 备份
  claude-code/
    stop-hook.ps1        Claude Code Stop hook 触发器
client/
  client.js              DSH 浏览器端 bundle：回合尾部 Speak/Stop 按钮 + 设置 → dsh-speak 设置页
docs/
  DESIGN.zh-CN.md        完整设计文档：设计取舍、踩坑记录、扩展指南
scripts/                 测试 + 手动开发辅助脚本（不随 npm 包发布）
  test-engine-static.js    引擎静态不变量：.ps1 的 BOM + PowerShell 语法解析、.sh 的 LF（prepublishOnly 也会跑）
  test-engine-longtext.js  两个引擎的长文守卫契约（speak.ps1 -DryRun / speak.sh 的 perl）
  test-speech-hook.js      宿主插件：事件触发、队列、工具出错详情过滤
  test-client-bundle.js    浏览器 bundle：slot 注册 + 组件渲染
  test-settings-integration.js  Config 投影接线（volatile 引用 + 实时写入）+ 已删除 API 回归守卫
  session-log-dump.js      读取 DSH 会话日志（手动：看引擎究竟收到了什么文本）
  settings-ui-check.py     Playwright UI 检查（手动：需要运行中且已鉴权的 dsh）
  dsh-events-check.py      Playwright 折叠行检查（手动）
```

## 编写新适配器

三种参考模式：**事件流**（DSH）、**Stop hook**（Claude Code）、**Agent 自调用**
（在 shell 里调 `speech-summary.ps1`）。无论哪种，适配器只需做一件事：
拿到*最终回复文本* → 调用引擎。详见
[docs/DESIGN.zh-CN.md §7 扩展](docs/DESIGN.zh-CN.md#7-扩展)。

## License

MIT — 见 [LICENSE](LICENSE)。

[NaturalVoiceSAPIAdapter]: https://github.com/gexgd0419/NaturalVoiceSAPIAdapter
