@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.
- package/.env.example +11 -0
- package/CHANGELOG.md +52 -0
- package/README.md +6 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +1 -1
- package/server/lib/archive.ts +3 -0
- package/server/lib/conversation.ts +1 -0
- package/server/lib/maintenance-lifecycle.ts +9 -0
- package/server/lib/meeting-batch-progress.ts +150 -3
- package/server/lib/meeting-batch-transcribe.ts +29 -2
- package/server/lib/query-job-runtime.ts +2 -2
- package/server/lib/transcribe-audio.ts +14 -15
- package/server/lib/unsaved-audio-quarantine.ts +314 -0
- package/server/lib/whisper-local.ts +49 -30
- package/server/routes/health.ts +16 -0
- package/server/routes/meeting.ts +281 -2
- package/server/routes/prompt-drafts.ts +77 -0
- package/server/routes/transcribe-stream.ts +65 -13
package/server/routes/meeting.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|