@goodandready/dsh-voice 0.9.17 → 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.
@@ -0,0 +1,186 @@
1
+ // lib/host-routes.js — Host HTTP routes for dsh-voice status, polish, and transcribe
2
+ import { writeJson, readBody, isTrustedCaller } from './http-util.js'
3
+
4
+ export function registerHostRoutes(ctx, {
5
+ live,
6
+ daemons,
7
+ statsTracker,
8
+ transcribe,
9
+ polishText,
10
+ sessionCommand,
11
+ extractVoiceActions,
12
+ applyJargonDictionary,
13
+ KNOWN_KEYS,
14
+ }) {
15
+ const { whisperAlive, sensevoiceAlive, getWhisperError, getSensevoiceError, startWhisper, startSensevoice } = daemons
16
+
17
+ ctx.effect(() => ctx.webServer.register({
18
+ kind: 'exact',
19
+ path: '/dsh-voice/status',
20
+ handler: async (req, res) => {
21
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
22
+ const cfg = live()
23
+ const whisperOk = await whisperAlive()
24
+ const sensevoiceOk = await sensevoiceAlive()
25
+ const whisperError = whisperOk ? null : getWhisperError()
26
+ const sensevoiceError = sensevoiceOk ? null : getSensevoiceError()
27
+ writeJson(res, 200, {
28
+ ok: true,
29
+ whisperRunning: whisperOk,
30
+ whisperError: whisperOk ? null : whisperError,
31
+ sensevoiceRunning: sensevoiceOk,
32
+ sensevoiceError: sensevoiceOk ? null : sensevoiceError,
33
+ hotkey: cfg.hotkey,
34
+ providers: KNOWN_KEYS.concat(
35
+ (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
36
+ .map((c) => String(c && c.key || '').trim()).filter(Boolean),
37
+ ),
38
+ modes: {
39
+ dictation: {
40
+ chain: cfg.dictation.chain, language: cfg.dictation.language,
41
+ vadSilenceMs: cfg.dictation.vadSilenceMs,
42
+ sendDelayMs: cfg.dictation.sendDelayMs, polish: cfg.dictation.polish,
43
+ stream: cfg.dictation.stream, streamChunkMs: cfg.dictation.streamChunkMs,
44
+ vadAdapt: cfg.dictation.vadAdapt,
45
+ },
46
+ message: {
47
+ chain: cfg.message.chain, language: cfg.message.language,
48
+ autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
49
+ sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend,
50
+ },
51
+ },
52
+ beep: cfg.beep,
53
+ localOnly: cfg.localOnly,
54
+ micDeviceId: cfg.micDeviceId,
55
+ noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
56
+ historyLimit: cfg.historyLimit,
57
+ voiceCommands: cfg.voiceCommands,
58
+ wakeWord: String(cfg.wakeWord || ''),
59
+ bargeIn: !!cfg.bargeIn,
60
+ polishBaseUrl: String(cfg.polishBaseUrl || ''),
61
+ noiseSuppression: cfg.noiseSuppression !== false,
62
+ contextGlossary: cfg.contextGlossary !== false,
63
+ sensevoiceUrl: cfg.sensevoiceUrl,
64
+ sensevoiceAutostart: !!cfg.sensevoiceAutostart,
65
+ sensevoiceProvider: cfg.sensevoiceProvider || 'cpu',
66
+ sensevoiceThreads: cfg.sensevoiceThreads || 4,
67
+ webgpuModel: cfg.webgpuModel || 'onnx-community/whisper-tiny',
68
+ deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
69
+ visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
70
+ gatedTurnTaking: cfg.gatedTurnTaking !== false,
71
+ autoSendVisualRing: cfg.autoSendVisualRing !== false,
72
+ voiceActions: cfg.voiceActions !== false,
73
+ techJargonCorrection: cfg.techJargonCorrection !== false,
74
+ jargonDictionary: Array.isArray(cfg.jargonDictionary) ? cfg.jargonDictionary : [],
75
+ liveInterimPreview: cfg.liveInterimPreview !== false,
76
+ structuredPromptVoice: cfg.structuredPromptVoice !== false,
77
+ providerStats: statsTracker.get(),
78
+ })
79
+ },
80
+ }), 'dsh-voice: /status route')
81
+
82
+ ctx.effect(() => ctx.webServer.register({
83
+ kind: 'exact',
84
+ path: '/dsh-voice/polish',
85
+ handler: async (req, res) => {
86
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
87
+ if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
88
+ const cfg = live()
89
+ let raw
90
+ try { raw = await readBody(req, 1024 * 1024) } catch (e) {
91
+ if (e && (e.statusCode === 413 || e.message === 'body too large')) {
92
+ writeJson(res, 413, { ok: false, error: { code: 'too-large', message: 'body exceeds limit of 1MB' } })
93
+ return
94
+ }
95
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
96
+ return
97
+ }
98
+ let payload = {}
99
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
100
+ const text = typeof payload.text === 'string' ? payload.text.trim() : ''
101
+ if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
102
+ const controller = new AbortController()
103
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
104
+ try {
105
+ const out = await polishText(text, { polish: true }, controller.signal)
106
+ writeJson(res, 200, { ok: true, text: out })
107
+ } catch (e) {
108
+ writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
109
+ } finally { clearTimeout(timer) }
110
+ },
111
+ }), 'dsh-voice: /polish route')
112
+
113
+ ctx.effect(() => ctx.webServer.register({
114
+ kind: 'exact',
115
+ path: '/dsh-voice/transcribe',
116
+ handler: async (req, res) => {
117
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
118
+ if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
119
+ const cfg = live()
120
+ const maxBodyBytes = Math.ceil((cfg.maxFileBytes * 4) / 3) + 64 * 1024
121
+ let raw
122
+ try {
123
+ raw = await readBody(req, maxBodyBytes)
124
+ } catch (e) {
125
+ if (e && e.message === 'body too large') {
126
+ writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio body exceeds limit for max ${cfg.maxFileBytes} bytes` } })
127
+ return
128
+ }
129
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
130
+ }
131
+ let payload
132
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
133
+
134
+ const dataBase64 = typeof payload.dataBase64 === 'string' ? payload.dataBase64 : ''
135
+ if (!dataBase64) { writeJson(res, 400, { ok: false, error: { code: 'no-audio', message: 'no audio data' } }); return }
136
+ const mime = typeof payload.mimeType === 'string' && payload.mimeType ? payload.mimeType : 'audio/webm'
137
+ const baseModeCfg = payload.mode === 'message' ? cfg.message : cfg.dictation
138
+ const modeCfg = Array.isArray(payload.chain)
139
+ ? { ...baseModeCfg, chain: payload.chain }
140
+ : baseModeCfg
141
+
142
+ let bytes
143
+ try { bytes = Buffer.from(dataBase64, 'base64') } catch { bytes = null }
144
+ if (!bytes || bytes.length === 0) {
145
+ writeJson(res, 400, { ok: false, error: { code: 'decode', message: 'failed to decode audio' } }); return
146
+ }
147
+ if (bytes.length > cfg.maxFileBytes) {
148
+ writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio is ${bytes.length} bytes, max ${cfg.maxFileBytes}` } }); return
149
+ }
150
+
151
+ if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
152
+ await startWhisper()
153
+ }
154
+ if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
155
+ await startSensevoice()
156
+ }
157
+
158
+ const controller = new AbortController()
159
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
160
+ try {
161
+ const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
162
+ if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
163
+ const cmd = sessionCommand(out.text)
164
+ if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
165
+ }
166
+ let text = await polishText(out.text, modeCfg, controller.signal)
167
+ if (cfg.techJargonCorrection !== false) {
168
+ text = applyJargonDictionary(text, cfg.jargonDictionary)
169
+ }
170
+ let action = null
171
+ if (cfg.voiceActions !== false) {
172
+ const actionRes = extractVoiceActions(text)
173
+ if (actionRes.action) {
174
+ text = actionRes.text
175
+ action = actionRes.action
176
+ }
177
+ }
178
+ writeJson(res, 200, { ok: true, text, action, provider: out.provider, tookMs: out.tookMs })
179
+ } catch (e) {
180
+ writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
181
+ } finally {
182
+ clearTimeout(timer)
183
+ }
184
+ },
185
+ }), 'dsh-voice: /transcribe route')
186
+ }
package/lib/index.js CHANGED
@@ -1,55 +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
- import { createLocalDaemons } from "./local-daemon.js"
35
28
  export { ChainEntry, CustomProvider, BaseConfig }
36
-
37
29
  export const name = '@goodandready/dsh-voice'
38
30
  export const NS = 'dsh-voice'
39
31
  export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings', 'llm']
40
32
 
41
33
  function makeVolatile(schema) {
42
- if (!schema || typeof schema.volatile !== 'function') return schema
43
- if (schema.type === 'object' && schema.dict) {
44
- const dict = {}
45
- for (const [k, v] of Object.entries(schema.dict)) {
46
- dict[k] = makeVolatile(v)
47
- }
48
- const res = z.object(dict)
49
- if (schema.meta) Object.assign(res.meta, schema.meta)
50
- 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)
51
42
  }
52
- return schema.volatile()
43
+ return res
53
44
  }
54
45
 
55
46
  export const Config = typeof z.number().volatile === 'function'
@@ -57,30 +48,27 @@ export const Config = typeof z.number().volatile === 'function'
57
48
  : BaseConfig
58
49
 
59
50
  export function apply(ctx, baseConfig) {
60
- let child = null
51
+ let settingsService = null
52
+ let scope = null
53
+ let getConfig = () => baseConfig
61
54
 
62
- // The settings card edits a namespace named after the plugin.
63
- // Using createConfigReader decodes volatile nodes on modern DSH cores
64
- // while caching unwrapped values across requests.
65
55
  const readEntryConfig = createConfigReader(baseConfig, ctx)
66
- let scope = null
67
- let settingsService = null
68
- let getConfig = readEntryConfig
56
+ getConfig = readEntryConfig
57
+
69
58
  const live = () => BaseConfig(structuredClone(getConfig() ?? {})) ?? readEntryConfig()
70
59
 
71
- async function resolveKey(ref) {
60
+ const resolveKey = async (ref) => {
61
+ if (!ref || !ref.type) return ''
72
62
  try {
73
63
  const resolved = await ctx.credentials.resolve(credentialRef(ref))
74
- if (resolved && resolved.value) return resolved.value
75
- } catch { /* fall through to environment */ }
76
- return process.env[ref] || ''
64
+ return resolved || ''
65
+ } catch {
66
+ return ''
67
+ }
77
68
  }
78
69
 
79
70
  const daemons = createLocalDaemons({ live, ctx })
80
- const {
81
- whisperAlive, startWhisper, getWhisperError,
82
- sensevoiceAlive, startSensevoice, getSensevoiceError,
83
- } = daemons
71
+ const { whisperAlive, sensevoiceAlive, startWhisper, startSensevoice } = daemons
84
72
 
85
73
  function triggerAutostart() {
86
74
  daemons.triggerAutostart()
@@ -105,11 +93,11 @@ export function apply(ctx, baseConfig) {
105
93
  triggerAutostart()
106
94
  })
107
95
 
108
- if (typeof ctx.on === "function") {
109
- if (typeof ctx.effect === "function") {
110
- ctx.effect(() => ctx.on('loader/volatile-update', () => triggerAutostart()), "dsh-voice: volatile autostart")
111
- ctx.effect(() => ctx.on('settings/document-updated', () => triggerAutostart()), "dsh-voice: settings autostart")
112
- 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')
113
101
  } else {
114
102
  ctx.on('loader/volatile-update', () => triggerAutostart())
115
103
  ctx.on('settings/document-updated', () => triggerAutostart())
@@ -119,10 +107,8 @@ export function apply(ctx, baseConfig) {
119
107
 
120
108
  triggerAutostart()
121
109
 
122
- // Provider health and latency stats (Latency & Health Dashboard).
123
110
  const statsTracker = createStatsTracker()
124
111
 
125
- // Shared recognition path: build providers from the mode chain and run it.
126
112
  async function transcribe(modeCfg, bytes, mime, signal, contextWords) {
127
113
  const cfg = live()
128
114
  const customKeys = (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
@@ -146,193 +132,28 @@ export function apply(ctx, baseConfig) {
146
132
  return runChain(order, providers, statsTracker.record)
147
133
  }
148
134
 
149
- // Transcript polish (#35) with local LLM support (#47).
150
- // Errors/timeouts do not block: return the raw text.
151
135
  const polishText = createPolishText({
152
136
  resolveKey,
153
137
  fetchImpl: (...args) => fetch(...args),
154
138
  getConfig: live,
155
139
  llm: ctx.llm,
156
140
  createUserMessage,
157
- getAgentDefaultModel: () => (ctx.get && ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection)
141
+ getAgentDefaultModel: () => (ctx.get && ctx.get('agentDefaultModel')?.currentSelection)
158
142
  ? ctx.get('agentDefaultModel').currentSelection()
159
143
  : null,
160
144
  })
161
145
 
162
- // sessionCommand imported from ./transcribe-core.js
163
-
164
- ctx.effect(() => ctx.webServer.register({
165
- kind: 'exact',
166
- path: '/dsh-voice/status',
167
- handler: async (req, res) => {
168
- if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
169
- const cfg = live()
170
- const whisperOk = await whisperAlive()
171
- const sensevoiceOk = await sensevoiceAlive()
172
- const whisperError = whisperOk ? null : getWhisperError()
173
- const sensevoiceError = sensevoiceOk ? null : getSensevoiceError()
174
- writeJson(res, 200, {
175
- ok: true,
176
- whisperRunning: whisperOk,
177
- whisperError: whisperOk ? null : whisperError,
178
- sensevoiceRunning: sensevoiceOk,
179
- sensevoiceError: sensevoiceOk ? null : sensevoiceError,
180
- // The hotkey is needed by the browser half: it installs the hold handler.
181
- hotkey: cfg.hotkey,
182
- providers: KNOWN_KEYS.concat(
183
- (Array.isArray(cfg.customProviders) ? cfg.customProviders : [])
184
- .map((c) => String(c && c.key || '').trim()).filter(Boolean),
185
- ),
186
- modes: {
187
- dictation: {
188
- chain: cfg.dictation.chain, language: cfg.dictation.language,
189
- vadSilenceMs: cfg.dictation.vadSilenceMs,
190
- sendDelayMs: cfg.dictation.sendDelayMs, polish: cfg.dictation.polish,
191
- stream: cfg.dictation.stream, streamChunkMs: cfg.dictation.streamChunkMs,
192
- vadAdapt: cfg.dictation.vadAdapt,
193
- },
194
- message: {
195
- chain: cfg.message.chain, language: cfg.message.language,
196
- autoSendMs: cfg.message.autoSendMs, polish: cfg.message.polish,
197
- sessionCommands: cfg.message.sessionCommands, polishSend: cfg.message.polishSend,
198
- },
199
- },
200
- beep: cfg.beep,
201
- localOnly: cfg.localOnly,
202
- micDeviceId: cfg.micDeviceId,
203
- noiseGateDb: cfg.noiseGateDb !== undefined ? cfg.noiseGateDb : -45,
204
- historyLimit: cfg.historyLimit,
205
- voiceCommands: cfg.voiceCommands,
206
- wakeWord: String(cfg.wakeWord || ''),
207
- bargeIn: !!cfg.bargeIn,
208
- polishBaseUrl: String(cfg.polishBaseUrl || ''),
209
- noiseSuppression: cfg.noiseSuppression !== false,
210
- contextGlossary: cfg.contextGlossary !== false,
211
- sensevoiceUrl: cfg.sensevoiceUrl,
212
- sensevoiceAutostart: !!cfg.sensevoiceAutostart,
213
- sensevoiceProvider: cfg.sensevoiceProvider || 'cpu',
214
- sensevoiceThreads: cfg.sensevoiceThreads || 4,
215
- webgpuModel: cfg.webgpuModel || 'onnx-community/whisper-tiny',
216
- deepgramBaseUrl: cfg.deepgramBaseUrl || 'https://api.deepgram.com',
217
- visualizerStyle: cfg.visualizerStyle || 'liquid-wave',
218
- gatedTurnTaking: cfg.gatedTurnTaking !== false,
219
- autoSendVisualRing: cfg.autoSendVisualRing !== false,
220
- voiceActions: cfg.voiceActions !== false,
221
- techJargonCorrection: cfg.techJargonCorrection !== false,
222
- jargonDictionary: Array.isArray(cfg.jargonDictionary) ? cfg.jargonDictionary : [],
223
- liveInterimPreview: cfg.liveInterimPreview !== false,
224
- structuredPromptVoice: cfg.structuredPromptVoice !== false,
225
- providerStats: statsTracker.get(),
226
- })
227
- },
228
- }), 'dsh-voice: /status route')
229
-
230
- ctx.effect(() => ctx.webServer.register({
231
- kind: 'exact',
232
- path: '/dsh-voice/polish',
233
- handler: async (req, res) => {
234
- if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
235
- if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
236
- const cfg = live()
237
- let raw
238
- try { raw = await readBody(req, 1024 * 1024) } catch (e) {
239
- if (e && (e.statusCode === 413 || e.message === 'body too large')) {
240
- writeJson(res, 413, { ok: false, error: { code: 'too-large', message: 'body exceeds limit of 1MB' } })
241
- return
242
- }
243
- writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
244
- return
245
- }
246
- let payload = {}
247
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
248
- const text = typeof payload.text === 'string' ? payload.text.trim() : ''
249
- if (!text) { writeJson(res, 400, { ok: false, error: { code: 'empty', message: 'text required' } }); return }
250
- const controller = new AbortController()
251
- const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
252
- try {
253
- const out = await polishText(text, { polish: true }, controller.signal)
254
- writeJson(res, 200, { ok: true, text: out })
255
- } catch (e) {
256
- writeJson(res, 502, { ok: false, error: { code: 'polish', message: String(e && e.message || e) } })
257
- } finally { clearTimeout(timer) }
258
- },
259
- }), 'dsh-voice: /polish route')
260
-
261
- ctx.effect(() => ctx.webServer.register({
262
- kind: 'exact',
263
- path: '/dsh-voice/transcribe',
264
- handler: async (req, res) => {
265
- if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
266
- if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
267
- const cfg = live()
268
- const maxBodyBytes = Math.ceil((cfg.maxFileBytes * 4) / 3) + 64 * 1024
269
- let raw
270
- try {
271
- raw = await readBody(req, maxBodyBytes)
272
- } catch (e) {
273
- if (e && e.message === 'body too large') {
274
- writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio body exceeds limit for max ${cfg.maxFileBytes} bytes` } })
275
- return
276
- }
277
- writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
278
- }
279
- let payload
280
- try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
281
-
282
- const dataBase64 = typeof payload.dataBase64 === 'string' ? payload.dataBase64 : ''
283
- if (!dataBase64) { writeJson(res, 400, { ok: false, error: { code: 'no-audio', message: 'no audio data' } }); return }
284
- const mime = typeof payload.mimeType === 'string' && payload.mimeType ? payload.mimeType : 'audio/webm'
285
- const modeCfg = payload.mode === 'message' ? cfg.message : cfg.dictation
286
-
287
- let bytes
288
- try { bytes = Buffer.from(dataBase64, 'base64') } catch { bytes = null }
289
- if (!bytes || bytes.length === 0) {
290
- writeJson(res, 400, { ok: false, error: { code: 'decode', message: 'failed to decode audio' } }); return
291
- }
292
- if (bytes.length > cfg.maxFileBytes) {
293
- writeJson(res, 413, { ok: false, error: { code: 'too-large', message: `audio is ${bytes.length} bytes, max ${cfg.maxFileBytes}` } }); return
294
- }
295
-
296
- // Local whisper in the chain — start the server up front, otherwise the
297
- // first chunk fails while the server is still booting.
298
- if ((modeCfg.chain || []).some((e) => e.provider === 'local-whisper') && !(await whisperAlive())) {
299
- await startWhisper()
300
- }
301
- // Same for SenseVoice-ONNX / Sherpa-ONNX.
302
- if ((modeCfg.chain || []).some((e) => e.provider === 'sensevoice') && !(await sensevoiceAlive())) {
303
- await startSensevoice()
304
- }
305
-
306
- const controller = new AbortController()
307
- const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
308
- try {
309
- const out = await transcribe(modeCfg, bytes, mime, controller.signal, payload.contextWords)
310
- // Session voice commands (#48): clean "send/cancel/stop/continue"
311
- // become a command for the browser instead of composer text.
312
- if (payload.mode === 'message' && modeCfg.sessionCommands === true) {
313
- const cmd = sessionCommand(out.text)
314
- if (cmd) { writeJson(res, 200, { ok: true, command: cmd, provider: out.provider, tookMs: out.tookMs }); return }
315
- }
316
- let text = await polishText(out.text, modeCfg, controller.signal)
317
- if (cfg.techJargonCorrection !== false) {
318
- text = applyJargonDictionary(text, cfg.jargonDictionary)
319
- }
320
- let action = null
321
- if (cfg.voiceActions !== false) {
322
- const actionRes = extractVoiceActions(text)
323
- if (actionRes.action) {
324
- text = actionRes.text
325
- action = actionRes.action
326
- }
327
- }
328
- writeJson(res, 200, { ok: true, text, action, provider: out.provider, tookMs: out.tookMs })
329
- } catch (e) {
330
- writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
331
- } finally {
332
- clearTimeout(timer)
333
- }
334
- },
335
- }), '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
+ })
336
157
 
337
158
  // Registers agent tool name: 'transcribe_audio'
338
159
  ctx.effect(() => registerTranscribeAudioTool(ctx, {
@@ -342,11 +163,6 @@ export function apply(ctx, baseConfig) {
342
163
  polishText,
343
164
  }), 'dsh-voice: transcribe_audio tool')
344
165
 
345
- ctx.effect(() => () => {
346
- if (child) { try { child.kill && child.kill() } catch { /* already dead */ } }
347
- if (sensevoiceChild) { try { sensevoiceChild.kill && sensevoiceChild.kill() } catch { /* already dead */ } }
348
- }, 'dsh-voice: stop child processes')
349
-
350
166
  ctx.effect(() => registerPluginUpdater(ctx, {
351
167
  endpoint: '/api/dsh-voice/update',
352
168
  packageName: '@goodandready/dsh-voice',
@@ -357,42 +173,19 @@ export function apply(ctx, baseConfig) {
357
173
  liveConfig: live,
358
174
  isAlive: sensevoiceAlive,
359
175
  updateConfig: async (patch) => {
360
- let saved = false
361
- const current = live()
362
- const merged = { ...current, ...patch }
176
+ const merged = { ...live(), ...patch }
363
177
  if (settingsService) {
364
178
  let rev
365
- try {
366
- rev = settingsService.describe?.()?.find?.((r) => r.ns === NS)?.revision
367
- } catch { /* ignore revision lookup errors */ }
368
-
369
- if (typeof settingsService.replace === 'function') {
370
- await settingsService.replace(NS, merged, rev)
371
- saved = true
372
- } else if (typeof settingsService.update === 'function') {
373
- await settingsService.update(NS, merged, rev)
374
- saved = true
375
- }
376
- }
377
-
378
- if (scope) {
379
- if (typeof scope.replace === 'function') {
380
- await scope.replace(merged)
381
- saved = true
382
- } else if (typeof scope.patch === 'function') {
383
- await scope.patch(merged)
384
- saved = true
385
- } else if (typeof scope.set === 'function') {
386
- for (const [k, v] of Object.entries(merged)) {
387
- await scope.set(k, v).catch(() => {})
388
- }
389
- 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(() => {})
390
187
  }
391
188
  }
392
-
393
- if (!saved) {
394
- throw new Error('failed to persist settings: neither settingsService nor scope available')
395
- }
396
189
  },
397
190
  startSensevoice,
398
191
  }), 'dsh-voice: sensevoice installer route')
@@ -406,4 +199,3 @@ export function apply(ctx, baseConfig) {
406
199
  triggerAutostart,
407
200
  }), 'dsh-voice: config route')
408
201
  }
409
-