@gotcos/glasses-server 6.15.2 → 6.15.5

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.
@@ -6,10 +6,10 @@
6
6
  // 2. whisper-cli (spawned per request, model loaded from disk) → ~500-700ms
7
7
  // 3. OpenAI API (cloud, handled by transcribe.ts) → ~1000-3000ms
8
8
 
9
- import { spawn, execFileSync } from 'node:child_process'
9
+ import { spawn, execFile } from 'node:child_process'
10
10
  import type { ChildProcess } from 'node:child_process'
11
11
  import { writeFileSync, unlinkSync, existsSync, readFileSync } from 'node:fs'
12
- import { join } from 'node:path'
12
+ import { basename, join } from 'node:path'
13
13
  import { homedir } from 'node:os'
14
14
  import crypto from 'node:crypto'
15
15
  import { getVocabulary, getOwnerName } from './profile.js'
@@ -137,6 +137,10 @@ function getWhisperPrompt(): string {
137
137
  // whisper-server runs on this port (started at server boot or by LaunchAgent)
138
138
  const WHISPER_SERVER_PORT = 8178
139
139
  const WHISPER_SERVER_URL = `http://127.0.0.1:${WHISPER_SERVER_PORT}`
140
+ const PROCESS_PROBE_TIMEOUT_MS = 2_000
141
+ const PROCESS_PROBE_MAX_BUFFER = 1024 * 1024
142
+ const PS_BIN = '/bin/ps'
143
+ const LSOF_BIN = existsSync('/usr/sbin/lsof') ? '/usr/sbin/lsof' : 'lsof'
140
144
 
141
145
  // Track which backends are available
142
146
  let cliAvailable = false
@@ -153,6 +157,10 @@ let serverStarting = false // Initial model load is not a circuit fai
153
157
  let serverStartPromise: Promise<void> | null = null
154
158
  let serverRestartPromise: Promise<WhisperRestartResult> | null = null
155
159
  let serverHealthProbe: Promise<boolean> | null = null
160
+ type WhisperStartupState = 'not_started' | 'preflight' | 'loading' | 'ready' | 'unavailable' | 'failed' | 'stopped'
161
+ let serverStartupState: WhisperStartupState = 'not_started'
162
+ let serverLastAttemptAt: string | null = null
163
+ let serverLastError: string | null = null
156
164
 
157
165
  interface ProcessEntry {
158
166
  pid: number
@@ -167,10 +175,34 @@ export interface WhisperRestartResult {
167
175
 
168
176
  const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
169
177
 
170
- function listProcesses(): ProcessEntry[] {
178
+ function boundedError(value: unknown): string {
179
+ const message = value instanceof Error ? value.message : String(value)
180
+ return message.replace(/[\r\n\t]+/g, ' ').slice(0, 240)
181
+ }
182
+
183
+ function runProcessProbe(file: string, args: string[]): Promise<string> {
184
+ return new Promise((resolve, reject) => {
185
+ execFile(file, args, {
186
+ encoding: 'utf8',
187
+ timeout: PROCESS_PROBE_TIMEOUT_MS,
188
+ maxBuffer: PROCESS_PROBE_MAX_BUFFER,
189
+ killSignal: 'SIGKILL',
190
+ }, (error, stdout) => {
191
+ if (error) reject(error)
192
+ else resolve(String(stdout))
193
+ })
194
+ })
195
+ }
196
+
197
+ async function listProcesses(): Promise<ProcessEntry[]> {
171
198
  // `command=` includes arguments, which lets us distinguish this COS-owned
172
199
  // port/model signature from unrelated whisper-server instances.
173
- const output = execFileSync('ps', ['-axww', '-o', 'pid=,ppid=,command='], { encoding: 'utf8' })
200
+ let output: string
201
+ try {
202
+ output = await runProcessProbe(PS_BIN, ['-axww', '-o', 'pid=,ppid=,command='])
203
+ } catch (error) {
204
+ throw new Error(`unable to inspect process table: ${boundedError(error)}`)
205
+ }
174
206
  return output.split('\n').flatMap(line => {
175
207
  const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/)
176
208
  if (!match) return []
@@ -178,24 +210,25 @@ function listProcesses(): ProcessEntry[] {
178
210
  })
179
211
  }
180
212
 
181
- function listeningPids(): number[] {
213
+ async function listeningPids(): Promise<number[]> {
182
214
  try {
183
- const output = execFileSync(
184
- 'lsof',
215
+ const output = await runProcessProbe(
216
+ LSOF_BIN,
185
217
  ['-nP', `-iTCP:${WHISPER_SERVER_PORT}`, '-sTCP:LISTEN', '-t'],
186
- { encoding: 'utf8' },
187
218
  )
188
219
  return output.split(/\s+/).map(Number).filter(pid => Number.isInteger(pid) && pid > 0)
189
220
  } catch (err: any) {
190
221
  // lsof uses exit 1 for "no matches". Anything else means we could not
191
222
  // prove the port state, so startup must fail closed.
192
- if (err?.status === 1) return []
193
- throw new Error(`unable to inspect whisper-server port ${WHISPER_SERVER_PORT}: ${err?.message ?? err}`)
223
+ if (err?.code === 1 || err?.status === 1) return []
224
+ throw new Error(`unable to inspect whisper-server port ${WHISPER_SERVER_PORT}: ${boundedError(err)}`)
194
225
  }
195
226
  }
196
227
 
197
228
  function isCosWhisperServerCommand(command: string): boolean {
198
- const executable = /(?:^|\s)(?:\S*\/)?whisper-server(?:\s|$)/.test(command)
229
+ const firstToken = command.trim().match(/^(?:"([^"]+)"|'([^']+)'|(\S+))/)
230
+ const executablePath = firstToken?.[1] ?? firstToken?.[2] ?? firstToken?.[3] ?? ''
231
+ const executable = basename(executablePath) === 'whisper-server'
199
232
  const configuredPort = new RegExp(`(?:^|\\s)--port(?:=|\\s+)${WHISPER_SERVER_PORT}(?:\\s|$)`).test(command)
200
233
  return executable && configuredPort && command.includes(MODEL_PATH)
201
234
  }
@@ -249,7 +282,7 @@ async function killAndReapWhisperProcesses(): Promise<void> {
249
282
  serverAvailable = false
250
283
 
251
284
  for (let round = 0; round < 3; round++) {
252
- const processes = listProcesses()
285
+ const processes = await listProcesses()
253
286
  const ownedPids = [...ownedServerChildren]
254
287
  .map(child => child.pid)
255
288
  .filter((pid): pid is number => typeof pid === 'number')
@@ -288,7 +321,7 @@ async function killAndReapWhisperProcesses(): Promise<void> {
288
321
  await sleep(50)
289
322
  }
290
323
 
291
- const remaining = listProcesses().filter(entry => isCosWhisperServerCommand(entry.command))
324
+ const remaining = (await listProcesses()).filter(entry => isCosWhisperServerCommand(entry.command))
292
325
  if (remaining.length > 0) {
293
326
  throw new Error(`stale whisper-server process(es) remain: ${remaining.map(entry => entry.pid).join(', ')}`)
294
327
  }
@@ -298,11 +331,11 @@ async function killAndReapWhisperProcesses(): Promise<void> {
298
331
 
299
332
  async function proveWhisperPortClear(): Promise<void> {
300
333
  for (let attempt = 0; attempt < 20; attempt++) {
301
- const pids = listeningPids()
334
+ const pids = await listeningPids()
302
335
  if (pids.length === 0) return
303
336
  if (attempt < 19) await sleep(250)
304
337
  }
305
- const pids = listeningPids()
338
+ const pids = await listeningPids()
306
339
  throw new Error(
307
340
  `whisper-server port ${WHISPER_SERVER_PORT} remains occupied${pids.length ? ` by PID(s) ${pids.join(', ')}` : ''}`,
308
341
  )
@@ -334,10 +367,17 @@ export async function startWhisperServer(): Promise<void> {
334
367
  if (serverAvailable && serverProcess) return
335
368
 
336
369
  serverStarting = true
370
+ serverStartupState = 'preflight'
371
+ serverLastAttemptAt = new Date().toISOString()
372
+ serverLastError = null
337
373
  const operation = startWhisperServerAttempt()
338
374
  serverStartPromise = operation
339
375
  try {
340
376
  await operation
377
+ } catch (error) {
378
+ serverStartupState = 'failed'
379
+ serverLastError = boundedError(error)
380
+ throw error
341
381
  } finally {
342
382
  if (serverStartPromise === operation) serverStartPromise = null
343
383
  serverStarting = false
@@ -346,6 +386,8 @@ export async function startWhisperServer(): Promise<void> {
346
386
 
347
387
  async function startWhisperServerAttempt(preflightCompleted = false): Promise<void> {
348
388
  if (!existsSync(WHISPER_SERVER) || !existsSync(MODEL_PATH)) {
389
+ serverStartupState = 'unavailable'
390
+ serverLastError = 'whisper-server or model not found'
349
391
  console.log('[whisper-local] whisper-server or model not found — using CLI fallback')
350
392
  return
351
393
  }
@@ -353,9 +395,12 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
353
395
  // The API process is the sole Whisper owner. Never adopt an untracked daemon:
354
396
  // reap stale trees, then prove the fixed local port is free before spawning.
355
397
  if (!preflightCompleted) {
398
+ console.log('[whisper-local] preflight: inspecting owned processes and port 8178')
399
+ serverStartupState = 'preflight'
356
400
  await killAndReapWhisperProcesses()
357
401
  await proveWhisperPortClear()
358
402
  }
403
+ serverStartupState = 'loading'
359
404
 
360
405
  // Assemble startup args. VAD only attaches if the ggml model is actually on
361
406
  // disk — missing-file is logged, not fatal (server still boots without VAD).
@@ -402,6 +447,8 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
402
447
  if (serverProcess !== child) return
403
448
  serverAvailable = false
404
449
  serverProcess = null
450
+ serverStartupState = 'failed'
451
+ serverLastError = `whisper-server exited${code == null ? '' : ` with code ${code}`}`
405
452
  if (code !== null && code !== 0) {
406
453
  console.warn(`[whisper-local] whisper-server exited with code ${code}`)
407
454
  }
@@ -422,6 +469,8 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
422
469
  const res = await fetch(`${WHISPER_SERVER_URL}/health`, { signal: AbortSignal.timeout(1000) })
423
470
  if (res.ok) {
424
471
  serverAvailable = true
472
+ serverStartupState = 'ready'
473
+ serverLastError = null
425
474
  const loadTime = ((Date.now() - startTime) / 1000).toFixed(1)
426
475
  console.log(`[whisper-local] whisper-server ready on port ${WHISPER_SERVER_PORT} (loaded in ${loadTime}s)`)
427
476
  return
@@ -444,6 +493,8 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
444
493
  console.error(`[whisper-local] ${failure} — reaping child and keeping local backend unavailable`)
445
494
  await killAndReapWhisperProcesses()
446
495
  await proveWhisperPortClear()
496
+ serverStartupState = 'failed'
497
+ serverLastError = failure
447
498
  throw new Error(failure)
448
499
  }
449
500
 
@@ -461,6 +512,7 @@ export function stopWhisperServer(): void {
461
512
  try { proc.kill('SIGKILL') } catch { /* already exited */ }
462
513
  }
463
514
  ownedHqChildren.clear()
515
+ serverStartupState = 'stopped'
464
516
  }
465
517
 
466
518
  export function isWhisperLocalAvailable(): boolean {
@@ -480,17 +532,25 @@ export function getWhisperBackend(): 'server' | 'cli' | 'none' {
480
532
  /** Detailed health status for diagnostics (/api/health) */
481
533
  export function getWhisperHealth(): {
482
534
  server: boolean
535
+ serverConfigured: boolean
483
536
  cli: boolean
484
537
  consecutiveFailures: number
485
538
  restarting: boolean
486
539
  circuitOpen: boolean
540
+ startupState: WhisperStartupState
541
+ lastAttemptAt: string | null
542
+ lastError: string | null
487
543
  } {
488
544
  return {
489
545
  server: serverAvailable,
546
+ serverConfigured: existsSync(WHISPER_SERVER) && existsSync(MODEL_PATH),
490
547
  cli: cliAvailable,
491
548
  consecutiveFailures: serverConsecutiveFailures,
492
549
  restarting: serverRestarting || serverStarting,
493
550
  circuitOpen: serverConsecutiveFailures >= SERVER_FAILURE_THRESHOLD,
551
+ startupState: serverStartupState,
552
+ lastAttemptAt: serverLastAttemptAt,
553
+ lastError: serverLastError,
494
554
  }
495
555
  }
496
556
 
@@ -604,14 +664,22 @@ export async function transcribeHighQuality(
604
664
 
605
665
  const text = await new Promise<string>((resolve, reject) => {
606
666
  const isolateBatchFromLiveMetal = opts.priority === 'batch'
667
+ // Interactive HQ: narrower beam (default 2) for latency. Meeting batch keeps 5.
668
+ // Override: COS_HQ_BEAM_INTERACTIVE=N
669
+ const interactiveBeamRaw = Number.parseInt(process.env.COS_HQ_BEAM_INTERACTIVE || '2', 10)
670
+ const interactiveBeam = Number.isFinite(interactiveBeamRaw) && interactiveBeamRaw >= 1
671
+ ? Math.min(interactiveBeamRaw, 5)
672
+ : 2
673
+ const beam = isolateBatchFromLiveMetal ? 5 : interactiveBeam
674
+ const bestOf = beam
607
675
  const args = [
608
676
  '-m', modelPath,
609
677
  '-f', tmpWav,
610
678
  '-t', isolateBatchFromLiveMetal ? '8' : '16',
611
679
  '-l', 'en',
612
680
  ...(isolateBatchFromLiveMetal ? ['-ng'] : ['-fa']),
613
- '-bs', '5', // Beam search width 5 (default disabled)
614
- '-bo', '5', // Best-of-5 candidates (default 2)
681
+ '-bs', String(beam),
682
+ '-bo', String(bestOf),
615
683
  '--no-timestamps',
616
684
  '-np',
617
685
  '--prompt', buildPrompt(context),
@@ -966,6 +1034,9 @@ export async function restartWhisperServer(): Promise<WhisperRestartResult> {
966
1034
  if (serverRestartPromise) return serverRestartPromise
967
1035
 
968
1036
  serverRestarting = true
1037
+ serverStartupState = 'preflight'
1038
+ serverLastAttemptAt = new Date().toISOString()
1039
+ serverLastError = null
969
1040
  const priorStart = serverStartPromise
970
1041
  const operation = (async (): Promise<WhisperRestartResult> => {
971
1042
  try {
@@ -984,6 +1055,8 @@ export async function restartWhisperServer(): Promise<WhisperRestartResult> {
984
1055
  throw new Error('whisper-server did not become healthy')
985
1056
  }
986
1057
  serverConsecutiveFailures = 0
1058
+ serverStartupState = 'ready'
1059
+ serverLastError = null
987
1060
  console.log('[whisper-local] Server restarted successfully — circuit breaker CLOSED')
988
1061
  return { status: 'recovered' }
989
1062
  } catch (err: any) {
@@ -992,6 +1065,8 @@ export async function restartWhisperServer(): Promise<WhisperRestartResult> {
992
1065
  // breaker cycle, and the next three failed calls may request one new cycle.
993
1066
  serverConsecutiveFailures = 0
994
1067
  const message = err?.message ?? String(err)
1068
+ serverStartupState = 'failed'
1069
+ serverLastError = boundedError(message)
995
1070
  console.error(`[whisper-local] Server restart error: ${message} — will retry after next 3 failures`)
996
1071
  return { status: 'failed', error: message }
997
1072
  }
@@ -180,6 +180,23 @@ healthRouter.get('/health', async (_req, res) => {
180
180
  // Whisper-server health + cloud budget — exposed so glasses + dashboards can
181
181
  // see whether we're at risk of falling to cloud and how much budget remains.
182
182
  const whisper_health = getWhisperHealth()
183
+ const whisperReadiness = !whisper_health.serverConfigured
184
+ ? 'not_configured'
185
+ : whisper_health.server
186
+ ? 'ready'
187
+ : whisper_health.startupState === 'preflight' || whisper_health.startupState === 'loading'
188
+ ? 'starting'
189
+ : 'degraded'
190
+ const readiness = {
191
+ // /api/health remains a liveness endpoint and intentionally returns HTTP
192
+ // 200 while the server can answer. This separate field prevents an HTTP-
193
+ // green response from hiding a configured local subsystem failure.
194
+ status: whisperReadiness === 'degraded' ? 'degraded' : 'ready',
195
+ admissions: maintenance.admissionsOpen ? 'open' : 'maintenance',
196
+ whisper: whisperReadiness,
197
+ whisperError: whisper_health.lastError,
198
+ localTts: tts_local.ready ? 'ready' : 'unavailable',
199
+ }
183
200
  const openai_whisper_budget = getOpenAIWhisperBudgetState()
184
201
  const codex_models = getCodexModelCatalogSnapshot()
185
202
  res.json({
@@ -190,6 +207,7 @@ healthRouter.get('/health', async (_req, res) => {
190
207
  generation_id: getServerGenerationId(),
191
208
  features,
192
209
  voice,
210
+ readiness,
193
211
  whisper_health,
194
212
  openai_whisper_budget,
195
213
  tts_local,
@@ -295,6 +295,21 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
295
295
  void inflightPromise.catch(() => { /* duplicate waiters observe the original rejection */ })
296
296
  inflightQueries.set(dedupKey, { promise: inflightPromise, timestamp: Date.now() })
297
297
 
298
+ // Register disconnect cancellation before the provider starts. G2 can abort
299
+ // its first fetch while Claude/Codex continues running; without this signal
300
+ // the provider and its maintenance lease can strand a Control restart for
301
+ // the full model timeout. The lease is released only after the bridge reaches
302
+ // its terminal callback/catch, never merely because the socket disappeared.
303
+ const providerAbort = new AbortController()
304
+ let responseFinished = false
305
+ let clientDisconnected = false
306
+ res.once('finish', () => { responseFinished = true })
307
+ res.once('close', () => {
308
+ if (responseFinished) return
309
+ clientDisconnected = true
310
+ providerAbort.abort(new Error('G2 client disconnected'))
311
+ })
312
+
298
313
  // ── Streaming response (SSE) ──
299
314
  if (stream) {
300
315
  res.writeHead(200, {
@@ -326,7 +341,7 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
326
341
  try {
327
342
  const returnedSid = await callModelStreaming(query, currentSessionId, {
328
343
  onChunk: (text) => {
329
- if (!done) {
344
+ if (!done && !clientDisconnected) {
330
345
  if (!firstChunkLogged) {
331
346
  firstChunkLogged = true
332
347
  actualTtfbMs = Date.now() - requestReceivedAt
@@ -360,16 +375,18 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
360
375
  stream_requested: true,
361
376
  })
362
377
  // Final chunk with finish_reason
363
- const finalChunk = {
364
- id: completionId,
365
- object: 'chat.completion.chunk',
366
- created: timestamp,
367
- model: responseModel,
368
- choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
378
+ if (!clientDisconnected) {
379
+ const finalChunk = {
380
+ id: completionId,
381
+ object: 'chat.completion.chunk',
382
+ created: timestamp,
383
+ model: responseModel,
384
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
385
+ }
386
+ res.write(`data: ${JSON.stringify(finalChunk)}\n\n`)
387
+ res.write('data: [DONE]\n\n')
388
+ res.end()
369
389
  }
370
- res.write(`data: ${JSON.stringify(finalChunk)}\n\n`)
371
- res.write('data: [DONE]\n\n')
372
- res.end()
373
390
  }
374
391
  } finally {
375
392
  maintenanceLease.release()
@@ -381,29 +398,34 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
381
398
  done = true
382
399
  rejectInflight!(new Error(error))
383
400
  inflightQueries.delete(dedupKey)
384
- const errChunk = {
385
- id: completionId,
386
- object: 'chat.completion.chunk',
387
- created: timestamp,
388
- model: responseModel,
389
- choices: [{ index: 0, delta: { content: `Error: ${error}` }, finish_reason: 'stop' }],
401
+ if (!clientDisconnected) {
402
+ const errChunk = {
403
+ id: completionId,
404
+ object: 'chat.completion.chunk',
405
+ created: timestamp,
406
+ model: responseModel,
407
+ choices: [{ index: 0, delta: { content: `Error: ${error}` }, finish_reason: 'stop' }],
408
+ }
409
+ res.write(`data: ${JSON.stringify(errChunk)}\n\n`)
410
+ res.write('data: [DONE]\n\n')
411
+ res.end()
390
412
  }
391
- res.write(`data: ${JSON.stringify(errChunk)}\n\n`)
392
- res.write('data: [DONE]\n\n')
393
- res.end()
394
413
  }
395
414
  } finally {
396
415
  maintenanceLease.release()
397
416
  }
398
417
  },
399
418
  onToolStatus: (status) => {
400
- if (!done) {
419
+ if (!done && !clientDisconnected) {
401
420
  // SSE comment — invisible to JSON parsers but keeps connection alive
402
421
  res.write(`: ${status}\n\n`)
403
422
  }
404
423
  },
405
424
  onStart: () => {},
406
- }, resolvedModel, undefined, undefined, undefined, { lightweight: true })
425
+ }, resolvedModel, undefined, undefined, undefined, {
426
+ lightweight: true,
427
+ abortSignal: providerAbort.signal,
428
+ })
407
429
  // Persist session ID for multi-turn context on subsequent G2 queries
408
430
  g2SessionId = returnedSid
409
431
  } catch (err: any) {
@@ -412,13 +434,13 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
412
434
  done = true
413
435
  rejectInflight!(err)
414
436
  inflightQueries.delete(dedupKey)
415
- res.write(`data: ${JSON.stringify({ error: { message: err.message } })}\n\n`)
416
- res.write('data: [DONE]\n\n')
417
- res.end()
437
+ if (!clientDisconnected) {
438
+ res.write(`data: ${JSON.stringify({ error: { message: err.message } })}\n\n`)
439
+ res.write('data: [DONE]\n\n')
440
+ res.end()
441
+ }
418
442
  }
419
443
  }
420
-
421
- req.on('close', () => { done = true })
422
444
  return
423
445
  }
424
446
 
@@ -479,11 +501,15 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
479
501
  },
480
502
  onToolStatus: () => {},
481
503
  onStart: () => {},
482
- }, resolvedModel, undefined, undefined, undefined, { lightweight: true })
504
+ }, resolvedModel, undefined, undefined, undefined, {
505
+ lightweight: true,
506
+ abortSignal: providerAbort.signal,
507
+ })
483
508
  .then(sid => { g2SessionId = sid })
484
509
  .catch(fail)
485
510
  })
486
511
 
512
+ if (clientDisconnected) return
487
513
  res.json({
488
514
  id: completionId,
489
515
  object: 'chat.completion',
@@ -497,6 +523,7 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
497
523
  usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
498
524
  })
499
525
  } catch (err: any) {
526
+ if (clientDisconnected) return
500
527
  res.status(500).json({
501
528
  error: { message: err.message, type: 'server_error' },
502
529
  })
@@ -52,9 +52,18 @@ export const promptDraftsRouter = Router()
52
52
  const MAX_CHUNK_BYTES = 25 * 1024 * 1024
53
53
  const MAX_DRAFT_BYTES = 256 * 1024 * 1024
54
54
  const MAX_CHUNKS = 600
55
+ /** Purpose-scoped keys (legacy). Prefer modeQualityJobs for HQ warm↔finalize dedupe. */
55
56
  const chunkTranscriptJobs = new Map<string, Promise<string>>()
57
+ /** Shared decode per draft/chunk/mode/hash — warm:hq and final:hq await the same promise. */
58
+ const modeQualityJobs = new Map<string, Promise<string>>()
56
59
  const finalizeJobs = new Map<string, Promise<any>>()
57
60
  let warmTail: Promise<void> = Promise.resolve()
61
+ let hqWarmTail: Promise<void> = Promise.resolve()
62
+
63
+ /** Speculative HQ warm while speaking. Set COS_HQ_SPECULATIVE_WARM=0 to restore Fast-only warm. */
64
+ function speculativeHqWarmEnabled(): boolean {
65
+ return !['0', 'false', 'off'].includes((process.env.COS_HQ_SPECULATIVE_WARM ?? '1').toLowerCase())
66
+ }
58
67
 
59
68
  const autoCleanBreaker = createBreaker({ label: 'dictation-autoclean' })
60
69
  const autoCleanCountFile = () => process.env.COS_DICTATION_AUTOCLEAN_COUNT_FILE || dataPath('.dictation_autoclean_count.json')
@@ -164,14 +173,49 @@ async function sendDraftError(res: Response, draftId: string, err: any): Promise
164
173
  res.status(err.status ?? 500).json({ error: err.message })
165
174
  }
166
175
 
176
+ function modeQualityKey(draftId: string, chunkIndex: number, mode: 'hq' | 'fast', hash: string): string {
177
+ return `${draftId}:${chunkIndex}:${mode}:${hash}`
178
+ }
179
+
167
180
  async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffer, mode: 'hq' | 'fast', purpose: 'warm' | 'final'): Promise<string> {
168
181
  const hash = audioHash(audio)
169
- const key = `${draftId}:${chunkIndex}:${purpose}:${mode}:${hash}`
170
- const existing = chunkTranscriptJobs.get(key)
182
+ const purposeKey = `${draftId}:${chunkIndex}:${purpose}:${mode}:${hash}`
183
+ const sharedKey = modeQualityKey(draftId, chunkIndex, mode, hash)
184
+
185
+ // HQ warm and HQ finalize must share one decode (plan step 8a).
186
+ const existingShared = modeQualityJobs.get(sharedKey)
187
+ if (existingShared) {
188
+ try {
189
+ const text = await existingShared
190
+ if (purpose === 'final' && text) {
191
+ const current = loadPromptDraftMeta(draftId)
192
+ const warm = current?.warmTranscripts?.[String(chunkIndex)]
193
+ const cachedFinal = current?.finalTranscripts?.[String(chunkIndex)]
194
+ if (!cachedFinal || cachedFinal.hash !== hash) {
195
+ await markPromptDraftChunkTranscript(draftId, chunkIndex, {
196
+ text,
197
+ hash,
198
+ requestedMode: warm?.requestedMode ?? mode,
199
+ actualQuality: warm?.actualQuality ?? (mode === 'hq' ? 'hq' : 'fast'),
200
+ backend: warm?.backend ?? 'shared-inflight',
201
+ degraded: warm?.degraded ?? false,
202
+ }, 'final')
203
+ }
204
+ }
205
+ return text
206
+ } catch {
207
+ // Shared warm failed under local-only; fall through so finalize can retry with automatic.
208
+ }
209
+ }
210
+
211
+ const existing = chunkTranscriptJobs.get(purposeKey)
171
212
  if (existing) return existing
213
+
172
214
  const job = (async () => {
173
215
  try {
174
- const result = await transcribeAudioBuffer(audio, { mode, policy: purpose === 'warm' ? 'local-only' : 'automatic' })
216
+ // Speculative warm is always local-only. Finalize may use automatic cloud fallback.
217
+ const policy = purpose === 'warm' ? 'local-only' as const : 'automatic' as const
218
+ const result = await transcribeAudioBuffer(audio, { mode, policy })
175
219
  if (!isCurrentChunk(draftId, chunkIndex, audio)) return ''
176
220
  const text = sanitizeTranscript(draftId, result.text)
177
221
  const record: PromptDraftTranscriptRecord = {
@@ -179,7 +223,7 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
179
223
  backend: result.backend, degraded: result.degraded,
180
224
  }
181
225
  await markPromptDraftChunkTranscript(draftId, chunkIndex, record, purpose)
182
- console.log(`[prompt-draft] chunk ${draftId}/${chunkIndex}: ${result.elapsedMs.toFixed(1)}ms | ${result.backend} | ${text.length} chars`)
226
+ console.log(`[prompt-draft] chunk ${draftId}/${chunkIndex}: ${result.elapsedMs.toFixed(1)}ms | ${result.backend} | ${purpose}/${mode} | ${text.length} chars`)
183
227
  return text
184
228
  } catch (err) {
185
229
  if (err instanceof NoSpeechDetectedError) {
@@ -190,10 +234,12 @@ async function transcribeChunk(draftId: string, chunkIndex: number, audio: Buffe
190
234
  }
191
235
  throw err
192
236
  } finally {
193
- chunkTranscriptJobs.delete(key)
237
+ chunkTranscriptJobs.delete(purposeKey)
238
+ modeQualityJobs.delete(sharedKey)
194
239
  }
195
240
  })()
196
- chunkTranscriptJobs.set(key, job)
241
+ chunkTranscriptJobs.set(purposeKey, job)
242
+ modeQualityJobs.set(sharedKey, job)
197
243
  return job
198
244
  }
199
245
 
@@ -203,9 +249,17 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
203
249
  const texts: string[] = []
204
250
  for (const chunk of readPromptDraftChunks(draftId)) {
205
251
  try {
252
+ const hash = audioHash(chunk.audioBuffer)
253
+ // Await in-flight HQ warm before deciding cache miss (plan step 8b belt).
254
+ if (mode === 'hq') {
255
+ const inflight = modeQualityJobs.get(modeQualityKey(draftId, chunk.chunkIndex, 'hq', hash))
256
+ if (inflight) {
257
+ try { await inflight } catch { /* finalize may retry with automatic below */ }
258
+ }
259
+ }
206
260
  const current = loadPromptDraftMeta(draftId)
207
261
  const cached = current?.finalTranscripts?.[String(chunk.chunkIndex)] ?? current?.warmTranscripts?.[String(chunk.chunkIndex)]
208
- const reusable = Boolean(cached && cached.hash === audioHash(chunk.audioBuffer) && (mode === 'fast' ? cached.actualQuality === 'fast' : cached.actualQuality === 'hq'))
262
+ const reusable = Boolean(cached && cached.hash === hash && (mode === 'fast' ? cached.actualQuality === 'fast' : cached.actualQuality === 'hq'))
209
263
  const raw = reusable ? cached!.text : await transcribeChunk(draftId, chunk.chunkIndex, chunk.audioBuffer, mode, 'final')
210
264
  const text = sanitizeTranscript(draftId, raw, !reusable)
211
265
  if (text.trim()) texts.push(text.trim())
@@ -265,10 +319,13 @@ promptDraftsRouter.post('/prompt-drafts/:draftId/chunks', async (req, res) => {
265
319
  const nextTotal = Object.values(before.chunkBytes).reduce((sum, bytes) => sum + bytes, 0) - existingBytes + audio.length
266
320
  if (nextTotal > MAX_DRAFT_BYTES) return res.status(413).json({ error: 'prompt draft too large' })
267
321
  const meta = await savePromptDraftChunk(req.params.draftId, chunkIndex, audio)
322
+ const requestedMode = routeMode(req)
268
323
  const warmLease = acquireMaintenanceWork('prompt_draft_warm', {
269
324
  allowDuringDrain: true,
270
325
  phase: 'queued',
271
326
  })
327
+ // Fast warm feeds the live HUD. Speculative HQ (when Settings HQ / default)
328
+ // overwrites warmTranscripts with actualQuality=hq for near-instant finalize.
272
329
  warmTail = warmTail.then(async () => {
273
330
  warmLease.setPhase('active')
274
331
  const text = await transcribeChunk(req.params.draftId, chunkIndex, audio, 'fast', 'warm')
@@ -283,6 +340,21 @@ promptDraftsRouter.post('/prompt-drafts/:draftId/chunks', async (req, res) => {
283
340
  }).catch(err => {
284
341
  console.warn(`[prompt-draft] warm transcription failed ${req.params.draftId}/${chunkIndex}: ${err.message}`)
285
342
  }).finally(() => warmLease.release())
343
+
344
+ if (requestedMode === 'hq' && speculativeHqWarmEnabled()) {
345
+ const hqLease = acquireMaintenanceWork('prompt_draft_warm', {
346
+ allowDuringDrain: true,
347
+ phase: 'queued',
348
+ })
349
+ hqWarmTail = hqWarmTail.then(async () => {
350
+ hqLease.setPhase('active')
351
+ // Cache only — never emitDisplay HQ (avoids HUD flicker). local-only via purpose=warm.
352
+ await transcribeChunk(req.params.draftId, chunkIndex, audio, 'hq', 'warm')
353
+ }).catch(err => {
354
+ console.warn(`[prompt-draft] speculative HQ warm failed ${req.params.draftId}/${chunkIndex}: ${err.message}`)
355
+ }).finally(() => hqLease.release())
356
+ }
357
+
286
358
  res.json({ draftId: meta.draftId, chunkIndex, acked: true, receivedChunkIndexes: meta.receivedChunkIndexes, chunkBytes: meta.chunkBytes[String(chunkIndex)] ?? audio.length, transcriptPending: true, expiresAt: meta.expiresAt })
287
359
  } catch (err: any) {
288
360
  if (err instanceof MaintenanceLifecycleError) {
@@ -1,5 +1,11 @@
1
1
  import { Router } from 'express'
2
2
  import { runProviderProof, type ProofProvider } from '../lib/provider-proof.js'
3
+ import {
4
+ acquireMaintenanceWork,
5
+ maintenanceErrorPayload,
6
+ maintenanceOperationCredentialsValid,
7
+ MaintenanceLifecycleError,
8
+ } from '../lib/maintenance-lifecycle.js'
3
9
 
4
10
  export const providerProofRouter = Router()
5
11
 
@@ -14,6 +20,38 @@ providerProofRouter.post('/diagnostics/provider-proof', async (req, res) => {
14
20
  if (provider !== 'claude' && provider !== 'codex') {
15
21
  return res.status(400).json({ error: 'provider must be claude or codex' })
16
22
  }
17
- const result = await runProviderProof(provider as ProofProvider)
18
- return res.status(result.ok ? 200 : 503).json(result)
23
+ const controllerProof = maintenanceOperationCredentialsValid({
24
+ leaseId: typeof req.headers['x-cos-maintenance-lease'] === 'string'
25
+ ? req.headers['x-cos-maintenance-lease'] : undefined,
26
+ operationId: typeof req.headers['x-cos-maintenance-operation'] === 'string'
27
+ ? req.headers['x-cos-maintenance-operation'] : undefined,
28
+ nonce: typeof req.headers['x-cos-maintenance-nonce'] === 'string'
29
+ ? req.headers['x-cos-maintenance-nonce'] : undefined,
30
+ })
31
+ let lease
32
+ try {
33
+ lease = acquireMaintenanceWork('api_mutation', { allowDuringDrain: controllerProof })
34
+ } catch (error) {
35
+ if (error instanceof MaintenanceLifecycleError) {
36
+ if (error.retryAfterSeconds != null) res.setHeader('Retry-After', String(error.retryAfterSeconds))
37
+ return res.status(error.status).json(maintenanceErrorPayload(error))
38
+ }
39
+ return res.status(500).json({ error: 'maintenance_internal_error', retryable: false })
40
+ }
41
+
42
+ const abort = new AbortController()
43
+ let responseFinished = false
44
+ const cancel = () => { if (!responseFinished) abort.abort(new Error('Control proof client disconnected')) }
45
+ req.once('aborted', cancel)
46
+ res.once('finish', () => { responseFinished = true })
47
+ res.once('close', cancel)
48
+ try {
49
+ const result = await runProviderProof(provider as ProofProvider, abort.signal)
50
+ if (abort.signal.aborted) return
51
+ return res.status(result.ok ? 200 : 503).json(result)
52
+ } finally {
53
+ req.removeListener('aborted', cancel)
54
+ res.removeListener('close', cancel)
55
+ lease.release()
56
+ }
19
57
  })