opencode-voice-plugin 0.0.0-stage → 0.1.1

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 kabrau
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 CHANGED
@@ -1,3 +1,399 @@
1
- # Temporary Holding Version
1
+ # opencode-voice-plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **English** · [Português (BR)](README.pt-BR.md)
4
+
5
+ Voice dictation for **OpenCode V2**: a microphone icon (`○`) in the prompt footer. Click it (or press
6
+ `ctrl+y`), speak, and the transcribed text becomes a message in the session.
7
+
8
+ Transcription uses **OpenRouter** (`POST /api/v1/audio/transcriptions`), and the API key is reused
9
+ from OpenCode itself when you already have OpenRouter connected — no environment variable, no
10
+ duplicating secrets. It works on **Windows, Linux and macOS** from the same code, and installs
11
+ **machine-wide** (not per project).
12
+
13
+ [Repository](https://github.com/kabrau/opencode-voice-plugin) ·
14
+ [Issues](https://github.com/kabrau/opencode-voice-plugin/issues) ·
15
+ [Design](DESIGN.md) · MIT
16
+
17
+ ---
18
+
19
+ ## How it works
20
+
21
+ 1. `○` in the prompt footer (visible inside a session).
22
+ 2. Click it, press `ctrl+y`, or type `/voice` → recording starts (`●` + seconds).
23
+ 3. Click again (or wait for `maxSeconds`) → it stops and transcribes (`…`).
24
+ 4. The **Dictation** dialog opens with the text — edit if you want and confirm → the message is sent.
25
+
26
+ It also works on the **home screen**: since there is no session yet, `ctrl+y` **creates one right
27
+ away** (before recording) and sends the text there — the same as typing and pressing Enter. Turn it
28
+ off with `"allowFromHome": false` if you prefer the microphone to work only inside a session.
29
+
30
+ ---
31
+
32
+ ## Requirements
33
+
34
+ - OpenCode **V2** (`opencode --version`).
35
+ - **ffmpeg** on the system:
36
+ - Windows: `choco install ffmpeg`
37
+ - Debian/Ubuntu: `sudo apt install ffmpeg pulseaudio-utils`
38
+ - macOS: `brew install ffmpeg`
39
+ - An **OpenRouter** API key (see [Cost](#cost) and [API key](#api-key)).
40
+
41
+ ---
42
+
43
+ ## Install (via npm — recommended)
44
+
45
+ ```bash
46
+ opencode plugin add opencode-voice-plugin
47
+ ```
48
+
49
+ That's it — no cloning, no copying files. To update or remove:
50
+
51
+ ```bash
52
+ opencode plugin update opencode-voice-plugin
53
+ opencode plugin remove opencode-voice-plugin
54
+ ```
55
+
56
+ > To **configure options** (icons, keybind, device, etc.), see
57
+ > [Where to declare options](#where-to-declare-options) — that is a detail `plugin add` alone
58
+ > does not cover.
59
+
60
+ ### Without npm (straight from GitHub)
61
+
62
+ ```bash
63
+ opencode plugin add github:kabrau/opencode-voice-plugin
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Manual install (machine-wide)
69
+
70
+ If you prefer a local copy, OpenCode discovers plugins in the global config directory:
71
+
72
+ ```
73
+ <global-config>/plugins/<name>/index.ts # server entrypoint
74
+ <global-config>/plugins/<name>/tui.tsx # TUI entrypoint (the plugin itself)
75
+ ```
76
+
77
+ On Windows the global config lives in `C:\Users\<you>\.config\opencode`. On Linux/macOS it is
78
+ `~/.config/opencode` (or `$XDG_CONFIG_HOME/opencode`).
79
+
80
+ ### Windows (PowerShell)
81
+
82
+ ```powershell
83
+ git clone https://github.com/kabrau/opencode-voice-plugin.git
84
+ $dest = "$env:USERPROFILE\.config\opencode\plugins\voice"
85
+ New-Item -ItemType Directory -Force -Path $dest | Out-Null
86
+ Copy-Item .\opencode-voice-plugin\src\* $dest
87
+ ```
88
+
89
+ ### Linux / macOS
90
+
91
+ ```bash
92
+ git clone https://github.com/kabrau/opencode-voice-plugin.git
93
+ dest="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/plugins/voice"
94
+ mkdir -p "$dest"
95
+ cp opencode-voice-plugin/src/* "$dest/"
96
+ ```
97
+
98
+ Then **restart `opencode`**.
99
+
100
+ ### Alternative install (pointing at the clone)
101
+
102
+ Point the global `cli.json` at the cloned repository. This keeps the plugin updated via `git pull`
103
+ and is the way to pass options, since the discovery folder has nowhere to declare them:
104
+
105
+ ```json
106
+ {
107
+ "plugins": [
108
+ {
109
+ "package": "file:///path/to/opencode-voice-plugin",
110
+ "options": {
111
+ "language": "en",
112
+ "keybind": "ctrl+y"
113
+ }
114
+ }
115
+ ]
116
+ }
117
+ ```
118
+
119
+ > The path can be an absolute path or a `file://` URL. **Add** it to the existing `plugins` array in
120
+ > `~/.config/opencode/cli.json`, without deleting the rest of the file.
121
+
122
+ ---
123
+
124
+ ## API key
125
+
126
+ The plugin resolves the key in this order:
127
+
128
+ 1. **Environment variable** — `OPENROUTER_API_KEY` (or whatever `apiKeyEnv` says), if present.
129
+ 2. **OpenCode's own credential store** — if you already connected OpenRouter, the plugin uses that
130
+ key automatically. **Nothing to configure.**
131
+
132
+ In other words: if OpenRouter already shows up as connected, you **do not** need to create any
133
+ variable. Check with:
134
+
135
+ ```bash
136
+ opencode auth list # should list "OpenRouter ... stored"
137
+ ```
138
+
139
+ If you prefer the env var (or do not have OpenRouter connected):
140
+
141
+ ```powershell
142
+ setx OPENROUTER_API_KEY "sk-or-..." # Windows; reopen the terminal afterwards
143
+ ```
144
+
145
+ ```bash
146
+ export OPENROUTER_API_KEY="sk-or-..." # Linux/macOS (~/.bashrc / ~/.zshrc)
147
+ ```
148
+
149
+ Create a key at <https://openrouter.ai/settings/keys>. Either way the key is **never** logged,
150
+ copied or stored by the plugin — and reading the store is read-only, from the file OpenCode itself
151
+ maintains (`<data>/opencode/auth.json`).
152
+
153
+ For an env-var-only policy, use `"apiKeyFromAuth": false`; to read from another integration (for
154
+ example the Console one), change `authIntegration`.
155
+
156
+ ---
157
+
158
+ ## Configuration
159
+
160
+ ### Where to declare options
161
+
162
+ Options are read from **`cli.json`** (global config), **not** from `opencode.jsonc`. This holds even
163
+ if you installed by copying into `plugins/voice/`: add the entry below to the `plugins` array in
164
+ `~/.config/opencode/cli.json`, keeping the rest of the file intact.
165
+
166
+ ```json
167
+ {
168
+ "plugins": [
169
+ {
170
+ "package": "C:\\Users\\<you>\\.config\\opencode\\plugins\\voice",
171
+ "options": {
172
+ "autoSubmit": true
173
+ }
174
+ }
175
+ ]
176
+ }
177
+ ```
178
+
179
+ > Verified in both directions: with this entry `context.options` receives the object; declaring the
180
+ > same options in the `plugins` array of **`opencode.jsonc`** makes the TUI component receive `{}`.
181
+ > There is no duplication from the plugin also being discovered in the global folder.
182
+
183
+ Every option is optional.
184
+
185
+ | Option | Default | Description |
186
+ |---|---|---|
187
+ | `apiKeyEnv` | `"OPENROUTER_API_KEY"` | Name of the environment variable holding the key. Takes priority over the store. |
188
+ | `apiKeyFromAuth` | `true` | When the env var is absent, use the key already stored by OpenCode. |
189
+ | `authIntegration` | `"openrouter"` | OpenCode integration to read the key from. |
190
+ | `model` | `"openai/whisper-large-v3"` | OpenRouter STT model. |
191
+ | `language` | `"auto"` | ISO-639-1 code, or `"auto"` to auto-detect. |
192
+ | `baseUrl` | `"https://openrouter.ai/api/v1"` | Base URL of a provider compatible with `/audio/transcriptions`. |
193
+ | `device` | `null` | Microphone. `null` = auto-detect. Per-OS formats below. |
194
+ | `ffmpegPath` | `null` | Path to ffmpeg. `null` = look in PATH and known locations. |
195
+ | `keybind` | `"ctrl+y"` | Dictation shortcut. Use `"<leader>v"` if you prefer (`<leader>` = `ctrl+x`). |
196
+ | `autoSubmit` | `false` | `true` sends right away, skipping the review dialog. |
197
+ | `allowFromHome` | `true` | Allow dictating on the home screen, creating the session on the spot. |
198
+ | `maxSeconds` | `55` | Automatic recording cutoff. Safety ceiling is `120`. |
199
+ | `keepAudio` | `false` | `true` keeps the temporary WAV (debugging). |
200
+ | `icons` | `{ idle: "○", recording: "●", transcribing: "…" }` | Icon glyphs. Override if your font supports emoji. |
201
+
202
+ > **Why the default glyphs are `○ ● …` and not 🎤:** emoji outside the BMP (such as `🎤`) are missing
203
+ > from many console fonts and render as `?`; worse, their width is ambiguous, which **misaligns the
204
+ > clickable area** (clicks are routed by coordinate hit-testing). The default glyphs are BMP,
205
+ > single-width, and present in Consolas/Cascadia/DejaVu. If your terminal renders emoji correctly,
206
+ > use for example:
207
+ >
208
+ > ```json
209
+ > { "options": { "icons": { "idle": "🎤", "recording": "🔴", "transcribing": "⏳" } } }
210
+ > ```
211
+
212
+ `device` formats:
213
+
214
+ | OS | Format | Example |
215
+ |---|---|---|
216
+ | Windows | exact DirectShow name | `"Microphone (C922 Pro Stream Webcam)"` |
217
+ | Linux | Pulse/PipeWire source name | `"default"`, `"@DEFAULT_SOURCE@"`, `"alsa_input.pci-0000_00_1f.3.analog-stereo"` |
218
+ | Linux (ALSA) | `hw:CARD,DEV` | `"hw:1,0"` |
219
+ | macOS | numeric index | `"0"` |
220
+
221
+ ---
222
+
223
+ ## Usage
224
+
225
+ | Action | How |
226
+ |---|---|
227
+ | Start / stop | click the icon, or `ctrl+y`, or `/voice` |
228
+ | Choose microphone | command palette → **"Voice: choose microphone"** (the choice is remembered) |
229
+ | Send without reviewing | `"autoSubmit": true` in the options |
230
+
231
+ Icon states: `○` idle · `● Ns` recording · `…` transcribing. Errors always become a toast and never
232
+ freeze the TUI.
233
+
234
+ ---
235
+
236
+ ## Cost
237
+
238
+ `openai/whisper-large-v3` is billed **per token** (the audio is tokenized), and the price varies by
239
+ provider. Values read from the OpenRouter API in Oct/2026 (per 1M prompt tokens):
240
+
241
+ | Provider | Price (prompt) |
242
+ |---|---|
243
+ | DeepInfra | US$ 7.50 / 1M tokens |
244
+ | Together | US$ 25.00 / 1M tokens |
245
+ | Groq | US$ 30.83 / 1M tokens |
246
+
247
+ OpenRouter routes to an available provider automatically — the real cost of each request comes back
248
+ in the `usage.cost` field of the response. As a rough order of magnitude, OpenRouter's own
249
+ documentation shows a 9.2 s transcription costing **US$ 0.000508**.
250
+
251
+ **Check the current numbers** on the model page: <https://openrouter.ai/openai/whisper-large-v3>
252
+ (and the spend dashboard at <https://openrouter.ai/activity>). Do not trust the figures above long
253
+ term.
254
+
255
+ To switch models, just change `model` in the options (browse models with
256
+ `output_modalities=transcription` at <https://openrouter.ai/models?output_modalities=transcription>).
257
+
258
+ ---
259
+
260
+ ## Troubleshooting
261
+
262
+ **The plugin does not show up / did not load**
263
+
264
+ Check the log (it exists on Windows too):
265
+
266
+ ```
267
+ C:\Users\<you>\.local\share\opencode\log\opencode.log # Windows
268
+ ~/.local/share/opencode/log/opencode.log # Linux/macOS
269
+ ```
270
+
271
+ Look for `role=cli`. Confirm the files are in `<global-config>/plugins/voice/` and restart
272
+ `opencode`.
273
+
274
+ **"ffmpeg not found" toast**
275
+
276
+ Install ffmpeg (commands above) or point `ffmpegPath` at it explicitly. On Windows the plugin
277
+ automatically descends from the Chocolatey *shim*
278
+ (`C:\ProgramData\chocolatey\bin\ffmpeg.exe`) to the real binary at
279
+ `C:\ProgramData\chocolatey\lib\ffmpeg\tools\ffmpeg\bin\ffmpeg.exe`, because the shim may not forward
280
+ the stop command to the recording process.
281
+
282
+ **"Missing API key" toast**
283
+
284
+ The variable is not visible to the process. On Windows `setx` only applies to **new** terminals —
285
+ close and reopen. Check with `echo $env:OPENROUTER_API_KEY` (PowerShell) or `echo $OPENROUTER_API_KEY`.
286
+
287
+ **Records silence / nothing is transcribed**
288
+
289
+ The default device is probably a virtual cable. Run **"Voice: choose microphone"** and pick the real
290
+ microphone. The heuristic already avoids names like `CABLE Output`, `Stereo Mix`, `loopback`,
291
+ `Virtual` and `.monitor`, but not every virtual device gives itself away by name.
292
+
293
+ **Linux: no microphone / PulseAudio or ALSA error**
294
+
295
+ Install `pulseaudio-utils` (it provides `pactl`, used to list microphones). The plugin uses
296
+ `-f pulse` when it finds `pactl`, otherwise it falls back to `-f alsa` with `default`. To list
297
+ sources manually:
298
+
299
+ ```bash
300
+ pactl list short sources # ignore lines ending in .monitor
301
+ arecord -l # ALSA alternative
302
+ ```
303
+
304
+ **WSL2**
305
+
306
+ Audio arrives through WSLg as a Pulse source (usually `RDPSource`). If `pactl info` fails, run
307
+ `wsl --shutdown` on Windows, reopen, and check the microphone permission under
308
+ *Settings → Privacy → Microphone*. As a last resort, set `device` explicitly.
309
+
310
+ **The icon shows as `?`** (or another odd character)
311
+
312
+ Your console font lacks the configured glyph. The default (`○ ● …`) works almost everywhere. If you
313
+ switched to emoji through `icons`, go back to the default or use a font with emoji support.
314
+
315
+ **I cannot click the icon**
316
+
317
+ 1. Make sure the terminal sends mouse events: on **mintty/Git Bash**, check *Options → Mouse*
318
+ (it should be enabled). OpenTUI turns on mouse tracking at startup.
319
+ 2. Prefer the default glyphs (`○ ● …`). Emoji have ambiguous width and shift the clickable area.
320
+ 3. Click **right on** the glyph (there is one space of padding on each side).
321
+ 4. Meanwhile the shortcut does exactly the same: `ctrl+y` (or `<leader>v`: `ctrl+x`, then `v`), or
322
+ `ctrl+p` → **"Voice: dictate"**, or `/voice`.
323
+
324
+ **Transcription fails or is slow**
325
+
326
+ The provider times out at around **60 s per request**. That is why `maxSeconds` defaults to 55. If
327
+ you raise it, the dialog warns that recordings over 1 minute may fail.
328
+
329
+ **The beginning of speech is lost (Windows)**
330
+
331
+ DirectShow takes about 1 s to open the device. Wait for `●` to appear, then start speaking.
332
+
333
+ ---
334
+
335
+ ## Development
336
+
337
+ ```bash
338
+ npm install
339
+ npm run typecheck
340
+ ```
341
+
342
+ No runtime dependencies — only Node built-ins (`child_process`, `fs`, `os`, `path`, `buffer`), which
343
+ work under the Bun runtime embedded in OpenCode.
344
+
345
+ All platform-specific logic is isolated in `src/capture.ts`. The other files contain no
346
+ `if (platform)`.
347
+
348
+ > Note: `@opencode/plugin` declares `engines: node >= 26.4.0`. On Node 24 `npm install` prints an
349
+ > `EBADENGINE` warning, harmless for typechecking.
350
+
351
+ ### Layout
352
+
353
+ ```
354
+ src/index.ts server entrypoint (empty; required by the discovery layout)
355
+ src/tui.tsx the plugin: icon slot, shortcuts, state machine, delivery
356
+ src/capture.ts the ONLY module with platform-specific logic
357
+ src/ffmpeg.ts binary resolution, process execution, clean stop
358
+ src/transcribe.ts OpenRouter /audio/transcriptions
359
+ src/settings.ts options + env + defaults
360
+ ```
361
+
362
+ Design decisions and the API research (what was verified and what is **not** possible) are in
363
+ [DESIGN.md](DESIGN.md) (currently in Portuguese).
364
+
365
+ ---
366
+
367
+ ## Known limitations
368
+
369
+ - **One-shot dictation:** one recording = one message. There is no continuous/streaming dictation and
370
+ no text-to-speech.
371
+ - **No direct writing into the prompt input:** V2 exposes no way — to any plugin — to insert text into
372
+ the composer. This was verified three ways: (1) the `UI` API has nothing for prompt/composer
373
+ (checked in 2.0.29 and 2.0.32); (2) the server OpenAPI has no `/tui/*` route; (3) the internal
374
+ `tui.prompt.append` event is consumed by the TUI (`insertText` on the composer) but **nothing
375
+ publishes it** — there is no HTTP endpoint and no RPC surface. V1 had `client.tui.appendPrompt`,
376
+ removed in V2. That is why the text goes through the review dialog (pre-filled and editable), and
377
+ the request to bring it back is at
378
+ [anomalyco/opencode#51209](https://github.com/anomalyco/opencode/issues/51209)
379
+ (see also [#54681](https://github.com/anomalyco/opencode/issues/54681)). Once the API exists,
380
+ swapping the dialog for "insert into the prompt" is a small, local change.
381
+ - **Cloud transcription:** the audio is sent to OpenRouter and to the selected provider. Do not use it
382
+ with sensitive information if that is a concern.
383
+ - **Linux and macOS** were validated through the commands and the logic, but the end-to-end test was
384
+ done on Windows only. A real Linux test is still pending.
385
+
386
+ ---
387
+
388
+ ## Security
389
+
390
+ - The key comes from the environment variable or, if absent, from OpenCode's own store (read-only).
391
+ It is never logged, copied or persisted by the plugin.
392
+ - The WAV is written to `os.tmpdir()` and deleted after transcription (unless `keepAudio: true`).
393
+ - A recording in progress is stopped when the plugin is unloaded.
394
+
395
+ ---
396
+
397
+ ## License
398
+
399
+ MIT — see [LICENSE](LICENSE).