@gotcos/glasses-server 6.15.3 → 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.
@@ -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
  })