@gotcos/glasses-server 6.16.9 → 6.18.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.
@@ -0,0 +1,58 @@
1
+ // Live Cues routes. Authenticated by the global /api X-Cos-Token middleware
2
+ // (index.ts:148) like every other /api route — no route-local auth needed.
3
+ //
4
+ // POST /live-cues/start { sessionId } -> { armed, sessionId, pipelinesUsed }
5
+ // POST /live-cues/stop { sessionId } -> { nudgesGenerated, nudges }
6
+ // GET /live-cues/status -> engine snapshot + capability
7
+
8
+ import { Router } from 'express'
9
+ import { errMsg } from '../lib/utils.js'
10
+ import {
11
+ armLiveCues,
12
+ disarmLiveCues,
13
+ getLiveCuesStatus,
14
+ LiveCuesArmError,
15
+ } from '../lib/live-cues-engine.js'
16
+ import { liveCuesCapability } from '../lib/live-cues-capability.js'
17
+
18
+ export const liveCuesRouter = Router()
19
+
20
+ liveCuesRouter.post('/live-cues/start', async (req, res) => {
21
+ const sessionId = typeof (req.body as { sessionId?: unknown })?.sessionId === 'string'
22
+ ? String((req.body as { sessionId: string }).sessionId).trim()
23
+ : ''
24
+ // A start with no sessionId is refused, never silently armed on a global
25
+ // counter — the per-meeting cap is only real when it is session-scoped.
26
+ if (!sessionId || !/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
27
+ return res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
28
+ }
29
+ try {
30
+ const result = await armLiveCues(sessionId)
31
+ return res.json(result)
32
+ } catch (error) {
33
+ if (error instanceof LiveCuesArmError) {
34
+ return res.status(error.status).json({ error: error.message, reason: error.code })
35
+ }
36
+ return res.status(500).json({ error: errMsg(error) })
37
+ }
38
+ })
39
+
40
+ liveCuesRouter.post('/live-cues/stop', (req, res) => {
41
+ const sessionId = typeof (req.body as { sessionId?: unknown })?.sessionId === 'string'
42
+ ? String((req.body as { sessionId: string }).sessionId).trim()
43
+ : ''
44
+ if (!sessionId) {
45
+ return res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
46
+ }
47
+ // Shape matches the existing client stop handler: it reads nudgesGenerated
48
+ // for its log line and hydrates meetingNudgeHistory from nudges when SSE
49
+ // missed events (the 200-event replay buffer is flooded by transcript_chunk).
50
+ return res.json(disarmLiveCues(sessionId))
51
+ })
52
+
53
+ liveCuesRouter.get('/live-cues/status', (_req, res) => {
54
+ res.json({
55
+ capability: liveCuesCapability(),
56
+ sessions: getLiveCuesStatus(),
57
+ })
58
+ })
@@ -41,6 +41,13 @@ import {
41
41
  } from './transcribe-stream.js'
42
42
  import { getServerInstanceId } from '../lib/server-instance-id.js'
43
43
  import { acquireMaintenanceWork, type MaintenanceWorkLease } from '../lib/maintenance-lifecycle.js'
44
+ import { handoffMeetingToOperations } from '../lib/g2-ops-handoff.js'
45
+
46
+ function cosOpsPipelineConfigured(): boolean {
47
+ // Read env live (not the module-load COS_SCRIPTS_DIR const) so unit tests that
48
+ // clear ops env stay standalone, and Control-updated env is visible.
49
+ return Boolean(process.env.COS_SCRIPTS_DIR?.trim())
50
+ }
44
51
 
45
52
  interface MeetingSessionSource {
46
53
  getTranscript(sessionId: string): string | null
@@ -313,24 +320,48 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
313
320
  allowDuringDrain: true,
314
321
  phase: 'queued',
315
322
  })
316
- const task = Promise.resolve().then(() => {
323
+ const task = Promise.resolve().then(async () => {
317
324
  batchLease.setPhase('active')
318
- return finalizeBatch({
319
- audioDir: pendingAudioDir,
320
- entries: chunkEntries.map(entry => ({ ...entry, chunk: { ...entry.chunk } })),
321
- streamingWordCount: countWords(transcript),
322
- meetingPath: saved.filepath,
323
- sidecarPath: saved.sidecarPath,
324
- runBatch,
325
- })
325
+ try {
326
+ await finalizeBatch({
327
+ audioDir: pendingAudioDir,
328
+ entries: chunkEntries.map(entry => ({ ...entry, chunk: { ...entry.chunk } })),
329
+ streamingWordCount: countWords(transcript),
330
+ meetingPath: saved.filepath,
331
+ sidecarPath: saved.sidecarPath,
332
+ runBatch,
333
+ })
334
+ } catch (error) {
335
+ // Raw audio deliberately remains for bounded cleanup / retry.
336
+ console.error(
337
+ `[meeting/save] Batch finalization failed for ${sessionId}: `
338
+ + `${error instanceof Error ? error.message : String(error)}`,
339
+ )
340
+ }
341
+ // Always hand off the durable local scribe (streaming or batch) into
342
+ // operations/ when COS pipeline is configured — this is what was
343
+ // missing on managed public server and left today's G2 files unsynced.
344
+ if (cosOpsPipelineConfigured()) {
345
+ await handoffMeetingToOperations(saved.filepath)
346
+ }
326
347
  }).catch(error => {
327
- // Raw audio deliberately remains for the existing two-hour cleanup.
328
348
  console.error(
329
- `[meeting/save] Batch finalization failed for ${sessionId}: `
349
+ `[meeting/save] G2 ops handoff failed for ${sessionId}: `
330
350
  + `${error instanceof Error ? error.message : String(error)}`,
331
351
  )
332
352
  }).finally(() => batchLease.release())
333
353
  scheduleBackground(task)
354
+ } else if (cosOpsPipelineConfigured()) {
355
+ // No HQ batch (no audio / incomplete writes) — still hand off the
356
+ // streaming scribe into operations when COS pipeline is configured.
357
+ scheduleBackground(
358
+ handoffMeetingToOperations(saved.filepath).catch(error => {
359
+ console.error(
360
+ `[meeting/save] G2 ops handoff failed for ${sessionId}: `
361
+ + `${error instanceof Error ? error.message : String(error)}`,
362
+ )
363
+ }),
364
+ )
334
365
  }
335
366
  } catch (error) {
336
367
  if (error instanceof MeetingStoreError) {
@@ -399,7 +430,7 @@ async function finalizeBatch(options: {
399
430
  if (canDeletePendingBatchAudio(transcriptApplied, metadataPersisted)) {
400
431
  rmSync(options.audioDir, { recursive: true, force: true })
401
432
  } else {
402
- console.warn('[meeting/save] Pending raw audio retained for bounded two-hour cleanup')
433
+ console.warn('[meeting/save] Pending raw audio retained for bounded cleanup')
403
434
  }
404
435
  }
405
436
 
@@ -46,8 +46,10 @@ import { getServerInstanceId } from '../lib/server-instance-id.js'
46
46
  import {
47
47
  acquireMaintenanceWork,
48
48
  maintenanceAdmissionsOpen,
49
+ maintenanceLifecycle,
49
50
  type MaintenanceWorkLease,
50
51
  } from '../lib/maintenance-lifecycle.js'
52
+ import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
51
53
 
52
54
  function ensurePrivateDirectory(path: string): void {
53
55
  if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 })
@@ -636,11 +638,15 @@ setInterval(() => {
636
638
  }
637
639
  }
638
640
  } catch {}
639
- // Purge stale pending-batch dirs older than 2 hours after move (batch should complete in minutes).
641
+ // Purge stale pending-batch dirs. HQ large-v3 on long meetings + Control
642
+ // restart can exceed 2h (2026-07-27: two sessions purged before batch).
643
+ // Use 12h, and never purge while a meeting_batch_finalization lease is held.
640
644
  // Age measured by _batch_pending.marker mtime (set by moveSessionAudioToPending), NOT the first
641
645
  // chunk's mtime — chunk files keep their original write-time across the atomic rename, so for
642
646
  // meetings > 1 hour, chunk mtimes would always look stale. Fallback: dir mtime for older marker-less dirs.
643
647
  try {
648
+ const batchBusy = ((maintenanceLifecycle.snapshot().activeByKind as Record<string, number>)
649
+ .meeting_batch_finalization ?? 0) > 0
644
650
  for (const dir of readdirSync(PENDING_BATCH_DIR)) {
645
651
  const dirPath = resolve(PENDING_BATCH_DIR, dir)
646
652
  try {
@@ -655,7 +661,11 @@ setInterval(() => {
655
661
  // Fallback for pre-v5.4.3 dirs: use directory ctime (changes on rename)
656
662
  ageSource = statSync(dirPath).ctimeMs
657
663
  }
658
- if (Date.now() - ageSource > 2 * 60 * 60 * 1000) {
664
+ if (Date.now() - ageSource > 12 * 60 * 60 * 1000) {
665
+ if (batchBusy) {
666
+ console.warn(`[cleanup] Retaining stale pending-batch while batch lease held: ${dir}`)
667
+ continue
668
+ }
659
669
  rmSync(dirPath, { recursive: true, force: true })
660
670
  console.log(`[cleanup] Purged stale pending-batch: ${dir}`)
661
671
  }
@@ -1538,6 +1548,11 @@ async function processStreamChunk(opts: {
1538
1548
  console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
1539
1549
 
1540
1550
  emitDisplay({ type: 'transcript_chunk', data: { text: trimmedText, speaker, chunkIndex, elapsed, sessionId } })
1551
+ // Live Cues feed — fire-and-forget, NEVER awaited: transcription must not
1552
+ // block on a cue, and no LLM runs on this path. .catch() is required — a
1553
+ // bare `void` on a rejecting promise is an unhandled rejection, which Node
1554
+ // throws on by default. All gates live inside the feed.
1555
+ void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
1541
1556
 
1542
1557
  console.log(`[perf] TOTAL request: ${(performance.now() - tReq).toFixed(1)}ms | chunk #${chunkIndex} | ${audioBuffer.length}b | rms=${Math.round(rms)} q=${isQuiet ? 1 : 0} | ${asrProvider} | "${trimmedText.slice(0, 50)}"`)
1543
1558
  return canonicalChunkResponse(chunk, sessionId, chunkIndex)
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
8
8
  import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount } from '../lib/speaker-embeddings.js'
9
9
  import { statSync } from 'node:fs'
10
10
  import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
11
+ import { getOwnerSpeakerLabel } from '../lib/profile.js'
11
12
 
12
13
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
13
14
  const AUDIO_SAVE_DIR = resolve(__dirname, '..', 'data', 'training-audio')
@@ -18,7 +19,10 @@ export const voiceRouter = Router()
18
19
  // POST /api/voice/enroll — accept WAV audio, extract embedding, store as profile
19
20
  voiceRouter.post('/voice/enroll', async (req, res) => {
20
21
  try {
21
- const name = (req.query.name as string) || 'MU'
22
+ // Default to the configured wearer label ('Me' unless owner_speaker_label
23
+ // is set). Hardcoding one user's initials here enrolled every other install
24
+ // under a stranger's name.
25
+ const name = (req.query.name as string) || getOwnerSpeakerLabel()
22
26
 
23
27
  // Collect raw audio body
24
28
  const buffers: Buffer[] = []
@@ -38,10 +42,12 @@ voiceRouter.post('/voice/enroll', async (req, res) => {
38
42
  }
39
43
  })
40
44
 
41
- // GET /api/voice/status — is MU enrolled?
45
+ // GET /api/voice/status — is the wearer enrolled?
42
46
  voiceRouter.get('/voice/status', (_req, res) => {
47
+ const owner = getOwnerSpeakerLabel()
43
48
  res.json({
44
- enrolled: isEnrolled('MU'),
49
+ owner,
50
+ enrolled: isEnrolled(owner),
45
51
  speakers: getAllSpeakerNames(),
46
52
  })
47
53
  })