opencode-voice-dictation 0.0.0-stage → 0.1.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 Fran Carlos
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,253 @@
1
- # Temporary Holding Version
1
+ # Dictado por voz para OpenCode Desktop
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
+ Plugin de [OpenCode](https://opencode.ai) V2 que añade voz a la app de
4
+ escritorio:
5
+
6
+ - **Dictado**: pulsas la píldora, hablas y el texto aparece en el prompt.
7
+ - **Lectura**: pulsa «Leer» y reproduce en voz alta la última respuesta.
8
+
9
+ - **Dictado offline**: usa `System.Speech` de Windows, sin API keys ni internet.
10
+ - **Sin portapapeles**: escribe con `composer.insert()` en la posición del
11
+ cursor, sin robar el foco ni simular `Ctrl+V`.
12
+ - **Lectura local**: voces del sistema (`speechSynthesis`), con respaldo a
13
+ `System.Speech.Synthesis` si Electron no expone voces.
14
+ - **Solo Windows**: la app declara `os: ["windows"]`; en otros sistemas aparece
15
+ como no disponible en vez de fallar.
16
+
17
+ ## Instalación rápida
18
+
19
+ ```bash
20
+ opencode plugin add opencode-voice-dictation
21
+ ```
22
+
23
+ Reinicia OpenCode Desktop. El plugin queda registrado en el `opencode.json`
24
+ global del entorno donde ejecutes el comando.
25
+
26
+ Actualizar:
27
+
28
+ ```bash
29
+ opencode plugin update opencode-voice-dictation
30
+ ```
31
+
32
+ También puedes instalarlo directamente desde el repositorio (el bundle `dist/`
33
+ está commiteado):
34
+
35
+ ```bash
36
+ opencode plugin add github:evious/opencode-voice-dictation
37
+ ```
38
+
39
+ ## Compatibilidad
40
+
41
+ Probado con OpenCode Desktop **2.2.6** en Windows 11. La API de plugins GUI es
42
+ experimental (`gui-experimental`) y puede cambiar entre versiones de OpenCode.
43
+
44
+ ## Cómo funciona
45
+
46
+ ```
47
+ ┌─ Ventana (Solid) ─────────────────────────────┐
48
+ │ ComposerPill 🎤 → ctx.main(Dictation).start │
49
+ │ ↑ │
50
+ │ evento "final" → composer.insert(texto) │
51
+ └───────────────┬───────────────────────────────┘
52
+ │ Ipc (proceso main de Electron)
53
+ ┌───────────────▼───────────────────────────────┐
54
+ │ powershell.exe -File dictation-<hash>.ps1 │
55
+ │ System.Speech + DictationGrammar │
56
+ │ una línea JSON por frase reconocida │
57
+ └───────────────────────────────────────────────┘
58
+ ```
59
+
60
+ El motor vive en el proceso `main` de Electron porque necesita
61
+ `node:child_process`. La ventana solo pinta el estado y escribe el texto.
62
+
63
+ ## Requisitos
64
+
65
+ - OpenCode Desktop en **Windows 10/11**.
66
+ - Node.js 22 o superior para compilar (solo en desarrollo).
67
+ - Un **paquete de idioma de reconocimiento de voz** instalado:
68
+ Configuración → Hora e idioma → Idioma y región → (idioma) → Opciones →
69
+ Reconocimiento de voz. No basta con el paquete de visualización.
70
+ - Micrófono predeterminado configurado.
71
+
72
+ Comprobación rápida en PowerShell:
73
+
74
+ ```powershell
75
+ Add-Type -AssemblyName System.Speech
76
+ [System.Speech.Recognition.SpeechRecognitionEngine]::InstalledRecognizers() |
77
+ ForEach-Object { $_.Culture.Name }
78
+ ```
79
+
80
+ ## Compilar
81
+
82
+ ```bash
83
+ npm install
84
+ npm run build
85
+ ```
86
+
87
+ Genera `dist/gui-experimental.cjs`. Para desarrollo, `npm run dev` reconstruye
88
+ en cada cambio y OpenCode Desktop recarga el plugin solo.
89
+
90
+ ## Instalación manual (desarrollo)
91
+
92
+ 1. Copia al lado de Windows una carpeta con:
93
+
94
+ ```
95
+ voice-dictation/
96
+ ├── package.json
97
+ ├── dist/gui-experimental.cjs
98
+ └── src/index.ts
99
+ ```
100
+
101
+ `node_modules` no hace falta: la parte servidor solo importa tipos y el
102
+ bundle usa los módulos que la app ya proporciona.
103
+
104
+ 2. Añade la carpeta al `opencode.json` **global** de la app de escritorio
105
+ (`C:\Users\<usuario>\.config\opencode\opencode.json`):
106
+
107
+ ```jsonc
108
+ {
109
+ "plugins": ["C:\\Users\\<usuario>\\opencode-plugins\\voice-dictation"]
110
+ }
111
+ ```
112
+
113
+ La app de escritorio solo lee plugins GUI del `opencode.json` global, no de
114
+ `cli.json`.
115
+
116
+ 3. Abre **Ajustes → Extensiones → Plugins** y comprueba que aparece
117
+ `voice-dictation`. Si no aparece, revisa la ruta y los logs de la app.
118
+
119
+ > Si compilas en WSL, copia la carpeta a una ruta de Windows. La app no puede
120
+ > cargar el bundle desde una ruta de Linux.
121
+
122
+ ## Uso
123
+
124
+ | Acción | Cómo |
125
+ |---|---|
126
+ | Empezar / parar dictado | Clic en la píldora 🎤 sobre el composer |
127
+ | Atajo de dictado | `Ctrl+Shift+M` (configurable en Ajustes → Atajos) |
128
+ | Comando de dictado | `/dictado` en el prompt |
129
+ | Leer el último mensaje | Clic en la píldora «Leer», o `Ctrl+Shift+L` |
130
+ | Comando de lectura | `/leer` en el prompt |
131
+ | Ajustes | Ajustes → Dictado por voz, o paleta: "Ajustes de dictado por voz" |
132
+
133
+ La píldora del micrófono muestra el estado: `Dictar`, `Iniciando…`,
134
+ `Escuchando…` (icono en rojo) y `Reintentar dictado` si algo falla.
135
+
136
+ La píldora «Leer» cambia a `Leyendo…`; un segundo clic detiene la lectura.
137
+ Dictar detiene la lectura y leer detiene el dictado, para que el micrófono no
138
+ capture la voz del altavoz.
139
+
140
+ El texto se inserta en la posición del cursor. Si ya hay texto y no termina en
141
+ espacio, se añade uno de separación (configurable).
142
+
143
+ ## Ajustes
144
+
145
+ | Ajuste | Por defecto | Qué hace |
146
+ |---|---|---|
147
+ | Idioma | `es-ES` | Reconocedor instalado en Windows. Si no existe el exacto, usa el mismo idioma base. |
148
+ | Confianza mínima | 0,4 | Descarta resultados con menor confianza para evitar ruido. |
149
+ | Espacio antes del texto | Sí | Separa el dictado de lo que ya hay escrito. |
150
+ | Enfocar el prompt al terminar | Sí | Mueve el cursor al prompt tras cada frase. |
151
+ | Probar micrófono | — | Lista los idiomas instalados y comprueba el micrófono. |
152
+
153
+ ### Lectura en voz alta
154
+
155
+ | Ajuste | Por defecto | Qué hace |
156
+ |---|---|---|
157
+ | Voz | Automática | Voz del sistema; automática elige una en el idioma de la app. |
158
+ | Velocidad | 1× | 0,8×, 1×, 1,2× o 1,5×. |
159
+ | Ignorar bloques de código | Sí | Lee solo la prosa; omite código, rutas y comandos. |
160
+ | Probar voz | — | Lee una frase corta con la configuración actual. |
161
+
162
+ La lectura limpia el Markdown antes de hablar: quita fences, backticks,
163
+ negritas, encabezados, viñetas y convierte `[texto](url)` en `texto`. Trocea el
164
+ texto por frases para poder detenerse rápido.
165
+
166
+ ## Permisos
167
+
168
+ El plugin es local y no hace peticiones de red. Para funcionar:
169
+
170
+ - Escribe un script `dictation-<hash>.ps1` o `speak-<hash>.ps1` en
171
+ `%TEMP%\opencode-voice-dictation`.
172
+ - Ejecuta `powershell.exe` con ese script.
173
+ - El script abre el micrófono (`System.Speech`) y, al leer, el altavoz.
174
+
175
+ Por eso Windows y antivirus como Kaspersky pueden pedir permiso la primera vez:
176
+ es esperado. Elige **Permitir ahora** y marca **Recordar mi elección**. Si
177
+ bloqueas el micrófono, verás el aviso «No hay ningún micrófono disponible».
178
+
179
+ ## Solución de problemas
180
+
181
+ | Síntoma | Causa / solución |
182
+ |---|---|
183
+ | «El idioma de reconocimiento es-ES no está instalado» | Instala el paquete de **reconocimiento** de voz, no solo el de visualización. El aviso muestra los idiomas disponibles. |
184
+ | «Windows no tiene ningún reconocedor de voz instalado» | Mismo caso: no hay ningún idioma de voz instalado. |
185
+ | «No hay ningún micrófono disponible» | Revisa Configuración → Sistema → Sonido → Entrada y el micrófono predeterminado. |
186
+ | El motor se detiene inesperadamente | Otro proceso puede estar usando el micrófono en modo exclusivo. Ejecuta la prueba de micrófono en Ajustes. |
187
+ | La píldora no aparece | Comprueba que el plugin está en el `opencode.json` global, que la app corre en Windows y que el plugin figura como cargado en Ajustes → Extensiones → Plugins. |
188
+ | No transcribe nada | Sube el nivel de entrada del micrófono y baja la confianza mínima a 0,2. |
189
+ | «Windows no tiene voces instaladas para la lectura» | Instala una voz en Configuración → Hora e idioma → Voz, o revisa que `System.Speech` esté disponible. El plugin usa primero las voces de Chromium y cae a SAPI si no hay. |
190
+ | La lectura se corta o no suena | Prueba «Probar voz» en Ajustes; si suena ahí, el problema es el dispositivo de salida de la app. |
191
+ | «Abre una conversación para leer su último mensaje» | En Home o en un chat nuevo sin mensajes no hay respuesta que leer. |
192
+
193
+ Los errores del plugin se registran en el log de OpenCode Desktop; los `toast`
194
+ de la app muestran los más importantes.
195
+
196
+ ## Limitaciones
197
+
198
+ - La API de plugins GUI es **experimental** (`gui-experimental`) y puede
199
+ cambiar entre versiones de OpenCode.
200
+ - `System.Speech` no añade puntuación ni mayúsculas automáticamente y su
201
+ precisión es inferior a Whisper o al dictado nativo de Windows 11.
202
+ - No hay resultados parciales: el texto aparece al terminar cada frase.
203
+ - La lectura usa las voces del sistema; si Electron no las expone, el respaldo
204
+ por PowerShell usa las voces SAPI, con una latencia de arranque de ~0,5 s.
205
+ - La lectura empieza con el mensaje tal como esté en ese momento: si el agente
206
+ sigue escribiendo, leerá la versión parcial.
207
+ - Una sola dictación a la vez (hay un micrófono). Si empiezas en otra ventana,
208
+ la anterior se detiene.
209
+ - Si cambias de sesión durante el dictado, el texto se descarta con un aviso
210
+ para no escribirlo en la sesión equivocada.
211
+
212
+ El motor está aislado en `src/gui/engines/`: para cambiar a Whisper local o a
213
+ una API en la nube basta con respetar el mismo contrato (`startEngine` y los
214
+ eventos `ready` / `final` / `error`).
215
+
216
+ ## Desarrollo
217
+
218
+ ```
219
+ src/
220
+ ├── index.ts Parte servidor: fija el id "voice-dictation"
221
+ ├── gui.tsx Entrada ./gui-experimental
222
+ └── gui/
223
+ ├── definition.ts os, stores, i18n es/en
224
+ ├── contract.ts Ipc: dictado y lectura
225
+ ├── window.tsx Píldoras, comandos e inserción de texto
226
+ ├── main.ts Dictado y voz de respaldo
227
+ ├── speak.ts Limpieza de Markdown, troceado y speechSynthesis
228
+ ├── settings.tsx Página de ajustes
229
+ └── engines/
230
+ ├── script.ts Script de PowerShell para el dictado
231
+ ├── system-speech.ts Spawn, parseo de JSON, diagnose, taskkill
232
+ ├── speak-script.ts Script de PowerShell para la lectura
233
+ └── sapi-speech.ts Voz de respaldo y listado de voces SAPI
234
+ ```
235
+
236
+ `npm run typecheck` valida los tipos, `npm run build` genera el bundle CJS y
237
+ `npm test` ejecuta el smoke test (`test/smoke.cjs`): simula el host de la app,
238
+ un `powershell.exe` falso y la API de voz del navegador para comprobar dictado,
239
+ lectura, limpieza de Markdown, cancelación, fallback y errores.
240
+
241
+ ## Publicar
242
+
243
+ Mantenedores:
244
+
245
+ ```bash
246
+ npm login
247
+ npm version patch # o minor / major
248
+ npm publish
249
+ git push --tags
250
+ ```
251
+
252
+ El workflow de release publica en npm al empujar un tag `v*`. Configura el
253
+ secreto `NPM_TOKEN` en GitHub, o usa Trusted Publishing con OIDC.
@@ -0,0 +1,165 @@
1
+ "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const b=require("@opencode/plugin/gui"),d=require("effect"),A=require("node:child_process"),z=require("node:crypto"),R=require("node:fs"),D=require("node:os"),T=require("node:path"),m=require("solid-js/web"),N=require("solid-js"),X=b.Plugin.define({os:["windows"],stores:{prefs:b.Store.global(d.Schema.Struct({locale:d.Schema.String,minConfidence:d.Schema.Number,trailingSpace:d.Schema.Boolean,autoFocus:d.Schema.Boolean}),{locale:"es-ES",minConfidence:.4,trailingSpace:!0,autoFocus:!0}),speech:b.Store.global(d.Schema.Struct({voiceURI:d.Schema.String,rate:d.Schema.Number,skipCode:d.Schema.Boolean}),{voiceURI:"",rate:1,skipCode:!0})},i18n:{en:{"pill.idle":"Dictate","pill.starting":"Starting…","pill.listening":"Listening…","pill.error":"Retry dictation","pill.title":"Voice dictation","pill.stop":"Stop dictation","command.toggle":"Toggle voice dictation","command.toggle.description":"Transcribe what you say into the prompt","command.configure":"Voice dictation settings","error.title":"Voice dictation","error.language":"The speech language {locale} is not installed. Available: {available}.","error.none":"Windows has no speech recognizer installed. Settings › Time & language › Language › Speech.","error.mic":"No microphone is available for speech recognition.","error.system":"System.Speech is not available in this Windows installation.","error.exit":"The dictation engine stopped unexpectedly.","error.unknown":"Dictation failed ({code}).","toast.discarded":"Dictation discarded: the active session changed.","settings.title":"Voice dictation","settings.description":"Offline dictation with the Windows speech recognizer. The text is inserted into the prompt; it is never sent automatically.","settings.section":"Dictation","settings.language":"Language","settings.language.description":"A speech recognizer installed in Windows. Accents and punctuation depend on it.","settings.confidence":"Minimum confidence","settings.confidence.description":"Results below this threshold are ignored to avoid noise.","confidence.low":"Low (0.2)","confidence.normal":"Normal (0.4)","confidence.high":"High (0.6)","settings.space":"Add a space before the text","settings.space.description":"Separates the dictation from what is already in the prompt.","settings.focus":"Focus the prompt after dictating","settings.focus.description":"Moves the cursor to the prompt so you can review or send it.","settings.test.section":"Diagnostics","settings.test":"Test microphone","settings.test.description":"Checks installed recognizers and the default microphone.","settings.test.run":"Run test","settings.test.success":"Speech recognition ready: {locale}","settings.test.failed":"Not ready: {reason}","common.reason.no-recognizers":"no speech language installed","common.reason.no-microphone":"no microphone available","read.idle":"Read","read.speaking":"Reading…","read.stop":"Stop reading","read.title":"Read the last message aloud","command.read":"Read the last message aloud","command.read.description":"Speaks the last reply with a system voice","read.noSession":"Open a conversation to read its last message.","read.empty":"The last message has no text to read.","read.error":"The last message could not be read.","read.noVoices":"Windows has no voices installed for reading.","speech.section":"Read aloud","speech.voice":"Voice","speech.voice.description":"A system voice. Automatic picks one in the app language.","speech.voice.auto":"Automatic","speech.rate":"Speed","speech.rate.description":"How fast the text is spoken.","speech.rate.slow":"0.8×","speech.rate.normal":"1×","speech.rate.fast":"1.2×","speech.rate.faster":"1.5×","speech.skipCode":"Skip code blocks","speech.skipCode.description":"Read only the prose; code, paths and commands are left out.","speech.preview":"Test voice","speech.preview.description":"Speaks a short sample with the current voice and speed.","speech.preview.text":"Hello, this is a reading test."},es:{"pill.idle":"Dictar","pill.starting":"Iniciando…","pill.listening":"Escuchando…","pill.error":"Reintentar dictado","pill.title":"Dictado por voz","pill.stop":"Detener dictado","command.toggle":"Alternar dictado por voz","command.toggle.description":"Transcribe lo que dices en el prompt","command.configure":"Ajustes de dictado por voz","error.title":"Dictado por voz","error.language":"El idioma de reconocimiento {locale} no está instalado. Disponibles: {available}.","error.none":"Windows no tiene ningún reconocedor de voz instalado. Configuración › Hora e idioma › Idioma › Voz.","error.mic":"No hay ningún micrófono disponible para el reconocimiento de voz.","error.system":"System.Speech no está disponible en esta instalación de Windows.","error.exit":"El motor de dictado se detuvo inesperadamente.","error.unknown":"El dictado falló ({code}).","toast.discarded":"Dictado descartado: la sesión activa cambió.","settings.title":"Dictado por voz","settings.description":"Dictado offline con el reconocedor de voz de Windows. El texto se inserta en el prompt; nunca se envía solo.","settings.section":"Dictado","settings.language":"Idioma","settings.language.description":"Un reconocedor instalado en Windows. Los acentos y la puntuación dependen de él.","settings.confidence":"Confianza mínima","settings.confidence.description":"Los resultados por debajo de este umbral se ignoran para evitar ruido.","confidence.low":"Baja (0,2)","confidence.normal":"Normal (0,4)","confidence.high":"Alta (0,6)","settings.space":"Añadir un espacio antes del texto","settings.space.description":"Separa el dictado de lo que ya hay en el prompt.","settings.focus":"Enfocar el prompt al terminar","settings.focus.description":"Mueve el cursor al prompt para revisar o enviar el texto.","settings.test.section":"Diagnóstico","settings.test":"Probar micrófono","settings.test.description":"Comprueba los reconocedores instalados y el micrófono predeterminado.","settings.test.run":"Ejecutar prueba","settings.test.success":"Reconocimiento de voz listo: {locale}","settings.test.failed":"No está listo: {reason}","common.reason.no-recognizers":"no hay ningún idioma de voz instalado","common.reason.no-microphone":"no hay micrófono disponible","read.idle":"Leer","read.speaking":"Leyendo…","read.stop":"Detener lectura","read.title":"Leer en voz alta el último mensaje","command.read":"Leer en voz alta el último mensaje","command.read.description":"Reproduce la última respuesta con una voz del sistema","read.noSession":"Abre una conversación para leer su último mensaje.","read.empty":"El último mensaje no tiene texto para leer.","read.error":"No se pudo leer el último mensaje.","read.noVoices":"Windows no tiene voces instaladas para la lectura.","speech.section":"Lectura en voz alta","speech.voice":"Voz","speech.voice.description":"Una voz del sistema. Automática elige una en el idioma de la app.","speech.voice.auto":"Automática","speech.rate":"Velocidad","speech.rate.description":"A qué ritmo se lee el texto.","speech.rate.slow":"0,8×","speech.rate.normal":"1×","speech.rate.fast":"1,2×","speech.rate.faster":"1,5×","speech.skipCode":"Ignorar bloques de código","speech.skipCode.description":"Lee solo la prosa; omite código, rutas y comandos.","speech.preview":"Probar voz","speech.preview.description":"Lee una frase corta con la voz y velocidad actuales.","speech.preview.text":"Hola, esta es una prueba de lectura."}}}),O=b.Ipc.define({id:"voice-dictation.dictation",state:d.Schema.Struct({status:d.Schema.Literals(["idle","starting","listening","error"]),error:d.Schema.String,locale:d.Schema.String}),methods:{start:{input:d.Schema.Struct({locale:d.Schema.String,minConfidence:d.Schema.Number})},stop:{},diagnose:{output:d.Schema.Struct({locales:d.Schema.Array(d.Schema.String),microphone:d.Schema.Boolean,selected:d.Schema.String,error:d.Schema.String})}},events:{final:d.Schema.Struct({text:d.Schema.String,confidence:d.Schema.Number}),error:d.Schema.Struct({code:d.Schema.String,detail:d.Schema.String,available:d.Schema.Array(d.Schema.String)})}}),V=b.Ipc.define({id:"voice-dictation.speech",methods:{speak:{input:d.Schema.Struct({text:d.Schema.String,rate:d.Schema.Number,voice:d.Schema.String})},stop:{},voices:{output:d.Schema.Array(d.Schema.String)}},events:{done:d.Schema.Struct({ok:d.Schema.Boolean})}}),F=String.raw`param(
2
+ [string]$Culture = "es-ES",
3
+ [double]$MinConfidence = 0.4,
4
+ [switch]$Diagnose
5
+ )
6
+
7
+ $ErrorActionPreference = "Stop"
8
+
9
+ try {
10
+ $utf8 = New-Object System.Text.UTF8Encoding($false)
11
+ [Console]::OutputEncoding = $utf8
12
+ } catch { }
13
+
14
+ function Emit {
15
+ param([hashtable]$Payload)
16
+ [Console]::Out.WriteLine(($Payload | ConvertTo-Json -Compress))
17
+ [Console]::Out.Flush()
18
+ }
19
+
20
+ try {
21
+ Add-Type -AssemblyName System.Speech
22
+ } catch {
23
+ Emit @{ type = "error"; code = "SYSTEM_SPEECH_UNAVAILABLE"; detail = "$($_.Exception.Message)"; available = @() }
24
+ exit 2
25
+ }
26
+
27
+ $installed = @()
28
+ try {
29
+ foreach ($entry in [System.Speech.Recognition.SpeechRecognitionEngine]::InstalledRecognizers()) {
30
+ $installed += $entry.Culture.Name
31
+ }
32
+ } catch { }
33
+
34
+ $selected = ""
35
+ foreach ($name in $installed) {
36
+ if ($name -eq $Culture) { $selected = $name; break }
37
+ }
38
+ if ($selected -eq "") {
39
+ $base = ($Culture -split "-")[0]
40
+ foreach ($name in $installed) {
41
+ if (($name -split "-")[0] -eq $base) { $selected = $name; break }
42
+ }
43
+ }
44
+
45
+ if ($Diagnose) {
46
+ $microphone = $false
47
+ $problem = ""
48
+ if ($installed.Count -gt 0) {
49
+ try {
50
+ $probeCulture = New-Object System.Globalization.CultureInfo($installed[0])
51
+ $probe = New-Object System.Speech.Recognition.SpeechRecognitionEngine($probeCulture)
52
+ $probe.SetInputToDefaultAudioDevice()
53
+ $microphone = $true
54
+ $probe.Dispose()
55
+ } catch {
56
+ $problem = "$($_.Exception.Message)"
57
+ }
58
+ } else {
59
+ $problem = "NO_RECOGNIZERS"
60
+ }
61
+ Emit @{ type = "diagnose"; locales = @($installed); microphone = $microphone; selected = $selected; error = $problem }
62
+ exit 0
63
+ }
64
+
65
+ if ($installed.Count -eq 0) {
66
+ Emit @{ type = "error"; code = "NO_RECOGNIZERS"; detail = ""; available = @() }
67
+ exit 3
68
+ }
69
+
70
+ if ($selected -eq "") {
71
+ Emit @{ type = "error"; code = "LANGUAGE_NOT_AVAILABLE"; detail = $Culture; available = @($installed) }
72
+ exit 4
73
+ }
74
+
75
+ try {
76
+ $cultureInfo = New-Object System.Globalization.CultureInfo($selected)
77
+ $recognizer = New-Object System.Speech.Recognition.SpeechRecognitionEngine($cultureInfo)
78
+ $recognizer.LoadGrammar((New-Object System.Speech.Recognition.DictationGrammar))
79
+ $recognizer.InitialSilenceTimeout = [TimeSpan]::FromSeconds(10)
80
+ $recognizer.BabbleTimeout = [TimeSpan]::FromSeconds(5)
81
+ $recognizer.EndSilenceTimeout = [TimeSpan]::FromSeconds(0.6)
82
+ $recognizer.EndSilenceTimeoutAmbiguous = [TimeSpan]::FromSeconds(1.2)
83
+ $recognizer.SetInputToDefaultAudioDevice()
84
+ } catch {
85
+ Emit @{ type = "error"; code = "MICROPHONE_UNAVAILABLE"; detail = "$($_.Exception.Message)"; available = @($installed) }
86
+ exit 5
87
+ }
88
+
89
+ Emit @{ type = "ready"; locale = $selected; requested = $Culture }
90
+
91
+ $failures = 0
92
+ while ($true) {
93
+ try {
94
+ $result = $recognizer.Recognize([TimeSpan]::FromSeconds(5))
95
+ $failures = 0
96
+ if ($result -ne $null -and $result.Text -ne "" -and $result.Confidence -ge $MinConfidence) {
97
+ Emit @{ type = "final"; text = $result.Text; confidence = [math]::Round($result.Confidence, 3); locale = $selected }
98
+ }
99
+ } catch {
100
+ $failures++
101
+ if ($failures -ge 5) {
102
+ Emit @{ type = "error"; code = "RECOGNITION_FAILED"; detail = "$($_.Exception.Message)"; available = @() }
103
+ exit 6
104
+ }
105
+ Start-Sleep -Milliseconds 250
106
+ }
107
+ }
108
+ `,J="powershell.exe";function U(){const e=T.join(D.tmpdir(),"opencode-voice-dictation");R.mkdirSync(e,{recursive:!0});const t=z.createHash("sha1").update(F).digest("hex").slice(0,12),i=T.join(e,`dictation-${t}.ps1`);return R.writeFileSync(i,F,"utf8"),i}function j(e){return A.spawn(J,e,{windowsHide:!0,stdio:["ignore","pipe","pipe"]})}function B(e){const t=e.pid;if(t===void 0){e.kill();return}A.spawn("taskkill",["/PID",String(t),"/T","/F"],{windowsHide:!0,stdio:"ignore"}).on("error",()=>e.kill())}function K(e,t){const i=j(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",U(),"-Culture",e.locale,"-MinConfidence",String(e.minConfidence)]);let c=!1,r="",s="";const g=o=>{switch(o.type){case"ready":t.onReady(o.locale??e.locale);break;case"final":o.text&&t.onFinal(o.text,o.confidence??0);break;case"error":t.onError(o.code??"UNKNOWN",o.detail??"",o.available??[]);break}};return i.stdout?.setEncoding("utf8"),i.stdout?.on("data",o=>{s+=o;let a=s.indexOf(`
109
+ `);for(;a>=0;){const l=s.slice(0,a).replace(/\r$/,"").trim();if(s=s.slice(a+1),l)try{g(JSON.parse(l))}catch{}a=s.indexOf(`
110
+ `)}}),i.stderr?.setEncoding("utf8"),i.stderr?.on("data",o=>{r=(r+o).slice(-2e3)}),i.on("error",o=>{c||t.onError("ENGINE_START_FAILED",o.message,[])}),i.on("close",o=>{c||t.onExit(o,r.trim().slice(-500))}),{stop(){c=!0,B(i)}}}function Z(){return new Promise((e,t)=>{const i=j(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",U(),"-Diagnose"]);let c="",r="",s=!1;const g=(a,l)=>{s||(s=!0,clearTimeout(o),a?t(a):e(l))},o=setTimeout(()=>{B(i),g(new Error("diagnose timed out"))},2e4);i.stdout?.setEncoding("utf8"),i.stdout?.on("data",a=>{c+=a}),i.stderr?.setEncoding("utf8"),i.stderr?.on("data",a=>{r+=a}),i.on("error",a=>g(a)),i.on("close",()=>{const a=c.split(/\r?\n/).map(l=>l.trim()).filter(l=>l.startsWith("{")).at(-1);if(!a){g(new Error(r.trim()||"no output from PowerShell"));return}try{const l=JSON.parse(a);g(void 0,{locales:l.locales??[],microphone:l.microphone===!0,selected:l.selected??"",error:l.error??""})}catch(l){g(l instanceof Error?l:new Error(String(l)))}})})}const P=String.raw`param(
111
+ [string]$TextFile = "",
112
+ [int]$Rate = 0,
113
+ [string]$Voice = "",
114
+ [switch]$ListVoices
115
+ )
116
+
117
+ $ErrorActionPreference = "Stop"
118
+
119
+ try {
120
+ $utf8 = New-Object System.Text.UTF8Encoding($false)
121
+ [Console]::OutputEncoding = $utf8
122
+ } catch { }
123
+
124
+ try {
125
+ Add-Type -AssemblyName System.Speech
126
+ } catch {
127
+ exit 2
128
+ }
129
+
130
+ try {
131
+ $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
132
+ } catch {
133
+ exit 3
134
+ }
135
+
136
+ if ($ListVoices) {
137
+ try {
138
+ foreach ($entry in $synth.GetInstalledVoices()) {
139
+ if ($entry.Enabled) {
140
+ [Console]::Out.WriteLine($entry.VoiceInfo.Name)
141
+ }
142
+ }
143
+ } catch { }
144
+ $synth.Dispose()
145
+ exit 0
146
+ }
147
+
148
+ if ($Rate -lt -10) { $Rate = -10 }
149
+ if ($Rate -gt 10) { $Rate = 10 }
150
+
151
+ try { $synth.Rate = $Rate } catch { }
152
+
153
+ if ($Voice -ne "") {
154
+ try { $synth.SelectVoice($Voice) } catch { }
155
+ }
156
+
157
+ try {
158
+ $text = [System.IO.File]::ReadAllText($TextFile, [System.Text.Encoding]::UTF8)
159
+ $synth.Speak($text)
160
+ } catch { }
161
+
162
+ $synth.Dispose()
163
+ `,Y="powershell.exe";function G(){const e=T.join(D.tmpdir(),"opencode-voice-dictation");return R.mkdirSync(e,{recursive:!0}),e}function M(){const e=z.createHash("sha1").update(P).digest("hex").slice(0,12),t=T.join(G(),`speak-${e}.ps1`);return R.writeFileSync(t,P,"utf8"),t}function Q(e){const t=T.join(G(),`speech-${z.randomUUID()}.txt`);return R.writeFileSync(t,e,"utf8"),t}function q(e){return A.spawn(Y,e,{windowsHide:!0,stdio:["ignore","pipe","pipe"]})}function H(e){const t=e.pid;if(t===void 0){e.kill();return}A.spawn("taskkill",["/PID",String(t),"/T","/F"],{windowsHide:!0,stdio:"ignore"}).on("error",()=>e.kill())}function x(e){return Math.max(-10,Math.min(10,Math.round((e-1)*5)))}function ee(e,t){const i=Q(e.text),c=q(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",M(),"-TextFile",i,"-Rate",String(x(e.rate)),"-Voice",e.voice]);let r=!1;const s=()=>{try{R.rmSync(i,{force:!0})}catch{}};return c.on("error",()=>{s(),r||t(!1)}),c.on("close",g=>{s(),r||t(g===0)}),{stop(){r=!0,H(c),s()}}}function te(){return new Promise(e=>{const t=q(["-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",M(),"-ListVoices"]);let i="",c=!1;const r=g=>{c||(c=!0,clearTimeout(s),e(g))},s=setTimeout(()=>{H(t),r([])},1e4);t.stdout?.setEncoding("utf8"),t.stdout?.on("data",g=>{i+=g}),t.on("error",()=>r([])),t.on("close",()=>{const g=i.split(/\r?\n/).map(o=>o.trim()).filter(o=>o.length>0);r(g)})})}const ie=e=>{let t="idle",i="",c="",r,s=!1;const g=()=>{r=void 0,s=!1},o=e.provide(O,{state:()=>({status:t,error:i,locale:c}),start(S,w){r&&(s=!0,r.stop(),g()),t="starting",i="",c=S.locale,o.changed();const p=w.window;try{r=K({locale:S.locale,minConfidence:S.minConfidence},{onReady(u){t="listening",c=u,o.changed()},onFinal(u,h){o.emit("final",{text:u,confidence:h},p)},onError(u,h,C){t="error",i=u,o.changed(),o.emit("error",{code:u,detail:h,available:C},p)},onExit(u,h){if(s){g(),t="idle",i="",c="",o.changed();return}g(),t!=="error"&&(t="error",i=u===null?"ENGINE_EXIT":`ENGINE_EXIT_${u}`,o.changed(),o.emit("error",{code:"ENGINE_EXIT",detail:h||(u===null?"":String(u)),available:[]},p))}})}catch(u){throw t="error",i="ENGINE_START_FAILED",o.changed(),u}},stop(){r&&(s=!0,r.stop(),g()),t="idle",i="",c="",o.changed()},diagnose(){return Z()}});let a;const l=e.provide(V,{speak(S){a?.stop(),a=ee(S,w=>{a=void 0,l.emit("done",{ok:w})})},stop(){a&&(a.stop(),a=void 0),l.emit("done",{ok:!0})},voices(){return te()}});e.onCleanup(()=>{r&&(s=!0,r.stop(),g()),a?.stop(),a=void 0})},ne=250;function oe(e,t){let i=e.replace(/\r\n/g,`
164
+ `);return t?i=i.replace(/```[\s\S]*?```/g," "):i=i.replace(/```[^\n]*\n([\s\S]*?)```/g,"$1"),i=i.replace(/`([^`]+)`/g,"$1"),i=i.replace(/!\[[^\]]*\]\([^)]*\)/g," "),i=i.replace(/\[([^\]]+)\]\([^)]*\)/g,"$1"),i=i.replace(/^\s*#{1,6}\s+/gm,""),i=i.replace(/^\s*>\s?/gm,""),i=i.replace(/^\s*[-*+]\s+/gm,""),i=i.replace(/^\s*\d+\.\s+/gm,""),i=i.replace(/(\*\*|__)(.*?)\1/g,"$2"),i=i.replace(/(\*|_)(.*?)\1/g,"$2"),i=i.replace(/^\s*\|.*\|\s*$/gm," "),i=i.replace(/\n{2,}/g,". "),i=i.replace(/\n/g," "),i=i.replace(/\s+/g," "),i=i.replace(/\s+([.,;:!?])/g,"$1"),i=i.replace(/([.,;:!?]){2,}/g,"$1"),i.trim()}function re(e,t=ne){const i=e.trim();if(!i)return[];const c=i.split(new RegExp("(?<=[.!?…])\\s+")),r=[];let s="";const g=o=>{let a="";for(const l of o.split(/\s+/))l&&(a&&a.length+1+l.length>t?(r.push(a),a=l):a=a?`${a} ${l}`:l);return a};for(const o of c){if(o.length>t){s&&(r.push(s),s=""),s=g(o);continue}s&&s.length+1+o.length>t?(r.push(s),s=o):s=s?`${s} ${o}`:o}return s&&r.push(s),r}function se(e,t){const i=e.stores.speech,[c,r]=N.createSignal(!1),[s,g]=N.createSignal([]),[o,a]=N.createSignal("none");let l=0,S=[];const w=()=>typeof globalThis.speechSynthesis<"u"?globalThis.speechSynthesis:void 0,p=()=>{const v=w()?.getVoices()??[];if(v.length===0)return;const f=i.value.voiceURI;if(f){const $=v.find(I=>I.voiceURI===f);if($)return $}const y=e.locale.locale().split("-")[0]?.toLowerCase()??"es";return v.find($=>$.lang.toLowerCase().startsWith(y))??v[0]},u=async()=>{const v=w()?.getVoices()??[];if(v.length>0){g(v.map(f=>({id:f.voiceURI,label:`${f.name} — ${f.lang}`}))),a("web");return}try{const f=await t.voices();if(f.length>0){g(f.map(y=>({id:y,label:y}))),a("sapi");return}}catch{}a("none")},h=()=>{l++,S=[],w()?.cancel(),r(!1),t.stop().catch(()=>{})},C=async v=>{const f=oe(v,i.value.skipCode);if(!f)return!1;if(h(),await u(),o()==="web"){const y=w();if(!y)return!1;const $=p(),I=i.value.rate,W=l;S=re(f),r(!0);const L=()=>{if(W!==l)return;const _=S.shift();if(!_){r(!1);return}const k=new SpeechSynthesisUtterance(_);$&&(k.voice=$),$?.lang&&(k.lang=$.lang),k.rate=I,k.onend=L,k.onerror=L,y.speak(k)};return L(),!0}if(o()==="sapi"){r(!0);try{await t.speak({text:f,rate:i.value.rate,voice:i.value.voiceURI})}catch(y){throw r(!1),y}return!0}throw r(!1),new Error(e.t("read.noVoices"))},n=async()=>{const v=e.sessions.current();if(!v)return"";const y=(await v.server.client.message.list({sessionID:v.id,type:"assistant",order:"desc",limit:1})).data[0];return!y||y.type!=="assistant"?"":y.content.filter($=>$.type==="text").map($=>$.text).join(`
165
+ `)};return t.on("done",()=>r(!1)),w()?.addEventListener?.("voiceschanged",()=>{u()}),u(),{speaking:c,backend:o,voices:s,refreshVoices:u,lastAssistantText:n,speak:C,cancel:h,preview:()=>C(e.t("speech.preview.text"))}}const ae=["es-ES","es-MX","es-AR","es-CO","pt-BR","pt-PT","en-US","en-GB","fr-FR","de-DE","it-IT"];function ce(e){const t=b.usePlugin(),i=t.ui,c=t.stores.prefs,r=t.stores.speech,s=t.main(O),[g,o]=N.createSignal([]),[a,l]=N.createSignal(!1);N.onMount(()=>{s.diagnose().then(n=>{n.locales.length>0&&o(n.locales)}).catch(()=>{}),e.speaker.refreshVoices()});const S=()=>{const n=g(),E=c.value.locale;return(n.includes(E)?n:[E,...n]).map(f=>({value:f,label:f}))},w=()=>[{value:"",label:t.t("speech.voice.auto")},...e.speaker.voices().map(n=>({value:n.id,label:n.label}))],p=n=>{c.update(E=>{n.locale!==void 0&&(E.locale=n.locale),n.minConfidence!==void 0&&(E.minConfidence=n.minConfidence),n.trailingSpace!==void 0&&(E.trailingSpace=n.trailingSpace),n.autoFocus!==void 0&&(E.autoFocus=n.autoFocus)})},u=n=>{r.update(E=>{n.voiceURI!==void 0&&(E.voiceURI=n.voiceURI),n.rate!==void 0&&(E.rate=n.rate),n.skipCode!==void 0&&(E.skipCode=n.skipCode)})},h=async()=>{try{await e.speaker.preview()}catch(n){t.toast.show({variant:"error",title:t.t("read.title"),description:n instanceof Error?n.message:t.t("read.error")})}},C=async()=>{l(!0);try{const n=await s.diagnose();if(n.locales.length===0){t.toast.show({variant:"error",title:t.t("error.title"),description:t.t("settings.test.failed",{reason:t.t("common.reason.no-recognizers")}),persistent:!0});return}if(!n.microphone){t.toast.show({variant:"error",title:t.t("error.title"),description:t.t("settings.test.failed",{reason:t.t("common.reason.no-microphone")}),persistent:!0});return}n.locales.length>0&&!g().length&&o(n.locales),t.toast.show({variant:"success",title:t.t("error.title"),description:t.t("settings.test.success",{locale:n.selected||n.locales[0]||c.value.locale})})}catch(n){t.toast.show({variant:"error",title:t.t("error.title"),description:n instanceof Error?n.message:String(n)})}finally{l(!1)}};return m.createComponent(i.SettingsLayout,{get title(){return t.t("settings.title")},get description(){return t.t("settings.description")},get children(){return[m.createComponent(i.SettingsSection,{get title(){return t.t("settings.section")},get children(){return[m.createComponent(i.SettingsRow,{id:"language",get title(){return t.t("settings.language")},get description(){return t.t("settings.language.description")},get children(){return m.createComponent(i.Select,{get label(){return t.t("settings.language")},get value(){return c.value.locale},get options(){return m.memo(()=>S().length>0)()?S():ae.map(n=>({value:n,label:n}))},onChange:n=>p({locale:n})})}}),m.createComponent(i.SettingsRow,{id:"confidence",get title(){return t.t("settings.confidence")},get description(){return t.t("settings.confidence.description")},get children(){return m.createComponent(i.Select,{get label(){return t.t("settings.confidence")},get value(){return String(c.value.minConfidence)},get options(){return[{value:"0.2",label:t.t("confidence.low")},{value:"0.4",label:t.t("confidence.normal")},{value:"0.6",label:t.t("confidence.high")}]},onChange:n=>p({minConfidence:Number(n)||.4})})}}),m.createComponent(i.SettingsRow,{id:"space",get title(){return t.t("settings.space")},get description(){return t.t("settings.space.description")},get children(){return m.createComponent(i.Switch,{get label(){return t.t("settings.space")},hideLabel:!0,get checked(){return c.value.trailingSpace},onChange:n=>p({trailingSpace:n})})}}),m.createComponent(i.SettingsRow,{id:"focus",get title(){return t.t("settings.focus")},get description(){return t.t("settings.focus.description")},get children(){return m.createComponent(i.Switch,{get label(){return t.t("settings.focus")},hideLabel:!0,get checked(){return c.value.autoFocus},onChange:n=>p({autoFocus:n})})}})]}}),m.createComponent(i.SettingsSection,{get title(){return t.t("speech.section")},get children(){return[m.createComponent(i.SettingsRow,{id:"voice",get title(){return t.t("speech.voice")},get description(){return t.t("speech.voice.description")},get children(){return m.createComponent(i.Select,{get label(){return t.t("speech.voice")},get value(){return r.value.voiceURI},get options(){return w()},onChange:n=>u({voiceURI:n})})}}),m.createComponent(i.SettingsRow,{id:"rate",get title(){return t.t("speech.rate")},get description(){return t.t("speech.rate.description")},get children(){return m.createComponent(i.Select,{get label(){return t.t("speech.rate")},get value(){return String(r.value.rate)},get options(){return[{value:"0.8",label:t.t("speech.rate.slow")},{value:"1",label:t.t("speech.rate.normal")},{value:"1.2",label:t.t("speech.rate.fast")},{value:"1.5",label:t.t("speech.rate.faster")}]},onChange:n=>u({rate:Number(n)||1})})}}),m.createComponent(i.SettingsRow,{id:"skip-code",get title(){return t.t("speech.skipCode")},get description(){return t.t("speech.skipCode.description")},get children(){return m.createComponent(i.Switch,{get label(){return t.t("speech.skipCode")},hideLabel:!0,get checked(){return r.value.skipCode},onChange:n=>u({skipCode:n})})}}),m.createComponent(i.SettingsRow,{id:"preview",get title(){return t.t("speech.preview")},get description(){return t.t("speech.preview.description")},get children(){return m.createComponent(i.Button,{onClick:()=>{h()},get children(){return t.t("read.idle")}})}})]}}),m.createComponent(i.SettingsSection,{get title(){return t.t("settings.test.section")},get children(){return m.createComponent(i.SettingsRow,{id:"test",get title(){return t.t("settings.test")},get description(){return t.t("settings.test.description")},get children(){return m.createComponent(i.Button,{get busy(){return a()},onClick:()=>{C()},get children(){return t.t("settings.test.run")}})}})}})]}})}const le=e=>{const t=e.main(O),i=e.main(V),c=se(e,i),r=e.stores.prefs;let s;const g=(p,u,h)=>{switch(p){case"LANGUAGE_NOT_AVAILABLE":return e.t("error.language",{locale:u||r.value.locale,available:h.length>0?h.join(", "):"—"});case"NO_RECOGNIZERS":return e.t("error.none");case"MICROPHONE_UNAVAILABLE":return e.t("error.mic");case"SYSTEM_SPEECH_UNAVAILABLE":return e.t("error.system");case"ENGINE_EXIT":return e.t("error.exit");default:return e.t("error.unknown",{code:p})}},o=()=>{c.cancel(),s=e.sessions.current()?.key,t.start({locale:r.value.locale,minConfidence:r.value.minConfidence}).catch(p=>{e.toast.show({variant:"error",title:e.t("error.title"),description:p instanceof Error?p.message:String(p)})})},a=()=>{t.stop().catch(()=>{})},l=()=>{const p=t.state()?.status??"idle";p==="starting"||p==="listening"?a():o()},S=()=>{if(c.speaking()){c.cancel();return}if(!e.sessions.current()){e.toast.show({variant:"error",title:e.t("read.title"),description:e.t("read.noSession")});return}const p=t.state()?.status??"idle";(p==="starting"||p==="listening")&&t.stop().catch(()=>{}),(async()=>{try{const u=await c.lastAssistantText();if(!u.trim()){e.toast.show({variant:"default",title:e.t("read.title"),description:e.t("read.empty")});return}await c.speak(u)||e.toast.show({variant:"default",title:e.t("read.title"),description:e.t("read.empty")})}catch(u){e.toast.show({variant:"error",title:e.t("read.title"),description:u instanceof Error?u.message:e.t("read.error")})}})()};t.on("final",p=>{const u=e.sessions.current()?.key;if(s&&u&&u!==s){e.toast.show({variant:"error",title:e.t("error.title"),description:e.t("toast.discarded")});return}const h=e.screen.current()?.composer;if(!h)return;const C=h.text(),n=r.value.trailingSpace&&C.length>0&&!/\s$/.test(C)?" ":"";h.insert(n+p.text),r.value.autoFocus&&h.focus()}),t.on("error",p=>{e.toast.show({variant:"error",title:e.t("error.title"),description:g(p.code,p.detail,p.available),persistent:p.code==="LANGUAGE_NOT_AVAILABLE"||p.code==="NO_RECOGNIZERS",duration:8e3})});const w=()=>{if(e.app.platform!=="desktop")return;const p=t.state()?.status??"idle";return p==="starting"?{label:e.t("pill.starting"),icon:"outline-wave-audio",title:e.t("pill.title"),run:l}:p==="listening"?{label:e.t("pill.listening"),icon:"outline-wave-audio",tone:"danger",title:e.t("pill.stop"),run:l}:p==="error"?{label:e.t("pill.error"),icon:"outline-wave-audio",tone:"warning",title:e.t("pill.title"),run:l}:{label:e.t("pill.idle"),icon:"outline-wave-audio",title:e.t("pill.title"),run:l}};e.add(b.ComposerPill,()=>({id:"dictate",show:()=>w()})),e.add(b.ComposerPill,()=>({id:"read",order:1,show:()=>{if(e.app.platform==="desktop")return c.speaking()?{label:e.t("read.speaking"),icon:"outline-audio",tone:"success",title:e.t("read.stop"),run:S}:{label:e.t("read.idle"),icon:"outline-audio",title:e.t("read.title"),run:S}}})),e.add(b.Command,()=>({id:"toggle",title:e.t("command.toggle"),description:e.t("command.toggle.description"),keywords:"voice dictation microphone speech dictado voz microfono",group:e.t("pill.title"),section:"prompt",bind:"mod+shift+m",slash:{name:"dictado"},editable:!0,enabled:e.app.platform==="desktop",run:l})),e.add(b.Command,()=>({id:"configure",title:e.t("command.configure"),keywords:"voice dictation microphone speech dictado voz microfono",group:e.t("pill.title"),enabled:e.app.platform==="desktop",run:()=>e.layout.settings(e.id)})),e.add(b.Command,()=>({id:"read",title:e.t("command.read"),description:e.t("command.read.description"),keywords:"read aloud speech voice leer voz altavoz lectura",group:e.t("pill.title"),section:"prompt",bind:"mod+shift+l",slash:{name:"leer"},editable:!0,enabled:e.app.platform==="desktop",run:S})),e.add(b.SettingsPage,{id:e.id,icon:"outline-audio",get title(){return e.t("settings.title")},get entries(){return[{id:"language",title:e.t("settings.language"),keywords:"voice language idioma voz"},{id:"confidence",title:e.t("settings.confidence"),keywords:"voice confidence confianza"},{id:"test",title:e.t("settings.test"),keywords:"voice microphone microphone test microfono"},{id:"voice",title:e.t("speech.voice"),keywords:"read aloud voice voz lectura altavoz"},{id:"rate",title:e.t("speech.rate"),keywords:"read aloud speed velocidad lectura"},{id:"skip-code",title:e.t("speech.skipCode"),keywords:"read aloud code codigo lectura"},{id:"preview",title:e.t("speech.preview"),keywords:"read aloud test voice probar voz"}]},render:()=>m.createComponent(ce,{speaker:c})})},de=b.Plugin.define({...X,setup:le,main:ie});exports.default=de;
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "opencode-voice-dictation",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Offline voice dictation and read-aloud for OpenCode Desktop on Windows",
5
+ "license": "MIT",
6
+ "author": "Fran Carlos <francarlos803@gmail.com>",
7
+ "homepage": "https://github.com/evious/opencode-voice-dictation#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/evious/opencode-voice-dictation.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/evious/opencode-voice-dictation/issues"
14
+ },
15
+ "keywords": [
16
+ "opencode",
17
+ "opencode-plugin",
18
+ "voice",
19
+ "dictation",
20
+ "speech-to-text",
21
+ "text-to-speech",
22
+ "windows",
23
+ "offline"
24
+ ],
25
+ "type": "module",
26
+ "exports": {
27
+ ".": "./src/index.ts",
28
+ "./gui-experimental": "./dist/gui-experimental.cjs"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "dist",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "build": "vite build",
41
+ "dev": "vite build --watch",
42
+ "test": "node test/smoke.cjs",
43
+ "typecheck": "tsc --noEmit",
44
+ "prepublishOnly": "npm run typecheck && npm run build && npm test"
45
+ },
46
+ "devDependencies": {
47
+ "@opencode/plugin": "2.2.6",
48
+ "effect": "4.1.0",
49
+ "@types/node": "^24.0.0",
50
+ "solid-js": "^1.9.17",
51
+ "typescript": "^5.9.0",
52
+ "vite": "^7.3.0",
53
+ "vite-plugin-solid": "^2.11.0"
54
+ }
55
+ }
@@ -0,0 +1,77 @@
1
+ import { Schema } from "effect"
2
+ import { Ipc } from "@opencode/plugin/gui"
3
+
4
+ /**
5
+ * Bridge between the window setup and the Electron main process.
6
+ *
7
+ * The engine lives in `main` because it needs `node:child_process`; the window
8
+ * only paints status and inserts the transcript.
9
+ */
10
+ export const Dictation = Ipc.define({
11
+ id: "voice-dictation.dictation",
12
+ state: Schema.Struct({
13
+ status: Schema.Literals(["idle", "starting", "listening", "error"]),
14
+ error: Schema.String,
15
+ locale: Schema.String,
16
+ }),
17
+ methods: {
18
+ start: {
19
+ input: Schema.Struct({
20
+ locale: Schema.String,
21
+ minConfidence: Schema.Number,
22
+ }),
23
+ },
24
+ stop: {},
25
+ diagnose: {
26
+ output: Schema.Struct({
27
+ locales: Schema.Array(Schema.String),
28
+ microphone: Schema.Boolean,
29
+ selected: Schema.String,
30
+ error: Schema.String,
31
+ }),
32
+ },
33
+ },
34
+ events: {
35
+ final: Schema.Struct({
36
+ text: Schema.String,
37
+ confidence: Schema.Number,
38
+ }),
39
+ error: Schema.Struct({
40
+ code: Schema.String,
41
+ detail: Schema.String,
42
+ available: Schema.Array(Schema.String),
43
+ }),
44
+ },
45
+ })
46
+
47
+ export interface DiagnoseResult {
48
+ locales: string[]
49
+ microphone: boolean
50
+ selected: string
51
+ error: string
52
+ }
53
+
54
+ /**
55
+ * Text-to-speech bridge. The window speaks with `speechSynthesis` when the
56
+ * Electron build exposes voices, and falls back to this main-process engine
57
+ * (`System.Speech.Synthesis`) when it does not.
58
+ */
59
+ export const Speech = Ipc.define({
60
+ id: "voice-dictation.speech",
61
+ methods: {
62
+ speak: {
63
+ input: Schema.Struct({
64
+ text: Schema.String,
65
+ rate: Schema.Number,
66
+ voice: Schema.String,
67
+ }),
68
+ },
69
+ stop: {},
70
+ voices: {
71
+ output: Schema.Array(Schema.String),
72
+ },
73
+ },
74
+ events: {
75
+ done: Schema.Struct({ ok: Schema.Boolean }),
76
+ },
77
+ })