@gotcos/glasses-server 6.21.6 → 6.21.9

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.
@@ -2,7 +2,7 @@
2
2
  // the standalone public meeting store. The live transcript and chunk metadata
3
3
  // are durable before the session is closed; batch improvement runs afterward.
4
4
 
5
- import { readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
5
+ import { existsSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
6
6
  import { resolve } from 'node:path'
7
7
  import { Router } from 'express'
8
8
  import { emitDisplay } from '../lib/display-bus.js'
@@ -28,6 +28,7 @@ import {
28
28
  enqueueSerializedHqWork,
29
29
  runMeetingBatchPipeline,
30
30
  segmentTranscriptChunks,
31
+ stopProgressiveHqSession,
31
32
  transcribeSegments,
32
33
  } from '../lib/meeting-batch-transcribe.js'
33
34
  import {
@@ -63,8 +64,23 @@ import {
63
64
  type TranscriptGapReport,
64
65
  } from './transcribe-stream.js'
65
66
  import { getServerInstanceId } from '../lib/server-instance-id.js'
66
- import { acquireMaintenanceWork, type MaintenanceWorkLease } from '../lib/maintenance-lifecycle.js'
67
- import { handoffMeetingToOperations } from '../lib/g2-ops-handoff.js'
67
+ import {
68
+ acquireMaintenanceWork,
69
+ maintenanceAdmissionsOpen,
70
+ type MaintenanceWorkLease,
71
+ } from '../lib/maintenance-lifecycle.js'
72
+ import {
73
+ claimMeetingInOperations,
74
+ earlyMeetingSyncEnabled,
75
+ handoffMeetingToOperations,
76
+ } from '../lib/g2-ops-handoff.js'
77
+ import {
78
+ canonicalFinalizationIsComplete,
79
+ MeetingFinalizationJobStore,
80
+ markCanonicalFinalizationState,
81
+ readFinalizationChunkEntries,
82
+ type MeetingFinalizationJob,
83
+ } from '../lib/meeting-finalization-jobs.js'
68
84
 
69
85
  function cosOpsPipelineConfigured(): boolean {
70
86
  // Read env live (not the module-load COS_SCRIPTS_DIR const) so unit tests that
@@ -92,9 +108,195 @@ export interface MeetingRouteDependencies {
92
108
  audioDir: string,
93
109
  entries: IndexedTranscriptChunk[],
94
110
  streamingWordCount: number,
111
+ sessionId?: string,
95
112
  ) => Promise<BatchTranscription>
96
113
  scheduleBackground?: (task: Promise<void>) => void
97
114
  emit?: typeof emitDisplay
115
+ finalizationJobs?: MeetingFinalizationJobStore
116
+ }
117
+
118
+ const activeFinalizationJobs = new Set<string>()
119
+ const finalizationRetryCounts = new Map<string, number>()
120
+
121
+ interface FinalizationRuntime {
122
+ finalizationJobs: MeetingFinalizationJobStore
123
+ runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
124
+ scheduleBackground: (task: Promise<void>) => void
125
+ sessions?: MeetingSessionSource
126
+ }
127
+
128
+ function validReplayEntries(raw: unknown[]): IndexedTranscriptChunk[] | null {
129
+ const entries: IndexedTranscriptChunk[] = []
130
+ for (const item of raw) {
131
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return null
132
+ const candidate = item as { chunkIndex?: unknown; chunk?: unknown }
133
+ if (!Number.isInteger(candidate.chunkIndex) || (candidate.chunkIndex as number) < 0) return null
134
+ if (!candidate.chunk || typeof candidate.chunk !== 'object' || Array.isArray(candidate.chunk)) return null
135
+ const chunk = candidate.chunk as Partial<TranscriptChunk>
136
+ if (typeof chunk.text !== 'string' || typeof chunk.speaker !== 'string') return null
137
+ if (typeof chunk.elapsed !== 'number' || !Number.isFinite(chunk.elapsed)) return null
138
+ if (typeof chunk.similarity !== 'number' || !Number.isFinite(chunk.similarity)) return null
139
+ entries.push({ chunkIndex: candidate.chunkIndex as number, chunk: chunk as TranscriptChunk })
140
+ }
141
+ return entries.length > 0 ? entries : null
142
+ }
143
+
144
+ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: FinalizationRuntime): void {
145
+ const key = `${runtime.finalizationJobs.root}:${job.sessionId}`
146
+ if (activeFinalizationJobs.has(key)) return
147
+ activeFinalizationJobs.add(key)
148
+
149
+ // Acquire before the foreground save lease can leave scope: there is no
150
+ // zero-count proof gap between pending-audio handoff and queued finalizer.
151
+ const lease = acquireMaintenanceWork('meeting_batch_finalization', {
152
+ allowDuringDrain: true,
153
+ phase: 'queued',
154
+ })
155
+ const task = Promise.resolve().then(async () => {
156
+ lease.setPhase('active')
157
+ let current = runtime.finalizationJobs.get(job.sessionId) ?? job
158
+ if (canonicalFinalizationIsComplete(current)) {
159
+ runtime.finalizationJobs.remove(current.sessionId)
160
+ return
161
+ }
162
+ if (current.phase === 'capture_pending') {
163
+ const source = runtime.sessions ?? defaultSessionSource
164
+ let audioDir = current.audioDir ?? runtime.finalizationJobs.findPendingAudioDir(current.sessionId)
165
+ if (!audioDir && source.hasAudio(current.sessionId)) {
166
+ try { await source.drainAudioWrites(current.sessionId) } catch { /* retain surviving evidence */ }
167
+ audioDir = source.moveAudioToPending(current.sessionId)
168
+ source.delete(current.sessionId, { preserveAudio: !audioDir })
169
+ }
170
+ const rawEntries = readFinalizationChunkEntries(current)
171
+ current = runtime.finalizationJobs.save({
172
+ sessionId: current.sessionId,
173
+ meetingPath: current.meetingPath,
174
+ sidecarPath: current.sidecarPath,
175
+ audioDir,
176
+ streamingWordCount: current.streamingWordCount,
177
+ phase: audioDir && rawEntries?.length ? 'batch_pending' : 'ops_pending',
178
+ claimPending: current.claimPending,
179
+ })
180
+ markCanonicalFinalizationState(current.sidecarPath, current.phase, current.claimPending)
181
+ }
182
+
183
+ if (current.claimPending) {
184
+ let claimPending: boolean = current.claimPending
185
+ if (earlyMeetingSyncEnabled()) {
186
+ try {
187
+ await claimMeetingInOperations(current.meetingPath)
188
+ claimPending = false
189
+ } catch (error) {
190
+ // Identity acceleration must never strand canonical HQ/final sync.
191
+ // Keep the durable bit for replay while the final handoff proceeds.
192
+ console.warn(
193
+ `[meeting/save] Early claim deferred for ${current.sessionId}: `
194
+ + `${error instanceof Error ? error.message : String(error)}`,
195
+ )
196
+ }
197
+ } else {
198
+ // Runtime rollback: disabling the canary must release old claim work.
199
+ claimPending = false
200
+ }
201
+ current = runtime.finalizationJobs.save({
202
+ sessionId: current.sessionId,
203
+ meetingPath: current.meetingPath,
204
+ sidecarPath: current.sidecarPath,
205
+ audioDir: current.audioDir,
206
+ streamingWordCount: current.streamingWordCount,
207
+ phase: current.phase,
208
+ claimPending,
209
+ })
210
+ markCanonicalFinalizationState(current.sidecarPath, current.phase, claimPending)
211
+ }
212
+
213
+ if (current.phase === 'batch_pending') {
214
+ if (current.audioDir && existsSync(current.audioDir)) {
215
+ const rawEntries = readFinalizationChunkEntries(current)
216
+ const entries = rawEntries ? validReplayEntries(rawEntries) : null
217
+ if (!entries) throw new Error('Durable finalization sidecar has no valid chunkEntries')
218
+ await finalizeBatch({
219
+ audioDir: current.audioDir,
220
+ entries,
221
+ streamingWordCount: current.streamingWordCount,
222
+ meetingPath: current.meetingPath,
223
+ sidecarPath: current.sidecarPath,
224
+ sessionId: current.sessionId,
225
+ runBatch: runtime.runBatch,
226
+ })
227
+ } else {
228
+ console.warn(`[meeting/save] Pending HQ audio missing for ${current.sessionId}; preserving streaming canonical`)
229
+ }
230
+ current = runtime.finalizationJobs.save({
231
+ sessionId: current.sessionId,
232
+ meetingPath: current.meetingPath,
233
+ sidecarPath: current.sidecarPath,
234
+ audioDir: current.audioDir,
235
+ streamingWordCount: current.streamingWordCount,
236
+ phase: 'ops_pending',
237
+ claimPending: current.claimPending,
238
+ })
239
+ markCanonicalFinalizationState(current.sidecarPath, 'ops_pending', current.claimPending)
240
+ }
241
+
242
+ if (cosOpsPipelineConfigured()) {
243
+ await handoffMeetingToOperations(current.meetingPath)
244
+ }
245
+ markCanonicalFinalizationState(current.sidecarPath, 'complete', false)
246
+ runtime.finalizationJobs.remove(current.sessionId)
247
+ finalizationRetryCounts.delete(key)
248
+ }).catch(error => {
249
+ const current = runtime.finalizationJobs.get(job.sessionId) ?? job
250
+ try {
251
+ runtime.finalizationJobs.save({
252
+ sessionId: current.sessionId,
253
+ meetingPath: current.meetingPath,
254
+ sidecarPath: current.sidecarPath,
255
+ audioDir: current.audioDir,
256
+ streamingWordCount: current.streamingWordCount,
257
+ phase: current.phase,
258
+ claimPending: current.claimPending,
259
+ lastError: error instanceof Error ? error.message : String(error),
260
+ })
261
+ } catch { /* keep the original durable job if error annotation fails */ }
262
+ console.error(
263
+ `[meeting/save] Durable finalization retained for retry (${job.sessionId}): `
264
+ + `${error instanceof Error ? error.message : String(error)}`,
265
+ )
266
+ const retryCount = finalizationRetryCounts.get(key) ?? 0
267
+ if (retryCount < 2) {
268
+ finalizationRetryCounts.set(key, retryCount + 1)
269
+ const retryRetained = () => {
270
+ if (!maintenanceAdmissionsOpen()) {
271
+ const waitForAdmissions = setTimeout(retryRetained, 30_000)
272
+ waitForAdmissions.unref()
273
+ return
274
+ }
275
+ const retained = runtime.finalizationJobs.get(job.sessionId)
276
+ if (retained) scheduleFinalizationJob(retained, runtime)
277
+ }
278
+ const retry = setTimeout(retryRetained, 60_000 * (retryCount + 1))
279
+ retry.unref()
280
+ }
281
+ }).finally(() => {
282
+ activeFinalizationJobs.delete(key)
283
+ lease.release()
284
+ })
285
+ runtime.scheduleBackground(task)
286
+ }
287
+
288
+ /** Boot hook: replay post-save HQ/operations work whose response already
289
+ * succeeded before a prior process exited. Safe to call more than once. */
290
+ export function resumeMeetingFinalizationJobs(): void {
291
+ const finalizationJobs = new MeetingFinalizationJobStore()
292
+ finalizationJobs.reconcileCanonicalSidecars()
293
+ for (const job of finalizationJobs.list()) {
294
+ scheduleFinalizationJob(job, {
295
+ finalizationJobs,
296
+ runBatch: runMeetingBatchPipeline,
297
+ scheduleBackground: task => { void task },
298
+ })
299
+ }
98
300
  }
99
301
 
100
302
  const defaultSessionSource: MeetingSessionSource = {
@@ -167,6 +369,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
167
369
  const runBatch = deps.runBatch ?? runMeetingBatchPipeline
168
370
  const scheduleBackground = deps.scheduleBackground ?? (task => { void task })
169
371
  const emit = deps.emit ?? emitDisplay
372
+ const finalizationJobs = deps.finalizationJobs ?? new MeetingFinalizationJobStore()
170
373
  const router = Router()
171
374
  const savingSessions = new Set<string>()
172
375
 
@@ -243,6 +446,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
243
446
  // the sidecar by session ID so client retry/restart is idempotent.
244
447
  const alreadySaved = store.findBySessionId(sessionId)
245
448
  if (alreadySaved) {
449
+ finalizationJobs.reconcileCanonicalSidecars()
450
+ const pendingJob = finalizationJobs.get(sessionId)
451
+ if (pendingJob) scheduleFinalizationJob(pendingJob, {
452
+ finalizationJobs,
453
+ runBatch,
454
+ scheduleBackground,
455
+ sessions,
456
+ })
246
457
  res.set('Cache-Control', 'private, no-store')
247
458
  res.json(publicSaveResponse(alreadySaved, true))
248
459
  return
@@ -279,6 +490,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
279
490
  ? durationFromTimeline
280
491
  : Math.max(0, Date.now() - startTime)
281
492
  const integrity = sessions.getIntegrity(sessionId)
493
+ const needsOperations = cosOpsPipelineConfigured()
494
+ const finalizationRequired = sessions.hasAudio(sessionId) || needsOperations
495
+ const claimPending = needsOperations && earlyMeetingSyncEnabled()
282
496
 
283
497
  // Initial canonical text + structured metadata are published before any
284
498
  // live state is removed or background work is scheduled.
@@ -293,8 +507,30 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
293
507
  chunkEntries,
294
508
  providerCandidates: sessions.getProviderCandidates(sessionId),
295
509
  transferIntegrity: integrity,
510
+ finalizationRequired,
511
+ claimPending,
296
512
  })
297
513
 
514
+ // Two-phase replay intent: the canonical sidecar above is the recovery
515
+ // source of truth, and this indexed job is its fast execution ledger.
516
+ let finalizationJob: MeetingFinalizationJob | null = null
517
+ if (finalizationRequired) {
518
+ finalizationJob = finalizationJobs.save({
519
+ sessionId,
520
+ meetingPath: saved.filepath,
521
+ sidecarPath: saved.sidecarPath,
522
+ audioDir: null,
523
+ streamingWordCount: countWords(transcript),
524
+ phase: 'capture_pending',
525
+ claimPending,
526
+ })
527
+ }
528
+
529
+ // Progressive HQ is a disposable cache, not a meeting owner. Abort any
530
+ // in-flight prefill before the session-audio directory is atomically
531
+ // renamed so no child can publish into a stale path after Stop.
532
+ await stopProgressiveHqSession(sessionId)
533
+
298
534
  // Wait for every raw-WAV write before rename. If any write failed, retain
299
535
  // surviving audio for recovery but do not run an incomplete batch.
300
536
  let audioWritesReady = true
@@ -332,59 +568,37 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
332
568
  console.warn('[meeting/save] Display notification failed after durable save:', error)
333
569
  }
334
570
 
571
+ const canBatch = audioWritesReady && pendingAudioDir && chunkEntries.length > 0
572
+ if (canBatch || needsOperations) {
573
+ // The replay record is durable before the response. A lost response,
574
+ // process crash, or macOS update can therefore resume the exact same
575
+ // meeting without minting a second scribe.
576
+ finalizationJob = finalizationJobs.save({
577
+ sessionId,
578
+ meetingPath: saved.filepath,
579
+ sidecarPath: saved.sidecarPath,
580
+ audioDir: canBatch ? pendingAudioDir : null,
581
+ streamingWordCount: countWords(transcript),
582
+ phase: canBatch ? 'batch_pending' : 'ops_pending',
583
+ claimPending,
584
+ })
585
+ markCanonicalFinalizationState(saved.sidecarPath, finalizationJob.phase, claimPending)
586
+ } else if (finalizationJob) {
587
+ markCanonicalFinalizationState(saved.sidecarPath, 'complete', false)
588
+ finalizationJobs.remove(sessionId)
589
+ finalizationJob = null
590
+ }
591
+
335
592
  res.set('Cache-Control', 'private, no-store')
336
593
  res.json(publicSaveResponse(saved))
337
594
 
338
- if (audioWritesReady && pendingAudioDir && chunkEntries.length > 0) {
339
- // Acquire before the foreground save lease can leave scope: there is
340
- // no zero-count proof gap between pending-audio handoff and the queued
341
- // background finalizer.
342
- const batchLease = acquireMaintenanceWork('meeting_batch_finalization', {
343
- allowDuringDrain: true,
344
- phase: 'queued',
595
+ if (finalizationJob) {
596
+ scheduleFinalizationJob(finalizationJob, {
597
+ finalizationJobs,
598
+ runBatch,
599
+ scheduleBackground,
600
+ sessions,
345
601
  })
346
- const task = Promise.resolve().then(async () => {
347
- batchLease.setPhase('active')
348
- try {
349
- await finalizeBatch({
350
- audioDir: pendingAudioDir,
351
- entries: chunkEntries.map(entry => ({ ...entry, chunk: { ...entry.chunk } })),
352
- streamingWordCount: countWords(transcript),
353
- meetingPath: saved.filepath,
354
- sidecarPath: saved.sidecarPath,
355
- runBatch,
356
- })
357
- } catch (error) {
358
- // Raw audio deliberately remains for bounded cleanup / retry.
359
- console.error(
360
- `[meeting/save] Batch finalization failed for ${sessionId}: `
361
- + `${error instanceof Error ? error.message : String(error)}`,
362
- )
363
- }
364
- // Always hand off the durable local scribe (streaming or batch) into
365
- // operations/ when COS pipeline is configured — this is what was
366
- // missing on managed public server and left today's G2 files unsynced.
367
- if (cosOpsPipelineConfigured()) {
368
- await handoffMeetingToOperations(saved.filepath)
369
- }
370
- }).catch(error => {
371
- console.error(
372
- `[meeting/save] G2 ops handoff failed for ${sessionId}: `
373
- + `${error instanceof Error ? error.message : String(error)}`,
374
- )
375
- }).finally(() => batchLease.release())
376
- scheduleBackground(task)
377
- } else if (cosOpsPipelineConfigured()) {
378
- // No HQ batch (no audio / incomplete writes) — still hand off the
379
- // streaming scribe into operations when COS pipeline is configured.
380
- scheduleBackground(
381
- handoffMeetingToOperations(saved.filepath).catch(error => {
382
- console.error(
383
- `[meeting/save] G2 ops handoff failed for ${sessionId}: `
384
- + `${error instanceof Error ? error.message : String(error)}`,
385
- )
386
- }),
387
- )
388
602
  }
389
603
  } catch (error) {
390
604
  if (error instanceof MeetingStoreError) {
@@ -654,12 +868,14 @@ async function finalizeBatch(options: {
654
868
  streamingWordCount: number
655
869
  meetingPath: string
656
870
  sidecarPath: string
871
+ sessionId?: string
657
872
  runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
658
873
  }): Promise<void> {
659
874
  const result = await options.runBatch(
660
875
  options.audioDir,
661
876
  options.entries,
662
877
  options.streamingWordCount,
878
+ options.sessionId,
663
879
  )
664
880
  let transcriptApplied = false
665
881
  let metadataPersisted = false
@@ -29,11 +29,13 @@ import {
29
29
  } from '../lib/openai-whisper-budget.js'
30
30
  import {
31
31
  stripInlineHallucinations as sharedStripInlineHallucinations,
32
+ stripInlineHallucinationsOneShot,
32
33
  isFullHallucination as sharedIsFullHallucination,
33
34
  clearSessionHallucinationState,
34
35
  streamSilenceDropReason,
35
36
  isVocabEchoOnly,
36
37
  } from '../lib/hallucination-filter.js'
38
+ import { transcribeWhisperMeetingPreview } from '../lib/whisper-preview.js'
37
39
  import { dataPath } from '../lib/data-dir.js'
38
40
  import {
39
41
  countChunkWavs,
@@ -57,6 +59,10 @@ import {
57
59
  } from '../lib/maintenance-lifecycle.js'
58
60
  import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
59
61
  import { preemptMetalBatchForLive, registerLiveActivityProbe } from '../lib/whisper-metal-gate.js'
62
+ import {
63
+ scheduleProgressiveHqCheckpoint,
64
+ stopProgressiveHqSession,
65
+ } from '../lib/meeting-batch-transcribe.js'
60
66
 
61
67
  /** Preempt hook 2 of 2 (1C): the recording_chunk backstop. Live audio is
62
68
  * arriving, so any Metal batch must yield the GPU now. This covers recovery,
@@ -101,6 +107,12 @@ function ensurePrivateDirectory(path: string): void {
101
107
  // Rollback: COS_WHISPER_STRIP_BRAND_URLS=0 (URL drops), COS_WHISPER_THANKYOU_FILTER=0.
102
108
  const STRIP_BRAND_URLS = process.env.COS_WHISPER_STRIP_BRAND_URLS !== '0'
103
109
  const THANKYOU_FILTER = process.env.COS_WHISPER_THANKYOU_FILTER !== '0'
110
+ const MEETING_PREVIEW_MAX_BYTES = 512 * 1024
111
+ let meetingPreviewBusy = false
112
+
113
+ function meetingTurboPreviewEnabled(): boolean {
114
+ return process.env.COS_WHISPER_MEETING_PREVIEW === '1'
115
+ }
104
116
 
105
117
  // Audio persistence: save G2-mic chunks for speakers who need more training data
106
118
  const AUDIO_SAVE_DIR = dataPath('training-audio')
@@ -912,13 +924,68 @@ export function getSessionChunkEntries(sessionId: string): IndexedTranscriptChun
912
924
  const session = sessions.get(sessionId)
913
925
  if (!session) return null
914
926
  const entries: IndexedTranscriptChunk[] = []
915
- for (let chunkIndex = 0; chunkIndex < session.chunks.length; chunkIndex++) {
927
+ const maxIndex = Math.max(
928
+ session.chunks.length - 1,
929
+ ...Object.keys(session.emptyCompletions ?? {}).map(Number).filter(Number.isInteger),
930
+ )
931
+ for (let chunkIndex = 0; chunkIndex <= maxIndex; chunkIndex++) {
916
932
  const chunk = session.chunks[chunkIndex]
917
- if (chunk?.text) entries.push({ chunkIndex, chunk })
933
+ if (chunk?.text) {
934
+ entries.push({ chunkIndex, chunk })
935
+ continue
936
+ }
937
+ const empty = session.emptyCompletions?.[String(chunkIndex)]
938
+ if (empty) {
939
+ entries.push({
940
+ chunkIndex,
941
+ chunk: {
942
+ text: '',
943
+ speaker: empty.speaker || 'Ext',
944
+ elapsed: empty.elapsed,
945
+ similarity: 0,
946
+ backend: empty.backend,
947
+ asrProvider: empty.asrProvider === 'iphone-whisperkit-beta'
948
+ ? 'iphone-whisperkit-beta'
949
+ : empty.asrProvider === 'server-whisper'
950
+ ? 'server-whisper'
951
+ : undefined,
952
+ fallbackReason: empty.fallbackReason,
953
+ },
954
+ })
955
+ }
918
956
  }
919
957
  return entries
920
958
  }
921
959
 
960
+ /** Private local path for guarded progressive HQ cache work. */
961
+ export function getSessionAudioDirectory(sessionId: string): string | null {
962
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
963
+ const path = resolve(SESSION_AUDIO_DIR, sessionId)
964
+ return existsSync(path) ? path : null
965
+ }
966
+
967
+ /** Snapshot the durable ASR-completion ledger for progressive HQ admission.
968
+ * Includes canonical and intentionally empty/silent chunks. */
969
+ export function getSessionAsrCompletedIndices(sessionId: string): number[] {
970
+ return [...(sessions.get(sessionId)?.asrCompletedIndices ?? [])]
971
+ }
972
+
973
+ function scheduleSessionProgressiveHq(sessionId: string): void {
974
+ // Preserve the default-off contract at the route boundary. In addition to
975
+ // avoiding any cache work, this keeps legacy/local-first transcription
976
+ // independent of the progressive HQ capability surface when the canary is
977
+ // disabled (including partial test and recovery environments).
978
+ if (process.env.COS_MEETING_PROGRESSIVE_HQ !== '1') return
979
+ const audioDir = getSessionAudioDirectory(sessionId)
980
+ if (!audioDir) return
981
+ scheduleProgressiveHqCheckpoint(
982
+ sessionId,
983
+ audioDir,
984
+ getSessionChunkEntries(sessionId) ?? [],
985
+ getSessionAsrCompletedIndices(sessionId),
986
+ )
987
+ }
988
+
922
989
  /** Get session start time */
923
990
  export function getSessionStartTime(sessionId: string): number | null {
924
991
  return sessions.get(sessionId)?.startTime ?? null
@@ -1036,6 +1103,9 @@ function closeTranscriptSession(
1036
1103
  reason: ClosedTranscriptSession['reason'],
1037
1104
  options: { preserveAudio?: boolean } = {},
1038
1105
  ): void {
1106
+ // Progressive HQ is disposable cache state. Every terminal session path —
1107
+ // save, expiry, quarantine, or abandonment — must release the single owner.
1108
+ void stopProgressiveHqSession(sessionId)
1039
1109
  const session = sessions.get(sessionId)
1040
1110
  const now = Date.now()
1041
1111
  const receivedIndices = [...(session?.receivedIndices ?? [])]
@@ -1180,6 +1250,21 @@ async function readRawBody(req: AsyncIterable<Buffer | Uint8Array | string>): Pr
1180
1250
  return Buffer.concat(buffers)
1181
1251
  }
1182
1252
 
1253
+ async function readBoundedRawBody(
1254
+ req: AsyncIterable<Buffer | Uint8Array | string>,
1255
+ maxBytes: number,
1256
+ ): Promise<Buffer> {
1257
+ const buffers: Buffer[] = []
1258
+ let total = 0
1259
+ for await (const chunk of req) {
1260
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
1261
+ total += buffer.length
1262
+ if (total > maxBytes) throw makeHttpError(413, 'audio chunk too large', 'chunk_too_large')
1263
+ buffers.push(buffer)
1264
+ }
1265
+ return Buffer.concat(buffers)
1266
+ }
1267
+
1183
1268
  async function persistRawSessionAudioChunk(sessionId: string, chunkIndex: number, audioBuffer: Buffer): Promise<void> {
1184
1269
  const sessionDir = resolve(SESSION_AUDIO_DIR, sessionId)
1185
1270
  ensurePrivateDirectory(sessionDir)
@@ -1587,6 +1672,7 @@ async function processStreamChunk(opts: {
1587
1672
  session.emptyCompletions[String(chunkIndex)] = emptyCompletion
1588
1673
  recordAsrCompleted(session, chunkIndex)
1589
1674
  persistSessionRequired(sessionId)
1675
+ scheduleSessionProgressiveHq(sessionId)
1590
1676
  return emptyChunkResponse(emptyCompletion, sessionId, chunkIndex)
1591
1677
  }
1592
1678
 
@@ -1631,6 +1717,8 @@ async function processStreamChunk(opts: {
1631
1717
  persistSessionRequired(sessionId)
1632
1718
  console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
1633
1719
 
1720
+ scheduleSessionProgressiveHq(sessionId)
1721
+
1634
1722
  emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
1635
1723
  // Live Cues feed — fire-and-forget, NEVER awaited: transcription must not
1636
1724
  // block on a cue, and no LLM runs on this path. .catch() is required — a
@@ -1664,6 +1752,70 @@ function sendStreamError(res: { status: (code: number) => { json: (body: unknown
1664
1752
  return res.status(status).json({ error: errMsg(err), reason: (err as any)?.reason })
1665
1753
  }
1666
1754
 
1755
+ /**
1756
+ * Default-off meeting preview canary.
1757
+ *
1758
+ * This route deliberately owns no meeting session, recovery ledger, speaker
1759
+ * attribution, or persistence. It accepts a pinned copy of the still-open
1760
+ * phrase and returns provisional Turbo text. Any contention or failure is a
1761
+ * 204 cosmetic miss; the canonical /transcribe-stream upload remains the only
1762
+ * durable and speaker-attributed result.
1763
+ */
1764
+ transcribeStreamRouter.post('/transcribe-stream/preview', async (req, res) => {
1765
+ try {
1766
+ if (!meetingTurboPreviewEnabled() || !maintenanceAdmissionsOpen()) {
1767
+ return void res.status(204).send()
1768
+ }
1769
+
1770
+ const headerPin = req.get('X-COS-Server-Instance')?.trim() ?? ''
1771
+ const queryPin = typeof req.query.serverInstanceId === 'string'
1772
+ ? req.query.serverInstanceId.trim()
1773
+ : ''
1774
+ if (!headerPin && !queryPin) {
1775
+ throw makeHttpError(400, 'server identity pin required', 'server_identity_pin_required')
1776
+ }
1777
+ const serverInstanceId = assertPinnedServerIdentity(headerPin, queryPin)
1778
+ const sessionId = String(req.query.sessionId ?? '')
1779
+ validateSessionId(sessionId)
1780
+ const chunkIndex = Number(req.query.chunkIndex)
1781
+ validateChunkIndex(chunkIndex)
1782
+ const previewGen = Number(req.query.previewGen)
1783
+ if (!Number.isInteger(previewGen) || previewGen < 0 || previewGen > 1_000_000_000) {
1784
+ throw makeHttpError(400, 'invalid previewGen', 'invalid_preview_generation')
1785
+ }
1786
+
1787
+ const audio = await readBoundedRawBody(req, MEETING_PREVIEW_MAX_BYTES)
1788
+ if (audio.length < 100) throw makeHttpError(400, 'audio too short', 'audio_too_short')
1789
+ if (!maintenanceAdmissionsOpen() || meetingPreviewBusy) {
1790
+ return void res.status(204).send()
1791
+ }
1792
+
1793
+ meetingPreviewBusy = true
1794
+ try {
1795
+ const result = await transcribeWhisperMeetingPreview(audio)
1796
+ if (!result) return void res.status(204).send()
1797
+ const cleaned = stripInlineHallucinationsOneShot(result.text.trim()).trim()
1798
+ if (!cleaned || sharedIsFullHallucination(cleaned)) {
1799
+ return void res.status(204).send()
1800
+ }
1801
+ return void res.json({
1802
+ sessionId,
1803
+ chunkIndex,
1804
+ previewGen,
1805
+ text: cleaned,
1806
+ provisional: true,
1807
+ model: result.model,
1808
+ backend: result.backend,
1809
+ serverInstanceId,
1810
+ })
1811
+ } finally {
1812
+ meetingPreviewBusy = false
1813
+ }
1814
+ } catch (err: unknown) {
1815
+ sendStreamError(res, err)
1816
+ }
1817
+ })
1818
+
1667
1819
  transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
1668
1820
  let maintenanceLease: MaintenanceWorkLease | undefined
1669
1821
  try {