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/src/capture.ts ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * ÚNICO módulo com lógica por plataforma.
3
+ *
4
+ * Aqui vivem: montagem dos argumentos de entrada do ffmpeg, listagem de dispositivos
5
+ * e a heurística de microfone padrão. Também expõe `ProcessControl`, que injeta no
6
+ * `ffmpeg.ts` tudo que é específico do sistema (caminhos conhecidos, como matar a
7
+ * árvore de processos, etc.), mantendo o resto do código livre de `if (platform)`.
8
+ */
9
+
10
+ import { spawn } from "node:child_process"
11
+ import { existsSync, statSync } from "node:fs"
12
+ import { findOnPath, runCommand } from "./ffmpeg.js"
13
+
14
+ export const PLATFORM: NodeJS.Platform = process.platform
15
+
16
+ /** Um arquivo abaixo disso é tratado como launcher/shim, não como o ffmpeg real. */
17
+ const SHIM_MAX_BYTES = 1_000_000
18
+
19
+ const WINDOWS_FFMPEG_PATHS = [
20
+ "C:\\ProgramData\\chocolatey\\lib\\ffmpeg\\tools\\ffmpeg\\bin\\ffmpeg.exe",
21
+ "C:\\ProgramData\\chocolatey\\bin\\ffmpeg.exe",
22
+ "C:\\ffmpeg\\bin\\ffmpeg.exe",
23
+ ]
24
+ const DARWIN_FFMPEG_PATHS = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"]
25
+ const LINUX_FFMPEG_PATHS = ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/snap/bin/ffmpeg"]
26
+
27
+ function pathExtensions(): readonly string[] {
28
+ return PLATFORM === "win32" ? [".exe", ".cmd", ".bat", ""] : [""]
29
+ }
30
+
31
+ function isShim(file: string): boolean {
32
+ if (PLATFORM !== "win32") return false
33
+ try {
34
+ const stats = statSync(file)
35
+ return stats.isFile() && stats.size < SHIM_MAX_BYTES
36
+ } catch {
37
+ return false
38
+ }
39
+ }
40
+
41
+ export interface ProcessControl {
42
+ readonly knownPaths: readonly string[]
43
+ readonly pathExtensions: readonly string[]
44
+ readonly spawnDetached: boolean
45
+ isShim(file: string): boolean
46
+ killTree(pid: number): Promise<void>
47
+ }
48
+
49
+ export function captureControl(): ProcessControl {
50
+ return {
51
+ knownPaths: PLATFORM === "win32" ? WINDOWS_FFMPEG_PATHS : PLATFORM === "darwin" ? DARWIN_FFMPEG_PATHS : LINUX_FFMPEG_PATHS,
52
+ pathExtensions: pathExtensions(),
53
+ spawnDetached: PLATFORM !== "win32",
54
+ isShim,
55
+ async killTree(pid: number) {
56
+ if (!pid) return
57
+ if (PLATFORM === "win32") {
58
+ await new Promise<void>((resolve) => {
59
+ const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true })
60
+ killer.on("close", () => resolve())
61
+ killer.on("error", () => resolve())
62
+ })
63
+ return
64
+ }
65
+ try {
66
+ process.kill(-pid, "SIGKILL")
67
+ } catch {
68
+ try {
69
+ process.kill(pid, "SIGKILL")
70
+ } catch {
71
+ // já morreu
72
+ }
73
+ }
74
+ },
75
+ }
76
+ }
77
+
78
+ const CONTROL: ProcessControl = captureControl()
79
+
80
+ export function installHint(): string {
81
+ if (PLATFORM === "win32") {
82
+ return "Install ffmpeg with `choco install ffmpeg` (or download it from ffmpeg.org and add it to PATH)."
83
+ }
84
+ if (PLATFORM === "darwin") {
85
+ return "Install ffmpeg with `brew install ffmpeg`."
86
+ }
87
+ return "Install ffmpeg with `sudo apt install ffmpeg pulseaudio-utils` (Debian/Ubuntu) or the equivalent for your distro."
88
+ }
89
+
90
+ export interface CaptureBackend {
91
+ /** argumentos de entrada do ffmpeg para o dispositivo (nome ou id) */
92
+ inputArgs(device?: string): string[]
93
+ /** lista de dispositivos legíveis (para log e para o caso de múltiplos microfones) */
94
+ listDevices(ffmpeg: string): Promise<string[]>
95
+ /** dispositivo usado quando o usuário não configura `device` */
96
+ defaultDevice(ffmpeg: string): Promise<string | undefined>
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Parsing (puro, testável)
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /** `"Microphone (C922)" (audio)` → `Microphone (C922)`; ignora dispositivos de vídeo. */
104
+ export function parseDshowDevices(text: string): string[] {
105
+ const devices: string[] = []
106
+ const pattern = /"([^"]+)"\s*\((?:audio|áudio)\)/g
107
+ let match: RegExpExecArray | null
108
+ while ((match = pattern.exec(text)) !== null) {
109
+ if (match[1]) devices.push(match[1].trim())
110
+ }
111
+ return devices
112
+ }
113
+
114
+ /** `pactl list short sources`: nome na 2ª coluna; descarta monitores de saída. */
115
+ export function parsePactlSources(text: string): string[] {
116
+ return text
117
+ .split(/\r?\n/)
118
+ .map((line) => line.split("\t"))
119
+ .filter((columns) => columns.length >= 2 && columns[1] !== undefined)
120
+ .map((columns) => (columns[1] ?? "").trim())
121
+ .filter((name) => name.length > 0 && !name.endsWith(".monitor"))
122
+ }
123
+
124
+ /** `arecord -l`: `card 1: PCH [...], device 0: ...` → `hw:1,0`. */
125
+ export function parseArecordDevices(text: string): string[] {
126
+ const devices: string[] = []
127
+ const pattern = /card\s+(\d+):[\s\S]*?device\s+(\d+):/g
128
+ let match: RegExpExecArray | null
129
+ while ((match = pattern.exec(text)) !== null) {
130
+ devices.push(`hw:${match[1]},${match[2]}`)
131
+ }
132
+ return devices
133
+ }
134
+
135
+ /** `ffmpeg -f avfoundation -list_devices`: lista os índices do bloco de áudio. */
136
+ export function parseAvfoundationDevices(text: string): string[] {
137
+ const marker = "AVFoundation audio devices:"
138
+ const index = text.indexOf(marker)
139
+ if (index < 0) return []
140
+ const section = text.slice(index)
141
+ const devices: string[] = []
142
+ const pattern = /\[(\d+)\]\s+(.+)/g
143
+ let match: RegExpExecArray | null
144
+ while ((match = pattern.exec(section)) !== null) {
145
+ if (match[1]) devices.push(match[1].trim())
146
+ }
147
+ return devices
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Heurística de dispositivo padrão
152
+ // ---------------------------------------------------------------------------
153
+
154
+ const VIRTUAL = [/cable output/i, /stereo mix/i, /what u hear/i, /\.monitor\b/i, /loopback/i, /virtual/i]
155
+ const PREFERRED = [/microphone/i, /microfone/i, /headset/i, /mic\b/i, /^default$/i, /@default_source@/i]
156
+
157
+ /**
158
+ * Muitas máquinas têm cabos virtuais (VB-Audio, MSI Sound Tune, monitores do Pulse)
159
+ * que abrem mas gravam silêncio. Preferimos nomes de microfone de verdade e evitamos
160
+ * os virtuais; sem candidato ideal, usamos o primeiro não virtual.
161
+ */
162
+ export function pickDefaultDevice(devices: readonly string[]): string | undefined {
163
+ const list = devices.map((device) => device.trim()).filter(Boolean)
164
+ if (list.length === 0) return undefined
165
+ const clean = list.filter((name) => !VIRTUAL.some((pattern) => pattern.test(name)))
166
+ const pool = clean.length > 0 ? clean : list
167
+ return pool.find((name) => PREFERRED.some((pattern) => pattern.test(name))) ?? pool[0]
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Backends por plataforma
172
+ // ---------------------------------------------------------------------------
173
+
174
+ function windowsInputArgs(device?: string): string[] {
175
+ if (!device) {
176
+ throw new Error("On Windows you must pick a DirectShow microphone before recording.")
177
+ }
178
+ return ["-f", "dshow", "-i", `audio=${device}`]
179
+ }
180
+
181
+ async function windowsListDevices(ffmpeg: string): Promise<string[]> {
182
+ // O ffmpeg sai com código != 0 aqui de propósito; a lista vem no stderr.
183
+ const { stderr } = await runCommand(
184
+ ffmpeg,
185
+ ["-hide_banner", "-f", "dshow", "-list_devices", "true", "-i", "dummy"],
186
+ CONTROL,
187
+ )
188
+ return parseDshowDevices(stderr)
189
+ }
190
+
191
+ const windowsBackend: CaptureBackend = {
192
+ inputArgs: windowsInputArgs,
193
+ listDevices: windowsListDevices,
194
+ defaultDevice: async (ffmpeg) => pickDefaultDevice(await windowsListDevices(ffmpeg)),
195
+ }
196
+
197
+ let pactlCache: boolean | undefined
198
+
199
+ function hasPactl(): boolean {
200
+ if (pactlCache === undefined) {
201
+ pactlCache = findOnPath("pactl", pathExtensions()) !== undefined
202
+ }
203
+ return pactlCache
204
+ }
205
+
206
+ function linuxInputArgs(device?: string): string[] {
207
+ const driver = hasPactl() ? "pulse" : "alsa"
208
+ return ["-f", driver, "-i", device ?? "default"]
209
+ }
210
+
211
+ async function linuxListDevices(_ffmpeg: string): Promise<string[]> {
212
+ if (hasPactl()) {
213
+ const { stdout } = await runCommand("pactl", ["list", "short", "sources"], CONTROL)
214
+ const sources = parsePactlSources(stdout)
215
+ if (sources.length > 0) return sources
216
+ }
217
+ const { stdout } = await runCommand("arecord", ["-l"], CONTROL)
218
+ return parseArecordDevices(stdout)
219
+ }
220
+
221
+ const linuxBackend: CaptureBackend = {
222
+ inputArgs: linuxInputArgs,
223
+ listDevices: linuxListDevices,
224
+ defaultDevice: async (ffmpeg) => pickDefaultDevice(await linuxListDevices(ffmpeg)),
225
+ }
226
+
227
+ function macInputArgs(device?: string): string[] {
228
+ return ["-f", "avfoundation", "-i", `:${device ?? "0"}`]
229
+ }
230
+
231
+ async function macListDevices(ffmpeg: string): Promise<string[]> {
232
+ const { stderr } = await runCommand(
233
+ ffmpeg,
234
+ ["-hide_banner", "-f", "avfoundation", "-list_devices", "true", "-i", ""],
235
+ CONTROL,
236
+ )
237
+ return parseAvfoundationDevices(stderr)
238
+ }
239
+
240
+ const macBackend: CaptureBackend = {
241
+ inputArgs: macInputArgs,
242
+ listDevices: macListDevices,
243
+ defaultDevice: async (ffmpeg) => pickDefaultDevice(await macListDevices(ffmpeg)),
244
+ }
245
+
246
+ const fallbackBackend: CaptureBackend = linuxBackend
247
+
248
+ export function getBackend(): CaptureBackend {
249
+ if (PLATFORM === "win32") return windowsBackend
250
+ if (PLATFORM === "darwin") return macBackend
251
+ if (existsSync("/proc") || PLATFORM === "linux") return linuxBackend
252
+ return fallbackBackend
253
+ }
package/src/ffmpeg.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Resolução do binário do ffmpeg, execução de processos e parada limpa.
3
+ * Este arquivo é intencionalmente livre de lógica por plataforma: tudo que muda
4
+ * entre sistemas vem injetado via `ProcessControl` (definido em `capture.ts`).
5
+ */
6
+
7
+ import { spawn } from "node:child_process"
8
+ import { existsSync } from "node:fs"
9
+ import { delimiter, join } from "node:path"
10
+ import type { ProcessControl } from "./capture.js"
11
+
12
+ export interface CommandResult {
13
+ code: number | null
14
+ stdout: string
15
+ stderr: string
16
+ }
17
+
18
+ /** Procura um executável no PATH. Usa o separador de PATH do próprio Node, sem branch de plataforma. */
19
+ export function findOnPath(name: string, extensions: readonly string[]): string | undefined {
20
+ const entries = (process.env.PATH ?? "").split(delimiter)
21
+ for (const entry of entries) {
22
+ if (!entry) continue
23
+ for (const extension of extensions) {
24
+ const candidate = join(entry, name + extension)
25
+ if (existsSync(candidate)) return candidate
26
+ }
27
+ }
28
+ return undefined
29
+ }
30
+
31
+ /** Executa um comando e captura stdout/stderr. Nunca rejeita: o resultado carrega o erro. */
32
+ export function runCommand(
33
+ command: string,
34
+ args: readonly string[],
35
+ control: ProcessControl,
36
+ timeoutMs = 15_000,
37
+ ): Promise<CommandResult> {
38
+ return new Promise((resolve) => {
39
+ let stdout = ""
40
+ let stderr = ""
41
+ const child = spawn(command, [...args], { windowsHide: true })
42
+ const timer = setTimeout(() => {
43
+ void control.killTree(child.pid ?? 0)
44
+ }, timeoutMs)
45
+
46
+ const finish = (code: number | null) => {
47
+ clearTimeout(timer)
48
+ resolve({ code, stdout, stderr })
49
+ }
50
+
51
+ child.stdout?.on("data", (chunk: Buffer) => {
52
+ stdout += chunk.toString()
53
+ })
54
+ child.stderr?.on("data", (chunk: Buffer) => {
55
+ stderr += chunk.toString()
56
+ })
57
+ child.on("close", (code) => finish(code))
58
+ child.on("error", (error) => {
59
+ stderr += (stderr ? "\n" : "") + error.message
60
+ finish(null)
61
+ })
62
+ })
63
+ }
64
+
65
+ const ffmpegCache = new Map<string, string>()
66
+
67
+ /**
68
+ * Ordem de resolução: opção explícita → PATH → caminhos conhecidos da plataforma.
69
+ * Se o caminho resolvido for um launcher (ex.: shim do Chocolatey no Windows),
70
+ * desce para o binário real, para que `q` no stdin chegue ao processo certo.
71
+ */
72
+ export function resolveFfmpeg(explicit: string | null | undefined, control: ProcessControl): Promise<string> {
73
+ const key = explicit ?? ""
74
+ const cached = ffmpegCache.get(key)
75
+ if (cached && existsSync(cached)) return Promise.resolve(cached)
76
+
77
+ const candidates: string[] = []
78
+ if (explicit) candidates.push(explicit)
79
+ const onPath = findOnPath("ffmpeg", control.pathExtensions)
80
+ if (onPath) candidates.push(onPath)
81
+ candidates.push(...control.knownPaths)
82
+
83
+ for (const candidate of candidates) {
84
+ if (!existsSync(candidate)) continue
85
+ const resolved = control.isShim(candidate)
86
+ ? (control.knownPaths.find((path) => existsSync(path) && !control.isShim(path)) ?? candidate)
87
+ : candidate
88
+ ffmpegCache.set(key, resolved)
89
+ return Promise.resolve(resolved)
90
+ }
91
+
92
+ return Promise.reject(new Error("ffmpeg not found"))
93
+ }
94
+
95
+ export interface RecordingResult {
96
+ code: number | null
97
+ stderr: string
98
+ }
99
+
100
+ export interface Recording {
101
+ readonly file: string
102
+ /** Escreve `q` no stdin para o ffmpeg fechar o WAV; mata a árvore após 2 s se preciso. */
103
+ stop(): Promise<RecordingResult>
104
+ }
105
+
106
+ export function startRecording(options: {
107
+ ffmpeg: string
108
+ args: readonly string[]
109
+ file: string
110
+ control: ProcessControl
111
+ }): Recording {
112
+ const { ffmpeg, args, file, control } = options
113
+ const child = spawn(ffmpeg, [...args], {
114
+ stdio: ["pipe", "ignore", "pipe"],
115
+ windowsHide: true,
116
+ detached: control.spawnDetached,
117
+ })
118
+
119
+ let stderr = ""
120
+ child.stderr?.on("data", (chunk: Buffer) => {
121
+ if (stderr.length < 8_000) stderr += chunk.toString()
122
+ })
123
+ // O stdin pode fechar sozinho se o ffmpeg morrer durante a gravação.
124
+ child.stdin?.on("error", () => {})
125
+
126
+ const exited = new Promise<RecordingResult>((resolve) => {
127
+ child.on("close", (code) => resolve({ code, stderr }))
128
+ child.on("error", (error) => {
129
+ stderr += (stderr ? "\n" : "") + error.message
130
+ resolve({ code: null, stderr })
131
+ })
132
+ })
133
+
134
+ let stopping: Promise<RecordingResult> | undefined
135
+
136
+ return {
137
+ file,
138
+ stop() {
139
+ if (stopping) return stopping
140
+ stopping = (async () => {
141
+ try {
142
+ child.stdin?.write("q\n")
143
+ child.stdin?.end()
144
+ } catch {
145
+ // stdin já estava fechado
146
+ }
147
+ const timer = setTimeout(() => {
148
+ void control.killTree(child.pid ?? 0)
149
+ }, 2_000)
150
+ const result = await exited
151
+ clearTimeout(timer)
152
+ return result
153
+ })()
154
+ return stopping
155
+ },
156
+ }
157
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+
3
+ /**
4
+ * Entrypoint de servidor exigido pelo layout de descoberta de plugins
5
+ * (`index.ts` + `tui` no mesmo diretório). Toda a funcionalidade vive no `tui.tsx`;
6
+ * este plugin é intencionalmente vazio.
7
+ */
8
+ export default Plugin.define({
9
+ id: "voice.server",
10
+ setup() {},
11
+ })
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Configuração do plugin: options do cli.json + variáveis de ambiente + defaults.
3
+ *
4
+ * Resolução da chave da API, nesta ordem:
5
+ * 1. `process.env[apiKeyEnv]`
6
+ * 2. store de credenciais do próprio OpenCode (`auth.json`), se `apiKeyFromAuth`
7
+ *
8
+ * A chave nunca é logada, copiada nem gravada em nenhum lugar.
9
+ */
10
+
11
+ import { readFileSync } from "node:fs"
12
+ import { homedir } from "node:os"
13
+ import { join } from "node:path"
14
+
15
+ /**
16
+ * Glifos do ícone. Os defaults são caracteres do BMP com largura 1, que existem
17
+ * nas fontes de console (Consolas, Cascadia, DejaVu) e são medidos corretamente —
18
+ * emojis fora do BMP (ex.: 🎤) aparecem como "?" em alguns terminais e desalinham
19
+ * a área clicável. Se sua fonte suportar emoji, sobrescreva via `icons`.
20
+ */
21
+ export interface VoiceIcons {
22
+ idle: string
23
+ recording: string
24
+ transcribing: string
25
+ }
26
+
27
+ export const DEFAULT_ICONS: VoiceIcons = {
28
+ idle: "○",
29
+ recording: "●",
30
+ transcribing: "…",
31
+ }
32
+
33
+ export interface VoiceSettings {
34
+ /** Nome da variável de ambiente que guarda a chave do OpenRouter (prioridade). */
35
+ apiKeyEnv: string
36
+ /** Se a env var não existir, usa a chave já guardada pelo OpenCode. */
37
+ apiKeyFromAuth: boolean
38
+ /** Integração do OpenCode de onde ler a chave (ex.: "openrouter"). */
39
+ authIntegration: string
40
+ /** Modelo de STT (ex.: "openai/whisper-large-v3"). */
41
+ model: string
42
+ /** Código ISO-639-1 ou "auto" (omite o campo no request). */
43
+ language: string
44
+ /** Base URL do provedor compatível com /audio/transcriptions. */
45
+ baseUrl: string
46
+ /** Dispositivo de áudio explícito; null = autodetectar. */
47
+ device: string | null
48
+ /** Caminho explícito do binário ffmpeg; null = resolver automaticamente. */
49
+ ffmpegPath: string | null
50
+ /** Binding do comando de ditado (ex.: "ctrl+y" ou "<leader>v"). */
51
+ keybind: string
52
+ /** Envia direto, sem abrir o dialog de revisão. */
53
+ autoSubmit: boolean
54
+ /** Permite ditar na tela inicial, criando a sessão automaticamente. */
55
+ allowFromHome: boolean
56
+ /** Corte automático da gravação, em segundos. */
57
+ maxSeconds: number
58
+ /** Mantém o WAV temporário depois de transcrever (debug). */
59
+ keepAudio: boolean
60
+ /** Glifos do ícone por estado. */
61
+ icons: VoiceIcons
62
+ }
63
+
64
+ export const DEFAULTS: VoiceSettings = {
65
+ apiKeyEnv: "OPENROUTER_API_KEY",
66
+ apiKeyFromAuth: true,
67
+ authIntegration: "openrouter",
68
+ model: "openai/whisper-large-v3",
69
+ language: "auto",
70
+ baseUrl: "https://openrouter.ai/api/v1",
71
+ device: null,
72
+ ffmpegPath: null,
73
+ keybind: "ctrl+y",
74
+ autoSubmit: false,
75
+ allowFromHome: true,
76
+ maxSeconds: 55,
77
+ keepAudio: false,
78
+ icons: DEFAULT_ICONS,
79
+ }
80
+
81
+ /**
82
+ * O provedor de STT estoura em ~60 s por requisição, então uma gravação mais longa
83
+ * que isso provavelmente falha. Mantemos o teto em 120 s apenas como rede de segurança.
84
+ */
85
+ export const MAX_SECONDS_CEILING = 120
86
+ export const PROVIDER_SAFE_SECONDS = 60
87
+
88
+ function nonEmptyString(value: unknown): string | undefined {
89
+ return typeof value === "string" && value.trim() ? value.trim() : undefined
90
+ }
91
+
92
+ export function resolveSettings(options: Readonly<Record<string, unknown>> | undefined): VoiceSettings {
93
+ const raw = options ?? {}
94
+ const settings: VoiceSettings = { ...DEFAULTS }
95
+
96
+ settings.apiKeyEnv = nonEmptyString(raw.apiKeyEnv) ?? settings.apiKeyEnv
97
+ settings.apiKeyFromAuth = raw.apiKeyFromAuth !== false
98
+ settings.authIntegration = nonEmptyString(raw.authIntegration) ?? settings.authIntegration
99
+ settings.model = nonEmptyString(raw.model) ?? settings.model
100
+ settings.language = nonEmptyString(raw.language) ?? settings.language
101
+ settings.baseUrl = nonEmptyString(raw.baseUrl) ?? settings.baseUrl
102
+ settings.device = nonEmptyString(raw.device) ?? null
103
+ settings.ffmpegPath = nonEmptyString(raw.ffmpegPath) ?? null
104
+ settings.keybind = nonEmptyString(raw.keybind) ?? settings.keybind
105
+ settings.autoSubmit = raw.autoSubmit === true
106
+ settings.allowFromHome = raw.allowFromHome !== false
107
+ settings.keepAudio = raw.keepAudio === true
108
+
109
+ const rawIcons = raw.icons as Partial<Record<keyof VoiceIcons, unknown>> | undefined
110
+ if (rawIcons && typeof rawIcons === "object") {
111
+ settings.icons = {
112
+ idle: nonEmptyString(rawIcons.idle) ?? DEFAULT_ICONS.idle,
113
+ recording: nonEmptyString(rawIcons.recording) ?? DEFAULT_ICONS.recording,
114
+ transcribing: nonEmptyString(rawIcons.transcribing) ?? DEFAULT_ICONS.transcribing,
115
+ }
116
+ }
117
+
118
+ if (typeof raw.maxSeconds === "number" && Number.isFinite(raw.maxSeconds) && raw.maxSeconds > 0) {
119
+ settings.maxSeconds = Math.min(Math.floor(raw.maxSeconds), MAX_SECONDS_CEILING)
120
+ }
121
+
122
+ return settings
123
+ }
124
+
125
+ /** Diretório de dados do OpenCode. Layout fixo, inclusive no Windows. */
126
+ function openCodeDataDir(): string {
127
+ const xdg = nonEmptyString(process.env.XDG_DATA_HOME)
128
+ return xdg ? join(xdg, "opencode") : join(homedir(), ".local", "share", "opencode")
129
+ }
130
+
131
+ /**
132
+ * Lê a chave da API que o próprio OpenCode guardou (ex.: via `/connect`).
133
+ * Somente leitura, sem log; qualquer falha ou formato inesperado retorna undefined.
134
+ */
135
+ export function readStoredApiKey(integration: string): string | undefined {
136
+ try {
137
+ const raw = readFileSync(join(openCodeDataDir(), "auth.json"), "utf8")
138
+ const parsed = JSON.parse(raw) as Record<string, { type?: unknown; key?: unknown } | undefined>
139
+ const entry = parsed?.[integration]
140
+ return entry ? nonEmptyString(entry.key) : undefined
141
+ } catch {
142
+ return undefined
143
+ }
144
+ }
145
+
146
+ export interface ApiKeyResolution {
147
+ key?: string
148
+ source: "env" | "auth-store" | "none"
149
+ }
150
+
151
+ /** Env var tem prioridade; senão, o store do OpenCode. */
152
+ export function resolveApiKey(settings: VoiceSettings): ApiKeyResolution {
153
+ const fromEnv = nonEmptyString(process.env[settings.apiKeyEnv])
154
+ if (fromEnv) return { key: fromEnv, source: "env" }
155
+
156
+ if (settings.apiKeyFromAuth) {
157
+ const stored = readStoredApiKey(settings.authIntegration)
158
+ if (stored) return { key: stored, source: "auth-store" }
159
+ }
160
+
161
+ return { source: "none" }
162
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Transcrição via OpenRouter (`POST /api/v1/audio/transcriptions`).
3
+ *
4
+ * O endpoint espera JSON com o áudio em base64 (não multipart). Mantemos a
5
+ * interface `TranscriptionProvider` para que outro provedor possa ser adicionado
6
+ * depois sem tocar no resto do plugin.
7
+ */
8
+
9
+ import { readFile } from "node:fs/promises"
10
+
11
+ export interface TranscribeInput {
12
+ /** Caminho do WAV a transcrever. */
13
+ file: string
14
+ apiKey: string
15
+ model: string
16
+ /** ISO-639-1; "auto" ou vazio omite o campo e deixa o provedor detectar. */
17
+ language?: string
18
+ /** Base do provedor; default é o OpenRouter. */
19
+ baseUrl?: string
20
+ timeoutMs?: number
21
+ /** Injetável para testes. */
22
+ fetchImpl?: typeof fetch
23
+ }
24
+
25
+ export interface TranscribeOutput {
26
+ text: string
27
+ cost?: number
28
+ seconds?: number
29
+ }
30
+
31
+ export class TranscribeError extends Error {
32
+ readonly status?: number
33
+ readonly body?: string
34
+
35
+ constructor(message: string, status?: number, body?: string) {
36
+ super(message)
37
+ this.name = "TranscribeError"
38
+ this.status = status
39
+ this.body = body
40
+ }
41
+ }
42
+
43
+ function truncate(value: string, max = 500): string {
44
+ return value.length > max ? `${value.slice(0, max)}…` : value
45
+ }
46
+
47
+ function toMessage(error: unknown): string {
48
+ return error instanceof Error ? error.message : String(error)
49
+ }
50
+
51
+ export async function transcribe(input: TranscribeInput): Promise<TranscribeOutput> {
52
+ const base = (input.baseUrl ?? "https://openrouter.ai/api/v1").replace(/\/+$/, "")
53
+ const doFetch = input.fetchImpl ?? fetch
54
+
55
+ const audio = await readFile(input.file)
56
+ const body: Record<string, unknown> = {
57
+ model: input.model,
58
+ input_audio: { data: audio.toString("base64"), format: "wav" },
59
+ }
60
+ if (input.language && input.language !== "auto") body.language = input.language
61
+
62
+ const controller = new AbortController()
63
+ const timeout = setTimeout(() => controller.abort(), input.timeoutMs ?? 90_000)
64
+
65
+ try {
66
+ const response = await doFetch(`${base}/audio/transcriptions`, {
67
+ method: "POST",
68
+ headers: {
69
+ Authorization: `Bearer ${input.apiKey}`,
70
+ "Content-Type": "application/json",
71
+ },
72
+ body: JSON.stringify(body),
73
+ signal: controller.signal,
74
+ })
75
+
76
+ const raw = await response.text()
77
+ if (!response.ok) {
78
+ throw new TranscribeError(`The provider returned ${response.status}.`, response.status, truncate(raw))
79
+ }
80
+
81
+ let parsed: { text?: unknown; usage?: { cost?: unknown; seconds?: unknown } }
82
+ try {
83
+ parsed = JSON.parse(raw) as typeof parsed
84
+ } catch {
85
+ throw new TranscribeError("Invalid response from the provider.", response.status, truncate(raw))
86
+ }
87
+
88
+ const text = typeof parsed.text === "string" ? parsed.text.trim() : ""
89
+ const cost = typeof parsed.usage?.cost === "number" ? parsed.usage.cost : undefined
90
+ const seconds = typeof parsed.usage?.seconds === "number" ? parsed.usage.seconds : undefined
91
+ return { text, cost, seconds }
92
+ } catch (error) {
93
+ if (error instanceof TranscribeError) throw error
94
+ if (error instanceof Error && error.name === "AbortError") {
95
+ throw new TranscribeError("Timed out talking to the provider.")
96
+ }
97
+ throw new TranscribeError(toMessage(error))
98
+ } finally {
99
+ clearTimeout(timeout)
100
+ }
101
+ }
102
+
103
+ export interface TranscriptionProvider {
104
+ readonly id: string
105
+ transcribe(input: TranscribeInput): Promise<TranscribeOutput>
106
+ }
107
+
108
+ export const openRouterProvider: TranscriptionProvider = {
109
+ id: "openrouter",
110
+ transcribe,
111
+ }