@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.
- package/.cos-profile.example.json +3 -3
- package/.env.example +18 -0
- package/CHANGELOG.md +78 -0
- package/README.md +23 -0
- package/bin/cli.cjs +156 -4
- package/managed-runtime-contract.json +3 -0
- package/package.json +1 -1
- package/server/index.ts +16 -1
- package/server/lib/hallucination-filter.ts +5 -8
- package/server/lib/maintenance-lifecycle.ts +8 -0
- package/server/lib/meeting-batch-progress.ts +150 -3
- package/server/lib/meeting-batch-transcribe.ts +29 -2
- package/server/lib/profile.ts +81 -2
- package/server/lib/unsaved-audio-quarantine.ts +314 -0
- package/server/lib/whisper-local.ts +7 -14
- package/server/lib/whisper-preview.ts +243 -0
- package/server/routes/glossary.ts +2 -11
- package/server/routes/health.ts +34 -2
- package/server/routes/meeting.ts +281 -2
- package/server/routes/prompt-drafts.ts +2 -5
- package/server/routes/transcribe-stream.ts +65 -13
package/server/routes/health.ts
CHANGED
|
@@ -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: {
|
|
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: {
|
|
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
|
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
|
|
|
@@ -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
|
|
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
|
-
//
|
|
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
|
|