@gotcos/glasses-server 6.7.0 → 6.9.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.
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { Router } from 'express'
6
6
  import { createHash } from 'node:crypto'
7
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, rmSync, renameSync, statSync } from 'node:fs'
7
+ import { chmodSync, readFileSync, writeFileSync, existsSync, lstatSync, mkdirSync, readdirSync, unlinkSync, rmSync, renameSync, statSync } from 'node:fs'
8
8
  import { writeFile } from 'node:fs/promises'
9
9
  import { resolve } from 'node:path'
10
10
  import { fileURLToPath } from 'node:url'
@@ -31,6 +31,16 @@ import {
31
31
  streamSilenceDropReason,
32
32
  isVocabEchoOnly,
33
33
  } from '../lib/hallucination-filter.js'
34
+ import { dataPath } from '../lib/data-dir.js'
35
+
36
+ function ensurePrivateDirectory(path: string): void {
37
+ if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
38
+ const stat = lstatSync(path)
39
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
40
+ throw new Error(`Unsafe private audio directory: ${path}`)
41
+ }
42
+ try { chmodSync(path, 0o700) } catch { /* individual writes still force 0600 */ }
43
+ }
34
44
 
35
45
  // Silence-hallucination drops (2026-05-29, v5.9.73). Contract in streamSilenceDropReason:
36
46
  // brand-URL-only -> dropped ALWAYS (vocab-seeded, never real speech).
@@ -41,13 +51,13 @@ const STRIP_BRAND_URLS = process.env.COS_WHISPER_STRIP_BRAND_URLS !== '0'
41
51
  const THANKYOU_FILTER = process.env.COS_WHISPER_THANKYOU_FILTER !== '0'
42
52
 
43
53
  // Audio persistence: save G2-mic chunks for speakers who need more training data
44
- import { dataPath } from '../lib/data-dir.js'
45
54
  const AUDIO_SAVE_DIR = dataPath('training-audio')
55
+ ensurePrivateDirectory(AUDIO_SAVE_DIR)
46
56
  const MAX_SAVED_CHUNKS_PER_SPEAKER = 30 // ~5 min of audio per speaker, cleaned after training
47
57
 
48
58
  // Unrecognized speaker audio: save Ext chunks for retroactive enrollment
49
59
  const EXT_AUDIO_DIR = dataPath('ext-audio')
50
- if (!existsSync(EXT_AUDIO_DIR)) mkdirSync(EXT_AUDIO_DIR, { recursive: true })
60
+ ensurePrivateDirectory(EXT_AUDIO_DIR)
51
61
  const EXT_AUDIO_TTL_MS = 72 * 60 * 60 * 1000 // 72-hour retention
52
62
  const MAX_EXT_CHUNKS_PER_SESSION = 40 // cap per session to avoid runaway storage
53
63
  const extAudioCounts = new Map<string, number>() // sessionId → chunk count
@@ -73,16 +83,27 @@ const _unused_hallucination_constants_placeholder = 0 as const
73
83
 
74
84
  // Session audio persistence: save all WAV chunks for batch re-transcription at save time
75
85
  const SESSION_AUDIO_DIR = dataPath('session-audio')
76
- if (!existsSync(SESSION_AUDIO_DIR)) mkdirSync(SESSION_AUDIO_DIR, { recursive: true })
86
+ ensurePrivateDirectory(SESSION_AUDIO_DIR)
77
87
  const PENDING_BATCH_DIR = dataPath('pending-batch')
78
- if (!existsSync(PENDING_BATCH_DIR)) mkdirSync(PENDING_BATCH_DIR, { recursive: true })
88
+ ensurePrivateDirectory(PENDING_BATCH_DIR)
79
89
  const MAX_SESSION_AUDIO_BYTES = 500 * 1024 * 1024 // 500MB cap per session (~2hr meeting ≈ 260MB)
90
+ const PRESERVED_SESSION_AUDIO_MARKER = '_meeting_save_preserved.marker'
91
+ const PRESERVED_SESSION_AUDIO_TTL_MS = 2 * 60 * 60 * 1000
80
92
  const MAX_CANDIDATE_WAV_BASE64_CHARS = 8 * 1024 * 1024 // stay below server/index.ts 10mb JSON parser cap
81
93
  const MAX_CANDIDATE_TEXT_CHARS = 8000
82
94
  const MAX_CANDIDATE_WORDS = 1200
83
95
  const sessionAudioBytes = new Map<string, number>() // track per-session byte count
84
96
  const sessionAudioWrites = new Map<string, Set<Promise<void>>>()
85
97
 
98
+ function hasFreshPreservedAudioMarker(dirPath: string): boolean {
99
+ try {
100
+ const marker = resolve(dirPath, PRESERVED_SESSION_AUDIO_MARKER)
101
+ return existsSync(marker) && Date.now() - statSync(marker).mtimeMs <= PRESERVED_SESSION_AUDIO_TTL_MS
102
+ } catch {
103
+ return false
104
+ }
105
+ }
106
+
86
107
  // In-memory training audio counts — lazy-initialized from disk on first access per speaker
87
108
  const trainingAudioCounts = new Map<string, number>()
88
109
  function getTrainingCount(speakerDir: string): number {
@@ -191,7 +212,7 @@ const CLOSED_SESSIONS_FILE = dataPath('closed-transcript-sessions.json')
191
212
 
192
213
  // Incremental chunk persistence — survive server restarts
193
214
  const CHUNK_PERSIST_DIR = dataPath('active-sessions')
194
- if (!existsSync(CHUNK_PERSIST_DIR)) mkdirSync(CHUNK_PERSIST_DIR, { recursive: true })
215
+ ensurePrivateDirectory(CHUNK_PERSIST_DIR)
195
216
 
196
217
  function readClosedSessions(): Record<string, number> {
197
218
  if (!existsSync(CLOSED_SESSIONS_FILE)) return {}
@@ -219,8 +240,9 @@ function persistClosedSessions(): void {
219
240
  }
220
241
  try {
221
242
  const tmp = `${CLOSED_SESSIONS_FILE}.tmp`
222
- writeFileSync(tmp, JSON.stringify(merged, null, 2), 'utf-8')
243
+ writeFileSync(tmp, JSON.stringify(merged, null, 2), { encoding: 'utf-8', mode: 0o600 })
223
244
  renameSync(tmp, CLOSED_SESSIONS_FILE)
245
+ try { chmodSync(CLOSED_SESSIONS_FILE, 0o600) } catch {}
224
246
  } catch { /* best-effort tombstones */ }
225
247
  }
226
248
 
@@ -239,8 +261,9 @@ function recoverClosedSessions(): void {
239
261
  if (dirty) {
240
262
  try {
241
263
  const tmp = `${CLOSED_SESSIONS_FILE}.tmp`
242
- writeFileSync(tmp, JSON.stringify(closed, null, 2), 'utf-8')
264
+ writeFileSync(tmp, JSON.stringify(closed, null, 2), { encoding: 'utf-8', mode: 0o600 })
243
265
  renameSync(tmp, CLOSED_SESSIONS_FILE)
266
+ try { chmodSync(CLOSED_SESSIONS_FILE, 0o600) } catch {}
244
267
  } catch {}
245
268
  }
246
269
  }
@@ -271,7 +294,8 @@ function persistSession(sessionId: string): void {
271
294
  maxChunkIndex: session.maxChunkIndex ?? -1,
272
295
  providerCandidates: session.providerCandidates ?? {},
273
296
  })
274
- writeFileSync(filePath, data, 'utf-8')
297
+ writeFileSync(filePath, data, { encoding: 'utf-8', mode: 0o600 })
298
+ try { chmodSync(filePath, 0o600) } catch { /* best effort on recovered installs */ }
275
299
  } catch { /* non-critical — don't break transcription for persistence */ }
276
300
  }
277
301
 
@@ -371,7 +395,7 @@ function recoverSessions(): void {
371
395
  try {
372
396
  if (existsSync(SESSION_AUDIO_DIR)) {
373
397
  for (const dir of readdirSync(SESSION_AUDIO_DIR)) {
374
- if (!recoveredIds.has(dir)) {
398
+ if (!recoveredIds.has(dir) && !hasFreshPreservedAudioMarker(resolve(SESSION_AUDIO_DIR, dir))) {
375
399
  rmSync(resolve(SESSION_AUDIO_DIR, dir), { recursive: true, force: true })
376
400
  console.log(`[session-recovery] Cleaned orphaned session-audio: ${dir}`)
377
401
  }
@@ -412,7 +436,7 @@ setInterval(() => {
412
436
  // Purge orphaned session-audio dirs (no matching active session)
413
437
  try {
414
438
  for (const dir of readdirSync(SESSION_AUDIO_DIR)) {
415
- if (!sessions.has(dir)) {
439
+ if (!sessions.has(dir) && !hasFreshPreservedAudioMarker(resolve(SESSION_AUDIO_DIR, dir))) {
416
440
  rmSync(resolve(SESSION_AUDIO_DIR, dir), { recursive: true, force: true })
417
441
  }
418
442
  }
@@ -585,6 +609,23 @@ export function getSessionChunks(sessionId: string): TranscriptChunk[] | null {
585
609
  return session.chunks.filter(c => c && c.text)
586
610
  }
587
611
 
612
+ export interface IndexedTranscriptChunk {
613
+ chunkIndex: number
614
+ chunk: TranscriptChunk
615
+ }
616
+
617
+ /** Preserve original raw-WAV indices for post-meeting batch assembly. */
618
+ export function getSessionChunkEntries(sessionId: string): IndexedTranscriptChunk[] | null {
619
+ const session = sessions.get(sessionId)
620
+ if (!session) return null
621
+ const entries: IndexedTranscriptChunk[] = []
622
+ for (let chunkIndex = 0; chunkIndex < session.chunks.length; chunkIndex++) {
623
+ const chunk = session.chunks[chunkIndex]
624
+ if (chunk?.text) entries.push({ chunkIndex, chunk })
625
+ }
626
+ return entries
627
+ }
628
+
588
629
  /** Get session start time */
589
630
  export function getSessionStartTime(sessionId: string): number | null {
590
631
  return sessions.get(sessionId)?.startTime ?? null
@@ -592,18 +633,34 @@ export function getSessionStartTime(sessionId: string): number | null {
592
633
 
593
634
  /** Move session audio to pending-batch before deletion (batch re-transcription needs it) */
594
635
  export function moveSessionAudioToPending(sessionId: string): string | null {
636
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
595
637
  const srcDir = resolve(SESSION_AUDIO_DIR, sessionId)
596
638
  if (!existsSync(srcDir)) return null
597
- const destDir = resolve(PENDING_BATCH_DIR, sessionId)
639
+ let destDir = resolve(PENDING_BATCH_DIR, sessionId)
598
640
  try {
641
+ // A prior interrupted finalization can leave the canonical destination in
642
+ // place. Never sacrifice the new raw audio: choose a private unique sibling.
643
+ if (existsSync(destDir)) {
644
+ let suffix = `${Date.now()}_${process.pid}`
645
+ destDir = resolve(PENDING_BATCH_DIR, `${sessionId}_${suffix}`)
646
+ while (existsSync(destDir)) {
647
+ suffix = `${Date.now()}_${process.pid}_${Math.random().toString(16).slice(2, 8)}`
648
+ destDir = resolve(PENDING_BATCH_DIR, `${sessionId}_${suffix}`)
649
+ }
650
+ }
599
651
  // Rename is atomic on same filesystem
600
652
  renameSync(srcDir, destDir)
653
+ try { chmodSync(destDir, 0o700) } catch {}
601
654
  // Write marker file with move timestamp. The cleanup interval checks THIS file's
602
655
  // mtime for TTL, not individual chunk files (whose mtimes date from original write).
603
656
  // Without this marker, meetings > 1 hour have first chunks older than the 1-hour
604
657
  // TTL, causing the interval to purge pending-batch before batch re-transcription
605
658
  // can run. Observed 2026-04-13: 103-min meeting lost audio to this race condition.
606
- try { writeFileSync(resolve(destDir, '_batch_pending.marker'), String(Date.now()), 'utf-8') } catch {}
659
+ try {
660
+ const markerPath = resolve(destDir, '_batch_pending.marker')
661
+ writeFileSync(markerPath, String(Date.now()), { encoding: 'utf-8', mode: 0o600 })
662
+ chmodSync(markerPath, 0o600)
663
+ } catch {}
607
664
  return destDir
608
665
  } catch (err: unknown) {
609
666
  console.warn(`[session-audio] Failed to move to pending-batch: ${errMsg(err)}`)
@@ -611,12 +668,17 @@ export function moveSessionAudioToPending(sessionId: string): string | null {
611
668
  }
612
669
  }
613
670
 
671
+ export function hasSessionAudio(sessionId: string): boolean {
672
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return false
673
+ try { return statSync(resolve(SESSION_AUDIO_DIR, sessionId)).isDirectory() } catch { return false }
674
+ }
675
+
614
676
  export function getSessionProviderCandidates(sessionId: string): Record<string, ProviderCandidateRecord> {
615
677
  return sessions.get(sessionId)?.providerCandidates ?? {}
616
678
  }
617
679
 
618
680
  /** Delete session after save */
619
- export function deleteSession(sessionId: string): void {
681
+ export function deleteSession(sessionId: string, options: { preserveAudio?: boolean } = {}): void {
620
682
  sessions.delete(sessionId)
621
683
  sessionAudioBytes.delete(sessionId)
622
684
  // Clean up inline hallucination tracking (was leaking until 4-hour interval fired)
@@ -633,7 +695,15 @@ export function deleteSession(sessionId: string): void {
633
695
  try { unlinkSync(resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)) } catch {}
634
696
  // Clean up session audio if it wasn't moved to pending-batch
635
697
  const audioDir = resolve(SESSION_AUDIO_DIR, sessionId)
636
- try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
698
+ if (options.preserveAudio && existsSync(audioDir)) {
699
+ try {
700
+ const marker = resolve(audioDir, PRESERVED_SESSION_AUDIO_MARKER)
701
+ writeFileSync(marker, String(Date.now()), { encoding: 'utf8', mode: 0o600 })
702
+ chmodSync(marker, 0o600)
703
+ } catch {}
704
+ } else {
705
+ try { rmSync(audioDir, { recursive: true, force: true }) } catch {}
706
+ }
637
707
  }
638
708
 
639
709
  function isIosAsrCandidateEnabled(): boolean {
@@ -695,7 +765,7 @@ async function readRawBody(req: AsyncIterable<Buffer | Uint8Array | string>): Pr
695
765
 
696
766
  async function persistRawSessionAudioChunk(sessionId: string, chunkIndex: number, audioBuffer: Buffer): Promise<void> {
697
767
  const sessionDir = resolve(SESSION_AUDIO_DIR, sessionId)
698
- if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true })
768
+ ensurePrivateDirectory(sessionDir)
699
769
  const chunkPath = resolve(sessionDir, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`)
700
770
  const existingSize = existsSync(chunkPath) ? statSync(chunkPath).size : 0
701
771
  const currentBytes = sessionAudioBytes.get(sessionId) ?? 0
@@ -704,7 +774,7 @@ async function persistRawSessionAudioChunk(sessionId: string, chunkIndex: number
704
774
  if (chunkIndex % 50 === 0) console.warn(`[session-audio] Session ${sessionId} hit 500MB cap — skipping WAV saves`)
705
775
  return
706
776
  }
707
- const writeJob = writeFile(chunkPath, audioBuffer)
777
+ const writeJob = writeFile(chunkPath, audioBuffer, { mode: 0o600 })
708
778
  await trackSessionAudioWrite(sessionId, writeJob)
709
779
  sessionAudioBytes.set(sessionId, Math.max(0, nextBytes))
710
780
  }
@@ -856,12 +926,12 @@ function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex
856
926
  if (embCount < 20) {
857
927
  try {
858
928
  const speakerDir = resolve(AUDIO_SAVE_DIR, speaker.replace(/\s+/g, '_'))
859
- if (!existsSync(speakerDir)) mkdirSync(speakerDir, { recursive: true })
929
+ ensurePrivateDirectory(speakerDir)
860
930
  const existing = getTrainingCount(speakerDir)
861
931
  if (existing < MAX_SAVED_CHUNKS_PER_SPEAKER) {
862
932
  const filename = `${sessionId}_chunk${chunkIndex}_sim${embeddingResult.similarity.toFixed(2)}.wav`
863
933
  const savePath = resolve(speakerDir, filename)
864
- writeFile(savePath, audioBuffer).catch(err =>
934
+ writeFile(savePath, audioBuffer, { mode: 0o600 }).catch(err =>
865
935
  console.warn(`[training-audio] Async save failed for ${speaker}: ${err.message}`)
866
936
  )
867
937
  trainingAudioCounts.set(speakerDir, existing + 1)
@@ -877,11 +947,11 @@ function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex
877
947
 
878
948
  if (speaker === 'Ext' && audioBuffer.length >= 16000) {
879
949
  const extSessionDir = resolve(EXT_AUDIO_DIR, sessionId)
880
- if (!existsSync(extSessionDir)) mkdirSync(extSessionDir, { recursive: true })
950
+ ensurePrivateDirectory(extSessionDir)
881
951
  const extCount = extAudioCounts.get(sessionId) ?? 0
882
952
  if (extCount < MAX_EXT_CHUNKS_PER_SESSION) {
883
953
  const filename = `ext_chunk${chunkIndex}_${Date.now()}.wav`
884
- writeFile(resolve(extSessionDir, filename), audioBuffer).catch(err =>
954
+ writeFile(resolve(extSessionDir, filename), audioBuffer, { mode: 0o600 }).catch(err =>
885
955
  console.warn(`[ext-audio] Save failed: ${err.message}`)
886
956
  )
887
957
  extAudioCounts.set(sessionId, extCount + 1)
@@ -910,6 +980,8 @@ async function processStreamChunk(opts: {
910
980
  audioBuffer: Buffer
911
981
  candidate?: StreamCandidateInput
912
982
  clientElapsed?: number
983
+ /** Original client recording start, applied only before canonical chunks. */
984
+ startTimeOverride?: number
913
985
  }): Promise<{ text: string; speaker: string; chunkIndex: number; elapsed: number; sessionId: string; backend?: string; asrProvider?: string; fallbackReason?: string }> {
914
986
  const { sessionId, chunkIndex, clientSpeaker, audioBuffer, candidate } = opts
915
987
  const tReq = performance.now()
@@ -920,6 +992,9 @@ async function processStreamChunk(opts: {
920
992
 
921
993
  const audioSha256 = sha256Hex(audioBuffer)
922
994
  const session = getSession(sessionId)
995
+ if (opts.startTimeOverride && session.chunks.filter(Boolean).length === 0) {
996
+ session.startTime = opts.startTimeOverride
997
+ }
923
998
  // Transfer integrity: log this index as delivered before any text filtering,
924
999
  // so a silent/hallucination-filtered chunk is NOT mistaken for a lost one.
925
1000
  recordReceivedChunk(session, chunkIndex)
@@ -1019,7 +1094,10 @@ async function processStreamChunk(opts: {
1019
1094
  }
1020
1095
 
1021
1096
  const { speaker, similarity } = await speakerPromise
1022
- const elapsed = Date.now() - session.startTime
1097
+ // Client time is authoritative for live network jitter and deferred replay.
1098
+ const elapsed = Number.isFinite(opts.clientElapsed) && (opts.clientElapsed as number) >= 0
1099
+ ? Math.round(opts.clientElapsed as number)
1100
+ : Date.now() - session.startTime
1023
1101
  const trimmedText = sanitized.text
1024
1102
 
1025
1103
  if (!trimmedText) {
@@ -1094,8 +1172,23 @@ transcribeStreamRouter.post('/transcribe-stream', async (req, res) => {
1094
1172
  const sessionId = (req.query.sessionId as string) || `g2_${Date.now()}`
1095
1173
  const chunkIndex = parseInt((req.query.chunkIndex as string) || '0', 10)
1096
1174
  const clientSpeaker = (req.query.speaker as string) || 'Unknown'
1175
+ const clientElapsedRaw = Number(req.query.elapsed)
1176
+ const clientElapsed = Number.isFinite(clientElapsedRaw) && clientElapsedRaw >= 0
1177
+ ? clientElapsedRaw
1178
+ : undefined
1179
+ const startTimeRaw = Number(req.query.startTime)
1180
+ const startTimeOverride = Number.isFinite(startTimeRaw) && startTimeRaw > 0
1181
+ ? startTimeRaw
1182
+ : undefined
1097
1183
  const audioBuffer = await readRawBody(req)
1098
- res.json(await processStreamChunk({ sessionId, chunkIndex, clientSpeaker, audioBuffer }))
1184
+ res.json(await processStreamChunk({
1185
+ sessionId,
1186
+ chunkIndex,
1187
+ clientSpeaker,
1188
+ audioBuffer,
1189
+ clientElapsed,
1190
+ startTimeOverride,
1191
+ }))
1099
1192
  } catch (err: unknown) {
1100
1193
  sendStreamError(res, err)
1101
1194
  }