@gotcos/glasses-server 6.21.7 → 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
@@ -59,6 +59,10 @@ import {
59
59
  } from '../lib/maintenance-lifecycle.js'
60
60
  import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
61
61
  import { preemptMetalBatchForLive, registerLiveActivityProbe } from '../lib/whisper-metal-gate.js'
62
+ import {
63
+ scheduleProgressiveHqCheckpoint,
64
+ stopProgressiveHqSession,
65
+ } from '../lib/meeting-batch-transcribe.js'
62
66
 
63
67
  /** Preempt hook 2 of 2 (1C): the recording_chunk backstop. Live audio is
64
68
  * arriving, so any Metal batch must yield the GPU now. This covers recovery,
@@ -920,13 +924,68 @@ export function getSessionChunkEntries(sessionId: string): IndexedTranscriptChun
920
924
  const session = sessions.get(sessionId)
921
925
  if (!session) return null
922
926
  const entries: IndexedTranscriptChunk[] = []
923
- 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++) {
924
932
  const chunk = session.chunks[chunkIndex]
925
- 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
+ }
926
956
  }
927
957
  return entries
928
958
  }
929
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
+
930
989
  /** Get session start time */
931
990
  export function getSessionStartTime(sessionId: string): number | null {
932
991
  return sessions.get(sessionId)?.startTime ?? null
@@ -1044,6 +1103,9 @@ function closeTranscriptSession(
1044
1103
  reason: ClosedTranscriptSession['reason'],
1045
1104
  options: { preserveAudio?: boolean } = {},
1046
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)
1047
1109
  const session = sessions.get(sessionId)
1048
1110
  const now = Date.now()
1049
1111
  const receivedIndices = [...(session?.receivedIndices ?? [])]
@@ -1610,6 +1672,7 @@ async function processStreamChunk(opts: {
1610
1672
  session.emptyCompletions[String(chunkIndex)] = emptyCompletion
1611
1673
  recordAsrCompleted(session, chunkIndex)
1612
1674
  persistSessionRequired(sessionId)
1675
+ scheduleSessionProgressiveHq(sessionId)
1613
1676
  return emptyChunkResponse(emptyCompletion, sessionId, chunkIndex)
1614
1677
  }
1615
1678
 
@@ -1654,6 +1717,8 @@ async function processStreamChunk(opts: {
1654
1717
  persistSessionRequired(sessionId)
1655
1718
  console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
1656
1719
 
1720
+ scheduleSessionProgressiveHq(sessionId)
1721
+
1657
1722
  emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
1658
1723
  // Live Cues feed — fire-and-forget, NEVER awaited: transcription must not
1659
1724
  // block on a cue, and no LLM runs on this path. .catch() is required — a