@gotcos/glasses-server 6.18.7 → 6.19.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.
@@ -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
 
@@ -61,6 +61,9 @@ const modeQualityJobs = new Map<string, Promise<string>>()
61
61
  const finalizeJobs = new Map<string, Promise<any>>()
62
62
  let warmTail: Promise<void> = Promise.resolve()
63
63
  let hqWarmTail: Promise<void> = Promise.resolve()
64
+ /** Peek decodes — drop-while-busy; never chained onto warmTail (HOL vs HQ). */
65
+ let peekTail: Promise<void> = Promise.resolve()
66
+ let peekBusy = false
64
67
 
65
68
  /** Speculative HQ warm while speaking. Set COS_HQ_SPECULATIVE_WARM=0 to restore Fast-only warm. */
66
69
  function speculativeHqWarmEnabled(): boolean {
@@ -372,6 +375,80 @@ promptDraftsRouter.post('/prompt-drafts/start', (req, res) => {
372
375
  }
373
376
  })
374
377
 
378
+ /**
379
+ * Provisional Turbo peek — cosmetic lens UX only.
380
+ * Does NOT save chunks, advance the recovery ledger, or write warm/final transcripts.
381
+ * Drop-while-busy: overlapping peeks return 204 without queueing behind HQ warm.
382
+ */
383
+ promptDraftsRouter.post('/prompt-drafts/:draftId/peek', async (req, res) => {
384
+ try {
385
+ const meta = loadPromptDraftMeta(req.params.draftId)
386
+ if (!meta) return void res.status(404).json({ error: 'draft not found' })
387
+
388
+ const rawIndex = Array.isArray(req.query.chunkIndex) ? req.query.chunkIndex[0] : req.query.chunkIndex
389
+ const chunkIndex = Number(rawIndex)
390
+ if (!Number.isInteger(chunkIndex) || chunkIndex < 0) {
391
+ return void res.status(400).json({ error: 'chunkIndex required' })
392
+ }
393
+ const rawGen = Array.isArray(req.query.peekGen) ? req.query.peekGen[0] : req.query.peekGen
394
+ const peekGen = Number(rawGen)
395
+ if (!Number.isInteger(peekGen) || peekGen < 0) {
396
+ return void res.status(400).json({ error: 'peekGen required' })
397
+ }
398
+
399
+ if (peekBusy) {
400
+ return void res.status(204).send()
401
+ }
402
+
403
+ const audio = await readRawBody(req)
404
+ if (!audio.length) return void res.status(400).json({ error: 'empty audio' })
405
+ if (audio.length > MAX_CHUNK_BYTES) return void res.status(413).json({ error: 'audio chunk too large' })
406
+
407
+ const lease = acquireMaintenanceWork('prompt_draft_peek', {
408
+ allowDuringDrain: true,
409
+ phase: 'queued',
410
+ })
411
+
412
+ peekBusy = true
413
+ const draftId = req.params.draftId
414
+ peekTail = peekTail.then(async () => {
415
+ lease.setPhase('active')
416
+ try {
417
+ const result = await transcribeAudioBuffer(audio, {
418
+ mode: 'fast',
419
+ policy: 'local-only',
420
+ affectsCircuit: false,
421
+ })
422
+ const text = sanitizeTranscript(draftId, result.text, false)
423
+ if (!text) return
424
+ if (!loadPromptDraftMeta(draftId)) return
425
+ emitDisplay({
426
+ type: 'prompt_transcript',
427
+ data: { draftId, chunkIndex, text, provisional: true, peekGen },
428
+ })
429
+ } catch (err: any) {
430
+ if (!(err instanceof NoSpeechDetectedError)) {
431
+ console.warn(`[prompt-draft] peek failed ${draftId}/${chunkIndex}: ${err?.message ?? err}`)
432
+ }
433
+ } finally {
434
+ peekBusy = false
435
+ lease.release()
436
+ }
437
+ }).catch(() => {
438
+ peekBusy = false
439
+ lease.release()
440
+ })
441
+
442
+ res.json({ draftId, chunkIndex, peekGen, accepted: true })
443
+ } catch (err: any) {
444
+ if (err instanceof MaintenanceLifecycleError) {
445
+ if (err.retryAfterSeconds != null) res.setHeader('Retry-After', String(err.retryAfterSeconds))
446
+ return void res.status(err.status).json({ ...maintenanceErrorPayload(err), draftPreserved: true })
447
+ }
448
+ res.status(err.status ?? (err.message === 'draft not found' ? 404 : 500)).json({ error: err.message })
449
+ }
450
+ })
451
+
375
452
  promptDraftsRouter.post('/prompt-drafts/:draftId/chunks', async (req, res) => {
376
453
  let maintenanceLease: MaintenanceWorkLease | undefined
377
454
  try {
@@ -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