@gotcos/glasses-server 6.18.8 → 6.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,6 +38,9 @@ import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
38
38
  import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
39
39
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
40
40
  import { getMeetingSyncSnapshot } from '../lib/meeting-batch-progress.js'
41
+ import { listUnsavedCaptures } from '../lib/unsaved-audio-quarantine.js'
42
+ import { getWhisperPreviewCapability } from '../lib/whisper-preview.js'
43
+ import { getTranscriptionProfileStatus } from '../lib/profile.js'
41
44
 
42
45
  export const healthRouter = Router()
43
46
 
@@ -202,6 +205,8 @@ healthRouter.get('/health', async (_req, res) => {
202
205
  const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
203
206
  const transcription = getTranscriptionPolicySnapshot()
204
207
  const transcriptionHq = getHighQualityTranscriptionCapability()
208
+ const transcriptionLive = getWhisperPreviewCapability()
209
+ const transcriptionProfile = getTranscriptionProfileStatus()
205
210
  const recovery = managedRuntimeCapability()
206
211
  const maintenance = maintenanceLifecycle.snapshot()
207
212
  const tts_local = getLocalTtsHealth()
@@ -261,6 +266,20 @@ healthRouter.get('/health', async (_req, res) => {
261
266
  const cursorSnapshot = getCursorModelCatalogSnapshot()
262
267
  const { agentBinary: _cursorAgentBinary, ...cursor_models } = cursorSnapshot
263
268
  const meeting_sync = getMeetingSyncSnapshot()
269
+ // Quarantined unsaved captures (6.19.0). Compact on this unauthenticated
270
+ // surface — same exposure level as meeting_sync's meetingIds. Full detail
271
+ // plus the recover action live on the authenticated /api/meeting/orphans.
272
+ const unsavedList = listUnsavedCaptures()
273
+ const unsaved_captures = {
274
+ count: unsavedList.filter(item => !item.recovered).length,
275
+ items: unsavedList.slice(0, 10).map(item => ({
276
+ sessionId: item.sessionId,
277
+ ageHours: item.ageHours,
278
+ chunkFiles: item.chunkFiles,
279
+ expiresAt: item.expiresAt,
280
+ recovered: item.recovered,
281
+ })),
282
+ }
264
283
  res.json({
265
284
  ...checks,
266
285
  server_version: managedServerVersion(),
@@ -276,8 +295,14 @@ healthRouter.get('/health', async (_req, res) => {
276
295
  codex_models,
277
296
  cursor_models,
278
297
  meeting_sync,
298
+ unsaved_captures,
279
299
  capabilities: {
280
- transcription: { ...transcription, hq: transcriptionHq },
300
+ transcription: {
301
+ ...transcription,
302
+ live: transcriptionLive,
303
+ hq: transcriptionHq,
304
+ profile: transcriptionProfile,
305
+ },
281
306
  recovery,
282
307
  maintenance: {
283
308
  state: maintenance.state,
@@ -310,6 +335,8 @@ healthRouter.get('/models', async (req, res) => {
310
335
  const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
311
336
  const transcription = getTranscriptionPolicySnapshot()
312
337
  const transcriptionHq = getHighQualityTranscriptionCapability()
338
+ const transcriptionLive = getWhisperPreviewCapability()
339
+ const transcriptionProfile = getTranscriptionProfileStatus()
313
340
  const cursorOptions = cursorCatalog.options.filter(option => !!option.id)
314
341
  res.json({
315
342
  ...catalog,
@@ -325,7 +352,12 @@ healthRouter.get('/models', async (req, res) => {
325
352
  enabled: durableJobs.enabled,
326
353
  protocolVersion: durableJobs.protocolVersion,
327
354
  },
328
- transcription: { ...transcription, hq: transcriptionHq },
355
+ transcription: {
356
+ ...transcription,
357
+ live: transcriptionLive,
358
+ hq: transcriptionHq,
359
+ profile: transcriptionProfile,
360
+ },
329
361
  cliDebug: CLI_DEBUG_CAPABILITY,
330
362
  recovery: managedRuntimeCapability(),
331
363
  // Same helper as /api/health — the companion's 15s liveness poll reads
@@ -2,7 +2,8 @@
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 { rmSync } from 'node:fs'
5
+ import { readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
6
+ import { resolve } from 'node:path'
6
7
  import { Router } from 'express'
7
8
  import { emitDisplay } from '../lib/display-bus.js'
8
9
  import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
@@ -17,7 +18,29 @@ import {
17
18
  persistBatchDecisionSidecar,
18
19
  replaceMeetingTranscriptAtomic,
19
20
  } from '../lib/meeting-batch-persistence.js'
20
- import { runMeetingBatchPipeline } from '../lib/meeting-batch-transcribe.js'
21
+ import {
22
+ BATCH_PENDING_MARKER,
23
+ clearMeetingBatchProgress,
24
+ readMeetingBatchProgress,
25
+ writeMeetingBatchTerminal,
26
+ } from '../lib/meeting-batch-progress.js'
27
+ import {
28
+ enqueueSerializedHqWork,
29
+ runMeetingBatchPipeline,
30
+ segmentTranscriptChunks,
31
+ transcribeSegments,
32
+ } from '../lib/meeting-batch-transcribe.js'
33
+ import {
34
+ clearActiveRecovery,
35
+ findQuarantineDir,
36
+ listUnsavedCaptures,
37
+ markRecovered,
38
+ registerActiveRecovery,
39
+ } from '../lib/unsaved-audio-quarantine.js'
40
+ import {
41
+ clearSessionHallucinationState,
42
+ stripInlineHallucinations,
43
+ } from '../lib/hallucination-filter.js'
21
44
  import {
22
45
  selectBatchTranscriptForPersistence,
23
46
  type BatchTranscription,
@@ -376,9 +399,255 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
376
399
  }
377
400
  })
378
401
 
402
+ // ── Unsaved-capture recovery (6.19.0) ─────────────────────────────────
403
+ // Surface-only by decision (Miles, 2026-08-02): the server NEVER drives
404
+ // recovery on its own. It lists what the quarantine holds, and one
405
+ // authenticated POST drives one capture to a durable scribe.
406
+ const recoveringOrphans = new Set<string>()
407
+
408
+ router.get('/meeting/orphans', (_req, res) => {
409
+ res.set('Cache-Control', 'private, no-store')
410
+ const items = listUnsavedCaptures()
411
+ // In-flight recovery progress: transcribeSegments writes its progress file
412
+ // into the quarantine dir, invisible to meeting_sync (different root) —
413
+ // surface it here so a long recovery is not a black box.
414
+ const recoveringProgress: Record<string, { segmentsDone: number; segmentsTotal: number } | null> = {}
415
+ for (const id of recoveringOrphans) {
416
+ const dir = findQuarantineDir(id)
417
+ const progress = dir ? readMeetingBatchProgress(dir) : null
418
+ recoveringProgress[id] = progress
419
+ ? { segmentsDone: progress.segmentsDone, segmentsTotal: progress.segmentsTotal }
420
+ : null
421
+ }
422
+ res.json({
423
+ count: items.filter(item => !item.recovered).length,
424
+ recovering: [...recoveringOrphans],
425
+ recoveringProgress,
426
+ items,
427
+ })
428
+ })
429
+
430
+ router.post('/meeting/orphans/:sessionId/recover', (req, res) => {
431
+ const sessionId = String(req.params.sessionId ?? '')
432
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
433
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
434
+ return
435
+ }
436
+ const body = (req.body ?? {}) as Record<string, unknown>
437
+ if (body.title !== undefined && typeof body.title !== 'string') {
438
+ res.status(400).json({ error: 'Invalid title', reason: 'invalid_title' })
439
+ return
440
+ }
441
+ if (body.domain !== undefined && typeof body.domain !== 'string') {
442
+ res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
443
+ return
444
+ }
445
+ // Idempotent: a capture that already reached a durable save replays its
446
+ // receipt — same contract as POST /meeting/save.
447
+ const alreadySaved = store.findBySessionId(sessionId)
448
+ if (alreadySaved) {
449
+ // Stamp the quarantine receipt too, or a saved-but-quarantined capture
450
+ // (e.g. save succeeded but the pending handoff failed, marker expired,
451
+ // sweep quarantined the leftovers — the meeting IS saved) counts as
452
+ // "unsaved" on health forever, a false alarm that trains the user to
453
+ // ignore the one channel built to report real losses.
454
+ const staleQuarantine = findQuarantineDir(sessionId)
455
+ if (staleQuarantine) markRecovered(staleQuarantine, alreadySaved.filename)
456
+ res.set('Cache-Control', 'private, no-store')
457
+ res.json({ accepted: false, alreadySaved: true, receipt: publicSaveResponse(alreadySaved, true) })
458
+ return
459
+ }
460
+ const quarantineDir = findQuarantineDir(sessionId)
461
+ if (!quarantineDir) {
462
+ res.status(404).json({ error: 'No quarantined audio for this session', reason: 'orphan_not_found' })
463
+ return
464
+ }
465
+ if (recoveringOrphans.has(sessionId)) {
466
+ res.status(409).json({ error: 'Recovery already in progress', reason: 'recovery_in_progress' })
467
+ return
468
+ }
469
+ const capture = synthesizeEntriesFromChunkWavs(quarantineDir)
470
+ const entries = capture.entries
471
+ if (entries.length === 0) {
472
+ res.status(422).json({ error: 'Quarantined directory holds no readable chunk audio', reason: 'no_chunk_audio' })
473
+ return
474
+ }
475
+
476
+ // Acquire BEFORE marking the session as recovering: if a drain gate makes
477
+ // acquire throw, nothing must linger in recoveringOrphans — a leaked entry
478
+ // would 409 every retry for the exact capture this route exists to save.
479
+ let lease: MaintenanceWorkLease
480
+ try {
481
+ lease = acquireMaintenanceWork('orphan_recovery', { phase: 'queued' })
482
+ } catch {
483
+ res.status(503).json({
484
+ error: 'Server is draining for maintenance — retry after the update completes',
485
+ reason: 'maintenance_drain',
486
+ })
487
+ return
488
+ }
489
+ recoveringOrphans.add(sessionId)
490
+ // Registry drives two protections: meeting_sync renders this as an active
491
+ // row (COS Control warns before an Update Server drain walks into a
492
+ // 20-90 min decode), and purgeExpiredQuarantine will not delete the dir
493
+ // mid-run even at the retention boundary.
494
+ registerActiveRecovery(sessionId, quarantineDir)
495
+ res.status(202).set('Cache-Control', 'private, no-store').json({
496
+ accepted: true,
497
+ sessionId,
498
+ chunkFiles: entries.length,
499
+ note: 'Recovery runs in the background behind the HQ decoder queue — expect minutes for a long meeting '
500
+ + '(watch recoveringProgress on GET /api/meeting/orphans). Speakers are labeled Unknown: no live ASR ever '
501
+ + 'ran for this capture. The capture leaves the unsaved count once its scribe is durable.',
502
+ })
503
+
504
+ const task = Promise.resolve().then(async () => {
505
+ lease.setPhase('active')
506
+ const startedAt = Date.now()
507
+ const segments = segmentTranscriptChunks(entries)
508
+ // transcribeSegments carries the batch contract (enhancement, Metal
509
+ // preemption discard + one CPU retry, overlap stripping) but NOT the
510
+ // decoder serialization — that lives in the queue tail. Chain onto it,
511
+ // or two back-to-back recoveries plus a live post-meeting batch would
512
+ // run parallel 16-thread large-v3 decoders on the user's Mac.
513
+ const results = await enqueueSerializedHqWork(
514
+ () => transcribeSegments(quarantineDir, segments, entries),
515
+ )
516
+ // Batch whisper over a dead session has no streaming baseline to
517
+ // compare against (evaluateBatchQuality needs one), but the inline
518
+ // hallucination filter needs none — run the same two-pass the boot
519
+ // session-recovery path uses: pass 1 builds the frequency blocklist
520
+ // across all segments, pass 2 strips with the final blocklist.
521
+ for (const item of results) {
522
+ if (item.text) stripInlineHallucinations(item.text, sessionId)
523
+ }
524
+ for (const item of results) {
525
+ if (item.text) item.text = stripInlineHallucinations(item.text, sessionId)
526
+ }
527
+ clearSessionHallucinationState(sessionId)
528
+ const transcript = cleanFinalTranscript(results.map(item => item.text).join(' '))
529
+ if (!transcript.trim()) {
530
+ throw new Error('recovery produced an empty transcript')
531
+ }
532
+ const recoveredChunks = results.map(item => ({
533
+ text: item.text,
534
+ speaker: 'Unknown',
535
+ elapsed: item.segment.startElapsed,
536
+ similarity: 0,
537
+ words: item.words,
538
+ canonical: true,
539
+ }))
540
+ const saved = store.save({
541
+ sessionId,
542
+ title: typeof body.title === 'string' && body.title.trim() ? body.title : undefined,
543
+ domain: typeof body.domain === 'string' && body.domain.trim() ? body.domain : undefined,
544
+ transcript,
545
+ startTime: capture.startTime,
546
+ durationMs: capture.durationMs,
547
+ chunks: recoveredChunks,
548
+ chunkEntries: recoveredChunks.map((chunk, position) => ({
549
+ chunkIndex: results[position]?.segment.startChunkIdx ?? position,
550
+ chunk,
551
+ })),
552
+ transferIntegrity: null,
553
+ })
554
+ markRecovered(quarantineDir, saved.filename)
555
+ console.log(
556
+ `[meeting/orphans] Recovered ${sessionId} → ${saved.filename} `
557
+ + `(${entries.length} chunks, ${Math.round((Date.now() - startedAt) / 1000)}s)`,
558
+ )
559
+ try {
560
+ emit({
561
+ type: 'recording_stop',
562
+ data: {
563
+ sessionId,
564
+ filename: saved.filename,
565
+ durationMin: saved.durationMin,
566
+ domain: saved.domain,
567
+ },
568
+ })
569
+ } catch { /* display is best-effort */ }
570
+ if (cosOpsPipelineConfigured()) {
571
+ await handoffMeetingToOperations(saved.filepath)
572
+ }
573
+ }).catch(error => {
574
+ // The quarantined audio is untouched on failure — retry stays possible
575
+ // until the retention clock clears it.
576
+ console.error(
577
+ `[meeting/orphans] Recovery failed for ${sessionId}: `
578
+ + `${error instanceof Error ? error.message : String(error)}`,
579
+ )
580
+ }).finally(() => {
581
+ // transcribeSegments wrote progress + a pending marker into the
582
+ // quarantine dir; the batch pipeline's own finally never runs here, so
583
+ // clean them up or the dir carries stale work files until retention.
584
+ try { clearMeetingBatchProgress(quarantineDir) } catch { /* best-effort */ }
585
+ try { unlinkSync(resolve(quarantineDir, BATCH_PENDING_MARKER)) } catch { /* best-effort */ }
586
+ clearActiveRecovery(quarantineDir)
587
+ recoveringOrphans.delete(sessionId)
588
+ lease.release()
589
+ })
590
+ scheduleBackground(task)
591
+ })
592
+
379
593
  return router
380
594
  }
381
595
 
596
+ const CHUNK_WAV_NAME = /^chunk_(\d{4})\.wav$/
597
+ const WAV_HEADER_BYTES = 44
598
+ const PCM_BYTES_PER_MS = 32 // 16 kHz mono 16-bit
599
+
600
+ interface RecoveredCapture {
601
+ entries: IndexedTranscriptChunk[]
602
+ /** Meeting start ≈ the earliest chunk's write time (chunk files keep their
603
+ * original mtimes across renames — the pending-batch cleanup relies on the
604
+ * same property). Falls back to now-minus-duration when mtimes are unusable. */
605
+ startTime: number
606
+ durationMs: number
607
+ }
608
+
609
+ /** Rebuild a chunk timeline for a dead session from its WAV files alone:
610
+ * index from the filename, duration from PCM byte length, elapsed cumulative.
611
+ * Text is empty — recovery exists precisely because no ASR ever ran. */
612
+ function synthesizeEntriesFromChunkWavs(audioDir: string): RecoveredCapture {
613
+ const rows: Array<{ chunkIndex: number; durationMs: number; mtimeMs: number }> = []
614
+ try {
615
+ for (const name of readdirSync(audioDir)) {
616
+ const match = CHUNK_WAV_NAME.exec(name)
617
+ if (!match) continue
618
+ try {
619
+ const stats = statSync(resolve(audioDir, name))
620
+ rows.push({
621
+ chunkIndex: Number(match[1]),
622
+ durationMs: Math.max(0, Math.round((stats.size - WAV_HEADER_BYTES) / PCM_BYTES_PER_MS)),
623
+ mtimeMs: stats.mtimeMs,
624
+ })
625
+ } catch { /* unreadable chunk — skip */ }
626
+ }
627
+ } catch {
628
+ return { entries: [], startTime: Date.now(), durationMs: 0 }
629
+ }
630
+ rows.sort((a, b) => a.chunkIndex - b.chunkIndex)
631
+ let elapsed = 0
632
+ const entries = rows.map(row => {
633
+ const entry: IndexedTranscriptChunk = {
634
+ chunkIndex: row.chunkIndex,
635
+ chunk: { text: '', speaker: 'Unknown', elapsed, similarity: 0 },
636
+ }
637
+ elapsed += row.durationMs
638
+ return entry
639
+ })
640
+ const durationMs = elapsed
641
+ const earliestMtime = rows.reduce(
642
+ (minimum, row) => (Number.isFinite(row.mtimeMs) && row.mtimeMs > 0 ? Math.min(minimum, row.mtimeMs) : minimum),
643
+ Number.POSITIVE_INFINITY,
644
+ )
645
+ const startTime = Number.isFinite(earliestMtime) && earliestMtime !== Number.POSITIVE_INFINITY
646
+ ? Math.round(earliestMtime)
647
+ : Date.now() - durationMs
648
+ return { entries, startTime, durationMs }
649
+ }
650
+
382
651
  async function finalizeBatch(options: {
383
652
  audioDir: string
384
653
  entries: IndexedTranscriptChunk[]
@@ -431,6 +700,16 @@ async function finalizeBatch(options: {
431
700
  rmSync(options.audioDir, { recursive: true, force: true })
432
701
  } else {
433
702
  console.warn('[meeting/save] Pending raw audio retained for bounded cleanup')
703
+ // The batch reached a terminal outcome but its WAVs stay behind. Record it
704
+ // so meeting_sync reports "retained", not perpetual active work. Rejected
705
+ // and pipeline-failed runs already wrote their terminal inside runBatch;
706
+ // this covers the accepted-but-not-fully-persisted case.
707
+ if (result.transcriptionQuality === 'batch') {
708
+ writeMeetingBatchTerminal(options.audioDir, {
709
+ outcome: 'accepted',
710
+ reason: transcriptApplied ? 'metadata_persist_failed' : 'transcript_apply_failed',
711
+ })
712
+ }
434
713
  }
435
714
  }
436
715
 
@@ -34,6 +34,7 @@ import {
34
34
  applyNegativeRules,
35
35
  } from '../lib/hallucination-filter.js'
36
36
  import { applyCorrections } from '../lib/whisper-local.js'
37
+ import { transcribeWhisperPreview } from '../lib/whisper-preview.js'
37
38
  import { autoCleanDictation, AUTOCLEAN_MAX_CHARS } from '../lib/dictation-clean.js'
38
39
  import { getVocabulary } from '../lib/profile.js'
39
40
  import { createBreaker } from '../lib/claude-circuit.js'
@@ -414,11 +415,7 @@ promptDraftsRouter.post('/prompt-drafts/:draftId/peek', async (req, res) => {
414
415
  peekTail = peekTail.then(async () => {
415
416
  lease.setPhase('active')
416
417
  try {
417
- const result = await transcribeAudioBuffer(audio, {
418
- mode: 'fast',
419
- policy: 'local-only',
420
- affectsCircuit: false,
421
- })
418
+ const result = await transcribeWhisperPreview(audio)
422
419
  const text = sanitizeTranscript(draftId, result.text, false)
423
420
  if (!text) return
424
421
  if (!loadPromptDraftMeta(draftId)) return
@@ -35,6 +35,12 @@ import {
35
35
  isVocabEchoOnly,
36
36
  } from '../lib/hallucination-filter.js'
37
37
  import { dataPath } from '../lib/data-dir.js'
38
+ import {
39
+ countChunkWavs,
40
+ purgeExpiredQuarantine,
41
+ quarantineSessionAudio,
42
+ sweepOrphanedSessionAudio,
43
+ } from '../lib/unsaved-audio-quarantine.js'
38
44
  import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
39
45
  import {
40
46
  LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
@@ -612,16 +618,28 @@ function recoverSessions(): void {
612
618
  }
613
619
  } catch { /* skip corrupt files */ }
614
620
  }
615
- // Remove orphaned session-audio dirs with no matching recovered session
621
+ // Orphaned session-audio dirs with no matching recovered session: audio
622
+ // evidence is QUARANTINED, never deleted (6.19.0 — two meetings were
623
+ // destroyed here on 2026-08-01 when their deferred saves never landed).
624
+ // Only chunk-less dirs are removed.
616
625
  try {
617
626
  if (existsSync(SESSION_AUDIO_DIR)) {
618
- for (const dir of readdirSync(SESSION_AUDIO_DIR)) {
619
- if (!recoveredIds.has(dir) && !hasFreshPreservedAudioMarker(resolve(SESSION_AUDIO_DIR, dir))) {
620
- rmSync(resolve(SESSION_AUDIO_DIR, dir), { recursive: true, force: true })
621
- console.log(`[session-recovery] Cleaned orphaned session-audio: ${dir}`)
627
+ const actions = sweepOrphanedSessionAudio(SESSION_AUDIO_DIR, {
628
+ isLive: id => recoveredIds.has(id),
629
+ hasFreshPreservedMarker: hasFreshPreservedAudioMarker,
630
+ reason: 'boot_sweep_unsaved',
631
+ })
632
+ for (const entry of actions) {
633
+ if (entry.action === 'quarantined') {
634
+ console.warn(`[session-recovery] Quarantined unsaved session-audio: ${entry.dir} → ${entry.target}`)
635
+ } else if (entry.action === 'deleted_empty') {
636
+ console.log(`[session-recovery] Cleaned empty session-audio: ${entry.dir}`)
637
+ } else if (entry.action === 'quarantine_failed') {
638
+ console.error(`[session-recovery] Quarantine move failed, source retained: ${entry.dir}`)
622
639
  }
623
640
  }
624
641
  }
642
+ purgeExpiredQuarantine()
625
643
  } catch {}
626
644
  } catch { /* non-critical */ }
627
645
  }
@@ -658,13 +676,24 @@ setInterval(() => {
658
676
  closeTranscriptSession(id, 'expired')
659
677
  }
660
678
  }
661
- // Purge orphaned session-audio dirs (no matching active session)
679
+ // Orphaned session-audio dirs (no matching active session): quarantine any
680
+ // dir still holding chunk audio; delete only chunk-less dirs. Quarantine
681
+ // itself expires on the unsaved-audio retention clock, the ONLY place
682
+ // quarantined audio is ever deleted.
662
683
  try {
663
- for (const dir of readdirSync(SESSION_AUDIO_DIR)) {
664
- if (!sessions.has(dir) && !hasFreshPreservedAudioMarker(resolve(SESSION_AUDIO_DIR, dir))) {
665
- rmSync(resolve(SESSION_AUDIO_DIR, dir), { recursive: true, force: true })
684
+ const actions = sweepOrphanedSessionAudio(SESSION_AUDIO_DIR, {
685
+ isLive: id => sessions.has(id),
686
+ hasFreshPreservedMarker: hasFreshPreservedAudioMarker,
687
+ reason: 'idle_expiry_unsaved',
688
+ })
689
+ for (const entry of actions) {
690
+ if (entry.action === 'quarantined') {
691
+ console.warn(`[cleanup] Quarantined unsaved session-audio: ${entry.dir} → ${entry.target}`)
692
+ } else if (entry.action === 'quarantine_failed') {
693
+ console.error(`[cleanup] Quarantine move failed, source retained: ${entry.dir}`)
666
694
  }
667
695
  }
696
+ purgeExpiredQuarantine()
668
697
  } catch {}
669
698
  // Purge stale pending-batch dirs. HQ large-v3 on long meetings + Control
670
699
  // restart can exceed 2h (2026-07-27: two sessions purged before batch).
@@ -1022,7 +1051,7 @@ function closeTranscriptSession(
1022
1051
  maxChunkIndex,
1023
1052
  reason,
1024
1053
  })
1025
- finishClosingTranscriptSession(sessionId, options)
1054
+ finishClosingTranscriptSession(sessionId, { ...options, closeReason: reason })
1026
1055
  }
1027
1056
 
1028
1057
  /** Delete session after save */
@@ -1030,7 +1059,10 @@ export function deleteSession(sessionId: string, options: { preserveAudio?: bool
1030
1059
  closeTranscriptSession(sessionId, 'saved', options)
1031
1060
  }
1032
1061
 
1033
- function finishClosingTranscriptSession(sessionId: string, options: { preserveAudio?: boolean }): void {
1062
+ function finishClosingTranscriptSession(
1063
+ sessionId: string,
1064
+ options: { preserveAudio?: boolean; closeReason?: ClosedTranscriptSession['reason'] },
1065
+ ): void {
1034
1066
  sessions.delete(sessionId)
1035
1067
  sessionAudioBytes.delete(sessionId)
1036
1068
  sessionAudioWrites.delete(sessionId)
@@ -1049,8 +1081,28 @@ function finishClosingTranscriptSession(sessionId: string, options: { preserveAu
1049
1081
  writeFileSync(marker, String(Date.now()), { encoding: 'utf8', mode: 0o600 })
1050
1082
  chmodSync(marker, 0o600)
1051
1083
  } catch {}
1052
- } else {
1053
- try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
1084
+ } else if (options.closeReason === 'saved') {
1085
+ // Saved sessions moved their audio to pending-batch (or explicitly
1086
+ // preserved it above); a leftover dir here is residue, safe to delete.
1087
+ // UNLESS chunks are still present — hasAudio() returns false on a
1088
+ // transient statSync error, which makes preserveAudio false while the
1089
+ // move never ran. Chunk-bearing evidence goes to quarantine, never rmSync.
1090
+ if (countChunkWavs(audioDir) > 0) {
1091
+ const target = quarantineSessionAudio(audioDir, 'close_saved_residual_chunks')
1092
+ if (target) console.warn(`[transcribe-stream] Saved-close left chunk audio behind; quarantined: ${sessionId} → ${target}`)
1093
+ } else {
1094
+ try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
1095
+ }
1096
+ } else if (existsSync(audioDir)) {
1097
+ // Any non-saved close (expired, error, …) with chunk audio still on disk
1098
+ // is an unsaved capture. Quarantine it — never delete evidence (6.19.0).
1099
+ if (countChunkWavs(audioDir) > 0) {
1100
+ const target = quarantineSessionAudio(audioDir, `close_${options.closeReason ?? 'unknown'}`)
1101
+ if (target) console.warn(`[transcribe-stream] Quarantined unsaved session-audio on close: ${sessionId} → ${target}`)
1102
+ else console.error(`[transcribe-stream] Quarantine move failed on close, source retained: ${sessionId}`)
1103
+ } else {
1104
+ try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
1105
+ }
1054
1106
  }
1055
1107
  }
1056
1108