@goodandready/dsh-voice 0.8.36 → 0.8.38

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/client.js CHANGED
@@ -2166,7 +2166,10 @@ window.__ModuleLoader__.load({
2166
2166
  // writes are dropped. The server does not share that restriction, and
2167
2167
  // dsh-lanmode rebuilds the same mechanics on the same calls. Prefer its
2168
2168
  // service when installed, otherwise the kernel one (fine on loopback).
2169
- const scope = ((ctx.get && ctx.get('lanSettings')) || ctx.configForms).get(NS)
2169
+ const settingsService = (ctx.get && ctx.get('lanSettings')) || ctx.configForms || ctx.settingsScope
2170
+ const scope = settingsService && typeof settingsService.get === 'function'
2171
+ ? settingsService.get(NS)
2172
+ : (settingsService && typeof settingsService.bind === 'function' ? settingsService.bind({ namespace: NS }) : null)
2170
2173
  const [snap, setSnap] = React.useState(null)
2171
2174
  const [draft, setDraft] = React.useState(null)
2172
2175
  const [saved, setSaved] = React.useState(false)
@@ -2190,11 +2193,11 @@ window.__ModuleLoader__.load({
2190
2193
 
2191
2194
  React.useEffect(() => {
2192
2195
  let alive = true
2193
- const render = () => { if (alive) setSnap(scope.getSnapshot()) }
2196
+ const render = () => { if (alive && scope && typeof scope.getSnapshot === 'function') setSnap(scope.getSnapshot()) }
2194
2197
  render()
2195
- const off = scope.subscribe(render)
2198
+ const off = (scope && typeof scope.subscribe === 'function') ? scope.subscribe(render) : () => {}
2196
2199
  return () => { alive = false; off() }
2197
- }, [])
2200
+ }, [scope])
2198
2201
 
2199
2202
  const [devices, setDevices] = React.useState([])
2200
2203
  React.useEffect(() => {
@@ -2214,7 +2217,13 @@ window.__ModuleLoader__.load({
2214
2217
  const timer = setInterval(() => {
2215
2218
  if (tries >= 15) { clearInterval(timer); return }
2216
2219
  tries += 1
2217
- try { ctx.configForms.describe().load() } catch (e) { /* service not up yet */ }
2220
+ try {
2221
+ if (ctx.configForms && typeof ctx.configForms.describe === 'function') {
2222
+ ctx.configForms.describe().load()
2223
+ } else if (ctx.settingsScope && typeof ctx.settingsScope.describe === 'function') {
2224
+ ctx.settingsScope.describe().load()
2225
+ }
2226
+ } catch (e) { /* service not up yet */ }
2218
2227
  }, 1000)
2219
2228
  return () => clearInterval(timer)
2220
2229
  }, [ready])
@@ -3125,7 +3134,7 @@ window.__ModuleLoader__.load({
3125
3134
  },
3126
3135
  PluginCard,
3127
3136
  ))
3128
- // Row seat and the legacy card stay as fallbacks.
3137
+ // Row seat (plugins.row.config): rendered in the plugin row in Plugins table.
3129
3138
  ctx.slots.inject('plugins.row.config', () => ctx.slots.register(
3130
3139
  {
3131
3140
  name: 'plugins.row.config',
@@ -3135,6 +3144,7 @@ window.__ModuleLoader__.load({
3135
3144
  },
3136
3145
  PluginCard,
3137
3146
  ))
3147
+ // Legacy seat fallback
3138
3148
  ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
3139
3149
  {
3140
3150
  name: 'settings.plugin.item',
@@ -0,0 +1,43 @@
1
+ // Decode modern volatile Config references while retaining stable identities for
2
+ // the request hot path. Legacy cores pass ordinary resolved configuration.
3
+ // If ctx is provided, invalidation listeners cache the unwrapped tree so the hot
4
+ // path never recursively walks volatile nodes on unchanged configuration.
5
+ export function createConfigReader(config, ctx) {
6
+ let previous
7
+ let dirty = true
8
+ if (ctx && typeof ctx.on === 'function') {
9
+ const markDirty = () => { dirty = true }
10
+ if (typeof ctx.effect === 'function') {
11
+ ctx.effect(() => ctx.on('settings/document-updated', markDirty))
12
+ ctx.effect(() => ctx.on('loader/volatile-update', markDirty))
13
+ ctx.effect(() => ctx.on('config', markDirty))
14
+ } else {
15
+ ctx.on('settings/document-updated', markDirty)
16
+ ctx.on('loader/volatile-update', markDirty)
17
+ ctx.on('config', markDirty)
18
+ }
19
+ }
20
+
21
+ const read = (value, held) => {
22
+ if (value && typeof value.get === 'function') value = value.get()
23
+ if (!value || typeof value !== 'object') return value
24
+ const keys = Object.keys(value)
25
+ const next = Array.isArray(value) ? [] : {}
26
+ let same = held && typeof held === 'object' && Array.isArray(value) === Array.isArray(held)
27
+ && keys.length === Object.keys(held).length
28
+ for (const key of keys) {
29
+ Object.defineProperty(next, key, { value: read(value[key], held?.[key]), writable: true, enumerable: true, configurable: true })
30
+ if (!held || !Object.hasOwn(held, key) || !Object.is(next[key], held[key])) same = false
31
+ }
32
+ return same ? held : next
33
+ }
34
+
35
+ return () => {
36
+ if (ctx && !dirty && previous !== undefined) {
37
+ return previous
38
+ }
39
+ previous = read(config, previous)
40
+ dirty = false
41
+ return previous
42
+ }
43
+ }
package/lib/index.js CHANGED
@@ -28,6 +28,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
28
28
  import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
29
29
  import { registerPluginUpdater } from './updater.js'
30
30
  import { registerSensevoiceInstaller } from './sensevoice-installer.js'
31
+ import { createConfigReader } from './config-compat.js'
31
32
 
32
33
  function isAutoLang(lang) {
33
34
  return !lang || lang === 'auto' || String(lang).includes(',')
@@ -65,7 +66,21 @@ const CustomProvider = z.object({
65
66
  + 'Empty means the built-in one.'),
66
67
  })
67
68
 
68
- export const Config = z.object({
69
+ function makeVolatile(schema) {
70
+ if (!schema || typeof schema.volatile !== 'function') return schema
71
+ if (schema.type === 'object' && schema.dict) {
72
+ const dict = {}
73
+ for (const [k, v] of Object.entries(schema.dict)) {
74
+ dict[k] = makeVolatile(v)
75
+ }
76
+ const res = z.object(dict)
77
+ if (schema.meta) Object.assign(res.meta, schema.meta)
78
+ return res
79
+ }
80
+ return schema.volatile()
81
+ }
82
+
83
+ const BaseConfig = z.object({
69
84
  dictation: z.object({
70
85
  chain: z.array(ChainEntry)
71
86
  .default([{ provider: 'deepgram', model: '' }, { provider: 'groq', model: '' }, { provider: 'local-whisper', model: '' }])
@@ -181,6 +196,10 @@ export const Config = z.object({
181
196
  .description('Target voice dictation and numeric choices to active DSH structured prompt modals.'),
182
197
  })
183
198
 
199
+ export const Config = typeof z.number().volatile === 'function'
200
+ ? makeVolatile(BaseConfig)
201
+ : BaseConfig
202
+
184
203
  const MIME_BY_EXT = {
185
204
  '.wav': 'audio/wav', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.mp4': 'audio/mp4',
186
205
  '.ogg': 'audio/ogg', '.oga': 'audio/ogg', '.flac': 'audio/flac', '.webm': 'audio/webm', '.aac': 'audio/aac',
@@ -190,19 +209,13 @@ const MIME_BY_EXT = {
190
209
  export function apply(ctx, baseConfig) {
191
210
  let child = null
192
211
 
193
- // The settings card edits a namespace named after the plugin. Until the
194
- // host registers it via settings.register the snapshot is empty and
195
- // read-only. Reading through live() means edits apply to the next request
196
- // instead of requiring a process restart.
212
+ // The settings card edits a namespace named after the plugin.
213
+ // Using createConfigReader decodes volatile nodes on modern DSH cores
214
+ // while caching unwrapped values across requests.
215
+ const readEntryConfig = createConfigReader(baseConfig, ctx)
197
216
  let scope = null
198
- let getConfig = () => baseConfig
199
- const live = () => Config(structuredClone(getConfig() ?? {})) ?? baseConfig
200
-
201
- ctx.inject(['settings'], (sctx) => {
202
- scope = sctx.settings.register(NS, Config, { base: baseConfig })
203
- getConfig = () => scope.get() ?? baseConfig
204
- sctx.effect(() => () => { getConfig = () => baseConfig; scope = null })
205
- })
217
+ let getConfig = readEntryConfig
218
+ const live = () => BaseConfig(structuredClone(getConfig() ?? {})) ?? readEntryConfig()
206
219
 
207
220
  async function resolveKey(ref) {
208
221
  try {
@@ -246,12 +259,27 @@ export function apply(ctx, baseConfig) {
246
259
  stdoutMaxBytes: 4 * 1024 * 1024,
247
260
  })
248
261
  child = ctx.shell.start(spec)
249
- for (let i = 0; i < 20; i++) {
262
+ if (child && typeof child.on === 'function') {
263
+ child.on('error', (err) => {
264
+ whisperError = err?.message || String(err)
265
+ })
266
+ child.on('exit', (code) => {
267
+ if (code !== 0 && code !== null) {
268
+ whisperError = `whisper server process exited with code ${code}`
269
+ }
270
+ })
271
+ }
272
+ // Wait for it to become healthy (up to 30s for cold model loading on Metal/CPU).
273
+ for (let i = 0; i < 60; i++) {
250
274
  await new Promise((r) => setTimeout(r, 500))
251
275
  if (await whisperAlive()) {
252
276
  whisperError = null
253
277
  return true
254
278
  }
279
+ if (child && child.exitCode !== null && child.exitCode !== undefined) {
280
+ whisperError = `whisper server process exited prematurely with code ${child.exitCode}`
281
+ return false
282
+ }
255
283
  }
256
284
  whisperError = 'whisper server process started but failed health check on port 8001'
257
285
  return false
@@ -262,8 +290,6 @@ export function apply(ctx, baseConfig) {
262
290
  finally { startingWhisper = false }
263
291
  }
264
292
 
265
- startWhisper().catch(() => {})
266
-
267
293
  // SenseVoice-ONNX / Sherpa-ONNX: autostart and health check.
268
294
  let sensevoiceChild = null
269
295
  let sensevoiceError = null
@@ -302,12 +328,27 @@ export function apply(ctx, baseConfig) {
302
328
  stdoutMaxBytes: 4 * 1024 * 1024,
303
329
  })
304
330
  sensevoiceChild = ctx.shell.start(spec)
305
- for (let i = 0; i < 20; i++) {
331
+ if (sensevoiceChild && typeof sensevoiceChild.on === 'function') {
332
+ sensevoiceChild.on('error', (err) => {
333
+ sensevoiceError = err?.message || String(err)
334
+ })
335
+ sensevoiceChild.on('exit', (code) => {
336
+ if (code !== 0 && code !== null) {
337
+ sensevoiceError = `sensevoice process exited with code ${code}`
338
+ }
339
+ })
340
+ }
341
+ // Wait for it to become healthy (up to 30s).
342
+ for (let i = 0; i < 60; i++) {
306
343
  await new Promise((r) => setTimeout(r, 500))
307
344
  if (await sensevoiceAlive()) {
308
345
  sensevoiceError = null
309
346
  return true
310
347
  }
348
+ if (sensevoiceChild && sensevoiceChild.exitCode !== null && sensevoiceChild.exitCode !== undefined) {
349
+ sensevoiceError = `sensevoice process exited prematurely with code ${sensevoiceChild.exitCode}`
350
+ return false
351
+ }
311
352
  }
312
353
  sensevoiceError = 'sensevoice process started but failed health check'
313
354
  return false
@@ -318,7 +359,38 @@ export function apply(ctx, baseConfig) {
318
359
  finally { startingSensevoice = false }
319
360
  }
320
361
 
321
- startSensevoice().catch(() => {})
362
+ function triggerAutostart() {
363
+ startWhisper().catch((err) => {
364
+ ctx.logger?.debug?.('whisper autostart failed: %s', err?.message || err)
365
+ })
366
+ startSensevoice().catch((err) => {
367
+ ctx.logger?.debug?.('sensevoice autostart failed: %s', err?.message || err)
368
+ })
369
+ }
370
+
371
+ ctx.inject(['settings'], (sctx) => {
372
+ try {
373
+ scope = sctx.settings.register(NS, Config, { base: baseConfig })
374
+ if (scope && typeof scope.get === 'function') {
375
+ getConfig = () => scope.get() ?? readEntryConfig()
376
+ }
377
+ if (scope && typeof scope.watch === 'function') {
378
+ scope.watch(() => {
379
+ triggerAutostart()
380
+ })
381
+ }
382
+ } catch { /* modern DSH handles settings via SettingsForms */ }
383
+ sctx.effect(() => () => { getConfig = readEntryConfig; scope = null })
384
+ triggerAutostart()
385
+ })
386
+
387
+ if (typeof ctx.on === 'function') {
388
+ ctx.on('loader/volatile-update', () => triggerAutostart())
389
+ ctx.on('settings/document-updated', () => triggerAutostart())
390
+ ctx.on('config', () => triggerAutostart())
391
+ }
392
+
393
+ triggerAutostart()
322
394
 
323
395
  // Provider health and latency stats (Latency & Health Dashboard).
324
396
  const statsTracker = createStatsTracker()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.36",
3
+ "version": "0.8.38",
4
4
  "description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
5
5
  "license": "MIT",
6
6
  "type": "module",