@gotcos/glasses-server 6.9.0 → 6.11.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 +8 -0
- package/CHANGELOG.md +50 -0
- package/README.md +19 -1
- package/package.json +1 -1
- package/server/index.ts +40 -11
- package/server/lib/claude-bridge.ts +115 -12
- package/server/lib/codex-bridge.ts +151 -28
- package/server/lib/conversation.ts +194 -1
- package/server/lib/local-first-meetings-contract.ts +53 -0
- package/server/lib/model-router.ts +6 -6
- package/server/lib/query-job-coordinator.ts +580 -0
- package/server/lib/query-job-feature.ts +20 -0
- package/server/lib/query-job-runtime.ts +255 -0
- package/server/lib/query-job-store.ts +1102 -0
- package/server/lib/query-job-types.ts +358 -0
- package/server/lib/run-output-images.ts +44 -0
- package/server/routes/health.ts +68 -2
- package/server/routes/meeting.ts +31 -0
- package/server/routes/query-jobs.ts +294 -0
- package/server/routes/transcribe-stream.ts +234 -72
|
@@ -32,6 +32,13 @@ import {
|
|
|
32
32
|
isVocabEchoOnly,
|
|
33
33
|
} from '../lib/hallucination-filter.js'
|
|
34
34
|
import { dataPath } from '../lib/data-dir.js'
|
|
35
|
+
import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
36
|
+
import {
|
|
37
|
+
LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
|
|
38
|
+
compressIndexRanges,
|
|
39
|
+
retainedUntilIso,
|
|
40
|
+
type IndexRange,
|
|
41
|
+
} from '../lib/local-first-meetings-contract.js'
|
|
35
42
|
|
|
36
43
|
function ensurePrivateDirectory(path: string): void {
|
|
37
44
|
if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
|
|
@@ -200,6 +207,8 @@ interface TranscriptSession {
|
|
|
200
207
|
// Sorted, de-duplicated. See computeGapReport()/analyzeTranscriptGaps().
|
|
201
208
|
receivedIndices?: number[]
|
|
202
209
|
maxChunkIndex?: number
|
|
210
|
+
/** Persisted idle-retention clock. Meeting date/duration still use startTime. */
|
|
211
|
+
lastActivityAt: number
|
|
203
212
|
// Count of consecutive vocab-echo (prompt-regurgitation) chunks. Reset to 0 by
|
|
204
213
|
// any real-content chunk. Used to drop a RUN of echoed brand names while keeping
|
|
205
214
|
// a single loud one-off (which could be a real terse list). See sanitizeStreamTranscript.
|
|
@@ -207,22 +216,60 @@ interface TranscriptSession {
|
|
|
207
216
|
}
|
|
208
217
|
|
|
209
218
|
const sessions = new Map<string, TranscriptSession>()
|
|
210
|
-
const CLOSED_SESSION_TTL_MS =
|
|
219
|
+
const CLOSED_SESSION_TTL_MS = LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
|
|
211
220
|
const CLOSED_SESSIONS_FILE = dataPath('closed-transcript-sessions.json')
|
|
212
221
|
|
|
222
|
+
interface ClosedTranscriptSession {
|
|
223
|
+
closedAt: number
|
|
224
|
+
lastActivityAt: number
|
|
225
|
+
receivedIndices: number[]
|
|
226
|
+
maxChunkIndex: number
|
|
227
|
+
reason: 'saved' | 'expired' | 'closed'
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const closedSessionRecords = new Map<string, ClosedTranscriptSession>()
|
|
231
|
+
|
|
213
232
|
// Incremental chunk persistence — survive server restarts
|
|
214
233
|
const CHUNK_PERSIST_DIR = dataPath('active-sessions')
|
|
215
234
|
ensurePrivateDirectory(CHUNK_PERSIST_DIR)
|
|
216
235
|
|
|
217
|
-
function readClosedSessions(): Record<string,
|
|
236
|
+
function readClosedSessions(): Record<string, ClosedTranscriptSession> {
|
|
218
237
|
if (!existsSync(CLOSED_SESSIONS_FILE)) return {}
|
|
219
238
|
try {
|
|
220
239
|
const parsed = JSON.parse(readFileSync(CLOSED_SESSIONS_FILE, 'utf-8')) as unknown
|
|
221
240
|
if (!parsed || typeof parsed !== 'object') return {}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
241
|
+
const normalized: Record<string, ClosedTranscriptSession> = {}
|
|
242
|
+
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
243
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(id)) continue
|
|
244
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
245
|
+
normalized[id] = {
|
|
246
|
+
closedAt: value,
|
|
247
|
+
lastActivityAt: value,
|
|
248
|
+
receivedIndices: [],
|
|
249
|
+
maxChunkIndex: -1,
|
|
250
|
+
reason: 'closed',
|
|
251
|
+
}
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
if (!value || typeof value !== 'object') continue
|
|
255
|
+
const raw = value as Record<string, unknown>
|
|
256
|
+
const closedAt = typeof raw.closedAt === 'number' && Number.isFinite(raw.closedAt) ? raw.closedAt : null
|
|
257
|
+
if (closedAt == null) continue
|
|
258
|
+
const lastActivityAt = typeof raw.lastActivityAt === 'number' && Number.isFinite(raw.lastActivityAt)
|
|
259
|
+
? raw.lastActivityAt
|
|
260
|
+
: closedAt
|
|
261
|
+
const receivedIndices = Array.isArray(raw.receivedIndices)
|
|
262
|
+
? Array.from(new Set(
|
|
263
|
+
raw.receivedIndices.filter((entry): entry is number => Number.isInteger(entry) && (entry as number) >= 0),
|
|
264
|
+
)).sort((a, b) => a - b)
|
|
265
|
+
: []
|
|
266
|
+
const maxChunkIndex = typeof raw.maxChunkIndex === 'number' && Number.isInteger(raw.maxChunkIndex)
|
|
267
|
+
? raw.maxChunkIndex
|
|
268
|
+
: (receivedIndices.at(-1) ?? -1)
|
|
269
|
+
const reason = raw.reason === 'saved' || raw.reason === 'expired' ? raw.reason : 'closed'
|
|
270
|
+
normalized[id] = { closedAt, lastActivityAt, receivedIndices, maxChunkIndex, reason }
|
|
271
|
+
}
|
|
272
|
+
return normalized
|
|
226
273
|
} catch {
|
|
227
274
|
try {
|
|
228
275
|
renameSync(CLOSED_SESSIONS_FILE, `${CLOSED_SESSIONS_FILE}.corrupt.${Date.now()}`)
|
|
@@ -234,25 +281,31 @@ function readClosedSessions(): Record<string, number> {
|
|
|
234
281
|
function persistClosedSessions(): void {
|
|
235
282
|
const now = Date.now()
|
|
236
283
|
const merged = readClosedSessions()
|
|
237
|
-
for (const id of deletedSessions)
|
|
238
|
-
|
|
239
|
-
|
|
284
|
+
for (const id of deletedSessions) {
|
|
285
|
+
merged[id] = closedSessionRecords.get(id) ?? {
|
|
286
|
+
closedAt: now,
|
|
287
|
+
lastActivityAt: now,
|
|
288
|
+
receivedIndices: [],
|
|
289
|
+
maxChunkIndex: -1,
|
|
290
|
+
reason: 'closed',
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const [id, record] of Object.entries(merged)) {
|
|
294
|
+
if (now - record.closedAt > CLOSED_SESSION_TTL_MS) delete merged[id]
|
|
240
295
|
}
|
|
241
296
|
try {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
renameSync(tmp, CLOSED_SESSIONS_FILE)
|
|
245
|
-
try { chmodSync(CLOSED_SESSIONS_FILE, 0o600) } catch {}
|
|
246
|
-
} catch { /* best-effort tombstones */ }
|
|
297
|
+
durableAtomicWriteFileSync(CLOSED_SESSIONS_FILE, JSON.stringify(merged, null, 2), { mode: 0o600 })
|
|
298
|
+
} catch { /* best-effort tombstones; saved receipts remain authoritative */ }
|
|
247
299
|
}
|
|
248
300
|
|
|
249
301
|
function recoverClosedSessions(): void {
|
|
250
302
|
const now = Date.now()
|
|
251
303
|
const closed = readClosedSessions()
|
|
252
304
|
let dirty = false
|
|
253
|
-
for (const [id,
|
|
254
|
-
if (now - closedAt <= CLOSED_SESSION_TTL_MS) {
|
|
255
|
-
|
|
305
|
+
for (const [id, record] of Object.entries(closed)) {
|
|
306
|
+
if (now - record.closedAt <= CLOSED_SESSION_TTL_MS) {
|
|
307
|
+
rememberDeletedSession(id)
|
|
308
|
+
closedSessionRecords.set(id, record)
|
|
256
309
|
} else {
|
|
257
310
|
delete closed[id]
|
|
258
311
|
dirty = true
|
|
@@ -260,20 +313,17 @@ function recoverClosedSessions(): void {
|
|
|
260
313
|
}
|
|
261
314
|
if (dirty) {
|
|
262
315
|
try {
|
|
263
|
-
|
|
264
|
-
writeFileSync(tmp, JSON.stringify(closed, null, 2), { encoding: 'utf-8', mode: 0o600 })
|
|
265
|
-
renameSync(tmp, CLOSED_SESSIONS_FILE)
|
|
266
|
-
try { chmodSync(CLOSED_SESSIONS_FILE, 0o600) } catch {}
|
|
316
|
+
durableAtomicWriteFileSync(CLOSED_SESSIONS_FILE, JSON.stringify(closed, null, 2), { mode: 0o600 })
|
|
267
317
|
} catch {}
|
|
268
318
|
}
|
|
269
319
|
}
|
|
270
320
|
|
|
271
|
-
/** Persist a session
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
321
|
+
/** Persist a session before acknowledging any chunk. Throws on failure so a
|
|
322
|
+
* client never interprets a non-durable index as accepted. */
|
|
323
|
+
function persistSessionRequired(sessionId: string): void {
|
|
324
|
+
const session = sessions.get(sessionId)
|
|
325
|
+
if (!session) throw makeHttpError(404, 'session not found', 'session_not_found')
|
|
326
|
+
const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
|
|
277
327
|
// chunksIndexed preserves each chunk's original index (a plain filter()
|
|
278
328
|
// would collapse the sparse array and destroy gap positions on recovery).
|
|
279
329
|
const chunksIndexed: Array<{ i: number; c: TranscriptChunk }> = []
|
|
@@ -281,9 +331,10 @@ function persistSession(sessionId: string): void {
|
|
|
281
331
|
const c = session.chunks[i]
|
|
282
332
|
if (c && c.text) chunksIndexed.push({ i, c })
|
|
283
333
|
}
|
|
284
|
-
|
|
334
|
+
const data = JSON.stringify({
|
|
285
335
|
sessionId,
|
|
286
336
|
startTime: session.startTime,
|
|
337
|
+
lastActivityAt: session.lastActivityAt,
|
|
287
338
|
title: session.title,
|
|
288
339
|
// `chunks` = legacy dense form, kept for backward compatibility with
|
|
289
340
|
// existing readers; `chunksIndexed` preserves original indices so gap
|
|
@@ -294,9 +345,16 @@ function persistSession(sessionId: string): void {
|
|
|
294
345
|
maxChunkIndex: session.maxChunkIndex ?? -1,
|
|
295
346
|
providerCandidates: session.providerCandidates ?? {},
|
|
296
347
|
})
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
} catch {
|
|
348
|
+
try {
|
|
349
|
+
durableAtomicWriteFileSync(filePath, data, { mode: 0o600 })
|
|
350
|
+
} catch (error) {
|
|
351
|
+
console.error(`[transcribe-stream] Durable session write failed for ${sessionId}: ${errMsg(error)}`)
|
|
352
|
+
throw makeHttpError(503, 'meeting session persistence unavailable', 'session_persistence_failed')
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function persistSessionBestEffort(sessionId: string): void {
|
|
357
|
+
try { persistSessionRequired(sessionId) } catch { /* recovery cleanup is non-admission work */ }
|
|
300
358
|
}
|
|
301
359
|
|
|
302
360
|
/** Recover sessions from disk on server restart */
|
|
@@ -315,9 +373,14 @@ function recoverSessions(): void {
|
|
|
315
373
|
Array.isArray(data.chunksIndexed) ? data.chunksIndexed : null
|
|
316
374
|
const legacy: TranscriptChunk[] | null = Array.isArray(data.chunks) ? data.chunks : null
|
|
317
375
|
const hasChunks = (indexed && indexed.length > 0) || (legacy && legacy.length > 0)
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
376
|
+
const persistedStat = statSync(resolve(CHUNK_PERSIST_DIR, file))
|
|
377
|
+
const lastActivityAt = typeof data.lastActivityAt === 'number' && Number.isFinite(data.lastActivityAt)
|
|
378
|
+
? data.lastActivityAt
|
|
379
|
+
: (Number.isFinite(persistedStat.mtimeMs) ? persistedStat.mtimeMs : data.startTime)
|
|
380
|
+
if (data.sessionId && (hasChunks || Array.isArray(data.receivedIndices) || Number.isFinite(lastActivityAt))) {
|
|
381
|
+
// Active retention is idle-based. Long meetings are not purged merely
|
|
382
|
+
// because their original start time is old.
|
|
383
|
+
if (Date.now() - lastActivityAt < LOCAL_FIRST_MEETING_IDLE_RETENTION_MS) {
|
|
321
384
|
const chunks: TranscriptChunk[] = []
|
|
322
385
|
if (indexed) {
|
|
323
386
|
for (const e of indexed) {
|
|
@@ -353,6 +416,7 @@ function recoverSessions(): void {
|
|
|
353
416
|
chunks,
|
|
354
417
|
startTime: data.startTime,
|
|
355
418
|
title: data.title || '',
|
|
419
|
+
lastActivityAt,
|
|
356
420
|
receivedIndices,
|
|
357
421
|
maxChunkIndex,
|
|
358
422
|
providerCandidates: data.providerCandidates && typeof data.providerCandidates === 'object'
|
|
@@ -380,12 +444,32 @@ function recoverSessions(): void {
|
|
|
380
444
|
recoveredIds.add(data.sessionId)
|
|
381
445
|
if (cleaned > 0) {
|
|
382
446
|
console.log(`[session-recovery] Cleaned inline hallucinations from ${cleaned} chunks`)
|
|
383
|
-
|
|
447
|
+
persistSessionBestEffort(data.sessionId) // re-persist cleaned data to disk
|
|
384
448
|
}
|
|
385
449
|
const gaps = computeGapReport(session).missingIndices.length
|
|
386
450
|
console.log(`[session-recovery] Recovered ${session.chunks.filter(c => c && c.text).length} chunks for ${data.sessionId}${gaps > 0 ? ` (${gaps} lost-chunk gap${gaps > 1 ? 's' : ''})` : ''}`)
|
|
387
451
|
} else {
|
|
388
|
-
//
|
|
452
|
+
// A stale unsaved session is a real closed state, not a missing
|
|
453
|
+
// session that a late/zombie client may silently recreate. Keep
|
|
454
|
+
// its exact receive ledger for one tombstone horizon after boot.
|
|
455
|
+
const receivedIndices = Array.isArray(data.receivedIndices)
|
|
456
|
+
? Array.from(new Set(
|
|
457
|
+
(data.receivedIndices as unknown[])
|
|
458
|
+
.filter((value): value is number => Number.isInteger(value) && (value as number) >= 0),
|
|
459
|
+
)).sort((left, right) => left - right)
|
|
460
|
+
: []
|
|
461
|
+
const maxChunkIndex = typeof data.maxChunkIndex === 'number' && Number.isInteger(data.maxChunkIndex)
|
|
462
|
+
? data.maxChunkIndex
|
|
463
|
+
: (receivedIndices.at(-1) ?? -1)
|
|
464
|
+
closedSessionRecords.set(data.sessionId, {
|
|
465
|
+
closedAt: Date.now(),
|
|
466
|
+
lastActivityAt,
|
|
467
|
+
receivedIndices,
|
|
468
|
+
maxChunkIndex,
|
|
469
|
+
reason: 'expired',
|
|
470
|
+
})
|
|
471
|
+
rememberDeletedSession(data.sessionId)
|
|
472
|
+
persistClosedSessions()
|
|
389
473
|
unlinkSync(resolve(CHUNK_PERSIST_DIR, file))
|
|
390
474
|
}
|
|
391
475
|
}
|
|
@@ -410,6 +494,12 @@ function recoverSessions(): void {
|
|
|
410
494
|
// Declared BEFORE deleteSession to avoid TDZ hazard (deleteSession references these).
|
|
411
495
|
const deletedSessions = new Set<string>()
|
|
412
496
|
const DELETED_SESSION_CAP = 50 // keep last 50 deleted IDs, trim older on overflow
|
|
497
|
+
function rememberDeletedSession(sessionId: string): void {
|
|
498
|
+
deletedSessions.add(sessionId)
|
|
499
|
+
if (deletedSessions.size <= DELETED_SESSION_CAP) return
|
|
500
|
+
const entries = Array.from(deletedSessions)
|
|
501
|
+
for (const id of entries.slice(0, entries.length - DELETED_SESSION_CAP)) deletedSessions.delete(id)
|
|
502
|
+
}
|
|
413
503
|
export function isSessionDeleted(sessionId: string): boolean {
|
|
414
504
|
return deletedSessions.has(sessionId)
|
|
415
505
|
}
|
|
@@ -418,19 +508,12 @@ export function isSessionDeleted(sessionId: string): boolean {
|
|
|
418
508
|
recoverClosedSessions()
|
|
419
509
|
recoverSessions()
|
|
420
510
|
|
|
421
|
-
// Auto-cleanup sessions
|
|
511
|
+
// Auto-cleanup sessions idle for the advertised retention horizon.
|
|
422
512
|
setInterval(() => {
|
|
423
|
-
const cutoff = Date.now() -
|
|
513
|
+
const cutoff = Date.now() - LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
|
|
424
514
|
for (const [id, session] of sessions) {
|
|
425
|
-
if (session.
|
|
426
|
-
|
|
427
|
-
sessionAudioBytes.delete(id)
|
|
428
|
-
sessionAudioWrites.delete(id)
|
|
429
|
-
clearSessionHallucinationState(id)
|
|
430
|
-
// Clean up persisted file too
|
|
431
|
-
try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${id}.json`)) } catch {}
|
|
432
|
-
// Clean up session audio
|
|
433
|
-
try { rmSync(resolve(SESSION_AUDIO_DIR, id), { recursive: true, force: true }) } catch {}
|
|
515
|
+
if (session.lastActivityAt < cutoff) {
|
|
516
|
+
closeTranscriptSession(id, 'expired')
|
|
434
517
|
}
|
|
435
518
|
}
|
|
436
519
|
// Purge orphaned session-audio dirs (no matching active session)
|
|
@@ -493,7 +576,8 @@ setInterval(() => {
|
|
|
493
576
|
export function getSession(sessionId: string): TranscriptSession {
|
|
494
577
|
let session = sessions.get(sessionId)
|
|
495
578
|
if (!session) {
|
|
496
|
-
|
|
579
|
+
const now = Date.now()
|
|
580
|
+
session = { chunks: [], startTime: now, lastActivityAt: now, title: '', providerCandidates: {} }
|
|
497
581
|
sessions.set(sessionId, session)
|
|
498
582
|
}
|
|
499
583
|
if (!session.providerCandidates) session.providerCandidates = {}
|
|
@@ -677,19 +761,81 @@ export function getSessionProviderCandidates(sessionId: string): Record<string,
|
|
|
677
761
|
return sessions.get(sessionId)?.providerCandidates ?? {}
|
|
678
762
|
}
|
|
679
763
|
|
|
764
|
+
export interface MeetingSessionStatusSnapshot {
|
|
765
|
+
state: 'active' | 'closed' | 'missing'
|
|
766
|
+
receivedRanges: IndexRange[]
|
|
767
|
+
receivedCount: number
|
|
768
|
+
maxChunkIndex: number
|
|
769
|
+
lastActivityAt: string | null
|
|
770
|
+
retainedUntil: string | null
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export function getMeetingSessionStatus(sessionId: string): MeetingSessionStatusSnapshot {
|
|
774
|
+
const active = sessions.get(sessionId)
|
|
775
|
+
if (active) {
|
|
776
|
+
const received = active.receivedIndices ?? []
|
|
777
|
+
return {
|
|
778
|
+
state: 'active',
|
|
779
|
+
receivedRanges: compressIndexRanges(received),
|
|
780
|
+
receivedCount: received.length,
|
|
781
|
+
maxChunkIndex: active.maxChunkIndex ?? (received.at(-1) ?? -1),
|
|
782
|
+
lastActivityAt: new Date(active.lastActivityAt).toISOString(),
|
|
783
|
+
retainedUntil: retainedUntilIso(active.lastActivityAt),
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const closed = closedSessionRecords.get(sessionId)
|
|
787
|
+
if (closed) {
|
|
788
|
+
return {
|
|
789
|
+
state: 'closed',
|
|
790
|
+
receivedRanges: compressIndexRanges(closed.receivedIndices),
|
|
791
|
+
receivedCount: closed.receivedIndices.length,
|
|
792
|
+
maxChunkIndex: closed.maxChunkIndex,
|
|
793
|
+
lastActivityAt: new Date(closed.lastActivityAt).toISOString(),
|
|
794
|
+
retainedUntil: new Date(closed.closedAt + CLOSED_SESSION_TTL_MS).toISOString(),
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return {
|
|
798
|
+
state: 'missing',
|
|
799
|
+
receivedRanges: [],
|
|
800
|
+
receivedCount: 0,
|
|
801
|
+
maxChunkIndex: -1,
|
|
802
|
+
lastActivityAt: null,
|
|
803
|
+
retainedUntil: null,
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function closeTranscriptSession(
|
|
808
|
+
sessionId: string,
|
|
809
|
+
reason: ClosedTranscriptSession['reason'],
|
|
810
|
+
options: { preserveAudio?: boolean } = {},
|
|
811
|
+
): void {
|
|
812
|
+
const session = sessions.get(sessionId)
|
|
813
|
+
const now = Date.now()
|
|
814
|
+
const receivedIndices = [...(session?.receivedIndices ?? [])]
|
|
815
|
+
const maxChunkIndex = session?.maxChunkIndex ?? (receivedIndices.at(-1) ?? -1)
|
|
816
|
+
closedSessionRecords.set(sessionId, {
|
|
817
|
+
closedAt: now,
|
|
818
|
+
lastActivityAt: session?.lastActivityAt ?? now,
|
|
819
|
+
receivedIndices,
|
|
820
|
+
maxChunkIndex,
|
|
821
|
+
reason,
|
|
822
|
+
})
|
|
823
|
+
finishClosingTranscriptSession(sessionId, options)
|
|
824
|
+
}
|
|
825
|
+
|
|
680
826
|
/** Delete session after save */
|
|
681
827
|
export function deleteSession(sessionId: string, options: { preserveAudio?: boolean } = {}): void {
|
|
828
|
+
closeTranscriptSession(sessionId, 'saved', options)
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function finishClosingTranscriptSession(sessionId: string, options: { preserveAudio?: boolean }): void {
|
|
682
832
|
sessions.delete(sessionId)
|
|
683
833
|
sessionAudioBytes.delete(sessionId)
|
|
834
|
+
sessionAudioWrites.delete(sessionId)
|
|
684
835
|
// Clean up inline hallucination tracking (was leaking until 4-hour interval fired)
|
|
685
836
|
clearSessionHallucinationState(sessionId)
|
|
686
837
|
// Track as deleted so orphan heartbeats get 410 Gone (prevents zombie client spam)
|
|
687
|
-
|
|
688
|
-
if (deletedSessions.size > DELETED_SESSION_CAP) {
|
|
689
|
-
// Trim oldest entries to prevent unbounded growth
|
|
690
|
-
const arr = Array.from(deletedSessions)
|
|
691
|
-
for (const id of arr.slice(0, arr.length - DELETED_SESSION_CAP)) deletedSessions.delete(id)
|
|
692
|
-
}
|
|
838
|
+
rememberDeletedSession(sessionId)
|
|
693
839
|
persistClosedSessions()
|
|
694
840
|
// Clean up persisted file
|
|
695
841
|
try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)) } catch {}
|
|
@@ -768,14 +914,27 @@ async function persistRawSessionAudioChunk(sessionId: string, chunkIndex: number
|
|
|
768
914
|
ensurePrivateDirectory(sessionDir)
|
|
769
915
|
const chunkPath = resolve(sessionDir, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`)
|
|
770
916
|
const existingSize = existsSync(chunkPath) ? statSync(chunkPath).size : 0
|
|
771
|
-
|
|
917
|
+
let currentBytes = sessionAudioBytes.get(sessionId)
|
|
918
|
+
if (currentBytes == null) {
|
|
919
|
+
currentBytes = 0
|
|
920
|
+
try {
|
|
921
|
+
for (const filename of readdirSync(sessionDir)) {
|
|
922
|
+
if (!/^chunk_\d{4}\.wav$/.test(filename)) continue
|
|
923
|
+
currentBytes += statSync(resolve(sessionDir, filename)).size
|
|
924
|
+
}
|
|
925
|
+
} catch (error) {
|
|
926
|
+
throw makeHttpError(503, `meeting audio inventory failed: ${errMsg(error)}`, 'session_audio_persistence_failed')
|
|
927
|
+
}
|
|
928
|
+
}
|
|
772
929
|
const nextBytes = currentBytes - existingSize + audioBuffer.length
|
|
773
|
-
if (nextBytes > MAX_SESSION_AUDIO_BYTES
|
|
774
|
-
|
|
775
|
-
|
|
930
|
+
if (nextBytes > MAX_SESSION_AUDIO_BYTES) {
|
|
931
|
+
throw makeHttpError(507, 'meeting audio capacity exceeded', 'meeting_audio_capacity_exceeded')
|
|
932
|
+
}
|
|
933
|
+
try {
|
|
934
|
+
durableAtomicWriteFileSync(chunkPath, audioBuffer, { mode: 0o600 })
|
|
935
|
+
} catch (error) {
|
|
936
|
+
throw makeHttpError(503, `meeting audio persistence failed: ${errMsg(error)}`, 'session_audio_persistence_failed')
|
|
776
937
|
}
|
|
777
|
-
const writeJob = writeFile(chunkPath, audioBuffer, { mode: 0o600 })
|
|
778
|
-
await trackSessionAudioWrite(sessionId, writeJob)
|
|
779
938
|
sessionAudioBytes.set(sessionId, Math.max(0, nextBytes))
|
|
780
939
|
}
|
|
781
940
|
|
|
@@ -995,9 +1154,6 @@ async function processStreamChunk(opts: {
|
|
|
995
1154
|
if (opts.startTimeOverride && session.chunks.filter(Boolean).length === 0) {
|
|
996
1155
|
session.startTime = opts.startTimeOverride
|
|
997
1156
|
}
|
|
998
|
-
// Transfer integrity: log this index as delivered before any text filtering,
|
|
999
|
-
// so a silent/hallucination-filtered chunk is NOT mistaken for a lost one.
|
|
1000
|
-
recordReceivedChunk(session, chunkIndex)
|
|
1001
1157
|
const alreadyCanonical = session.chunks[chunkIndex]
|
|
1002
1158
|
|
|
1003
1159
|
let candidateRecordKey: string | undefined
|
|
@@ -1020,21 +1176,24 @@ async function processStreamChunk(opts: {
|
|
|
1020
1176
|
// Do not let late duplicate/replayed candidates replace canonical raw audio.
|
|
1021
1177
|
// Batch re-transcription relies on chunk_000N.wav matching the accepted chunk.
|
|
1022
1178
|
if (alreadyCanonical?.canonical) {
|
|
1179
|
+
session.lastActivityAt = Date.now()
|
|
1180
|
+
recordReceivedChunk(session, chunkIndex)
|
|
1023
1181
|
if (candidate && candidateRecordKey) {
|
|
1024
1182
|
session.providerCandidates![candidateRecordKey].accepted =
|
|
1025
1183
|
alreadyCanonical.asrProvider === 'iphone-whisperkit-beta' && alreadyCanonical.audioSha256 === audioSha256
|
|
1026
1184
|
session.providerCandidates![candidateRecordKey].fallbackReason =
|
|
1027
1185
|
session.providerCandidates![candidateRecordKey].accepted ? undefined : 'canonical_exists'
|
|
1028
|
-
persistSession(sessionId)
|
|
1029
1186
|
}
|
|
1187
|
+
persistSessionRequired(sessionId)
|
|
1030
1188
|
return canonicalChunkResponse(alreadyCanonical, sessionId, chunkIndex)
|
|
1031
1189
|
}
|
|
1032
1190
|
|
|
1033
1191
|
await persistRawSessionAudioChunk(sessionId, chunkIndex, audioBuffer)
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1192
|
+
// Commit the received-index ledger only after the canonical raw WAV is
|
|
1193
|
+
// durable. A failure is typed non-2xx and a retry remains safe.
|
|
1194
|
+
session.lastActivityAt = Date.now()
|
|
1195
|
+
recordReceivedChunk(session, chunkIndex)
|
|
1196
|
+
persistSessionRequired(sessionId)
|
|
1038
1197
|
|
|
1039
1198
|
const pcmData = audioBuffer.subarray(44)
|
|
1040
1199
|
let sumSq = 0
|
|
@@ -1105,8 +1264,8 @@ async function processStreamChunk(opts: {
|
|
|
1105
1264
|
if (candidate && candidateRecordKey && session.providerCandidates?.[candidateRecordKey]) {
|
|
1106
1265
|
session.providerCandidates[candidateRecordKey].accepted = false
|
|
1107
1266
|
session.providerCandidates[candidateRecordKey].fallbackReason = sanitized.fallbackReason || fallbackReason || 'empty'
|
|
1108
|
-
persistSession(sessionId)
|
|
1109
1267
|
}
|
|
1268
|
+
persistSessionRequired(sessionId)
|
|
1110
1269
|
return { text: '', speaker: clientSpeaker, chunkIndex, elapsed, sessionId, backend, asrProvider, fallbackReason: sanitized.fallbackReason || fallbackReason }
|
|
1111
1270
|
}
|
|
1112
1271
|
|
|
@@ -1132,8 +1291,9 @@ async function processStreamChunk(opts: {
|
|
|
1132
1291
|
finalExisting.asrProvider === 'iphone-whisperkit-beta' && finalExisting.audioSha256 === audioSha256
|
|
1133
1292
|
session.providerCandidates[candidateRecordKey].fallbackReason =
|
|
1134
1293
|
session.providerCandidates[candidateRecordKey].accepted ? undefined : 'canonical_exists'
|
|
1135
|
-
persistSession(sessionId)
|
|
1136
1294
|
}
|
|
1295
|
+
session.lastActivityAt = Date.now()
|
|
1296
|
+
persistSessionRequired(sessionId)
|
|
1137
1297
|
return canonicalChunkResponse(finalExisting, sessionId, chunkIndex)
|
|
1138
1298
|
}
|
|
1139
1299
|
session.chunks[chunkIndex] = chunk
|
|
@@ -1144,7 +1304,8 @@ async function processStreamChunk(opts: {
|
|
|
1144
1304
|
}
|
|
1145
1305
|
}
|
|
1146
1306
|
const tPersist = performance.now()
|
|
1147
|
-
|
|
1307
|
+
session.lastActivityAt = Date.now()
|
|
1308
|
+
persistSessionRequired(sessionId)
|
|
1148
1309
|
console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
|
|
1149
1310
|
|
|
1150
1311
|
emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
|
|
@@ -1207,7 +1368,8 @@ transcribeStreamRouter.post('/transcribe-stream/offline-sessions/start', async (
|
|
|
1207
1368
|
: undefined
|
|
1208
1369
|
if (startTime && session.chunks.filter(Boolean).length === 0) session.startTime = startTime
|
|
1209
1370
|
if (typeof body.title === 'string') session.title = body.title.slice(0, 160)
|
|
1210
|
-
|
|
1371
|
+
session.lastActivityAt = Date.now()
|
|
1372
|
+
persistSessionRequired(sessionId)
|
|
1211
1373
|
res.json({ sessionId, startTime: session.startTime, chunks: session.chunks.filter(Boolean).length })
|
|
1212
1374
|
} catch (err: unknown) {
|
|
1213
1375
|
sendStreamError(res, err)
|