@goodandready/dsh-voice 0.9.46 → 0.9.47

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/chain.js CHANGED
@@ -1,21 +1,18 @@
1
1
  // Provider fallback chain. No network and no cordis — a pure function so
2
2
  // failure behaviour is unit-testable.
3
-
4
- function normalizeError(err) {
5
- const cause = err?.cause?.message || err?.message || String(err)
6
- return cause.slice(0, 200)
7
- }
3
+ import { classifyProviderOutcome, shouldFallbackError, ChainExhaustedError } from './errors.js'
8
4
 
9
5
  /**
10
6
  * @param order {string[]} provider keys in attempt order
11
- * @param providers {Record<string, () => Promise<{ok, provider?, text?, reason?}>>}
12
- * @param onAttempt {(provider: string, res: {ok: boolean, tookMs: number, reason?: string}) => void} optional stats callback
7
+ * @param providers {Record<string, () => Promise<{ok, provider?, text?, reason?, code?, fallbackEligible?}>>}
8
+ * @param onAttempt {(provider: string, res: {ok: boolean, tookMs: number, reason?: string, code?: string}) => void} optional stats callback
13
9
  * @returns {Promise<{provider: string, text: string, tookMs: number}>}
14
10
  */
15
11
  export async function runChain(order, providers, onAttempt) {
16
12
  const t0 = Date.now()
17
13
  const keys = (Array.isArray(order) ? order : []).filter((k) => typeof providers[k] === 'function')
18
14
  const errors = []
15
+ const attempts = []
19
16
  for (const key of keys) {
20
17
  const startT = Date.now()
21
18
  try {
@@ -27,19 +24,55 @@ export async function runChain(order, providers, onAttempt) {
27
24
  }
28
25
  return { provider: out.provider || key, text: out.text, tookMs: Date.now() - t0 }
29
26
  }
30
- const reason = (out && out.reason) || 'unknown'
27
+ const classified = classifyProviderOutcome((out && out.provider) || key, out || { reason: 'unknown' })
28
+ const pName = classified.provider || key
31
29
  if (typeof onAttempt === 'function') {
32
- onAttempt((out && out.provider) || key, { ok: false, tookMs, reason })
30
+ onAttempt(pName, { ok: false, tookMs, reason: classified.message, code: classified.code })
31
+ }
32
+ attempts.push({
33
+ provider: pName,
34
+ code: classified.code,
35
+ reason: classified.message,
36
+ tookMs,
37
+ isRetryable: classified.isRetryable,
38
+ })
39
+ errors.push(`${pName}: ${classified.message}`)
40
+ if (!shouldFallbackError(classified)) {
41
+ throw classified
33
42
  }
34
- errors.push(`${(out && out.provider) || key}: ${reason}`)
35
43
  } catch (err) {
36
44
  const tookMs = Date.now() - startT
37
- const normErr = normalizeError(err)
45
+ const classified = classifyProviderOutcome(key, err)
46
+ if (!shouldFallbackError(classified)) {
47
+ const alreadyRecorded = attempts.some(
48
+ (a) => a.provider === (classified.provider || key) && a.reason === classified.message
49
+ )
50
+ if (!alreadyRecorded && typeof onAttempt === 'function') {
51
+ onAttempt(classified.provider || key, {
52
+ ok: false,
53
+ tookMs,
54
+ reason: classified.message,
55
+ code: classified.code,
56
+ })
57
+ }
58
+ throw classified
59
+ }
38
60
  if (typeof onAttempt === 'function') {
39
- onAttempt(key, { ok: false, tookMs, reason: normErr })
61
+ onAttempt(key, { ok: false, tookMs, reason: classified.message, code: classified.code })
40
62
  }
41
- errors.push(`${key}: ${normErr}`)
63
+ attempts.push({
64
+ provider: key,
65
+ code: classified.code,
66
+ reason: classified.message,
67
+ tookMs,
68
+ isRetryable: classified.isRetryable,
69
+ })
70
+ errors.push(`${key}: ${classified.message}`)
42
71
  }
43
72
  }
44
- throw new Error(`all providers failed (${errors.join('; ')})`)
73
+ const anyRetryable = attempts.some((a) => a.isRetryable)
74
+ throw new ChainExhaustedError(`all providers failed (${errors.join('; ')})`, {
75
+ attempts,
76
+ isRetryable: anyRetryable,
77
+ })
45
78
  }
@@ -1,3 +1,5 @@
1
+ export { isVolatileRef, plainConfig, resolveConfig, markVolatile, makeVolatile } from './schema.js'
2
+
1
3
  // Decode modern volatile Config references while retaining stable identities for
2
4
  // the request hot path. Legacy cores pass ordinary resolved configuration.
3
5
  // If ctx is provided, invalidation listeners cache the unwrapped tree so the hot
package/lib/errors.js ADDED
@@ -0,0 +1,252 @@
1
+ // lib/errors.js — Typed domain error hierarchy and failure classification (#309)
2
+
3
+ export class VoiceError extends Error {
4
+ /**
5
+ * @param {string} message
6
+ * @param {{
7
+ * code?: string,
8
+ * provider?: string,
9
+ * status?: number,
10
+ * isRetryable?: boolean,
11
+ * fallbackEligible?: boolean,
12
+ * retryAfterMs?: number,
13
+ * cause?: unknown
14
+ * }} [options]
15
+ */
16
+ constructor(message, options = {}) {
17
+ super(message)
18
+ this.name = new.target.name
19
+ this.code = options.code || 'VOICE_ERROR'
20
+ this.provider = options.provider || ''
21
+ if (typeof options.status === 'number') this.status = options.status
22
+ this.isRetryable = Boolean(options.isRetryable)
23
+ this.fallbackEligible = options.fallbackEligible !== false
24
+ if (typeof options.retryAfterMs === 'number') this.retryAfterMs = options.retryAfterMs
25
+ if (options.cause !== undefined) this.cause = options.cause
26
+ }
27
+
28
+ /**
29
+ * Whether the STT fallback chain should advance to the next provider.
30
+ * @returns {boolean}
31
+ */
32
+ shouldFallback() {
33
+ return this.fallbackEligible
34
+ }
35
+
36
+ toJSON() {
37
+ return {
38
+ ok: false,
39
+ code: this.code,
40
+ message: this.message,
41
+ ...(this.provider ? { provider: this.provider } : {}),
42
+ ...(this.status !== undefined ? { status: this.status } : {}),
43
+ isRetryable: this.isRetryable,
44
+ fallbackEligible: this.fallbackEligible,
45
+ ...(this.retryAfterMs !== undefined ? { retryAfterMs: this.retryAfterMs } : {}),
46
+ }
47
+ }
48
+ }
49
+
50
+ export class ProviderAuthError extends VoiceError {
51
+ constructor(message, options = {}) {
52
+ super(message, {
53
+ ...options,
54
+ code: options.code || 'PROVIDER_AUTH_ERROR',
55
+ isRetryable: false,
56
+ fallbackEligible: options.fallbackEligible !== false,
57
+ })
58
+ }
59
+ }
60
+
61
+ export class ProviderQuotaError extends VoiceError {
62
+ constructor(message, options = {}) {
63
+ super(message, {
64
+ ...options,
65
+ code: options.code || 'PROVIDER_QUOTA_ERROR',
66
+ isRetryable: false,
67
+ fallbackEligible: options.fallbackEligible !== false,
68
+ })
69
+ }
70
+ }
71
+
72
+ export class ProviderRateLimitError extends VoiceError {
73
+ constructor(message, options = {}) {
74
+ super(message, {
75
+ ...options,
76
+ code: options.code || 'PROVIDER_RATE_LIMIT',
77
+ isRetryable: true,
78
+ fallbackEligible: options.fallbackEligible !== false,
79
+ })
80
+ }
81
+ }
82
+
83
+ export class ProviderServerError extends VoiceError {
84
+ constructor(message, options = {}) {
85
+ super(message, {
86
+ ...options,
87
+ code: options.code || 'PROVIDER_SERVER_ERROR',
88
+ isRetryable: options.isRetryable ?? true,
89
+ fallbackEligible: options.fallbackEligible !== false,
90
+ })
91
+ }
92
+ }
93
+
94
+ export class DaemonUnavailableError extends VoiceError {
95
+ constructor(message, options = {}) {
96
+ super(message, {
97
+ ...options,
98
+ code: options.code || 'DAEMON_UNAVAILABLE',
99
+ isRetryable: false,
100
+ fallbackEligible: options.fallbackEligible !== false,
101
+ })
102
+ }
103
+ }
104
+
105
+ export class AudioFormatError extends VoiceError {
106
+ constructor(message, options = {}) {
107
+ super(message, {
108
+ ...options,
109
+ code: options.code || 'AUDIO_FORMAT_ERROR',
110
+ isRetryable: false,
111
+ fallbackEligible: Boolean(options.fallbackEligible),
112
+ })
113
+ }
114
+ }
115
+
116
+ export class ChainExhaustedError extends VoiceError {
117
+ constructor(message, options = {}) {
118
+ super(message, {
119
+ ...options,
120
+ code: options.code || 'CHAIN_EXHAUSTED',
121
+ isRetryable: Boolean(options.isRetryable),
122
+ fallbackEligible: false,
123
+ })
124
+ this.attempts = Array.isArray(options.attempts) ? options.attempts : []
125
+ }
126
+
127
+ toJSON() {
128
+ return {
129
+ ...super.toJSON(),
130
+ attempts: this.attempts,
131
+ }
132
+ }
133
+ }
134
+
135
+ export function isQuotaText(detail = '') {
136
+ const lower = String(detail).toLowerCase()
137
+ return /\bquota\b/.test(lower)
138
+ || lower.includes('usage limit')
139
+ || lower.includes('usage_limit')
140
+ || lower.includes('limit reached')
141
+ || lower.includes('insufficient credit')
142
+ || lower.includes('insufficient balance')
143
+ || lower.includes('insufficient funds')
144
+ || lower.includes('credit balance')
145
+ || lower.includes('out of credit')
146
+ || lower.includes('payment required')
147
+ || lower.includes('billing')
148
+ }
149
+
150
+ export function parseRetryAfterMs(headers) {
151
+ if (!headers || typeof headers.get !== 'function') return undefined
152
+ const raw = headers.get('retry-after')
153
+ if (raw === null || raw === undefined || String(raw).trim() === '') return undefined
154
+ const seconds = Number(raw)
155
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.trunc(seconds * 1000)
156
+ const at = Date.parse(raw)
157
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined
158
+ }
159
+
160
+ /**
161
+ * Classify an HTTP status and message into a typed VoiceError subclass.
162
+ * @param {string} provider
163
+ * @param {number} status
164
+ * @param {string} message
165
+ * @param {Headers} [headers]
166
+ * @returns {VoiceError}
167
+ */
168
+ export function classifyHttpError(provider, status, message, headers) {
169
+ const msg = String(message || `HTTP ${status}`).trim()
170
+ const retryAfterMs = parseRetryAfterMs(headers)
171
+ const opts = { provider, status, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) }
172
+
173
+ if (status === 402 || (isQuotaText(msg) && status !== 401)) {
174
+ return new ProviderQuotaError(msg, opts)
175
+ }
176
+ if (status === 401 || status === 403) {
177
+ return new ProviderAuthError(msg, opts)
178
+ }
179
+ if (status === 429) {
180
+ return new ProviderRateLimitError(msg, opts)
181
+ }
182
+ if (status === 415 || status === 422) {
183
+ return new AudioFormatError(msg, { ...opts, fallbackEligible: true })
184
+ }
185
+ if (status === 408 || status === 504 || status >= 500) {
186
+ return new ProviderServerError(msg, opts)
187
+ }
188
+ return new VoiceError(msg, { ...opts, code: 'PROVIDER_HTTP_ERROR', isRetryable: false, fallbackEligible: true })
189
+ }
190
+
191
+ /**
192
+ * Classify a provider refusal object or thrown Error into a VoiceError.
193
+ * @param {string} provider
194
+ * @param {unknown} outcomeOrErr
195
+ * @returns {VoiceError}
196
+ */
197
+ export function classifyProviderOutcome(provider, outcomeOrErr) {
198
+ if (outcomeOrErr instanceof VoiceError) {
199
+ if (!outcomeOrErr.provider && provider) outcomeOrErr.provider = provider
200
+ return outcomeOrErr
201
+ }
202
+ if (outcomeOrErr instanceof Error) {
203
+ const rawMsg = outcomeOrErr?.cause?.message || outcomeOrErr?.message || String(outcomeOrErr)
204
+ const msg = rawMsg.slice(0, 200)
205
+ const statusMatch = msg.match(/\bHTTP\s+(\d{3})\b/i)
206
+ if (statusMatch) {
207
+ return classifyHttpError(provider, Number(statusMatch[1]), msg)
208
+ }
209
+ if (isQuotaText(msg)) {
210
+ return new ProviderQuotaError(msg, { provider, cause: outcomeOrErr })
211
+ }
212
+ if (/econnrefused|enotfound|fetch failed|daemon|not running|offline/i.test(msg)) {
213
+ return new DaemonUnavailableError(msg, { provider, cause: outcomeOrErr })
214
+ }
215
+ return new VoiceError(msg, {
216
+ provider,
217
+ code: 'PROVIDER_RUNTIME_ERROR',
218
+ isRetryable: /timeout|timed out|aborted|network/i.test(msg),
219
+ fallbackEligible: true,
220
+ cause: outcomeOrErr,
221
+ })
222
+ }
223
+ if (outcomeOrErr && typeof outcomeOrErr === 'object') {
224
+ const reason = String(outcomeOrErr.reason || outcomeOrErr.message || 'unknown').slice(0, 200)
225
+ const p = String(outcomeOrErr.provider || provider || '')
226
+ const fallbackEligible = outcomeOrErr.fallbackEligible !== false
227
+ if (outcomeOrErr.code === 'AUDIO_FORMAT_ERROR') {
228
+ return new AudioFormatError(reason, { provider: p, fallbackEligible })
229
+ }
230
+ if (/^no\s+[A-Z0-9_]+/i.test(reason) || /unauthorized|invalid.*key|forbidden/i.test(reason)) {
231
+ return new ProviderAuthError(reason, { provider: p, fallbackEligible })
232
+ }
233
+ if (isQuotaText(reason)) {
234
+ return new ProviderQuotaError(reason, { provider: p, fallbackEligible })
235
+ }
236
+ if (/offline|unreachable|not running|recognition runs in the page/i.test(reason)) {
237
+ return new DaemonUnavailableError(reason, { provider: p, fallbackEligible })
238
+ }
239
+ return new VoiceError(reason, {
240
+ provider: p,
241
+ code: outcomeOrErr.code || 'PROVIDER_REFUSAL',
242
+ isRetryable: false,
243
+ fallbackEligible,
244
+ })
245
+ }
246
+ return new VoiceError(String(outcomeOrErr || 'unknown').slice(0, 200), { provider })
247
+ }
248
+
249
+ export function shouldFallbackError(err) {
250
+ if (err instanceof VoiceError) return err.shouldFallback()
251
+ return true
252
+ }
@@ -357,7 +357,16 @@ export function registerHostRoutes(ctx, deps) {
357
357
  lowVolume: Boolean(payload.lowVolume),
358
358
  })
359
359
  }
360
- writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
360
+ writeJson(res, 502, {
361
+ ok: false,
362
+ error: {
363
+ code: 'chain',
364
+ domainCode: (e && e.code) || 'CHAIN_EXHAUSTED',
365
+ isRetryable: Boolean(e && e.isRetryable),
366
+ ...(Array.isArray(e && e.attempts) ? { attempts: e.attempts } : {}),
367
+ message: String(e && e.message || e),
368
+ },
369
+ })
361
370
  } finally {
362
371
  clearTimeout(timer)
363
372
  }
package/lib/index.js CHANGED
@@ -23,48 +23,57 @@ import { createPolishText } from './polish.js'
23
23
  import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
24
24
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
25
25
  import { applyJargonDictionary, extractVoiceActions } from './normalize.js'
26
- import { ChainEntry, CustomProvider, BaseConfig } from './schema.js'
26
+ import {
27
+ ChainEntry,
28
+ CustomProvider,
29
+ BaseConfig,
30
+ markVolatile,
31
+ makeVolatile as schemaMakeVolatile,
32
+ isVolatileRef,
33
+ plainConfig,
34
+ resolveConfig,
35
+ } from './schema.js'
36
+ import {
37
+ VoiceError,
38
+ ProviderAuthError,
39
+ ProviderQuotaError,
40
+ ProviderRateLimitError,
41
+ ProviderServerError,
42
+ DaemonUnavailableError,
43
+ AudioFormatError,
44
+ ChainExhaustedError,
45
+ } from './errors.js'
27
46
  import { createLocalDaemons } from './local-daemon.js'
28
47
 
29
- export { ChainEntry, CustomProvider, BaseConfig }
48
+ export {
49
+ ChainEntry,
50
+ CustomProvider,
51
+ BaseConfig,
52
+ markVolatile,
53
+ isVolatileRef,
54
+ plainConfig,
55
+ resolveConfig,
56
+ VoiceError,
57
+ ProviderAuthError,
58
+ ProviderQuotaError,
59
+ ProviderRateLimitError,
60
+ ProviderServerError,
61
+ DaemonUnavailableError,
62
+ AudioFormatError,
63
+ ChainExhaustedError,
64
+ }
30
65
  export const name = '@goodandready/dsh-voice'
31
66
  export const NS = 'dsh-voice'
32
67
  export const inject = ['tools', 'credentials', 'webServer', 'shell', 'settings', 'llm']
33
68
 
34
69
  function makeVolatile(schema) {
35
- if (!schema || typeof schema !== 'object' || !schema.dict) return schema
36
- const dict = {}
37
- for (const [k, v] of Object.entries(schema.dict)) {
38
- dict[k] = (v && typeof v.volatile === 'function') ? v.volatile() : v
39
- }
40
- const res = z.object(dict)
41
- if (typeof schema.description === 'function' && schema.meta?.description) {
42
- return res.description(schema.meta.description)
43
- }
44
- return res
70
+ return schemaMakeVolatile(schema)
45
71
  }
46
72
 
47
73
  export const Config = typeof z.number().volatile === 'function'
48
74
  ? makeVolatile(BaseConfig)
49
75
  : BaseConfig
50
76
 
51
- export function isVolatileRef(value) {
52
- return value !== null && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
53
- }
54
-
55
- export function plainConfig(value) {
56
- while (typeof value === 'function') {
57
- value = value()
58
- }
59
- if (isVolatileRef(value)) return plainConfig(value.get())
60
- if (value === null || typeof value !== 'object') return value
61
- if (value instanceof Date || value instanceof RegExp || (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))) {
62
- return value
63
- }
64
- if (Array.isArray(value)) return value.map(plainConfig)
65
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
66
- }
67
-
68
77
  export function apply(ctx, baseConfig) {
69
78
  let settingsService = null
70
79
  let scope = null
@@ -84,12 +93,9 @@ export function apply(ctx, baseConfig) {
84
93
 
85
94
  const live = () => {
86
95
  try {
87
- const raw = getConfig() ?? {}
88
- const unwrapped = plainConfig(raw)
89
- const validated = BaseConfig(unwrapped)
90
- return plainConfig(validated) ?? plainConfig(readEntryConfig())
96
+ return resolveConfig(getConfig() ?? readEntryConfig())
91
97
  } catch {
92
- return plainConfig(readEntryConfig())
98
+ return resolveConfig(readEntryConfig())
93
99
  }
94
100
  }
95
101
 
@@ -1,4 +1,5 @@
1
1
  // Pure helper functions for STT provider HTTP requests and responses.
2
+ import { classifyHttpError } from './errors.js'
2
3
 
3
4
  /**
4
5
  * Check if the language is unspecified or set to automatic detection.
@@ -62,3 +63,15 @@ export async function readErrorDetail(res, defaultLabel) {
62
63
  } catch { /* not json */ }
63
64
  return `${defaultLabel} ${detail}`
64
65
  }
66
+
67
+ /**
68
+ * Extract and classify a non-2xx HTTP response into a typed VoiceError subclass.
69
+ * @param {Response} res
70
+ * @param {string} defaultLabel
71
+ * @param {string} [providerKey]
72
+ * @returns {Promise<import('./errors.js').VoiceError>}
73
+ */
74
+ export async function readHttpError(res, defaultLabel, providerKey = '') {
75
+ const detail = await readErrorDetail(res, defaultLabel)
76
+ return classifyHttpError(providerKey || defaultLabel.toLowerCase(), res?.status || 500, detail, res?.headers)
77
+ }
package/lib/providers.js CHANGED
@@ -68,8 +68,8 @@ const CHAT_AUDIO_PROMPT =
68
68
  'Transcribe the audio verbatim. Reply with the transcript text only, '
69
69
  + 'without comments, quotes or formatting.'
70
70
 
71
- import { readErrorDetail, safeJson, isAutoLang, fileName, chatAudioFormat } from './provider-http.js'
72
- export { readErrorDetail, safeJson, isAutoLang, fileName, chatAudioFormat }
71
+ import { readErrorDetail, readHttpError, safeJson, isAutoLang, fileName, chatAudioFormat } from './provider-http.js'
72
+ export { readErrorDetail, readHttpError, safeJson, isAutoLang, fileName, chatAudioFormat }
73
73
 
74
74
  export const DEFAULT_MODELS = {
75
75
  deepgram: 'nova-2',
@@ -110,7 +110,7 @@ export function makeProviders(deps, req) {
110
110
  signal,
111
111
  keepalive: true,
112
112
  })
113
- if (!res.ok) throw new Error(await readErrorDetail(res, 'Deepgram'))
113
+ if (!res.ok) throw await readHttpError(res, 'Deepgram', 'deepgram')
114
114
  let data
115
115
  try { data = await safeJson(res, 'Deepgram') } catch (e) {
116
116
  return { ok: false, provider: 'deepgram', reason: e.message }
@@ -135,7 +135,7 @@ export function makeProviders(deps, req) {
135
135
  signal,
136
136
  keepalive: true,
137
137
  })
138
- if (!res.ok) throw new Error(await readErrorDetail(res, 'Groq'))
138
+ if (!res.ok) throw await readHttpError(res, 'Groq', 'groq')
139
139
  let data
140
140
  try { data = await safeJson(res, 'Groq') } catch (e) {
141
141
  return { ok: false, provider: 'groq', reason: e.message }
@@ -155,7 +155,7 @@ export function makeProviders(deps, req) {
155
155
  signal,
156
156
  keepalive: true,
157
157
  })
158
- if (!res.ok) throw new Error(await readErrorDetail(res, 'HF'))
158
+ if (!res.ok) throw await readHttpError(res, 'HF', 'hf')
159
159
  let data
160
160
  try { data = await safeJson(res, 'HF') } catch (e) {
161
161
  return { ok: false, provider: 'hf', reason: e.message }
@@ -289,7 +289,7 @@ export function makeProviders(deps, req) {
289
289
  const res = await fetchImpl(`${base}/audio/transcriptions`, {
290
290
  method: 'POST', headers, body: form, signal,
291
291
  })
292
- if (!res.ok) throw new Error(await readErrorDetail(res, label))
292
+ if (!res.ok) throw await readHttpError(res, label, spec.key)
293
293
  const data = await safeJson(res, label)
294
294
  return (data?.text || '').trim()
295
295
  }
@@ -322,7 +322,7 @@ export function makeProviders(deps, req) {
322
322
  }),
323
323
  signal,
324
324
  })
325
- if (!res.ok) throw new Error(await readErrorDetail(res, label))
325
+ if (!res.ok) throw await readHttpError(res, label, spec.key)
326
326
  const data = await safeJson(res, label)
327
327
  return String(data?.choices?.[0]?.message?.content || '').trim()
328
328
  }
package/lib/schema.js CHANGED
@@ -181,3 +181,68 @@ export const BaseConfig = z.object({
181
181
  voiceMacros: z.array(VoiceMacro).default([])
182
182
  .description('User voice action macros (spoken trigger phrase -> structured prompt replacement).'),
183
183
  })
184
+
185
+ export function markVolatile(schema) {
186
+ return (schema && typeof schema.volatile === 'function') ? schema.volatile() : schema
187
+ }
188
+
189
+ export function makeVolatile(schema) {
190
+ if (!schema || typeof schema !== 'object' || !schema.dict) return markVolatile(schema)
191
+ const dict = {}
192
+ for (const [k, v] of Object.entries(schema.dict)) {
193
+ dict[k] = markVolatile(v)
194
+ }
195
+ const res = z.object(dict)
196
+ if (typeof schema.description === 'function' && schema.meta?.description) {
197
+ return res.description(schema.meta.description)
198
+ }
199
+ return res
200
+ }
201
+
202
+ export const Config = typeof z.number().volatile === 'function'
203
+ ? makeVolatile(BaseConfig)
204
+ : BaseConfig
205
+
206
+ export function isVolatileRef(value) {
207
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
208
+ }
209
+
210
+ export function plainConfig(value) {
211
+ while (typeof value === 'function') {
212
+ value = value()
213
+ }
214
+ if (isVolatileRef(value)) return plainConfig(value.get())
215
+ if (value === null || typeof value !== 'object') return value
216
+ if (value instanceof Date || value instanceof RegExp || (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))) {
217
+ return value
218
+ }
219
+ if (Array.isArray(value)) return value.map(plainConfig)
220
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
221
+ }
222
+
223
+ /**
224
+ * Normalize raw configuration (including volatile boxes or partial settings)
225
+ * into a deterministic validated configuration object (#305).
226
+ */
227
+ export function resolveConfig(rawConfig) {
228
+ const unwrapped = plainConfig(rawConfig ?? {})
229
+ const input = (unwrapped && typeof unwrapped === 'object' && !Array.isArray(unwrapped)) ? unwrapped : {}
230
+ let validated
231
+ try {
232
+ validated = plainConfig(BaseConfig(structuredClone(input)))
233
+ } catch {
234
+ const defaults = plainConfig(BaseConfig({}))
235
+ validated = { ...defaults, ...input }
236
+ if (typeof validated.lowVolumeThreshold !== 'number' || !Number.isFinite(validated.lowVolumeThreshold)) {
237
+ validated.lowVolumeThreshold = defaults.lowVolumeThreshold
238
+ } else {
239
+ validated.lowVolumeThreshold = Math.min(1, Math.max(0, validated.lowVolumeThreshold))
240
+ }
241
+ }
242
+ return {
243
+ ...validated,
244
+ deepgramBaseUrl: String(validated.deepgramBaseUrl || 'https://api.deepgram.com').trim() || 'https://api.deepgram.com',
245
+ whisperUrl: String(validated.whisperUrl || 'http://127.0.0.1:8001/inference').trim() || 'http://127.0.0.1:8001/inference',
246
+ sensevoiceUrl: String(validated.sensevoiceUrl || 'http://127.0.0.1:6006/api/v1/asr').trim() || 'http://127.0.0.1:6006/api/v1/asr',
247
+ }
248
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.46",
3
+ "version": "0.9.47",
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",