@gotcos/glasses-server 6.21.22 → 6.21.24

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/CHANGELOG.md CHANGED
@@ -1,3 +1,50 @@
1
+ ## 6.21.24
2
+
3
+ - A leaked recording session can no longer block every restart. The maintenance
4
+ drain gate counted `sessions.size`, so a session whose phone dropped
5
+ mid-recording without sending a close pinned that count at 1 indefinitely and
6
+ Install, Repair, Restart and Update Server each drained it, timed out after
7
+ 60s, and failed — with no user-visible reason. Hit twice on 2026-08-06
8
+ (6.21.20 and 6.21.22); the second phantom had been silent 54 minutes and the
9
+ only way through was to finalize it as a real meeting, which it was not.
10
+
11
+ The gate now counts only sessions active within the last 30 minutes. A stale
12
+ one is reported in status as `staleTranscriptionSessions` with its session id,
13
+ silent duration and chunk count, so a blocked operator can see the cause —
14
+ and `/api/meeting/orphans` reporting 0 can no longer coexist silently with a
15
+ held lock, since the two read different stores.
16
+
17
+ **Nothing is reaped.** The session, its chunks and its recoverability are
18
+ untouched; only its claim on the restart gate expires. The 30-minute window
19
+ sits far above the reasons a live recording legitimately goes quiet (a
20
+ backgrounded phone buffering to IndexedDB, a network drop) and far below the
21
+ 4h durable-chunk retention.
22
+
23
+ Note for anyone reading the health payload: `oldestWorkStartedAt: null` does
24
+ NOT indicate a leak. `recording_session` reaches the gate through
25
+ `extraActiveByKind`, which never contributes a timestamp, so every recording
26
+ session reports null — healthy or not. Only `lastActivityAt` separates them.
27
+
28
+ ## 6.21.23
29
+
30
+ - `GET /meeting/:sessionId/embeddings` — why each chunk was labelled the way it
31
+ was. Reads the per-chunk embeddings the pipeline has retained since 6.21.15
32
+ and scores each one against every enrolled profile now, returning the top
33
+ matches and the margin between the best two. Until this route that store had
34
+ **no production reader at all**: the data was collected for weeks and never
35
+ looked at.
36
+
37
+ The margin is the point. It separates "missed by 0.02 against one profile"
38
+ from "equidistant between three" — a fixable near-miss versus a genuinely
39
+ ambiguous voice — and the review panel cannot tell those apart today. On a
40
+ face-mounted microphone that distinction is most of the available signal.
41
+
42
+ Read-only, and deliberately does NOT return the raw 192-float vectors: ~1 KB
43
+ of base64 per chunk that means nothing to a reader. Whole-session reads are
44
+ capped (default 50, max 400) because each chunk is scored against every
45
+ profile. A retained-but-absent chunk is reported in `missing` rather than
46
+ dropped, and `retained:false` stays distinguishable from "scored, no match".
47
+
1
48
  ## 6.21.22
2
49
 
3
50
  - The meeting Turbo preview is ON by default. `COS_WHISPER_MEETING_PREVIEW` is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.22",
3
+ "version": "6.21.24",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,146 @@
1
+ // Why a chunk was labelled the way it was.
2
+ //
3
+ // `chunk-embedding-store.ts` has retained a per-chunk embedding for every
4
+ // identified chunk, for 14 days, since 6.21.15 — and until now NOTHING in
5
+ // production read it back. The reader helpers existed; no route called them.
6
+ //
7
+ // That store is the difference between a reviewer (human or agent) who can only
8
+ // restate what the panel already shows and one who can answer the question that
9
+ // actually matters on a face-mounted microphone: not "who is this", but "how
10
+ // close did we get, and to WHAT". A row that missed its match by 0.02 against
11
+ // one profile is a very different problem from a row sitting equidistant
12
+ // between three, and the panel cannot tell them apart today.
13
+ //
14
+ // WHAT THIS DELIBERATELY DOES NOT RETURN: the raw 192-float vectors. They are
15
+ // ~1 KB of base64 each, they mean nothing to a reader, and a 400-chunk meeting
16
+ // would be 400 KB of noise. Similarity against each enrolled profile is the
17
+ // diagnostic; the vector is just how it is computed.
18
+
19
+ import {
20
+ chunkEmbeddingsForIndices,
21
+ readChunkEmbeddings,
22
+ type ChunkEmbeddingRow,
23
+ } from './chunk-embedding-store.js'
24
+ import { rawCosineSimilarity, readVoiceProfiles } from './speaker-embeddings.js'
25
+
26
+ /** Profiles scored per chunk. More than this is noise in a review context. */
27
+ const TOP_MATCHES = 5
28
+
29
+ export interface ProfileMatch {
30
+ speaker: string
31
+ /** Best cosine against any embedding held for that profile. */
32
+ similarity: number
33
+ /** How many embeddings that profile holds, so a strong score against a
34
+ * 1-sample profile is not read as equal to one against 20. */
35
+ embeddings: number
36
+ }
37
+
38
+ export interface ChunkDiagnostic {
39
+ chunk: number
40
+ /** The label the identifier chose live, which is what a correction corrects. */
41
+ chosen: string
42
+ /** The score it chose on, as recorded at capture time. */
43
+ chosenSimilarity: number
44
+ /** Best-scoring profiles NOW, recomputed against the current store — which
45
+ * can differ from capture time if the profile has been trained since. */
46
+ matches: ProfileMatch[]
47
+ /** Gap between the top two current matches. A small margin means the choice
48
+ * was nearly a coin flip, and that is invisible in the panel today. */
49
+ margin: number | null
50
+ }
51
+
52
+ export interface ChunkDiagnosticsResult {
53
+ sessionId: string
54
+ /** False when the store holds nothing for this session — aged out past the
55
+ * 14-day TTL, captured before 6.21.15, or embeddings disabled. Distinct from
56
+ * an empty result set so a caller never reads "no data" as "no match". */
57
+ retained: boolean
58
+ chunks: ChunkDiagnostic[]
59
+ /** Chunks asked for that the store does not hold. */
60
+ missing: number[]
61
+ /** Profiles the scores were computed against, so a reader can see the
62
+ * candidate pool rather than assume it. */
63
+ profileCount: number
64
+ }
65
+
66
+ /**
67
+ * Score one embedding against every enrolled profile.
68
+ *
69
+ * Best-of rather than mean: a profile holds up to 20 embeddings spanning
70
+ * different rooms and microphones, and averaging them buries the one recorded
71
+ * in conditions like these.
72
+ */
73
+ function scoreAgainstProfiles(embedding: Float32Array): ProfileMatch[] {
74
+ const store = readVoiceProfiles()
75
+ const scored: ProfileMatch[] = []
76
+ for (const profile of store.profiles) {
77
+ let best = -1
78
+ for (const candidate of profile.embeddings) {
79
+ // The profile store holds plain number[]; the chunk store holds
80
+ // Float32Array. Convert at the boundary rather than widening the
81
+ // similarity function, which is on the live identification hot path.
82
+ const value = rawCosineSimilarity(embedding, new Float32Array(candidate))
83
+ if (value > best) best = value
84
+ }
85
+ if (best > -1) {
86
+ scored.push({
87
+ speaker: profile.name,
88
+ similarity: Number(best.toFixed(4)),
89
+ embeddings: profile.embeddings.length,
90
+ })
91
+ }
92
+ }
93
+ scored.sort((a, b) => b.similarity - a.similarity)
94
+ return scored.slice(0, TOP_MATCHES)
95
+ }
96
+
97
+ function toDiagnostic(row: ChunkEmbeddingRow): ChunkDiagnostic {
98
+ const matches = scoreAgainstProfiles(row.embedding)
99
+ return {
100
+ chunk: row.i,
101
+ chosen: row.speaker,
102
+ chosenSimilarity: Number(row.similarity.toFixed(4)),
103
+ matches,
104
+ margin: matches.length >= 2
105
+ ? Number((matches[0].similarity - matches[1].similarity).toFixed(4))
106
+ : null,
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Diagnostics for specific chunks, or for the whole session when `indices` is
112
+ * empty.
113
+ *
114
+ * Bounded by `limit` because a long meeting holds hundreds of chunks and each
115
+ * one is scored against every profile — a whole-session call on a 400-chunk
116
+ * meeting with 77 profiles is 30,000 cosine comparisons. Fine to ask for, worth
117
+ * capping by default.
118
+ */
119
+ export function chunkDiagnostics(
120
+ sessionId: string,
121
+ indices: number[] = [],
122
+ limit = 50,
123
+ ): ChunkDiagnosticsResult {
124
+ const profileCount = readVoiceProfiles().profiles.length
125
+
126
+ if (indices.length > 0) {
127
+ const rows = chunkEmbeddingsForIndices(sessionId, indices)
128
+ const found = new Set(rows.map(r => r.i))
129
+ return {
130
+ sessionId,
131
+ retained: readChunkEmbeddings(sessionId).rows.length > 0,
132
+ chunks: rows.slice(0, limit).map(toDiagnostic),
133
+ missing: indices.filter(i => !found.has(i)),
134
+ profileCount,
135
+ }
136
+ }
137
+
138
+ const all = readChunkEmbeddings(sessionId).rows
139
+ return {
140
+ sessionId,
141
+ retained: all.length > 0,
142
+ chunks: all.slice(0, limit).map(toDiagnostic),
143
+ missing: [],
144
+ profileCount,
145
+ }
146
+ }
@@ -15,7 +15,7 @@ import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
15
15
  import { serverMetrics } from '../lib/server-metrics.js'
16
16
  import { getServerInstanceId } from '../lib/server-instance-id.js'
17
17
  import { getWhisperHealth } from '../lib/whisper-local.js'
18
- import { getActiveTranscriptionSessionCount } from './transcribe-stream.js'
18
+ import { getActiveTranscriptionSessionCount, getTranscriptionSessionLiveness } from './transcribe-stream.js'
19
19
 
20
20
  export const maintenanceRouter = Router()
21
21
 
@@ -71,15 +71,21 @@ function operationCredentials(req: Request): MaintenanceOperationCredentials {
71
71
  function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
72
72
  const jobs = getQueryJobRuntimeHealth()
73
73
  const activeTranscriptionSessions = getActiveTranscriptionSessionCount()
74
+ // Only sessions still plausibly recording may hold the drain gate. A leaked
75
+ // session — phone dropped mid-recording, no close ever sent — otherwise pins
76
+ // this at 1 forever and every Install/Repair/Restart/Update drains, times out
77
+ // and fails with nothing to show the user. The stale ones are reported below
78
+ // rather than counted, and are never deleted here.
79
+ const sessionLiveness = getTranscriptionSessionLiveness()
74
80
  const managed = managedRuntimeCapability()
75
81
  const tracked = maintenanceLifecycle.snapshot(credentials, {
76
- recording_session: activeTranscriptionSessions,
82
+ recording_session: sessionLiveness.live,
77
83
  })
78
84
  const untrackedDurableRuns = Math.max(0, jobs.activeRuns - (tracked.activeByKind.durable_query ?? 0))
79
85
  const lifecycle = untrackedDurableRuns > 0
80
86
  ? maintenanceLifecycle.snapshot(credentials, {
81
87
  durable_query_runtime: untrackedDurableRuns,
82
- recording_session: activeTranscriptionSessions,
88
+ recording_session: sessionLiveness.live,
83
89
  })
84
90
  : tracked
85
91
  return {
@@ -91,6 +97,13 @@ function statusSnapshot(credentials: MaintenanceOperationCredentials = {}) {
91
97
  bootId: serverMetrics.bootId,
92
98
  activeJobs: jobs.activeRuns,
93
99
  activeTranscriptionSessions,
100
+ // Split out so a blocked operator can SEE why, and so "0 orphans" can never
101
+ // again coexist with a held lock: the orphans endpoint reads the quarantine
102
+ // directory, this reads the in-memory session map, and they are different
103
+ // stores. A stale session is surfaced here and blocks nothing.
104
+ liveTranscriptionSessions: sessionLiveness.live,
105
+ staleTranscriptionSessions: sessionLiveness.stale,
106
+ staleTranscriptionSessionDetail: sessionLiveness.staleSessions,
94
107
  shuttingDown: jobs.shuttingDown,
95
108
  durableStoreState: jobs.store.state,
96
109
  lifecycle,
@@ -11,6 +11,8 @@ import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
11
11
  import { appendCorrection, pendingCorrections } from '../lib/meeting-corrections.js'
12
12
  import { isSampleFromSession, untraceableSampleCount } from '../lib/training-audio-provenance.js'
13
13
  import { sendAudioFile } from '../lib/send-audio.js'
14
+ import { chunkDiagnostics } from '../lib/chunk-embedding-diagnostics.js'
15
+ import { errMsg } from '../lib/utils.js'
14
16
  import {
15
17
  extAudioChunkPath,
16
18
  listExtAudioChunks,
@@ -1182,6 +1184,57 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1182
1184
  sendAudioFile(res, path)
1183
1185
  })
1184
1186
 
1187
+ /**
1188
+ * Why each chunk was labelled the way it was.
1189
+ *
1190
+ * Reads the per-chunk embeddings the pipeline has retained since 6.21.15 and
1191
+ * scores each one against every enrolled profile RIGHT NOW. Until this route
1192
+ * that store had no production reader at all — the data was collected and
1193
+ * never looked at.
1194
+ *
1195
+ * This is what lets a reviewer distinguish "missed by 0.02 against one
1196
+ * profile" from "equidistant between three", which is the difference between
1197
+ * a fixable near-miss and a genuinely ambiguous voice. On a face-mounted
1198
+ * microphone that distinction is most of the signal.
1199
+ *
1200
+ * Read-only. It scores and reports; it changes no profile and no meeting.
1201
+ *
1202
+ * ?chunks=4,17,23 specific chunks (omit for the whole session)
1203
+ * ?limit=50 cap, because each chunk is scored against every profile
1204
+ */
1205
+ router.get('/meeting/:sessionId/embeddings', (req, res) => {
1206
+ res.set('Cache-Control', 'private, no-store')
1207
+ const sessionId = String(req.params.sessionId ?? '')
1208
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1209
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1210
+ return
1211
+ }
1212
+
1213
+ const rawChunks = String(req.query.chunks ?? '').trim()
1214
+ const indices: number[] = []
1215
+ if (rawChunks) {
1216
+ for (const part of rawChunks.split(',')) {
1217
+ const value = Number(part.trim())
1218
+ // Reject the whole request rather than silently scoring a subset: a
1219
+ // caller asking about chunk 17 must not get an answer about chunk 4.
1220
+ if (!Number.isInteger(value) || value < 0) {
1221
+ res.status(400).json({ error: `Invalid chunk index "${part.trim()}"`, reason: 'invalid_chunk_index' })
1222
+ return
1223
+ }
1224
+ indices.push(value)
1225
+ }
1226
+ }
1227
+
1228
+ const rawLimit = Number(req.query.limit ?? 50)
1229
+ const limit = Number.isInteger(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 400) : 50
1230
+
1231
+ try {
1232
+ res.json(chunkDiagnostics(sessionId, indices, limit))
1233
+ } catch (error) {
1234
+ res.status(500).json({ error: errMsg(error), reason: 'diagnostics_failed' })
1235
+ }
1236
+ })
1237
+
1185
1238
  /** What audio a meeting still has, so the panel can show play buttons only
1186
1239
  * where they will work. */
1187
1240
  router.get('/meeting/:sessionId/audio', (req, res) => {
@@ -342,6 +342,76 @@ export function getActiveTranscriptionSessionCount(): number {
342
342
  return sessions.size
343
343
  }
344
344
 
345
+ /**
346
+ * How long a session may go silent before it stops BLOCKING a restart.
347
+ *
348
+ * Not a retention policy and not a reap: the session, its chunks and its
349
+ * recoverability are untouched. This decides one thing — whether it still
350
+ * counts toward the maintenance drain gate.
351
+ *
352
+ * Sized against the reasons a LIVE recording legitimately goes quiet: the phone
353
+ * backgrounded and buffering to IndexedDB, a network drop, a long pause. Those
354
+ * run to minutes. It sits deliberately far below
355
+ * LOCAL_FIRST_MEETING_IDLE_RETENTION_MS (4h), which governs how long chunks stay
356
+ * recoverable and is far too long to hold a restart on.
357
+ */
358
+ export const RECORDING_SESSION_STALE_MS = 30 * 60 * 1000
359
+
360
+ export interface TranscriptionSessionLiveness {
361
+ /** Sessions still plausibly recording. These block a restart. */
362
+ live: number
363
+ /** Sessions silent past the threshold. Surfaced, NOT counted, NOT deleted. */
364
+ stale: number
365
+ /** Which ones, least-silent first, so an operator can act on a name. */
366
+ staleSessions: Array<{ sessionId: string; silentForMs: number; chunks: number }>
367
+ }
368
+
369
+ /**
370
+ * Split active sessions into live and stale.
371
+ *
372
+ * WHY THIS EXISTS. The maintenance gate counts `sessions.size` and blocks every
373
+ * restart while it is non-zero. A session that leaks — the phone drops
374
+ * mid-recording and never sends a close — pins that count at 1 indefinitely, so
375
+ * Install, Repair, Restart and Update Server all drain, time out and fail with
376
+ * no user-visible reason. Observed twice on 2026-08-06 (servers 6.21.20 and
377
+ * 6.21.22); the second phantom had been silent 54 minutes and the only way
378
+ * through was to finalize it as a real meeting, which it was not.
379
+ *
380
+ * THE DISCRIMINATOR IS `lastActivityAt`, NOT `oldestWorkStartedAt`. The obvious
381
+ * reading — "activeTotal >= 1 with oldestWorkStartedAt null means leaked" — is
382
+ * WRONG and would strand healthy recordings. `recording_session` reaches the
383
+ * gate through `extraActiveByKind` (routes/maintenance.ts), and the snapshot's
384
+ * `oldestStartedAtMs` loop walks only tracked `work` entries, so an extra count
385
+ * never contributes a timestamp. EVERY recording session therefore reports
386
+ * `oldestWorkStartedAt: null`, healthy or leaked. Only the session's own
387
+ * `lastActivityAt` tells them apart.
388
+ */
389
+ /**
390
+ * The live session map, for tests only.
391
+ *
392
+ * Exposed because the liveness split is pure logic over `lastActivityAt` but
393
+ * the map is module-private, and a test that cannot seed it can only assert the
394
+ * empty case. An earlier draft of the liveness test guarded on this symbol
395
+ * existing and silently no-opped four of its six cases — passing green while
396
+ * exercising nothing. Never guard a test on its own seam; make the seam real.
397
+ */
398
+ export const __sessionsForTests = sessions
399
+
400
+ export function getTranscriptionSessionLiveness(now = Date.now()): TranscriptionSessionLiveness {
401
+ let live = 0
402
+ const staleSessions: TranscriptionSessionLiveness['staleSessions'] = []
403
+ for (const [sessionId, session] of sessions) {
404
+ const silentForMs = now - session.lastActivityAt
405
+ if (silentForMs >= RECORDING_SESSION_STALE_MS) {
406
+ staleSessions.push({ sessionId, silentForMs, chunks: session.chunks.length })
407
+ } else {
408
+ live++
409
+ }
410
+ }
411
+ staleSessions.sort((a, b) => a.silentForMs - b.silentForMs)
412
+ return { live, stale: staleSessions.length, staleSessions }
413
+ }
414
+
345
415
  // Incremental chunk persistence — survive server restarts
346
416
  const CHUNK_PERSIST_DIR = dataPath('active-sessions')
347
417
  ensurePrivateDirectory(CHUNK_PERSIST_DIR)