@goodandready/dsh-voice 0.9.16 → 0.9.18

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/lib/index.js CHANGED
@@ -1,54 +1,46 @@
1
- // dsh-voice — host half.
2
- //
3
- // Two voice input modes, each with its own provider fallback chain:
4
- // dictation — the browser cuts speech on pauses and posts chunks; text is
5
- // appended to the composer;
6
- // message — one whole recording; text is sent after the cancel window.
7
- //
8
- // Routes:
9
- // POST /dsh-voice/transcribe {dataBase64, mimeType, mode} -> {ok, text, provider, tookMs}
10
- // GET /dsh-voice/status -> {ok, whisperRunning, modes}
11
- //
12
- // Provider keys are resolved on the host via ctx.credentials and never reach
13
- // the browser.
1
+ // Cordis plugin entrypoint for @goodandready/dsh-voice
2
+ // Provides:
3
+ // - Fallback STT chain across cloud and local engines
4
+ // - Volatile config schema for dynamic UI updates
5
+ // - HTTP routes: /dsh-voice/status, /dsh-voice/polish, /dsh-voice/transcribe, /dsh-voice/config
6
+ // - Agent tool: transcribe_audio
7
+ // - Sensevoice installer and self-updater routes
8
+
9
+ import z from '@deepseek-ai/schemastery'
10
+ import { toWav16k } from './wav.js'
11
+ import { makeProviders, KNOWN_KEYS, DEFAULT_MODELS } from './providers.js'
12
+ import { runChain } from './chain.js'
13
+ import { registerSensevoiceInstaller } from './sensevoice-installer.js'
14
+ import { registerPluginUpdater } from './updater.js'
15
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
16
+ import { registerConfigRoutes } from './config-routes.js'
17
+ import { registerHostRoutes } from './host-routes.js'
18
+ import { createConfigReader } from './config-compat.js'
19
+ import { createStatsTracker, mergeContextVocabulary } from './stats.js'
20
+ import { registerTranscribeAudioTool } from './tool.js'
21
+ import { createPolishText } from './polish.js'
22
+ import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
23
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
24
+ import { applyJargonDictionary, extractVoiceActions } from './normalize.js'
25
+ import { ChainEntry, CustomProvider, BaseConfig } from './schema.js'
26
+ import { createLocalDaemons } from './local-daemon.js'
14
27
 
15
- import z from "@deepseek-ai/schemastery"
16
- import { credentialRef } from "@deepseek-ai/dsh-credentials"
17
- import path from "node:path"
18
- import { runChain } from "./chain.js"
19
- import { makeProviders, KNOWN_KEYS, DEFAULT_MODELS } from "./providers.js"
20
- import { toWav16k } from "./wav.js"
21
- import { applyJargonDictionary, extractVoiceActions } from "./normalize.js"
22
- import { createStatsTracker, mergeContextVocabulary } from "./stats.js"
23
- import { writeJson, readBody, isTrustedCaller } from "./http-util.js"
24
- import { createPolishText } from "./polish.js"
25
- import { createUserMessage } from "@deepseek-ai/dsh-llm"
26
- import { buildProviderOrder, sessionCommand } from "./transcribe-core.js"
27
- import { registerPluginUpdater } from "./updater.js"
28
- import { registerSensevoiceInstaller, buildSensevoiceArgs } from "./sensevoice-installer.js"
29
- import { createConfigReader } from "./config-compat.js"
30
- import { registerConfigRoutes } from "./config-routes.js"
31
- import { isAutoLang } from "./provider-http.js"
32
- import { ChainEntry, CustomProvider, BaseConfig } from "./schema.js"
33
- import { registerTranscribeAudioTool } from "./tool.js"
34
28
  export { ChainEntry, CustomProvider, BaseConfig }
35
-
36
29
  export const name = '@goodandready/dsh-voice'
37
30
  export const NS = 'dsh-voice'
38
31
  export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings', 'llm']
39
32
 
40
33
  function makeVolatile(schema) {
41
- if (!schema || typeof schema.volatile !== 'function') return schema
42
- if (schema.type === 'object' && schema.dict) {
43
- const dict = {}
44
- for (const [k, v] of Object.entries(schema.dict)) {
45
- dict[k] = makeVolatile(v)
46
- }
47
- const res = z.object(dict)
48
- if (schema.meta) Object.assign(res.meta, schema.meta)
49
- return res
34
+ if (!schema || typeof schema !== 'object' || !schema.dict) return schema
35
+ const dict = {}
36
+ for (const [k, v] of Object.entries(schema.dict)) {
37
+ dict[k] = (v && typeof v.volatile === 'function') ? v.volatile() : v
38
+ }
39
+ const res = z.object(dict)
40
+ if (typeof schema.description === 'function' && schema.meta?.description) {
41
+ return res.description(schema.meta.description)
50
42
  }
51
- return schema.volatile()
43
+ return res
52
44
  }
53
45
 
54
46
  export const Config = typeof z.number().volatile === 'function'
@@ -56,214 +48,34 @@ export const Config = typeof z.number().volatile === 'function'
56
48
  : BaseConfig
57
49
 
58
50
  export function apply(ctx, baseConfig) {
59
- let child = null
51
+ let settingsService = null
52
+ let scope = null
53
+ let getConfig = () => baseConfig
60
54
 
61
- // The settings card edits a namespace named after the plugin.
62
- // Using createConfigReader decodes volatile nodes on modern DSH cores
63
- // while caching unwrapped values across requests.
64
55
  const readEntryConfig = createConfigReader(baseConfig, ctx)
65
- let scope = null
66
- let settingsService = null
67
- let getConfig = readEntryConfig
56
+ getConfig = readEntryConfig
57
+
68
58
  const live = () => BaseConfig(structuredClone(getConfig() ?? {})) ?? readEntryConfig()
69
59
 
70
- async function resolveKey(ref) {
60
+ const resolveKey = async (ref) => {
61
+ if (!ref || !ref.type) return ''
71
62
  try {
72
63
  const resolved = await ctx.credentials.resolve(credentialRef(ref))
73
- if (resolved && resolved.value) return resolved.value
74
- } catch { /* fall through to environment */ }
75
- return process.env[ref] || ''
76
- }
77
-
78
- async function whisperAlive() {
79
- const rawUrl = String(live().whisperUrl || '').trim()
80
- const base = rawUrl.includes('/inference') ? rawUrl.split('/inference')[0] : rawUrl.replace(/\/+$/, '')
81
- if (!base) return false
82
- try {
83
- const res = await fetch(base + '/', { signal: AbortSignal.timeout(2000) })
84
- return res.ok
85
- } catch {
86
- return false
87
- }
88
- }
89
-
90
- let whisperError = null
91
- let startingWhisper = false
92
- async function startWhisper() {
93
- if (startingWhisper) return false
94
- startingWhisper = true
95
- try {
96
- const cfg = live()
97
- if (!cfg.autoStart) return false
98
- // Without a model path there is nothing to start: the package cannot know
99
- // where a user's model lives and must not launch a random binary.
100
- if (!cfg.whisperModel) return false
101
- if (await whisperAlive()) {
102
- whisperError = null
103
- return true
104
- }
105
- if (child && typeof child.kill === 'function') {
106
- try { child.kill() } catch { /* ignore */ }
107
- child = null
108
- }
109
- const spec = typeof ctx.shell?.resolve === 'function'
110
- ? ctx.shell.resolve({
111
- command: `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
112
- + ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`,
113
- timeoutMs: 0,
114
- stdoutMaxBytes: 4 * 1024 * 1024,
115
- })
116
- : {
117
- command: `${JSON.stringify(cfg.whisperBin)} -m ${JSON.stringify(cfg.whisperModel)}`
118
- + ` --host 127.0.0.1 --port 8001 -t 8 -p 1 -l ${isAutoLang(cfg.dictation.language) ? 'auto' : cfg.dictation.language}`,
119
- timeoutMs: 0,
120
- stdoutMaxBytes: 4 * 1024 * 1024,
121
- }
122
- child = typeof ctx.shell?.execute === 'function'
123
- ? ctx.shell.execute(spec)
124
- : typeof ctx.shell?.start === 'function'
125
- ? ctx.shell.start(spec)
126
- : null
127
- if (child && typeof child.on === 'function') {
128
- child.on('error', (err) => {
129
- whisperError = err?.message || String(err)
130
- })
131
- child.on('exit', (code) => {
132
- if (code !== 0 && code !== null) {
133
- whisperError = `whisper server process exited with code ${code}`
134
- }
135
- })
136
- }
137
- if (child && child.done && typeof child.done.then === 'function') {
138
- child.done.then((res) => {
139
- if (res && res.code !== 0 && res.code !== null && res.code !== undefined) {
140
- whisperError = `whisper server process exited with code ${res.code}`
141
- }
142
- }).catch((err) => {
143
- whisperError = err?.message || String(err)
144
- })
145
- }
146
- // Wait for it to become healthy (up to 30s for cold model loading on Metal/CPU).
147
- for (let i = 0; i < 60; i++) {
148
- await new Promise((r) => setTimeout(r, 500))
149
- if (await whisperAlive()) {
150
- whisperError = null
151
- return true
152
- }
153
- if (child && child.exitCode !== null && child.exitCode !== undefined) {
154
- whisperError = `whisper server process exited prematurely with code ${child.exitCode}`
155
- return false
156
- }
157
- if (child && (child.status === 'exited' || child.status === 'failed')) {
158
- whisperError = `whisper server process exited prematurely with status ${child.status}`
159
- return false
160
- }
161
- }
162
- whisperError = 'whisper server process started but failed health check on port 8001'
163
- return false
164
- } catch (err) {
165
- whisperError = String(err && err.message ? err.message : err)
166
- return false
167
- }
168
- finally { startingWhisper = false }
169
- }
170
-
171
- // SenseVoice-ONNX / Sherpa-ONNX: autostart and health check.
172
- let sensevoiceChild = null
173
- let sensevoiceError = null
174
-
175
- async function sensevoiceAlive() {
176
- const cfg = live()
177
- const base = String(cfg.sensevoiceUrl || '').replace(/\/api\/.*$/, '').replace(/\/v1\/.*$/, '').replace(/\/+$/, '')
178
- if (!base) return false
179
- try {
180
- const res = await fetch(base + '/', { signal: AbortSignal.timeout(2000) })
181
- return res.ok || res.status === 404 // sherpa-onnx answers 404 on / but is alive
64
+ return resolved || ''
182
65
  } catch {
183
- return false
66
+ return ''
184
67
  }
185
68
  }
186
69
 
187
- let startingSensevoice = false
188
- async function startSensevoice() {
189
- if (startingSensevoice) return false
190
- startingSensevoice = true
191
- try {
192
- const cfg = live()
193
- if (!cfg.sensevoiceAutostart) return false
194
- if (!cfg.sensevoiceModel) return false
195
- if (await sensevoiceAlive()) {
196
- sensevoiceError = null
197
- return true
198
- }
199
- if (sensevoiceChild && typeof sensevoiceChild.kill === 'function') {
200
- try { sensevoiceChild.kill() } catch { /* ignore */ }
201
- sensevoiceChild = null
202
- }
203
- let port = '6006'
204
- try { port = new URL(cfg.sensevoiceUrl).port || '6006' } catch { /* invalid URL */ }
205
- const cmdArgs = buildSensevoiceArgs(cfg, port)
206
- const commandStr = `${JSON.stringify(cfg.sensevoiceBin)}${cmdArgs}`
207
- const specOpts = { command: commandStr, timeoutMs: 0, stdoutMaxBytes: 4 * 1024 * 1024 }
208
- const spec = typeof ctx.shell?.resolve === 'function' ? ctx.shell.resolve(specOpts) : specOpts
209
- sensevoiceChild = typeof ctx.shell?.execute === 'function'
210
- ? ctx.shell.execute(spec)
211
- : typeof ctx.shell?.start === 'function'
212
- ? ctx.shell.start(spec)
213
- : null
214
- if (sensevoiceChild && typeof sensevoiceChild.on === 'function') {
215
- sensevoiceChild.on('error', (err) => {
216
- sensevoiceError = err?.message || String(err)
217
- })
218
- sensevoiceChild.on('exit', (code) => {
219
- if (code !== 0 && code !== null) {
220
- sensevoiceError = `sensevoice process exited with code ${code}`
221
- }
222
- })
223
- }
224
- if (sensevoiceChild && sensevoiceChild.done && typeof sensevoiceChild.done.then === 'function') {
225
- sensevoiceChild.done.then((res) => {
226
- if (res && res.code !== 0 && res.code !== null && res.code !== undefined) {
227
- sensevoiceError = `sensevoice process exited with code ${res.code}`
228
- }
229
- }).catch((err) => {
230
- sensevoiceError = err?.message || String(err)
231
- })
232
- }
233
- // Wait for it to become healthy (up to 30s).
234
- for (let i = 0; i < 60; i++) {
235
- await new Promise((r) => setTimeout(r, 500))
236
- if (await sensevoiceAlive()) {
237
- sensevoiceError = null
238
- return true
239
- }
240
- if (sensevoiceChild && sensevoiceChild.exitCode !== null && sensevoiceChild.exitCode !== undefined) {
241
- sensevoiceError = `sensevoice process exited prematurely with code ${sensevoiceChild.exitCode}`
242
- return false
243
- }
244
- if (sensevoiceChild && (sensevoiceChild.status === 'exited' || sensevoiceChild.status === 'failed')) {
245
- sensevoiceError = `sensevoice process exited prematurely with status ${sensevoiceChild.status}`
246
- return false
247
- }
248
- }
249
- sensevoiceError = 'sensevoice process started but failed health check'
250
- return false
251
- } catch (err) {
252
- sensevoiceError = String(err && err.message ? err.message : err)
253
- return false
254
- }
255
- finally { startingSensevoice = false }
256
- }
70
+ const daemons = createLocalDaemons({ live, ctx })
71
+ const { whisperAlive, sensevoiceAlive, startWhisper, startSensevoice } = daemons
257
72
 
258
73
  function triggerAutostart() {
259
- startWhisper().catch((err) => {
260
- ctx.logger?.debug?.('whisper autostart failed: %s', err?.message || err)
261
- })
262
- startSensevoice().catch((err) => {
263
- ctx.logger?.debug?.('sensevoice autostart failed: %s', err?.message || err)
264
- })
74
+ daemons.triggerAutostart()
265
75
  }
266
76
 
77
+ ctx.effect(() => () => daemons.dispose(), 'dsh-voice: daemon cleanup')
78
+
267
79
  ctx.inject(['settings'], (sctx) => {
268
80
  settingsService = sctx.settings
269
81
  try {
@@ -281,11 +93,11 @@ export function apply(ctx, baseConfig) {
281
93
  triggerAutostart()
282
94
  })
283
95
 
284
- if (typeof ctx.on === "function") {
285
- if (typeof ctx.effect === "function") {
286
- ctx.effect(() => ctx.on('loader/volatile-update', () => triggerAutostart()), "dsh-voice: volatile autostart")
287
- ctx.effect(() => ctx.on('settings/document-updated', () => triggerAutostart()), "dsh-voice: settings autostart")
288
- ctx.effect(() => ctx.on('config', () => triggerAutostart()), "dsh-voice: config autostart")
96
+ if (typeof ctx.on === 'function') {
97
+ if (typeof ctx.effect === 'function') {
98
+ ctx.effect(() => ctx.on('loader/volatile-update', () => triggerAutostart()), 'dsh-voice: volatile autostart')
99
+ ctx.effect(() => ctx.on('settings/document-updated', () => triggerAutostart()), 'dsh-voice: settings autostart')
100
+ ctx.effect(() => ctx.on('config', () => triggerAutostart()), 'dsh-voice: config autostart')
289
101
  } else {
290
102
  ctx.on('loader/volatile-update', () => triggerAutostart())
291
103
  ctx.on('settings/document-updated', () => triggerAutostart())
@@ -295,10 +107,8 @@ export function apply(ctx, baseConfig) {
295
107
 
296
108
  triggerAutostart()
297
109
 
298
- // Provider health and latency stats (Latency & Health Dashboard).
299
110
  const statsTracker = createStatsTracker()
300
111
 
301
- // Shared recognition path: build providers from the mode chain and run it.
302
112
  async function transcribe(modeCfg, bytes, mime, signal, contextWords) {
303
113
  const cfg = live()
304
114
  const customKeys = (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
@@ -322,191 +132,28 @@ export function apply(ctx, baseConfig) {
322
132
  return runChain(order, providers, statsTracker.record)
323
133
  }
324
134
 
325
- // Transcript polish (#35) with local LLM support (#47).
326
- // Errors/timeouts do not block: return the raw text.
327
135
  const polishText = createPolishText({
328
136
  resolveKey,
329
137
  fetchImpl: (...args) => fetch(...args),
330
138
  getConfig: live,
331
139
  llm: ctx.llm,
332
140
  createUserMessage,
333
- getAgentDefaultModel: () => (ctx.get && ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection)
141
+ getAgentDefaultModel: () => (ctx.get && ctx.get('agentDefaultModel')?.currentSelection)
334
142
  ? ctx.get('agentDefaultModel').currentSelection()
335
143
  : null,
336
144
  })
337
145
 
338
- // sessionCommand imported from ./transcribe-core.js
339
-
340
- ctx.effect(() => ctx.webServer.register({
341
- kind: 'exact',
342
- path: '/dsh-voice/status',
343
- handler: async (req, res) => {
344
- if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
345
- const cfg = live()
346
- const whisperOk = await whisperAlive()
347
- const sensevoiceOk = await sensevoiceAlive()
348
- writeJson(res, 200, {
349
- ok: true,
350
- whisperRunning: whisperOk,
351
- whisperError: whisperOk ? null : whisperError,
352
- sensevoiceRunning: sensevoiceOk,
353
- sensevoiceError: sensevoiceOk ? null : sensevoiceError,
354
- // The hotkey is needed by the browser half: it installs the hold handler.
355
- hotkey: cfg.hotkey,
356
- providers: KNOWN_KEYS.concat(
357
- (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
358
- .map((c) => String(c && c.key || '').trim()).filter(Boolean),
359
- ),
360
- modes: {
361
- dictation: {
362
- chain: cfg.dictation.chain, language: cfg.dictation.language,
363
- vadSilenceMs: cfg.dictation.vadSilenceMs,
364
- sendDelayMs: cfg.dictation.sendDelayMs, polish: cfg.dictation.polish,
365
- stream: cfg.dictation.stream, streamChunkMs: cfg.dictation.streamChunkMs,
366
- vadAdapt: cfg.dictation.vadAdapt,
367
- },
368
- message: {
369
- chain: cfg.message.chain, language: cfg.message.language,
370
- autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
371
- sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend,
372
- },
373
- },
374
- beep: cfg.beep,
375
- localOnly: cfg.localOnly,
376
- micDeviceId: cfg.micDeviceId,
377
- noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
378
- historyLimit: cfg.historyLimit,
379
- voiceCommands: cfg.voiceCommands,
380
- wakeWord: String(cfg.wakeWord || ''),
381
- bargeIn: !!cfg.bargeIn,
382
- polishBaseUrl: String(cfg.polishBaseUrl || ''),
383
- noiseSuppression: cfg.noiseSuppression !== false,
384
- contextGlossary: cfg.contextGlossary !== false,
385
- sensevoiceUrl: cfg.sensevoiceUrl,
386
- sensevoiceAutostart: !!cfg.sensevoiceAutostart,
387
- sensevoiceProvider: cfg.sensevoiceProvider || 'cpu',
388
- sensevoiceThreads: cfg.sensevoiceThreads || 4,
389
- webgpuModel: cfg.webgpuModel || 'onnx-community/whisper-tiny',
390
- deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
391
- visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
392
- gatedTurnTaking: cfg.gatedTurnTaking !== false,
393
- autoSendVisualRing: cfg.autoSendVisualRing !== false,
394
- voiceActions: cfg.voiceActions !== false,
395
- techJargonCorrection: cfg.techJargonCorrection !== false,
396
- jargonDictionary: Array.isArray(cfg.jargonDictionary) ? cfg.jargonDictionary : [],
397
- liveInterimPreview: cfg.liveInterimPreview !== false,
398
- structuredPromptVoice: cfg.structuredPromptVoice !== false,
399
- providerStats: statsTracker.get(),
400
- })
401
- },
402
- }), 'dsh-voice: /status route')
403
-
404
- ctx.effect(() => ctx.webServer.register({
405
- kind: 'exact',
406
- path: '/dsh-voice/polish',
407
- handler: async (req, res) => {
408
- if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
409
- if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
410
- const cfg = live()
411
- let raw
412
- try { raw = await readBody(req, 1024 * 1024) } catch (e) {
413
- if (e && (e.statusCode === 413 || e.message === 'body too large')) {
414
- writeJson(res, 413, { ok: false, error: { code: 'too-large', message: 'body exceeds limit of 1MB' } })
415
- return
416
- }
417
- writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
418
- return
419
- }
420
- let payload = {}
421
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
422
- const text = typeof payload.text === 'string' ? payload.text.trim() : ''
423
- if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
424
- const controller = new AbortController()
425
- const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
426
- try {
427
- const out = await polishText(text, { polish: true }, controller.signal)
428
- writeJson(res, 200, { ok: true, text: out })
429
- } catch (e) {
430
- writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
431
- } finally { clearTimeout(timer) }
432
- },
433
- }), 'dsh-voice: /polish route')
434
-
435
- ctx.effect(() => ctx.webServer.register({
436
- kind: 'exact',
437
- path: '/dsh-voice/transcribe',
438
- handler: async (req, res) => {
439
- if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
440
- if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
441
- const cfg = live()
442
- const maxBodyBytes = Math.ceil((cfg.maxFileBytes * 4) / 3) + 64 * 1024
443
- let raw
444
- try {
445
- raw = await readBody(req, maxBodyBytes)
446
- } catch (e) {
447
- if (e && e.message === 'body too large') {
448
- writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio body exceeds limit for max ${cfg.maxFileBytes} bytes` } })
449
- return
450
- }
451
- writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
452
- }
453
- let payload
454
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
455
-
456
- const dataBase64 = typeof payload.dataBase64 === 'string' ? payload.dataBase64 : ''
457
- if (!dataBase64) { writeJson(res, 400, { ok: false, error: { code: 'no-audio', message: 'no audio data' } }); return }
458
- const mime = typeof payload.mimeType === 'string' && payload.mimeType ? payload.mimeType : 'audio/webm'
459
- const modeCfg = payload.mode === 'message' ? cfg.message : cfg.dictation
460
-
461
- let bytes
462
- try { bytes = Buffer.from(dataBase64, 'base64') } catch { bytes = null }
463
- if (!bytes || bytes.length === 0) {
464
- writeJson(res, 400, { ok: false, error: { code: 'decode', message: 'failed to decode audio' } }); return
465
- }
466
- if (bytes.length > cfg.maxFileBytes) {
467
- writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio is ${bytes.length} bytes, max ${cfg.maxFileBytes}` } }); return
468
- }
469
-
470
- // Local whisper in the chain — start the server up front, otherwise the
471
- // first chunk fails while the server is still booting.
472
- if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
473
- await startWhisper()
474
- }
475
- // Same for SenseVoice-ONNX / Sherpa-ONNX.
476
- if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
477
- await startSensevoice()
478
- }
479
-
480
- const controller = new AbortController()
481
- const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
482
- try {
483
- const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
484
- // Session voice commands (#48): clean "send/cancel/stop/continue"
485
- // become a command for the browser instead of composer text.
486
- if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
487
- const cmd = sessionCommand(out.text)
488
- if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
489
- }
490
- let text = await polishText(out.text, modeCfg, controller.signal)
491
- if (cfg.techJargonCorrection !== false) {
492
- text = applyJargonDictionary(text, cfg.jargonDictionary)
493
- }
494
- let action = null
495
- if (cfg.voiceActions !== false) {
496
- const actionRes = extractVoiceActions(text)
497
- if (actionRes.action) {
498
- text = actionRes.text
499
- action = actionRes.action
500
- }
501
- }
502
- writeJson(res, 200, { ok: true, text, action, provider: out.provider, tookMs: out.tookMs })
503
- } catch (e) {
504
- writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
505
- } finally {
506
- clearTimeout(timer)
507
- }
508
- },
509
- }), 'dsh-voice: /transcribe route')
146
+ registerHostRoutes(ctx, {
147
+ live,
148
+ daemons,
149
+ statsTracker,
150
+ transcribe,
151
+ polishText,
152
+ sessionCommand,
153
+ extractVoiceActions,
154
+ applyJargonDictionary,
155
+ KNOWN_KEYS,
156
+ })
510
157
 
511
158
  // Registers agent tool name: 'transcribe_audio'
512
159
  ctx.effect(() => registerTranscribeAudioTool(ctx, {
@@ -516,11 +163,6 @@ export function apply(ctx, baseConfig) {
516
163
  polishText,
517
164
  }), 'dsh-voice: transcribe_audio tool')
518
165
 
519
- ctx.effect(() => () => {
520
- if (child) { try { child.kill && child.kill() } catch { /* already dead */ } }
521
- if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* already dead */ } }
522
- }, 'dsh-voice: stop child processes')
523
-
524
166
  ctx.effect(() => registerPluginUpdater(ctx, {
525
167
  endpoint: '/api/dsh-voice/update',
526
168
  packageName: '@goodandready/dsh-voice',
@@ -531,42 +173,19 @@ export function apply(ctx, baseConfig) {
531
173
  liveConfig: live,
532
174
  isAlive: sensevoiceAlive,
533
175
  updateConfig: async (patch) => {
534
- let saved = false
535
- const current = live()
536
- const merged = { ...current, ...patch }
176
+ const merged = { ...live(), ...patch }
537
177
  if (settingsService) {
538
178
  let rev
539
- try {
540
- rev = settingsService.describe?.()?.find?.((r) => r.ns === NS)?.revision
541
- } catch { /* ignore revision lookup errors */ }
542
-
543
- if (typeof settingsService.replace === 'function') {
544
- await settingsService.replace(NS, merged, rev)
545
- saved = true
546
- } else if (typeof settingsService.update === 'function') {
547
- await settingsService.update(NS, merged, rev)
548
- saved = true
549
- }
550
- }
551
-
552
- if (scope) {
553
- if (typeof scope.replace === 'function') {
554
- await scope.replace(merged)
555
- saved = true
556
- } else if (typeof scope.patch === 'function') {
557
- await scope.patch(merged)
558
- saved = true
559
- } else if (typeof scope.set === 'function') {
560
- for (const [k, v] of Object.entries(merged)) {
561
- await scope.set(k, v).catch(() => {})
562
- }
563
- saved = true
179
+ try { rev = settingsService.describe?.()?.find?.((r) => r.ns === NS)?.revision } catch {}
180
+ if (typeof settingsService.replace === 'function') await settingsService.replace(NS, merged, rev)
181
+ else if (typeof settingsService.update === 'function') await settingsService.update(NS, merged, rev)
182
+ } else if (scope) {
183
+ if (typeof scope.replace === 'function') await scope.replace(merged)
184
+ else if (typeof scope.patch === 'function') await scope.patch(merged)
185
+ else if (typeof scope.set === 'function') {
186
+ for (const [k, v] of Object.entries(merged)) await scope.set(k, v).catch(() => {})
564
187
  }
565
188
  }
566
-
567
- if (!saved) {
568
- throw new Error('failed to persist settings: neither settingsService nor scope available')
569
- }
570
189
  },
571
190
  startSensevoice,
572
191
  }), 'dsh-voice: sensevoice installer route')
@@ -580,4 +199,3 @@ export function apply(ctx, baseConfig) {
580
199
  triggerAutostart,
581
200
  }), 'dsh-voice: config route')
582
201
  }
583
-