dsh-speak 1.0.0 → 1.2.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/docs/DESIGN.md CHANGED
@@ -1,245 +1,270 @@
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
- ```
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 system speech
16
+ synthesis (Windows SAPI5 / macOS `say`) so you know *without looking* that a long
17
+ task finished — and what its 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 / bash + macOS `say`)
23
+ that any process can call,
24
+ - **adapter layers** that turn harness-specific events into engine calls
25
+ (DSH session events, Claude Code Stop hooks, ...).
26
+
27
+ ## 2. Goals / non-goals
28
+
29
+ Goals:
30
+
31
+ - One-command install for DSH users (engine + plugin + registration).
32
+ - Engine callable from any harness via a trivial command line.
33
+ - Best-effort speech: never throws, never blocks a harness, never breaks a session.
34
+ - Natural-sounding voices: Windows 11 built-in natural voice packs, or
35
+ NaturalVoiceSAPIAdapter on Windows 10; graceful fallback to stock voices.
36
+
37
+ Non-goals (for now):
38
+
39
+ - Both engines Windows `speak.ps1` and macOS `speak.sh` (built-in `say`) are
40
+ **officially supported** (macOS ships in the npm package since 1.2.0).
41
+ Linux/headless TTS is not supported.
42
+ - In-repo packaging of NaturalVoiceSAPIAdapter (Windows 10 only) or voice data —
43
+ they are prerequisites, not bundled.
44
+ - Streaming/queued playback, per-voice audio files, non-Chinese voice curation.
45
+
46
+ ## 3. Architecture
47
+
48
+ ```
49
+ +--------------------------------------------------------------+
50
+ | harness |
51
+ | (DSH web app | Claude Code | anything with a shell) |
52
+ +--------+-----------------------------+-----------------------+
53
+ | |
54
+ | session events | Stop hook JSON (stdin)
55
+ v v
56
+ +------------------+ +--------------------------+
57
+ | adapters/dsh/ | | adapters/claude-code/ |
58
+ | speech-hook.js | | stop-hook.ps1 |
59
+ | (event filter, | | (transcript extraction) |
60
+ | throttle, | +------------+-------------+
61
+ | cancel) | |
62
+ +--------+---------+ |
63
+ | text | text
64
+ v v
65
+ +---------------------------------------------------------------+
66
+ | engine/speak.ps1 / speak.sh (harness-agnostic) |
67
+ | text -> clean (markdown/emoji/length) -> system speech |
68
+ +--------------------+------------------------------------------+
69
+ | |
70
+ v v
71
+ Windows SAPI5 (System.Speech) — macOS say (system voice):
72
+ voices: * default follows the system
73
+ * preferred: a natural voice — voice (may be a Siri voice;
74
+ Windows 11 built-in pack, or not listed by `say -v '?'`,
75
+ one registered by not selectable by name)
76
+ NaturalVoiceSAPIAdapter on * or -v forces a classic voice
77
+ Windows 10 (e.g. "Microsoft (Eddy / Tingting / Flo ...)
78
+ Xiaoxiao") * no volume flag (follows
79
+ * fallback: any zh voice (e.g. the system output)
80
+ "Microsoft Huihui")
81
+ ```
82
+
83
+ ### 3.1 Engine `engine/speak.ps1` (+ `engine/speak.sh` on macOS)
84
+
85
+ The only file a new adapter needs. Two input modes: `-Text "..."` inline, or
86
+ `-File C:\path\msg.txt` (UTF-8). Also `-Volume`, `-Rate`, `-MaxChars`,
87
+ `-LongTextMessage` (see §5). On macOS the plugin auto-picks `speak.sh` (the
88
+ `say` command; default voice follows the system the Siri voices "声音 1-4"
89
+ are not exposed to `say`, use `-v` to force a name; no volume flag).
90
+
91
+ Processing pipeline (in order):
92
+
93
+ 1. **Read** text (file read is always UTF-8).
94
+ 2. **Strip markdown** — code blocks, inline code, links, bare URLs, emphasis chars.
95
+ 3. **Strip emoji / non-printable** keep CJK, CJK punctuation, full-width ranges,
96
+ ASCII printable (regex `[^一-龥 -〿＀-￯ - -~]`).
97
+ 4. **Collapse whitespace.**
98
+ 5. **Length guard** if cleaned text exceeds `MaxChars` (default 300), replace with
99
+ `LongTextMessage` (default: `本次播报内容较长,请自行阅读。`).
100
+ 6. **Speak** — `System.Speech.Synthesis.SpeechSynthesizer`, volume/rate applied,
101
+ best zh natural voice selected, then `Speak()`.
102
+
103
+ Engine contract for adapters:
104
+
105
+ - exit 0 always; never writes to stdout/stderr on failure paths;
106
+ - synchronous (returns when the utterance finishes, or immediately on any failure);
107
+ - safe to call from a sandboxed process *provided* the caller does not need to nest
108
+ another `powershell.exe` inside a harness sandbox (see §6.3).
109
+
110
+ ### 3.2 DSH adapter`adapters/dsh/speech-hook.js`
111
+
112
+ A DSH web-profile plugin (Cordis plugin) registered via `cordis.patch.yml`. DSH has
113
+ no "reply finished" hook, so the plugin observes the session event stream:
114
+
115
+ - listens to `session/event`;
116
+ - filters `assistant/message` events with `surfaceOp == 'append'`;
117
+ - extracts only `text` content blocks (reasoning / tool_use blocks are skipped);
118
+ - buffers the text and starts a throttle timer (default 1500 ms) to merge
119
+ multi-step messages of one reply;
120
+ - a `tool/call` event **cancels** the pending announcement — that round's assistant
121
+ text is process narration, not the final reply;
122
+ - on fire: writes the text to a temp file and `spawn`s
123
+ `powershell.exe -File <engine> -File <tmp>` with `windowsHide` + `stdio: 'ignore'`
124
+ so the harness is never blocked; the temp file is deleted on exit.
125
+
126
+ Registration snippet (also automated by `install.ps1`):
127
+
128
+ ```yaml
129
+ # ~/.dsh/profiles/web/cordis.patch.yml
130
+ - insert:
131
+ - id: speech-hook
132
+ # replace <your-username> with your Windows username
133
+ name: 'file:///C:/Users/<your-username>/.dsh/profiles/web/plugins/speech-hook.js'
134
+ ```
135
+
136
+ > Node's ESM loader does not accept Windows absolute paths as plugin names — the
137
+ > `file:///C:/...` URL form is required.
138
+
139
+ ### 3.3 Claude Code adapter — `adapters/claude-code/stop-hook.ps1`
140
+
141
+ Claude Code *does* have a Stop hook. The hook JSON (with `transcript_path`) arrives
142
+ on stdin; the script scans the transcript backwards for the last assistant message
143
+ that contains text (the final entry is often a pure tool call), writes it to a temp
144
+ file and launches the engine in its own hidden powershell process, so the hook
145
+ returns immediately. (Async spawning is safe here — the nested-spawn restriction in
146
+ §6.3 is specific to DSH's sandbox.)
147
+
148
+ ## 4. Event-flow truth table (DSH)
149
+
150
+ | assistant round contains | announced? |
151
+ | ------------------------------- | ----------- |
152
+ | final text reply, no tool call | after throttle |
153
+ | text + tool/call(s) | (cancelled narration) |
154
+ | reasoning only, no text | (no text block) |
155
+ | streaming chunks | (filtered) |
156
+
157
+ ## 5. Configuration reference
158
+
159
+ ### Engine (`speak.ps1` parameters)
160
+
161
+ | param | default | meaning |
162
+ | ----------------- | --------------------------- | ---------------------------------------- |
163
+ | `-Text` | `''` | inline text (used when `-File` is empty) |
164
+ | `-File` | `''` | UTF-8 file to read |
165
+ | `-Volume` | `50` | 0–100 |
166
+ | `-Rate` | `1` | speech rate (SAPI scale) |
167
+ | `-MaxChars` | `300` | beyond this, replaced by `LongTextMessage` |
168
+ | `-LongTextMessage`| `本次播报内容较长,请自行阅读。` | spoken instead of over-long text |
169
+
170
+ ### DSH plugin (environment variables)
171
+
172
+ | var | default | meaning |
173
+ | -------------------- | ---------------------------------------- | ------------------------------ |
174
+ | `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`) |
175
+ | `DSH_SPEAK_THROTTLE_MS` | `1500` | merge delay before announcing |
176
+
177
+ ## 6. Pitfalls (hard-won; do not "fix" casually)
178
+
179
+ | # | pitfall | symptom | fix / rule |
180
+ |---|---------|---------|------------|
181
+ | 6.1 | Emoji / surrogate pairs reach `Speak()` | **silent** no audio, no error | strip non-CJK/ASCII before speaking (engine step 3) |
182
+ | 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) |
183
+ | 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) |
184
+ | 6.4 | Plugin name with a raw Windows path in `cordis.patch.yml` | plugin fails to load | `file:///C:/...` URL form |
185
+ | 6.5 | Matching adapter voices by name only | falls back to robotic stock voice | match `Name + Description` against `Natural\|Online` |
186
+ | 6.6 | Reading/writing speech text as ANSI | mojibake or empty speech | always UTF-8 (`[System.IO.File]::ReadAllText(..., UTF8)`) |
187
+ | 6.7 | A repo `.sh` checked out as CRLF by `core.autocrlf=true`; `npm pack` bundles the **working-tree** file | the published `speak.sh` dies in bash on macOS (`command not found`, `syntax error near {`), silent failure | `.gitattributes` pins `*.sh text eol=lf` (check `file engine/speak.sh` for CRLF before publishing) |
188
+ | 6.8 | Log path hard-coded as `/tmp` | on macOS `os.tmpdir()` is `/var/folders/.../T`, the log is not at `/tmp` | look for the log at `os.tmpdir()` (= `$TMPDIR`) |
189
+
190
+ ## 7. Extending
191
+
192
+ ### New engine backend
193
+ The engine is the single seam for TTS backends. A future `speak-edge.ps1` could
194
+ wrap `edge-tts`, or a `speak-piper.ps1` a local offline modelsame parameter
195
+ contract, same cleaning pipeline, swap the `Speak()` step. Adapters never change.
196
+
197
+ ### New harness adapter
198
+ Implement: *capture the final reply text → call the engine*. DSH (event stream),
199
+ Claude Code (Stop hook), and any shell-based harness (`speech-summary.ps1` called
200
+ by the agent) are the three reference patterns.
201
+
202
+ ## 8. Scope
203
+
204
+ This project is intentionally **not** a living product. It documents one proven way
205
+ to give a harness a voice: a small engine + the two adapter patterns (event-stream
206
+ and stop-hook) that worked. If you need more (voice management UI, more backends,
207
+ cross-platform), treat the engine as the seam and build on top — this repository
208
+ stays as a minimal, self-contained reference implementation.
209
+
210
+ ## 9. Publishing as an npm plugin (appendix)
211
+
212
+ The DSH plugin mechanism is Cordis-based, and the official install path for
213
+ out-of-tree plugins is `dsh plugin --profile web add <package>` (pnpm-managed
214
+ dependencies in the profile). This repository is prepared for that path:
215
+
216
+ ### Package layout
217
+
218
+ - `package.json` `name: dsh-speak`, `main: adapters/dsh/speech-hook.js`,
219
+ `files` whitelists exactly what ships (plugin, `engine/*.ps1`, `install.ps1`,
220
+ docs, license). `prepublishOnly` runs `node --check` on the plugin.
221
+ - The plugin entry is the same CJS module (`module.exports = { apply(ctx) }`)
222
+ already used by the file install — no code change is needed to publish.
223
+
224
+ ### Engine resolution (npm vs file install)
225
+
226
+ `speech-hook.js` locates `engine/speak.ps1` in this order:
227
+
228
+ 1. `DSH_SPEAK_ENGINE` environment override;
229
+ 2. `<package>/engine/speak.ps1` resolved relative to the plugin file — covers
230
+ both a repo checkout and `node_modules/dsh-speak/` after `npm install`;
231
+ 3. legacy `%USERPROFILE%\.dsh\hooks\speak.ps1` (the file-install location).
232
+
233
+ Because the engine rides inside the npm package, `dsh plugin --profile web add
234
+ dsh-speak` alone is sufficient no separate copying step.
235
+
236
+ ### Publish steps (maintainer)
237
+
238
+ ```powershell
239
+ npm login --registry=https://registry.npmjs.org # official registry, 2FA required
240
+ npm publish # publishConfig.registry pins the official registry
241
+ # bump "version" in package.json before every subsequent publish
242
+ ```
243
+
244
+ > China note: if your global `.npmrc` points at a mirror (`registry.npmmirror.com`
245
+ > etc.), `npm login`/`npm publish` would target the mirror, which does **not**
246
+ > accept publishes. The package's `publishConfig.registry` pins publishing to the
247
+ > official registry; just make sure the login used the official registry too.
248
+
249
+ ### Install steps (DSH user)
250
+
251
+ ```powershell
252
+ dsh plugin --profile web add dsh-speak
253
+ # then register in ~/.dsh/profiles/web/cordis.patch.yml:
254
+ # - insert:
255
+ # - id: speech-hook
256
+ # name: 'dsh-speak'
257
+ # restart the DSH web app
258
+ ```
259
+
260
+ > No pnpm installed? `dsh plugin` forwards to pnpm; the npm equivalent is
261
+ > (same result: package lands in the profile's `dependencies` + `node_modules`):
262
+ >
263
+ > - Windows (PowerShell):
264
+ > `npm install --prefix "$env:USERPROFILE\.dsh\profiles\web" dsh-speak`
265
+ > - macOS (bash):
266
+ > `npm install --prefix "$HOME/.dsh/profiles/web" dsh-speak`
267
+ >
268
+ > The patch watcher hot-reloads the plugin tree on `cordis.patch.yml` changes —
269
+ > verified: the plugin re-applies with the npm-bundled engine path, no restart
270
+ > needed for the registration switch itself (verified on macOS with 1.2.0).