@gotcos/glasses-server 6.21.24 → 6.21.26

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,48 @@
1
+ ## 6.21.26
2
+
3
+ - `assertedSegments` added to the speaker review: how many segments belong to
4
+ voices the panel actually shows WITH A NAME. `attributed` is a boolean that
5
+ only goes false at 100% unidentified, so a meeting where 295 of 299 chunks
6
+ matched nobody reported `true` and rendered as though normally attributed.
7
+ Measured unidentified share across 14 retained sessions ran 24% to 100%, all
8
+ of it collapsing onto that one boolean.
9
+
10
+ It counts asserted voices (`nameAsserted`), NOT chunks carrying a
11
+ person-shaped label. Those diverge sharply: on session 0i1xv3 the label-based
12
+ count is 287 of 379 segments while only 177 are displayed as names, so a
13
+ header built on labels would claim three quarters of the meeting identified
14
+ above a list of rows reading "Unidentified voice".
15
+
16
+ Purely additive — the route already spreads the review object and no client
17
+ decodes this payload strictly. COS Control 0.5.5+ renders it, and omits the
18
+ line against an older server rather than showing a zero.
19
+
20
+ ## 6.21.25
21
+
22
+ - `POST /meeting/:sessionId/confirm` — record that a human vouched for a label
23
+ the display floor demoted. The floor exists so the identifier cannot assert a
24
+ name it did not earn, but a person who was in the room is better evidence
25
+ than a cosine score, and there was no way to say so. A rename could not
26
+ express it: `relabelSidecarJson` rejects `from === to`. So the panel demoted
27
+ the row, instructed the reviewer to name it, and offered a candidate list
28
+ that excluded the very name they wanted.
29
+
30
+ A confirmation rewrites nothing — the sidecar already carries the label. It
31
+ records the vouch, and `reviewMeetingSpeakers` then reports the row as
32
+ asserted. Meeting-scoped like every other correction: vouching for a voice in
33
+ one room says nothing about a different room. Refuses with 409 if no chunk in
34
+ the meeting actually carries that label, so a typo cannot become a permanent
35
+ confirmation in an append-only ledger.
36
+
37
+ A confirmed row still shows its thrash caveat. The name is asserted; the
38
+ evidence that it swaps with someone else is not hidden.
39
+
40
+ - Fixed a latent trap found while building it: `readCorrections` validated
41
+ `phase` against a hardcoded list, so adding a phase to the TYPE made every
42
+ such row unusable at read time — the write succeeded, the read silently
43
+ dropped it, and no error surfaced anywhere. Phase validation now derives from
44
+ a single list with a narrowing guard.
45
+
1
46
  ## 6.21.24
2
47
 
3
48
  - A leaked recording session can no longer block every restart. The maintenance
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.24",
3
+ "version": "6.21.26",
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.
@@ -225,6 +227,23 @@ export interface MeetingSpeakerReview {
225
227
  segments: number
226
228
  /** False when no chunk carries a real speaker — a recovered capture. */
227
229
  attributed: boolean
230
+ /**
231
+ * Segments belonging to voices this review asserts a NAME for.
232
+ *
233
+ * `attributed` is a boolean that only goes false at 100% unidentified, so a
234
+ * meeting where 295 of 299 chunks matched nobody still reports `true` and
235
+ * renders as though it were normally attributed. This is the graded version:
236
+ * measured Ext share across 14 retained sessions ran from 24% to 100%.
237
+ *
238
+ * Counts SEGMENTS OF ASSERTED VOICES, not chunks carrying a person-shaped
239
+ * label — see the derivation for why those differ. Denominator is `segments`
240
+ * (every chunk, including unlabelled ones), so `assertedSegments / segments`
241
+ * is the ratio every client should compute.
242
+ *
243
+ * Not named "coverage": `coverageRatio` in batch-transcript-quality.ts is an
244
+ * unrelated word-overlap measure and the two must not read as siblings.
245
+ */
246
+ assertedSegments: number
228
247
  durationMs: number
229
248
  voices: VoiceReview[]
230
249
  /** Chronological spans, so a ribbon can be a timeline instead of a share bar. */
@@ -385,9 +404,21 @@ export function selectPhrases(
385
404
  /** Build the whole review for one meeting's chunks. */
386
405
  export function reviewMeetingSpeakers(
387
406
  chunks: ReviewChunk[],
388
- options: { owner?: string; phrasesPerVoice?: number; durationMs?: number } = {},
407
+ options: {
408
+ owner?: string
409
+ phrasesPerVoice?: number
410
+ durationMs?: number
411
+ /**
412
+ * Labels a human vouched for in THIS meeting. The floor is a guard against
413
+ * the identifier over-claiming; it was never meant to overrule a person who
414
+ * was in the room. A confirmed label asserts its name with no rewrite —
415
+ * the sidecar already carries it.
416
+ */
417
+ confirmed?: Set<string>
418
+ } = {},
389
419
  ): MeetingSpeakerReview {
390
420
  const owner = options.owner ?? 'Me'
421
+ const confirmed = options.confirmed ?? new Set<string>()
391
422
  const limit = options.phrasesPerVoice ?? 3
392
423
  const sequence = chunks.map(c => c.speaker ?? '')
393
424
  // The caller's durationMs (the sidecar's own) is the meeting's true end.
@@ -439,6 +470,12 @@ export function reviewMeetingSpeakers(
439
470
  const assertionBlockers: string[] = []
440
471
  if (unattributed) {
441
472
  assertionBlockers.push('no name was ever assigned to this voice')
473
+ } else if (confirmed.has(label)) {
474
+ // A human said this is them. Blockers stay OFF the list rather than being
475
+ // listed-then-overridden, because a reviewer reading "similarity 0.56
476
+ // below 0.65" under a name they personally confirmed would reasonably
477
+ // conclude the confirmation had not taken. `thrashesWith` still renders,
478
+ // so a genuinely mixed row is still visibly mixed.
442
479
  } else if (label === owner) {
443
480
  // The wearer is exempt. Their identity is established by wearing the
444
481
  // device, not by cosine — and the owner is verified at exactly this floor
@@ -466,6 +503,8 @@ export function reviewMeetingSpeakers(
466
503
  meanRun: Math.round(mean(runs) * 100) / 100,
467
504
  longestRun: runs.length ? Math.max(...runs) : 0,
468
505
  isOwner: label === owner,
506
+ /** True when a human vouched for this label in this meeting. */
507
+ confirmedByHuman: confirmed.has(label),
469
508
  reliability,
470
509
  nameAsserted: assertionBlockers.length === 0,
471
510
  assertionBlockers,
@@ -478,6 +517,14 @@ export function reviewMeetingSpeakers(
478
517
  return {
479
518
  segments: chunks.length,
480
519
  attributed: named.length > 0,
520
+ // Derived from `nameAsserted`, NOT from `isUnattributed`. Those two answer
521
+ // different questions and diverge badly: measured on session 0i1xv3, the
522
+ // label-based count is 287 of 379 segments while only 177 belong to voices
523
+ // the panel actually shows with a name — a 29-point gap. A header built on
524
+ // labels would claim three quarters of the meeting was identified above a
525
+ // list of rows reading "Unidentified voice", which is the confusion this
526
+ // number exists to remove.
527
+ assertedSegments: voices.reduce((n, v) => (v.nameAsserted ? n + v.segments : n), 0),
481
528
  durationMs,
482
529
  voices,
483
530
  timeline: speakerTimeline(chunks, durationMs),
@@ -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 ?? '')