@gotcos/glasses-server 6.21.23 → 6.21.25

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,56 @@
1
+ ## 6.21.25
2
+
3
+ - `POST /meeting/:sessionId/confirm` — record that a human vouched for a label
4
+ the display floor demoted. The floor exists so the identifier cannot assert a
5
+ name it did not earn, but a person who was in the room is better evidence
6
+ than a cosine score, and there was no way to say so. A rename could not
7
+ express it: `relabelSidecarJson` rejects `from === to`. So the panel demoted
8
+ the row, instructed the reviewer to name it, and offered a candidate list
9
+ that excluded the very name they wanted.
10
+
11
+ A confirmation rewrites nothing — the sidecar already carries the label. It
12
+ records the vouch, and `reviewMeetingSpeakers` then reports the row as
13
+ asserted. Meeting-scoped like every other correction: vouching for a voice in
14
+ one room says nothing about a different room. Refuses with 409 if no chunk in
15
+ the meeting actually carries that label, so a typo cannot become a permanent
16
+ confirmation in an append-only ledger.
17
+
18
+ A confirmed row still shows its thrash caveat. The name is asserted; the
19
+ evidence that it swaps with someone else is not hidden.
20
+
21
+ - Fixed a latent trap found while building it: `readCorrections` validated
22
+ `phase` against a hardcoded list, so adding a phase to the TYPE made every
23
+ such row unusable at read time — the write succeeded, the read silently
24
+ dropped it, and no error surfaced anywhere. Phase validation now derives from
25
+ a single list with a narrowing guard.
26
+
27
+ ## 6.21.24
28
+
29
+ - A leaked recording session can no longer block every restart. The maintenance
30
+ drain gate counted `sessions.size`, so a session whose phone dropped
31
+ mid-recording without sending a close pinned that count at 1 indefinitely and
32
+ Install, Repair, Restart and Update Server each drained it, timed out after
33
+ 60s, and failed — with no user-visible reason. Hit twice on 2026-08-06
34
+ (6.21.20 and 6.21.22); the second phantom had been silent 54 minutes and the
35
+ only way through was to finalize it as a real meeting, which it was not.
36
+
37
+ The gate now counts only sessions active within the last 30 minutes. A stale
38
+ one is reported in status as `staleTranscriptionSessions` with its session id,
39
+ silent duration and chunk count, so a blocked operator can see the cause —
40
+ and `/api/meeting/orphans` reporting 0 can no longer coexist silently with a
41
+ held lock, since the two read different stores.
42
+
43
+ **Nothing is reaped.** The session, its chunks and its recoverability are
44
+ untouched; only its claim on the restart gate expires. The 30-minute window
45
+ sits far above the reasons a live recording legitimately goes quiet (a
46
+ backgrounded phone buffering to IndexedDB, a network drop) and far below the
47
+ 4h durable-chunk retention.
48
+
49
+ Note for anyone reading the health payload: `oldestWorkStartedAt: null` does
50
+ NOT indicate a leak. `recording_session` reaches the gate through
51
+ `extraActiveByKind`, which never contributes a timestamp, so every recording
52
+ session reports null — healthy or not. Only `lastActivityAt` separates them.
53
+
1
54
  ## 6.21.23
2
55
 
3
56
  - `GET /meeting/:sessionId/embeddings` — why each chunk was labelled the way it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.23",
3
+ "version": "6.21.25",
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": {
@@ -35,7 +35,31 @@ export const CORRECTIONS_DIR = 'meeting-corrections'
35
35
  * `intent` goes down before any file is touched; `applied` or `failed` closes it.
36
36
  * An unclosed intent is an incomplete correction, not a successful one.
37
37
  */
38
- export type CorrectionPhase = 'intent' | 'applied' | 'failed'
38
+ export type CorrectionPhase = 'intent' | 'applied' | 'failed' | 'confirmed'
39
+ /**
40
+ * A human confirming the identifier was RIGHT about a label the display floor
41
+ * demoted.
42
+ *
43
+ * Distinct from a rename, and it has to be: `relabelSidecarJson` rejects
44
+ * `from === to`, so "yes, this really is Queen Ukaoma" cannot be expressed as a
45
+ * correction at all. The floor exists because a 0.56 match is not evidence — but
46
+ * a person who was in the room IS evidence, and there was no way to record it.
47
+ * The panel demoted the row, told the reviewer to name it, and then offered a
48
+ * list that deliberately excluded the one name they wanted.
49
+ *
50
+ * A confirmation rewrites NOTHING. The sidecar already carries the label; this
51
+ * only records that a human vouched for it, so the review stops presenting it
52
+ * as unearned. Scoped to one meeting like every other correction.
53
+ */
54
+ export const CONFIRMATION_PHASE = 'confirmed' as const
55
+
56
+ /** Runtime counterpart of CorrectionPhase. Must stay in step with it. */
57
+ const VALID_PHASES = new Set<string>(['intent', 'applied', 'failed', 'confirmed'])
58
+
59
+ /** Narrowing guard, so the reader keeps its type safety with one phase list. */
60
+ function isCorrectionPhase(value: unknown): value is CorrectionPhase {
61
+ return typeof value === 'string' && VALID_PHASES.has(value)
62
+ }
39
63
 
40
64
  export interface CorrectionSurfaces {
41
65
  /** Chunks whose `speaker` changed in the sidecar. */
@@ -123,7 +147,11 @@ export function readCorrections(sessionId: string): CorrectionReadResult {
123
147
  try {
124
148
  const o = JSON.parse(line) as Record<string, unknown>
125
149
  if (typeof o.id !== 'string' || typeof o.from !== 'string' || typeof o.to !== 'string') { unusable++; continue }
126
- if (o.phase !== 'intent' && o.phase !== 'applied' && o.phase !== 'failed') { unusable++; continue }
150
+ // Kept as one list so adding a phase to the TYPE cannot silently make
151
+ // every such row unusable at read time — which is exactly what happened
152
+ // when 'confirmed' was added: appendCorrection wrote it, this dropped it,
153
+ // and the confirmation vanished with no error anywhere.
154
+ if (!isCorrectionPhase(o.phase)) { unusable++; continue }
127
155
  rows.push({
128
156
  id: o.id,
129
157
  phase: o.phase,
@@ -156,6 +184,22 @@ function isSurfaces(v: unknown): v is CorrectionSurfaces {
156
184
  * and a meeting with a pending correction should be treated as possibly
157
185
  * half-written rather than clean.
158
186
  */
187
+ /**
188
+ * Labels a human has confirmed for this meeting.
189
+ *
190
+ * Read by the speaker review to clear the display floor for exactly those
191
+ * labels. A confirmation is per-meeting and never global: vouching for a voice
192
+ * in one room says nothing about a different room, which is the same reasoning
193
+ * that makes every correction here meeting-scoped.
194
+ */
195
+ export function confirmedLabels(sessionId: string): Set<string> {
196
+ const confirmed = new Set<string>()
197
+ for (const row of readCorrections(sessionId).rows) {
198
+ if (row.phase === 'confirmed') confirmed.add(row.to)
199
+ }
200
+ return confirmed
201
+ }
202
+
159
203
  export function pendingCorrections(sessionId: string): CorrectionRow[] {
160
204
  const { rows } = readCorrections(sessionId)
161
205
  const closed = new Set(rows.filter(r => r.phase !== 'intent').map(r => r.id))
@@ -130,6 +130,8 @@ export interface VoiceReview {
130
130
  meanRun: number
131
131
  longestRun: number
132
132
  isOwner: boolean
133
+ /** A human confirmed this label for this meeting; the floor is waived. */
134
+ confirmedByHuman: boolean
133
135
  reliability: Reliability
134
136
  /**
135
137
  * Whether `label` may be shown to a human AS A NAME.
@@ -385,9 +387,21 @@ export function selectPhrases(
385
387
  /** Build the whole review for one meeting's chunks. */
386
388
  export function reviewMeetingSpeakers(
387
389
  chunks: ReviewChunk[],
388
- options: { owner?: string; phrasesPerVoice?: number; durationMs?: number } = {},
390
+ options: {
391
+ owner?: string
392
+ phrasesPerVoice?: number
393
+ durationMs?: number
394
+ /**
395
+ * Labels a human vouched for in THIS meeting. The floor is a guard against
396
+ * the identifier over-claiming; it was never meant to overrule a person who
397
+ * was in the room. A confirmed label asserts its name with no rewrite —
398
+ * the sidecar already carries it.
399
+ */
400
+ confirmed?: Set<string>
401
+ } = {},
389
402
  ): MeetingSpeakerReview {
390
403
  const owner = options.owner ?? 'Me'
404
+ const confirmed = options.confirmed ?? new Set<string>()
391
405
  const limit = options.phrasesPerVoice ?? 3
392
406
  const sequence = chunks.map(c => c.speaker ?? '')
393
407
  // The caller's durationMs (the sidecar's own) is the meeting's true end.
@@ -439,6 +453,12 @@ export function reviewMeetingSpeakers(
439
453
  const assertionBlockers: string[] = []
440
454
  if (unattributed) {
441
455
  assertionBlockers.push('no name was ever assigned to this voice')
456
+ } else if (confirmed.has(label)) {
457
+ // A human said this is them. Blockers stay OFF the list rather than being
458
+ // listed-then-overridden, because a reviewer reading "similarity 0.56
459
+ // below 0.65" under a name they personally confirmed would reasonably
460
+ // conclude the confirmation had not taken. `thrashesWith` still renders,
461
+ // so a genuinely mixed row is still visibly mixed.
442
462
  } else if (label === owner) {
443
463
  // The wearer is exempt. Their identity is established by wearing the
444
464
  // device, not by cosine — and the owner is verified at exactly this floor
@@ -466,6 +486,8 @@ export function reviewMeetingSpeakers(
466
486
  meanRun: Math.round(mean(runs) * 100) / 100,
467
487
  longestRun: runs.length ? Math.max(...runs) : 0,
468
488
  isOwner: label === owner,
489
+ /** True when a human vouched for this label in this meeting. */
490
+ confirmedByHuman: confirmed.has(label),
469
491
  reliability,
470
492
  nameAsserted: assertionBlockers.length === 0,
471
493
  assertionBlockers,
@@ -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,
@@ -13,6 +13,7 @@ import { isSampleFromSession, untraceableSampleCount } from '../lib/training-aud
13
13
  import { sendAudioFile } from '../lib/send-audio.js'
14
14
  import { chunkDiagnostics } from '../lib/chunk-embedding-diagnostics.js'
15
15
  import { errMsg } from '../lib/utils.js'
16
+ import { confirmedLabels } from '../lib/meeting-corrections.js'
16
17
  import {
17
18
  extAudioChunkPath,
18
19
  listExtAudioChunks,
@@ -724,6 +725,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
724
725
  const withIndices = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
725
726
  const review = reviewMeetingSpeakers(withIndices, {
726
727
  owner: getOwnerSpeakerLabel(),
728
+ // Labels a human already vouched for in this meeting. Without this the
729
+ // floor re-demotes a confirmed name on every reload, and the reviewer
730
+ // confirms the same voice forever.
731
+ confirmed: confirmedLabels(sessionId),
727
732
  phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
728
733
  // The sidecar's own durationMs is the meeting's true end. Deriving it from
729
734
  // max(elapsed) uses the START of the last chunk, which made the final
@@ -929,6 +934,87 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
929
934
  // like and will keep matching. Removing only the label fixes the transcript and
930
935
  // leaves the profile poisoned — the exact mechanism that grew the phantom
931
936
  // "Erick Hernandez" from 3 mislabelled seeds to 18 samples.
937
+ /**
938
+ * Confirm the identifier was RIGHT about a label the display floor demoted.
939
+ *
940
+ * Rewrites nothing. The sidecar already carries the label; this records that
941
+ * a human vouched for it so the review stops presenting it as unearned.
942
+ *
943
+ * This exists because "yes, that really is her" was inexpressible. A rename
944
+ * cannot say it — `relabelSidecarJson` rejects `from === to` — so the panel
945
+ * demoted the row, instructed the reviewer to name it, and then offered a
946
+ * candidate list that excluded the very name they wanted. The floor is a
947
+ * guard against the IDENTIFIER over-claiming; it was never meant to overrule
948
+ * a person who was in the room.
949
+ *
950
+ * Meeting-scoped, like every other correction: vouching for a voice in one
951
+ * room says nothing about a different room.
952
+ */
953
+ router.post('/meeting/:sessionId/confirm', (req, res) => {
954
+ res.set('Cache-Control', 'private, no-store')
955
+ const sessionId = String(req.params.sessionId ?? '')
956
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
957
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
958
+ return
959
+ }
960
+ const label = typeof req.body?.label === 'string' ? req.body.label : ''
961
+ const bad = invalidLabelReason(label)
962
+ if (bad) {
963
+ res.status(400).json({ error: `label: ${bad}`, reason: 'invalid_label' })
964
+ return
965
+ }
966
+
967
+ // Same resolution as GET /speakers and the relabel route, so the panel and
968
+ // the confirmation act on the same copy of the meeting.
969
+ const operations = cosOperationsMeetingsConfigured()
970
+ ? findCosOperationsMeetingBySessionId(sessionId)
971
+ : null
972
+ const saved = operations ? null : store.findBySessionId(sessionId)
973
+ if (!operations && !saved) {
974
+ res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
975
+ return
976
+ }
977
+ const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
978
+
979
+ // Refuse to confirm a label the meeting does not actually carry. Otherwise
980
+ // a typo becomes a permanent confirmation for a speaker who was never here,
981
+ // and the ledger is append-only.
982
+ let carried = 0
983
+ try {
984
+ const doc = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
985
+ const rows = Array.isArray(doc.chunks) ? doc.chunks : []
986
+ carried = rows.filter(r => r && typeof r === 'object'
987
+ && (r as Record<string, unknown>).speaker === label).length
988
+ } catch {
989
+ res.status(500).json({ error: 'Could not read the chunk sidecar', reason: 'sidecar_unreadable' })
990
+ return
991
+ }
992
+ if (carried === 0) {
993
+ res.status(409).json({
994
+ error: `No chunk in this meeting is labelled "${label}"`,
995
+ reason: 'label_not_present',
996
+ })
997
+ return
998
+ }
999
+
1000
+ const ok = appendCorrection(sessionId, {
1001
+ id: `confirm-${Date.now()}`,
1002
+ phase: 'confirmed',
1003
+ at: new Date().toISOString(),
1004
+ // `from` and `to` are the same by definition — that is what makes this a
1005
+ // confirmation rather than a rename, and why relabel could not express it.
1006
+ from: label,
1007
+ to: label,
1008
+ chunks: [],
1009
+ scope: 'meeting',
1010
+ })
1011
+ if (!ok) {
1012
+ res.status(500).json({ error: 'Could not record the confirmation', reason: 'ledger_write_failed' })
1013
+ return
1014
+ }
1015
+ res.json({ confirmed: true, label, segments: carried })
1016
+ })
1017
+
932
1018
  router.post('/meeting/:sessionId/deattribute', (req, res) => {
933
1019
  res.set('Cache-Control', 'private, no-store')
934
1020
  const sessionId = String(req.params.sessionId ?? '')
@@ -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)