@gotcos/glasses-server 6.15.3 → 6.16.0

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.
@@ -61,6 +61,7 @@ import {
61
61
  type MediaAttachmentRef,
62
62
  } from '../../shared/media-attachment.js'
63
63
  import { terminalProviderAuthFailure } from './provider-terminal-error.js'
64
+ import { terminateProviderProcess } from './provider-process-lifecycle.js'
64
65
 
65
66
  const INACTIVITY_MS = 180_000
66
67
  const WALL_MAX_MS = 900_000
@@ -361,12 +362,14 @@ export async function callCodexStreaming(
361
362
  stdio: ['pipe', 'pipe', 'pipe'],
362
363
  env,
363
364
  cwd: codexCwd,
365
+ detached: true,
364
366
  })
365
367
 
366
368
  let fullText = ''
367
369
  let stderr = ''
368
370
  let buffer = ''
369
371
  let finalized = false
372
+ let terminationRequested = false
370
373
  let terminalTextError: string | null = null
371
374
  let lastActivity = Date.now()
372
375
  const emittedBlocks = new Set<string>()
@@ -571,10 +574,43 @@ export async function callCodexStreaming(
571
574
  }
572
575
  }
573
576
 
574
- function handleAbort() {
577
+ function abandonLostDurableOwnership(message: string) {
575
578
  if (finalized) return
576
- proc.kill('SIGTERM')
577
- finalizeError('codex-bridge: client disconnected before Codex completed.', null, 'client_disconnected')
579
+ finalized = true
580
+ cleanup()
581
+ cleanupImages()
582
+ outputImagePublisher?.cleanup()
583
+ removeExchange(sid, pendingUserExchange)
584
+ clearEngineSessionBestEffort('provider_ownership_lost')
585
+ finishRunBestEffort({
586
+ status: 'failed',
587
+ startedAtMs: startTime,
588
+ error: message,
589
+ exitCode: null,
590
+ })
591
+ }
592
+
593
+ async function terminateForTerminal(
594
+ reason: string,
595
+ onClosed: (result: Awaited<ReturnType<typeof terminateProviderProcess>>) => void | Promise<void>,
596
+ ) {
597
+ if (finalized || terminationRequested) return
598
+ terminationRequested = true
599
+ cleanup()
600
+ const result = await terminateProviderProcess(proc)
601
+ if (!result.closed) {
602
+ console.error(`[codex-bridge] provider did not close after SIGKILL (${reason}); retaining lifecycle ownership`)
603
+ return
604
+ }
605
+ await onClosed(result)
606
+ }
607
+
608
+ function handleAbort() {
609
+ void terminateForTerminal('client disconnect', result => finalizeError(
610
+ 'codex-bridge: client disconnected before Codex completed.',
611
+ result.code,
612
+ 'client_disconnected',
613
+ ))
578
614
  }
579
615
 
580
616
  const heartbeat = setInterval(() => {
@@ -583,28 +619,30 @@ export async function callCodexStreaming(
583
619
  }, HEARTBEAT_INTERVAL_MS)
584
620
 
585
621
  let inactivityTimer = setTimeout(() => {
586
- proc.kill('SIGTERM')
587
622
  const elapsed = Math.round((Date.now() - startTime) / 1000)
588
- finalizeError(`No output for ${INACTIVITY_MS / 1000}s (${elapsed}s total). Codex process killed.`)
623
+ void terminateForTerminal('inactivity timeout', result => finalizeError(
624
+ `No output for ${INACTIVITY_MS / 1000}s (${elapsed}s total). Codex process killed.`,
625
+ result.code,
626
+ ))
589
627
  }, INACTIVITY_MS)
590
628
 
591
629
  function resetInactivity() {
592
630
  lastActivity = Date.now()
593
631
  clearTimeout(inactivityTimer)
594
632
  inactivityTimer = setTimeout(() => {
595
- proc.kill('SIGTERM')
596
633
  const elapsed = Math.round((Date.now() - startTime) / 1000)
597
- finalizeError(`No output for ${INACTIVITY_MS / 1000}s (${elapsed}s total). Codex process killed.`)
634
+ void terminateForTerminal('inactivity timeout', result => finalizeError(
635
+ `No output for ${INACTIVITY_MS / 1000}s (${elapsed}s total). Codex process killed.`,
636
+ result.code,
637
+ ))
598
638
  }, INACTIVITY_MS)
599
639
  }
600
640
 
601
641
  const wallTimer = setTimeout(() => {
602
- proc.kill('SIGTERM')
603
- if (fullText) {
604
- void finalize(fullText)
605
- } else {
606
- finalizeError(`Wall clock limit reached (${WALL_MAX_MS / 1000}s). Codex process killed.`)
607
- }
642
+ void terminateForTerminal('wall timeout', result => {
643
+ if (fullText) return finalize(fullText)
644
+ return finalizeError(`Wall clock limit reached (${WALL_MAX_MS / 1000}s). Codex process killed.`, result.code)
645
+ })
608
646
  }, WALL_MAX_MS)
609
647
 
610
648
  function handleEvent(event: any) {
@@ -677,6 +715,7 @@ export async function callCodexStreaming(
677
715
  })
678
716
 
679
717
  proc.on('close', (code) => {
718
+ if (terminationRequested) return
680
719
  if (buffer.trim()) {
681
720
  try { handleEvent(JSON.parse(buffer.trim())) } catch { /* ignore */ }
682
721
  }
@@ -696,9 +735,11 @@ export async function callCodexStreaming(
696
735
  })
697
736
 
698
737
  proc.on('error', (err) => {
738
+ if (terminationRequested) return
699
739
  finalizeError(`codex-bridge: ${err.message}`)
700
740
  })
701
741
  proc.stdin.on('error', (err) => {
742
+ if (terminationRequested) return
702
743
  finalizeError(`codex-bridge: stdin failed — ${err.message}`)
703
744
  })
704
745
 
@@ -719,18 +760,8 @@ export async function callCodexStreaming(
719
760
  generation: options?.generation,
720
761
  })
721
762
  if (providerOwned === false) {
722
- proc.kill('SIGTERM')
723
- finalized = true
724
- cleanup()
725
- cleanupImages()
726
- outputImagePublisher?.cleanup()
727
- removeExchange(sid, pendingUserExchange)
728
- clearEngineSessionBestEffort('provider_ownership_lost')
729
- finishRunBestEffort({
730
- status: 'failed',
731
- startedAtMs: startTime,
732
- error: 'codex-bridge: durable provider ownership was lost.',
733
- exitCode: null,
763
+ await terminateForTerminal('provider ownership lost', () => {
764
+ abandonLostDurableOwnership('codex-bridge: durable provider ownership was lost.')
734
765
  })
735
766
  return sid
736
767
  }
@@ -740,8 +771,10 @@ export async function callCodexStreaming(
740
771
  } catch (err) {
741
772
  const message = err instanceof Error ? err.message : String(err)
742
773
  if (!finalized) {
743
- proc.kill('SIGTERM')
744
- await finalizeError(`codex-bridge: provider start failed — ${message}`)
774
+ await terminateForTerminal('provider start failure', result => finalizeError(
775
+ `codex-bridge: provider start failed — ${message}`,
776
+ result.code,
777
+ ))
745
778
  }
746
779
  }
747
780
 
@@ -310,6 +310,7 @@ export class MaintenanceLifecycle {
310
310
  private blockedGateReason: BlockedGateReason | null = null
311
311
  private blockedGateVersion: number | null = null
312
312
  private readonly work = new Map<string, WorkEntry>()
313
+ private readonly admissionsOpenListeners = new Set<() => void>()
313
314
 
314
315
  constructor(options: MaintenanceLifecycleOptions = {}) {
315
316
  this.path = options.path ?? process.env.COS_MAINTENANCE_GATE_PATH?.trim() ?? DEFAULT_GATE_PATH
@@ -356,11 +357,44 @@ export class MaintenanceLifecycle {
356
357
  try {
357
358
  removeGate(this.path)
358
359
  this.gate = null
360
+ this.notifyAdmissionsOpen()
359
361
  } catch {
360
362
  this.blockedGateReason = 'invalid_schema'
361
363
  }
362
364
  }
363
365
 
366
+ /**
367
+ * Register deferred runtime initialization that is safe only after the
368
+ * durable maintenance gate opens. Delivery is asynchronous so the release
369
+ * response is never held open by model/cache initialization. The callback is
370
+ * also delivered for an already-open lifecycle, closing the boot/release
371
+ * race without forcing index.ts to poll.
372
+ */
373
+ onAdmissionsOpen(listener: () => void): () => void {
374
+ this.expireSameBootGateIfPermitted()
375
+ this.admissionsOpenListeners.add(listener)
376
+ if (!this.gate && !this.blockedGateReason) this.deliverAdmissionsOpen(listener)
377
+ return () => { this.admissionsOpenListeners.delete(listener) }
378
+ }
379
+
380
+ private deliverAdmissionsOpen(listener: () => void): void {
381
+ queueMicrotask(() => {
382
+ if (!this.admissionsOpenListeners.has(listener)) return
383
+ // State may have closed again between scheduling and microtask delivery.
384
+ // Never start admitted runtime from a stale accepting notification.
385
+ this.expireSameBootGateIfPermitted()
386
+ if (this.gate || this.blockedGateReason) return
387
+ try { listener() } catch (error) {
388
+ console.error('[maintenance] admissions-open listener failed:', error)
389
+ }
390
+ })
391
+ }
392
+
393
+ private notifyAdmissionsOpen(): void {
394
+ if (this.gate || this.blockedGateReason) return
395
+ for (const listener of this.admissionsOpenListeners) this.deliverAdmissionsOpen(listener)
396
+ }
397
+
364
398
  private credentialsMatch(credentials: MaintenanceOperationCredentials): {
365
399
  leaseMatches: boolean
366
400
  operationMatches: boolean
@@ -615,6 +649,7 @@ export class MaintenanceLifecycle {
615
649
  )
616
650
  }
617
651
  this.gate = null
652
+ this.notifyAdmissionsOpen()
618
653
  }
619
654
 
620
655
  cancelDrain(identity: MaintenanceOperationIdentity, credentials: MaintenanceOperationCredentials): void {
@@ -641,6 +676,7 @@ export class MaintenanceLifecycle {
641
676
  )
642
677
  }
643
678
  this.gate = null
679
+ this.notifyAdmissionsOpen()
644
680
  }
645
681
 
646
682
  snapshot(credentials: MaintenanceOperationCredentials = {}, extraActiveByKind: Record<string, number> = {}) {
@@ -741,6 +777,10 @@ export function maintenanceAdmissionsOpen(): boolean {
741
777
  return maintenanceLifecycle.snapshot().admissionsOpen
742
778
  }
743
779
 
780
+ export function onMaintenanceAdmissionsOpen(listener: () => void): () => void {
781
+ return maintenanceLifecycle.onAdmissionsOpen(listener)
782
+ }
783
+
744
784
  export function maintenanceErrorPayload(error: MaintenanceLifecycleError) {
745
785
  return {
746
786
  error: error.code,
@@ -20,6 +20,7 @@ export interface PromptDraftTranscriptRecord {
20
20
  actualQuality: 'hq' | 'fast' | 'cloud'
21
21
  backend: string
22
22
  degraded: boolean
23
+ degradationReason?: string
23
24
  acceptedDegraded?: boolean
24
25
  }
25
26
 
@@ -0,0 +1,139 @@
1
+ import type { ChildProcess } from 'node:child_process'
2
+
3
+ export interface ProviderTerminationResult {
4
+ closed: boolean
5
+ escalated: boolean
6
+ code: number | null
7
+ signal: NodeJS.Signals | null
8
+ }
9
+
10
+ interface ProviderTerminationOptions {
11
+ termGraceMs?: number
12
+ killWaitMs?: number
13
+ }
14
+
15
+ /** A detached provider's process group can outlive its CLI leader. ESRCH is
16
+ * the only proof that no member remains; EPERM and unknown probe failures are
17
+ * treated as alive so lifecycle ownership fails closed. */
18
+ function providerGroupAlive(pid: number | undefined): boolean {
19
+ if (!pid || process.platform === 'win32') return false
20
+ try {
21
+ process.kill(-pid, 0)
22
+ return true
23
+ } catch (error: any) {
24
+ return error?.code !== 'ESRCH'
25
+ }
26
+ }
27
+
28
+ /** Signal the detached provider process group so tool subprocesses cannot be
29
+ * orphaned behind the CLI wrapper. Direct-child signaling is a safe fallback
30
+ * for a process that failed before its process group was established. */
31
+ function signalProviderTree(proc: ChildProcess, signal: NodeJS.Signals): void {
32
+ const pid = proc.pid
33
+ if (pid && process.platform !== 'win32') {
34
+ try {
35
+ process.kill(-pid, signal)
36
+ return
37
+ } catch {
38
+ // Fall through to the direct child. close/error remains authoritative.
39
+ }
40
+ }
41
+ try { proc.kill(signal) } catch { /* process already terminal */ }
42
+ }
43
+
44
+ /**
45
+ * Terminate provider work and resolve only after Node observes leader close
46
+ * and the detached process group no longer exists.
47
+ * A caller must retain its maintenance lease when `closed` is false: releasing
48
+ * without a close event could let Control restart while a tool is still alive.
49
+ */
50
+ export function terminateProviderProcess(
51
+ proc: ChildProcess,
52
+ options: ProviderTerminationOptions = {},
53
+ ): Promise<ProviderTerminationResult> {
54
+ const termGraceMs = Math.max(10, options.termGraceMs ?? 2_000)
55
+ const killWaitMs = Math.max(10, options.killWaitMs ?? 2_000)
56
+ const groupPid = proc.pid
57
+ const initiallyClosed = proc.exitCode !== null || proc.signalCode !== null
58
+
59
+ if (initiallyClosed && !providerGroupAlive(groupPid)) {
60
+ return Promise.resolve({
61
+ closed: true,
62
+ escalated: false,
63
+ code: proc.exitCode,
64
+ signal: proc.signalCode,
65
+ })
66
+ }
67
+
68
+ return new Promise(resolve => {
69
+ let settled = false
70
+ let escalated = false
71
+ let leaderClosed = initiallyClosed
72
+ let leaderCode = proc.exitCode
73
+ let leaderSignal = proc.signalCode
74
+ let escalationTimer: ReturnType<typeof setTimeout> | undefined
75
+ let terminalTimer: ReturnType<typeof setTimeout> | undefined
76
+
77
+ const finish = (result: ProviderTerminationResult) => {
78
+ if (settled) return
79
+ settled = true
80
+ if (escalationTimer) clearTimeout(escalationTimer)
81
+ if (terminalTimer) clearTimeout(terminalTimer)
82
+ proc.removeListener('close', onClose)
83
+ proc.removeListener('error', onError)
84
+ resolve(result)
85
+ }
86
+ const finishIfTreeClosed = (): boolean => {
87
+ if (!leaderClosed || providerGroupAlive(groupPid)) return false
88
+ finish({
89
+ closed: true,
90
+ escalated,
91
+ code: leaderCode,
92
+ signal: leaderSignal,
93
+ })
94
+ return true
95
+ }
96
+ const onClose = (code: number | null, signal: NodeJS.Signals | null) => {
97
+ leaderClosed = true
98
+ leaderCode = code
99
+ leaderSignal = signal
100
+ finishIfTreeClosed()
101
+ }
102
+ const onError = () => {
103
+ // A spawn failure has no live process to retain. Running children still
104
+ // produce close after an error, so wait for that authoritative event.
105
+ if (!proc.pid) {
106
+ leaderClosed = true
107
+ leaderCode = null
108
+ leaderSignal = null
109
+ finishIfTreeClosed()
110
+ }
111
+ }
112
+
113
+ proc.once('close', onClose)
114
+ proc.once('error', onError)
115
+ try { proc.stdin?.destroy() } catch { /* best effort */ }
116
+ signalProviderTree(proc, 'SIGTERM')
117
+
118
+ escalationTimer = setTimeout(() => {
119
+ if (finishIfTreeClosed()) return
120
+ escalated = true
121
+ signalProviderTree(proc, 'SIGKILL')
122
+ const deadline = Date.now() + killWaitMs
123
+ const pollForTreeExit = () => {
124
+ if (finishIfTreeClosed()) return
125
+ if (Date.now() < deadline) {
126
+ terminalTimer = setTimeout(pollForTreeExit, 25)
127
+ return
128
+ }
129
+ finish({
130
+ closed: false,
131
+ escalated: true,
132
+ code: leaderCode,
133
+ signal: leaderSignal,
134
+ })
135
+ }
136
+ terminalTimer = setTimeout(pollForTreeExit, 25)
137
+ }, termGraceMs)
138
+ })
139
+ }
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import { cosBrainDir } from './launch-dir.js'
3
+ import { terminateProviderProcess } from './provider-process-lifecycle.js'
3
4
 
4
5
  export type ProofProvider = 'claude' | 'codex'
5
6
 
@@ -21,6 +22,7 @@ interface ProcessResult {
21
22
  stdout: string
22
23
  stderr: string
23
24
  timedOut: boolean
25
+ aborted: boolean
24
26
  }
25
27
 
26
28
  function runBounded(
@@ -28,15 +30,20 @@ function runBounded(
28
30
  args: string[],
29
31
  input: string,
30
32
  timeoutMs = 120_000,
33
+ signal?: AbortSignal,
31
34
  ): Promise<ProcessResult> {
32
35
  return new Promise((resolvePromise) => {
36
+ if (signal?.aborted) {
37
+ resolvePromise({ code: null, stdout: '', stderr: '', timedOut: false, aborted: true })
38
+ return
39
+ }
33
40
  const env = { ...process.env }
34
41
  delete env.CLAUDECODE
35
42
  const child = spawn(command, args, {
36
43
  cwd: cosBrainDir() ?? process.cwd(),
37
44
  env,
38
45
  stdio: ['pipe', 'pipe', 'pipe'],
39
- detached: false,
46
+ detached: true,
40
47
  })
41
48
  let stdout = ''
42
49
  let stderr = ''
@@ -48,15 +55,25 @@ function runBounded(
48
55
  if (settled) return
49
56
  settled = true
50
57
  clearTimeout(timer)
58
+ signal?.removeEventListener('abort', abort)
51
59
  resolvePromise(result)
52
60
  }
53
61
  const timer = setTimeout(() => {
54
- try { child.kill('SIGKILL') } catch { /* already exited */ }
55
- finish({ code: null, stdout, stderr, timedOut: true })
62
+ void terminateProviderProcess(child, { termGraceMs: 50 }).then(result => {
63
+ if (result.closed) finish({ code: result.code, stdout, stderr, timedOut: true, aborted: false })
64
+ else console.error('[provider-proof] timed-out provider did not close after SIGKILL; retaining request ownership')
65
+ })
56
66
  }, timeoutMs)
57
67
  timer.unref?.()
58
- child.once('error', err => finish({ code: null, stdout, stderr: err.message, timedOut: false }))
59
- child.once('close', code => finish({ code, stdout, stderr, timedOut: false }))
68
+ const abort = () => {
69
+ void terminateProviderProcess(child).then(result => {
70
+ if (result.closed) finish({ code: result.code, stdout, stderr, timedOut: false, aborted: true })
71
+ else console.error('[provider-proof] canceled provider did not close after SIGKILL; retaining request ownership')
72
+ })
73
+ }
74
+ child.once('error', err => finish({ code: null, stdout, stderr: err.message, timedOut: false, aborted: false }))
75
+ child.once('close', code => finish({ code, stdout, stderr, timedOut: false, aborted: false }))
76
+ signal?.addEventListener('abort', abort, { once: true })
60
77
  child.stdin.on('error', () => { /* close/error is authoritative */ })
61
78
  child.stdin.end(input)
62
79
  })
@@ -94,12 +111,13 @@ export function codexProofText(stdout: string): string {
94
111
  }
95
112
 
96
113
  function safeProofError(result: ProcessResult): string {
114
+ if (result.aborted) return 'provider proof canceled'
97
115
  if (result.timedOut) return 'provider proof timed out'
98
116
  if (result.code !== 0) return `provider process exited ${result.code ?? 'before launch'}`
99
117
  return 'provider returned no valid proof response'
100
118
  }
101
119
 
102
- async function executeProof(provider: ProofProvider): Promise<ProviderProofResult> {
120
+ async function executeProof(provider: ProofProvider, signal?: AbortSignal): Promise<ProviderProofResult> {
103
121
  const started = Date.now()
104
122
  const result = provider === 'claude'
105
123
  ? await runBounded('claude', [
@@ -110,7 +128,7 @@ async function executeProof(provider: ProofProvider): Promise<ProviderProofResul
110
128
  '--allowedTools', '',
111
129
  '--system-prompt', PROOF_PROMPT,
112
130
  PROOF_PROMPT,
113
- ], '')
131
+ ], '', 120_000, signal)
114
132
  : await runBounded('codex', [
115
133
  'exec',
116
134
  '--sandbox', 'read-only',
@@ -119,7 +137,7 @@ async function executeProof(provider: ProofProvider): Promise<ProviderProofResul
119
137
  '--cd', cosBrainDir() ?? process.cwd(),
120
138
  '--ephemeral',
121
139
  '-',
122
- ], PROOF_PROMPT)
140
+ ], PROOF_PROMPT, 120_000, signal)
123
141
  const text = provider === 'claude'
124
142
  ? claudeProofText(result.stdout)
125
143
  : codexProofText(result.stdout)
@@ -134,12 +152,12 @@ async function executeProof(provider: ProofProvider): Promise<ProviderProofResul
134
152
  }
135
153
 
136
154
  /** Actual no-tool model turn, cached only after success for this server boot. */
137
- export async function runProviderProof(provider: ProofProvider): Promise<ProviderProofResult> {
155
+ export async function runProviderProof(provider: ProofProvider, signal?: AbortSignal): Promise<ProviderProofResult> {
138
156
  const cached = successCache.get(provider)
139
157
  if (cached) return { ...cached, cached: true }
140
158
  const existing = inFlight.get(provider)
141
159
  if (existing) return existing
142
- const operation = executeProof(provider).then(result => {
160
+ const operation = executeProof(provider, signal).then(result => {
143
161
  if (result.ok) successCache.set(provider, result)
144
162
  return result
145
163
  }).finally(() => {
@@ -34,6 +34,7 @@ export interface TranscribeAudioResult {
34
34
  requestedMode: TranscribeMode
35
35
  actualQuality: 'hq' | 'fast' | 'cloud'
36
36
  degraded: boolean
37
+ degradationReason?: string
37
38
  elapsedMs: number
38
39
  audioBytes: number
39
40
  }
@@ -62,6 +63,12 @@ export class NoSpeechDetectedError extends Error {
62
63
  // ceiling (anything longer is a dictation, not a query — use meetings instead).
63
64
  const HQ_MAX_SECONDS = 60
64
65
 
66
+ /** Short interactive clips use light enhance (highpass only). Override via env. */
67
+ function hqEnhanceLightMaxSeconds(): number {
68
+ const value = Number.parseInt(process.env.COS_HQ_ENHANCE_LIGHT_MAX_SEC || '15', 10)
69
+ return Number.isFinite(value) && value >= 0 ? value : 15
70
+ }
71
+
65
72
  function unavailableAfterLocalFailure(): TranscriptionUnavailableError | null {
66
73
  const fallback = getTranscriptionPolicySnapshot()
67
74
  if (fallback.openaiFallbackReady) return null
@@ -154,17 +161,26 @@ export async function transcribeAudioBuffer(
154
161
  let text: string
155
162
  let backend: string
156
163
  let actualQuality: 'hq' | 'fast' | 'cloud'
164
+ let degradationReason: string | undefined = effectiveMode !== requestedMode ? 'audio_too_long' : undefined
157
165
  const tStart = performance.now()
158
166
 
159
167
  if (effectiveMode === 'hq') {
160
168
  try {
161
- const enhanced = await enhanceAudio(audioBuffer)
169
+ const lightMax = hqEnhanceLightMaxSeconds()
170
+ const enhanceProfile = audioSeconds < lightMax ? 'light' as const : 'full' as const
171
+ const enhanced = await enhanceAudio(audioBuffer, { profile: enhanceProfile })
162
172
  const result = await transcribeHighQuality(enhanced)
163
173
  text = result.text
164
- backend = 'hq-large-v3'
165
- actualQuality = 'hq'
174
+ actualQuality = result.actualQuality
175
+ if (result.actualQuality === 'hq') {
176
+ backend = enhanceProfile === 'light' ? 'hq-large-v3-light' : 'hq-large-v3'
177
+ } else {
178
+ backend = result.backend === 'whisper-cli' ? 'fast-cli-turbo' : 'fast-local-server'
179
+ degradationReason = result.degradationReason ?? 'hq_unavailable'
180
+ }
166
181
  } catch (hqErr: any) {
167
182
  console.warn(`[transcribe] HQ path failed, falling back to fast: ${hqErr.message}`)
183
+ degradationReason = 'hq_decode_failed'
168
184
  try {
169
185
  const result = await transcribeLocal(audioBuffer)
170
186
  text = result.text
@@ -260,6 +276,7 @@ export async function transcribeAudioBuffer(
260
276
  requestedMode,
261
277
  actualQuality,
262
278
  degraded: requestedMode === 'hq' && actualQuality !== 'hq',
279
+ ...(degradationReason ? { degradationReason } : {}),
263
280
  elapsedMs,
264
281
  audioBytes: audioBuffer.length,
265
282
  }