@gotcos/glasses-server 6.94.2 → 6.95.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.
@@ -16,6 +16,7 @@ import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contra
16
16
  import { isSileroAvailable } from '../lib/vad-silero.js'
17
17
  import { profileProvenanceSummary, speakerModelState, speakerReadiness } from '../lib/speaker-embeddings.js'
18
18
  import { nemotronLive } from '../lib/nemotron-live.js'
19
+ import { speakerShadowHealthFields } from '../lib/speaker-shadow.js'
19
20
  import { finalPassSnapshot } from '../lib/nemotron-final.js'
20
21
  import { chunkEmbeddingStoreStats } from '../lib/chunk-embedding-store.js'
21
22
  import { mintDisplayTicket, DISPLAY_TICKET_TTL_SECONDS } from '../lib/display-ticket.js'
@@ -579,6 +580,10 @@ healthRouter.get('/health', async (_req, res) => {
579
580
  // count only (health is public); the items and their Retry are behind the token at
580
581
  // GET /api/maintenance/status.
581
582
  crash_loop_parking: crashLoopParkingHealth(),
583
+ // 6.95.0: speaker merge shadow mode, only while it is on (absent when off). Codes and counts
584
+ // only (health is public): the proposals and the last error's text are behind the token at
585
+ // /api/speaker-shadow/*.
586
+ ...speakerShadowHealthFields(),
582
587
  // Key lanes (plan v4 3.1): `details.keyLanes` only while enabled. Absent, never null, when off.
583
588
  ...keyLanesHealthFields(),
584
589
  })
@@ -203,6 +203,7 @@ import { enrichStandaloneMeeting } from '../lib/meeting-summary-persistence.js'
203
203
  import { bootResumeGate, type BootResumeGate } from '../lib/boot-resume.js'
204
204
  import { recordFinalPassSkip, runNemotronFinalPass, type FinalPassOptions, type FinalPassOutcome } from '../lib/nemotron-final.js'
205
205
  import { runTranscriptCleanup, type CleanupOutcome } from '../lib/transcript-cleanup.js'
206
+ import { notifySpeakerShadowFinalized } from '../lib/speaker-shadow.js'
206
207
  import { sweepTranscriptCleanupData } from '../lib/transcript-cleanup-screen.js'
207
208
  import type { TranscriptCleanupRouteDeps } from './transcript-cleanup.js'
208
209
 
@@ -491,6 +492,9 @@ export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Fi
491
492
  await enrichStandaloneMeeting(current.meetingPath, jobStartedAt)
492
493
  }
493
494
  markCanonicalFinalizationState(current.sidecarPath, 'complete', false)
495
+ // 6.95.0: speaker merge shadow mode (off unless COS_SPEAKER_SHADOW=1). Log only and
496
+ // fire-and-forget: it queues, defers under live work, and never throws back here.
497
+ if (MAC_CAPTURE_ID.test(current.sessionId)) notifySpeakerShadowFinalized({ sessionId: current.sessionId, sidecarPath: current.sidecarPath })
494
498
  // The capture is finished and its files are durable, so the merge engine may score it.
495
499
  // Fire-and-forget by contract: the runner queues, defers under a drain or a live
496
500
  // capture, and never throws back at the finalization path.
@@ -0,0 +1,79 @@
1
+ // Speaker merge shadow mode (6.95.0). Token-protected by the /api middleware. READ ONLY.
2
+ //
3
+ // GET /api/speaker-shadow/proposals the logged proposal records, newest first
4
+ // ?limit=1..100 (default 10) ?sessionId=mac_<uuid>
5
+ // GET /api/speaker-shadow/status the switch, queue, daily cap, breaker, last error and
6
+ // setup readiness (models, CLI, sandbox)
7
+ // GET /api/speaker-shadow/settings { enabled, source } source: env | setting | default
8
+ // POST /api/speaker-shadow/settings {"enabled": true|false}: this Mac's saved switch (no
9
+ // restart); 409 env_forced when COS_SPEAKER_SHADOW pins it
10
+ //
11
+ // The settings POST is the only write, and it writes only <data>/speaker-shadow/settings.json.
12
+ // Nothing on these routes changes a meeting, a label, a name or a profile. A 404 means a
13
+ // server older than 6.95.0.
14
+
15
+ import { Router } from 'express'
16
+ import { getSpeakerShadowRunner, readSpeakerShadowRecords, resolveSpeakerShadowSwitch, speakerShadowEnvSwitch, writeSpeakerShadowSetting } from '../lib/speaker-shadow.js'
17
+ import { speakerShadowRoot } from '../lib/speaker-shadow-models.js'
18
+
19
+ export interface SpeakerShadowRouterDeps {
20
+ root?: () => string
21
+ env?: NodeJS.ProcessEnv
22
+ snapshot?: () => ReturnType<ReturnType<typeof getSpeakerShadowRunner>['snapshot']>
23
+ /** Called after the setting turns shadow mode on (the runner picks up anything still queued). */
24
+ onEnabled?: () => void
25
+ }
26
+
27
+ const SESSION_ID = /^mac_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
28
+
29
+ export function createSpeakerShadowRouter(deps: SpeakerShadowRouterDeps = {}): Router {
30
+ const router = Router()
31
+ const root = deps.root ?? speakerShadowRoot
32
+ const snapshot = deps.snapshot ?? (() => getSpeakerShadowRunner().snapshot({ detail: true }))
33
+ const env = deps.env ?? process.env
34
+ const onEnabled = deps.onEnabled ?? (() => getSpeakerShadowRunner().resume())
35
+
36
+ router.get('/speaker-shadow/proposals', async (req, res) => {
37
+ const rawLimit = Number(req.query.limit ?? 10)
38
+ if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 100) { res.status(400).json({ error: 'limit must be an integer from 1 to 100' }); return }
39
+ const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : undefined
40
+ if (sessionId !== undefined && !SESSION_ID.test(sessionId)) { res.status(400).json({ error: 'sessionId must be a mac_<uuid> session id' }); return }
41
+ try {
42
+ const records = await readSpeakerShadowRecords(root(), { limit: rawLimit, sessionId })
43
+ res.json({ logOnly: true, shadow: snapshot(), count: records.length, records })
44
+ } catch (error) {
45
+ res.status(500).json({ error: `could not read the shadow log: ${error instanceof Error ? error.message : String(error)}` })
46
+ }
47
+ })
48
+
49
+ router.get('/speaker-shadow/status', (_req, res) => {
50
+ res.json({ logOnly: true, ...snapshot() })
51
+ })
52
+
53
+ router.get('/speaker-shadow/settings', (_req, res) => {
54
+ res.set('Cache-Control', 'private, no-store')
55
+ res.json(resolveSpeakerShadowSwitch(env, root()))
56
+ })
57
+
58
+ router.post('/speaker-shadow/settings', (req, res) => {
59
+ res.set('Cache-Control', 'private, no-store')
60
+ const body = req.body as { enabled?: unknown } | undefined
61
+ if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.enabled !== 'boolean' || Object.keys(body).some(k => k !== 'enabled')) {
62
+ res.status(400).json({ error: 'invalid_request', expected: '{"enabled": true|false}' })
63
+ return
64
+ }
65
+ const pinned = speakerShadowEnvSwitch(env)
66
+ if (pinned !== null && pinned !== body.enabled) { res.status(409).json({ error: 'env_forced', ...resolveSpeakerShadowSwitch(env, root()) }); return }
67
+ try {
68
+ writeSpeakerShadowSetting(body.enabled, root())
69
+ } catch {
70
+ res.status(500).json({ error: 'settings_write_failed' })
71
+ return
72
+ }
73
+ const view = resolveSpeakerShadowSwitch(env, root())
74
+ if (view.enabled) { try { onEnabled() } catch { /* the next finished meeting starts it */ } }
75
+ res.json(view)
76
+ })
77
+
78
+ return router
79
+ }