@gotcos/glasses-server 6.21.17 → 6.21.19

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.
@@ -26,6 +26,19 @@
26
26
  export interface ReviewChunk {
27
27
  text?: string
28
28
  speaker?: string
29
+ /**
30
+ * RAW capture index — the number in `chunk_NNNN.wav`, NOT this chunk's position
31
+ * in the array.
32
+ *
33
+ * These differ and the gap grows through a meeting. Measured on the 2026-08-06
34
+ * Ditto sidecar: 885 compacted chunks against raw indices 0..945 with 36 gaps,
35
+ * so array position 884 is really raw chunk 940 — a 56-chunk error, minutes of
36
+ * audio. `chunks` is filtered to text-bearing entries (transcribe-stream's
37
+ * getSessionChunks) while the WAV is written for EVERY received chunk before
38
+ * ASR, so the position can never address the audio. Populated from the
39
+ * sidecar's `chunkEntries`, which exists precisely to preserve these.
40
+ */
41
+ chunkIndex?: number
29
42
  /** MILLISECONDS from meeting start. Confirmed against the writer:
30
43
  * meeting.ts accumulates `elapsed += row.durationMs`. Treating this as
31
44
  * seconds reports a 32-minute meeting as 30,936 minutes. */
@@ -36,6 +49,24 @@ export interface ReviewChunk {
36
49
  /** Labels that mean "nobody was identified", not a person. */
37
50
  export const UNATTRIBUTED = new Set(['Unknown', 'Ext', '', 'Speaker 1', 'Speaker 2', 'Speaker 3'])
38
51
 
52
+ /**
53
+ * Prefix for a voice a human de-attributed, numbered so distinct people stay
54
+ * distinct.
55
+ *
56
+ * De-attributing to a single shared `Ext` folded every corrected voice into one
57
+ * row: on the 2026-08-06 Ditto meeting Miles named five wrong attributions, and
58
+ * collapsing them would have destroyed his ability to tell those five voices
59
+ * apart afterwards — which is exactly what he then needs playback for. Numbering
60
+ * keeps them separable while asserting no identity.
61
+ */
62
+ export const DEATTRIBUTED_PREFIX = 'Unidentified'
63
+
64
+ /** True when a label asserts no identity — the exact set, or a numbered
65
+ * de-attribution. Prefix-aware so `Unidentified 3` is treated as unnamed. */
66
+ export function isUnattributed(label: string): boolean {
67
+ return UNATTRIBUTED.has(label) || new RegExp(`^${DEATTRIBUTED_PREFIX} \\d+$`).test(label)
68
+ }
69
+
39
70
  /** Calibrated from the control pair above. A pair must be BOTH flip-happy and
40
71
  * short-run to be called unreliable — either alone has honest explanations
41
72
  * (a rapid-fire exchange is flip-happy; a brief interjection is short-run). */
@@ -44,6 +75,27 @@ export const THRASH_MEAN_RUN = 8
44
75
  /** Below the search-accept threshold a name was never asserted with confidence. */
45
76
  export const CONFIDENT_SIMILARITY = 0.65
46
77
 
78
+ /**
79
+ * FLOOR FOR PRESENTING A NAME AT ALL.
80
+ *
81
+ * The identifier accepts a match at SEARCH_THRESHOLD = 0.55, so a single segment
82
+ * scoring 0.55 currently arrives in the panel wearing somebody's full name. On
83
+ * Miles's 2026-08-06 Ditto meeting that produced Richard Jenkins (1 segment,
84
+ * 0.60), Luke Henry (1 segment, 0.55), Dylan Jackson (2 segments, 0.58) and
85
+ * Navaz Sharif (3 segments, 0.58) — and he confirmed none of them were in the
86
+ * room. Presenting those as names is the defect; the reviewer then has to undo
87
+ * an assertion the system should never have made.
88
+ *
89
+ * Standing rule this enforces: speaker identity is a SUGGESTION, never an
90
+ * assertion. Below the floor the row is "unidentified" and the label survives
91
+ * only as a scored candidate.
92
+ *
93
+ * A floor cannot catch everything — a wrong match can still score well — so this
94
+ * removes obvious noise rather than guaranteeing correctness.
95
+ */
96
+ export const ASSERT_MIN_SIMILARITY = CONFIDENT_SIMILARITY
97
+ export const ASSERT_MIN_SEGMENTS = 3
98
+
47
99
  export type Reliability = 'confident' | 'weak' | 'unreliable' | 'unattributed'
48
100
 
49
101
  export interface ThrashPair {
@@ -58,6 +110,13 @@ export interface Phrase {
58
110
  /** Milliseconds from meeting start, matching the sidecar. */
59
111
  atMs: number
60
112
  similarity: number | null
113
+ /**
114
+ * Raw capture index for playback, or null when the sidecar cannot supply one
115
+ * (pre-`chunkEntries` captures). Null means "do not offer playback" — a
116
+ * guessed index plays somebody else's voice, which is worse than no button
117
+ * on a screen whose whole purpose is confirming identity.
118
+ */
119
+ chunkIndex: number | null
61
120
  }
62
121
 
63
122
  export interface VoiceReview {
@@ -72,22 +131,138 @@ export interface VoiceReview {
72
131
  longestRun: number
73
132
  isOwner: boolean
74
133
  reliability: Reliability
134
+ /**
135
+ * Whether `label` may be shown to a human AS A NAME.
136
+ *
137
+ * False means the UI must render the row as unidentified and offer `label`
138
+ * only as a scored candidate. Carried as data rather than left to each client
139
+ * to re-derive, so the phone, the lens and Control cannot disagree about
140
+ * whether a name was earned.
141
+ */
142
+ nameAsserted: boolean
143
+ /** Why the name is not asserted — so a UI can explain rather than just hide. */
144
+ assertionBlockers: string[]
75
145
  thrashesWith: ThrashPair[]
76
146
  phrases: Phrase[]
77
147
  }
78
148
 
149
+ /** One stretch of the meeting held by a single label. */
150
+ export interface TimelineSpan {
151
+ /** The label as stored. Whether it may be SHOWN as a name is still governed by
152
+ * the matching voice row's `nameAsserted` — a span is not a second opinion. */
153
+ speaker: string
154
+ /** Milliseconds from meeting start. `elapsed` on a chunk is its START offset:
155
+ * meeting.ts assigns `elapsed` and only then does `elapsed += durationMs`. */
156
+ startMs: number
157
+ endMs: number
158
+ segments: number
159
+ }
160
+
161
+ /**
162
+ * Collapse the chunk sequence into consecutive same-speaker spans.
163
+ *
164
+ * This exists because the ribbon needs a TIME axis. The previous ribbon drew one
165
+ * rectangle per voice sized by share of segments and labelled itself "who spoke,
166
+ * in order" — there was no ordering in it at all, so hovering could not report
167
+ * anything true.
168
+ *
169
+ * Non-monotonic or missing `elapsed` values are carried forward rather than
170
+ * trusted: a span with a negative width would render as an invisible or
171
+ * inverted block, which is worse than a slightly wrong boundary.
172
+ */
173
+ export function speakerTimeline(chunks: ReviewChunk[], durationMs: number): TimelineSpan[] {
174
+ const spans: TimelineSpan[] = []
175
+ let cursor = 0
176
+ for (const c of chunks) {
177
+ const label = c.speaker ?? ''
178
+ const raw = typeof c.elapsed === 'number' && Number.isFinite(c.elapsed) ? c.elapsed : 0
179
+ // ONE clamp does all three jobs, because `cursor` is monotonic and never
180
+ // negative: it recovers a missing value, a negative value, and a value that
181
+ // goes backwards. Mutation showed the extra Math.max(0, raw) and the
182
+ // `: cursor` fallback were both unreachable behind it.
183
+ const startMs = Math.max(cursor, raw)
184
+ cursor = startMs
185
+ const last = spans[spans.length - 1]
186
+ if (last && last.speaker === label) {
187
+ last.segments++
188
+ } else {
189
+ spans.push({ speaker: label, startMs, endMs: startMs, segments: 1 })
190
+ }
191
+ }
192
+ // Each span ends where the next begins. The LAST one is the problem: `elapsed`
193
+ // is a start offset, and on real sidecars `durationMs` frequently equals the
194
+ // final chunk's start exactly (measured on 2026-08-06 Ditto: both 5,783,732),
195
+ // so taking the meeting end verbatim leaves the closing turn zero-width — a
196
+ // 1.5pt sliver for what may be a long monologue.
197
+ //
198
+ // The tail gets ONE TYPICAL CHUNK of width, derived from the median gap between
199
+ // this meeting's own chunk starts. That is measured from the data rather than
200
+ // invented, and it is the shortest defensible non-zero answer.
201
+ const gaps: number[] = []
202
+ for (let i = 1; i < spans.length; i++) {
203
+ const d = spans[i].startMs - spans[i - 1].startMs
204
+ if (d > 0) gaps.push(d)
205
+ }
206
+ gaps.sort((a, b) => a - b)
207
+ const typicalGap = gaps.length ? gaps[Math.floor(gaps.length / 2)] : 0
208
+ for (let i = 0; i < spans.length; i++) {
209
+ const next = spans[i + 1]
210
+ if (next) {
211
+ spans[i].endMs = Math.max(spans[i].startMs, next.startMs)
212
+ } else if (durationMs > spans[i].startMs) {
213
+ // A real meeting end, later than this span's start: use it verbatim.
214
+ spans[i].endMs = durationMs
215
+ } else {
216
+ // durationMs is absent, or equals the final chunk's start (the common real
217
+ // case). Fall back to one typical chunk so the closing turn has width.
218
+ spans[i].endMs = spans[i].startMs + typicalGap
219
+ }
220
+ }
221
+ return spans
222
+ }
223
+
79
224
  export interface MeetingSpeakerReview {
80
225
  segments: number
81
226
  /** False when no chunk carries a real speaker — a recovered capture. */
82
227
  attributed: boolean
83
228
  durationMs: number
84
229
  voices: VoiceReview[]
230
+ /** Chronological spans, so a ribbon can be a timeline instead of a share bar. */
231
+ timeline: TimelineSpan[]
85
232
  }
86
233
 
87
234
  function mean(xs: number[]): number {
88
235
  return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0
89
236
  }
90
237
 
238
+ /**
239
+ * Attach raw capture indices to a compacted chunk array.
240
+ *
241
+ * The i-th compacted chunk is the i-th TEXT-BEARING `chunkEntries` row, whose
242
+ * `chunkIndex` is the raw WAV number. Verified against every 2026-07/08 sidecar
243
+ * that carries chunkEntries: the text-bearing count always equals the compacted
244
+ * count and the text lines up positionally.
245
+ *
246
+ * Returns the chunks UNCHANGED when the counts disagree or chunkEntries is
247
+ * absent. A partial or shifted mapping is worse than none: it would silently
248
+ * point playback at a neighbouring speaker, and this screen exists to confirm
249
+ * identity.
250
+ */
251
+ export function attachRawChunkIndices(chunks: ReviewChunk[], chunkEntries: unknown): ReviewChunk[] {
252
+ if (!Array.isArray(chunkEntries)) return chunks
253
+ const textBearing = chunkEntries.filter(e => {
254
+ const chunk = (e as { chunk?: { text?: unknown } } | null)?.chunk
255
+ return typeof chunk?.text === 'string' && chunk.text.trim() !== ''
256
+ })
257
+ if (textBearing.length !== chunks.length) return chunks
258
+ return chunks.map((c, i) => {
259
+ const raw = (textBearing[i] as { chunkIndex?: unknown }).chunkIndex
260
+ return typeof raw === 'number' && Number.isInteger(raw) && raw >= 0
261
+ ? { ...c, chunkIndex: raw }
262
+ : c
263
+ })
264
+ }
265
+
91
266
  /** Consecutive-run lengths for one speaker across the whole meeting. */
92
267
  export function speakerRuns(sequence: string[], speaker: string): number[] {
93
268
  const runs: number[] = []
@@ -179,6 +354,7 @@ export function selectPhrases(
179
354
  text: (c.text ?? '').trim(),
180
355
  atMs: typeof c.elapsed === 'number' ? c.elapsed : 0,
181
356
  similarity: typeof c.similarity === 'number' ? c.similarity : null,
357
+ chunkIndex: typeof c.chunkIndex === 'number' ? c.chunkIndex : null,
182
358
  score: phraseScore(c.text ?? ''),
183
359
  }))
184
360
  .filter(p => p.score > 0)
@@ -203,27 +379,34 @@ export function selectPhrases(
203
379
  }
204
380
  return picked
205
381
  .sort((a, b) => a.atMs - b.atMs)
206
- .map(({ text, atMs, similarity }) => ({ text, atMs, similarity }))
382
+ .map(({ text, atMs, similarity, chunkIndex }) => ({ text, atMs, similarity, chunkIndex }))
207
383
  }
208
384
 
209
385
  /** Build the whole review for one meeting's chunks. */
210
386
  export function reviewMeetingSpeakers(
211
387
  chunks: ReviewChunk[],
212
- options: { owner?: string; phrasesPerVoice?: number } = {},
388
+ options: { owner?: string; phrasesPerVoice?: number; durationMs?: number } = {},
213
389
  ): MeetingSpeakerReview {
214
390
  const owner = options.owner ?? 'Me'
215
391
  const limit = options.phrasesPerVoice ?? 3
216
392
  const sequence = chunks.map(c => c.speaker ?? '')
217
- const durationMs = chunks.reduce((max, c) => Math.max(max, typeof c.elapsed === 'number' ? c.elapsed : 0), 0)
393
+ // The caller's durationMs (the sidecar's own) is the meeting's true end.
394
+ // Falling back to max(elapsed) uses the START of the last chunk, which makes
395
+ // the final timeline span zero-width — so prefer the real value and only
396
+ // derive when it is absent.
397
+ const lastStart = chunks.reduce((max, c) => Math.max(max, typeof c.elapsed === 'number' ? c.elapsed : 0), 0)
398
+ const durationMs = typeof options.durationMs === 'number' && options.durationMs > lastStart
399
+ ? options.durationMs
400
+ : lastStart
218
401
 
219
402
  const labels = [...new Set(sequence)].filter(s => s.length > 0)
220
- const named = labels.filter(l => !UNATTRIBUTED.has(l))
403
+ const named = labels.filter(l => !isUnattributed(l))
221
404
 
222
405
  const voices: VoiceReview[] = labels.map(label => {
223
406
  const own = chunks.filter(c => (c.speaker ?? '') === label)
224
407
  const sims = own.map(c => c.similarity).filter((s): s is number => typeof s === 'number' && s > 0)
225
408
  const runs = speakerRuns(sequence, label)
226
- const unattributed = UNATTRIBUTED.has(label)
409
+ const unattributed = isUnattributed(label)
227
410
 
228
411
  const thrashesWith: ThrashPair[] = []
229
412
  if (!unattributed) {
@@ -250,6 +433,32 @@ export function reviewMeetingSpeakers(
250
433
  ? 'unreliable'
251
434
  : (meanSim ?? 0) >= CONFIDENT_SIMILARITY ? 'confident' : 'weak'
252
435
 
436
+ // What stops this label being presented as a name. Collected as reasons
437
+ // rather than a bare boolean: "2 segments" and "similarity 0.58" are
438
+ // different problems and a reviewer deserves to see which one applies.
439
+ const assertionBlockers: string[] = []
440
+ if (unattributed) {
441
+ assertionBlockers.push('no name was ever assigned to this voice')
442
+ } else if (label === owner) {
443
+ // The wearer is exempt. Their identity is established by wearing the
444
+ // device, not by cosine — and the owner is verified at exactly this floor
445
+ // (VERIFY_THRESHOLD 0.65), so they sit permanently on the boundary and any
446
+ // thrash pair flips them. Measured across the 2026-08-06 corpus: the owner
447
+ // row read "Unidentified voice" in 4 of 9 meetings, including one with 285
448
+ // of their own segments. `thrashesWith` still renders, so a mixed row is
449
+ // still visible — the name is asserted, the caveat is not hidden.
450
+ } else {
451
+ if (own.length < ASSERT_MIN_SEGMENTS) {
452
+ assertionBlockers.push(`only ${own.length} segment${own.length === 1 ? '' : 's'} (needs ${ASSERT_MIN_SEGMENTS})`)
453
+ }
454
+ if ((meanSim ?? 0) < ASSERT_MIN_SIMILARITY) {
455
+ assertionBlockers.push(`similarity ${(meanSim ?? 0).toFixed(2)} below ${ASSERT_MIN_SIMILARITY}`)
456
+ }
457
+ if (thrashesWith.length > 0) {
458
+ assertionBlockers.push(`swaps with ${thrashesWith[0].speaker} every ${Math.round(thrashesWith[0].meanRun)} segments`)
459
+ }
460
+ }
461
+
253
462
  return {
254
463
  label,
255
464
  segments: own.length,
@@ -258,11 +467,19 @@ export function reviewMeetingSpeakers(
258
467
  longestRun: runs.length ? Math.max(...runs) : 0,
259
468
  isOwner: label === owner,
260
469
  reliability,
470
+ nameAsserted: assertionBlockers.length === 0,
471
+ assertionBlockers,
261
472
  thrashesWith,
262
473
  phrases: selectPhrases(chunks, label, limit, durationMs),
263
474
  }
264
475
  })
265
476
 
266
477
  voices.sort((a, b) => b.segments - a.segments)
267
- return { segments: chunks.length, attributed: named.length > 0, durationMs, voices }
478
+ return {
479
+ segments: chunks.length,
480
+ attributed: named.length > 0,
481
+ durationMs,
482
+ voices,
483
+ timeline: speakerTimeline(chunks, durationMs),
484
+ }
268
485
  }
@@ -0,0 +1,70 @@
1
+ // Tracing a stored voice sample back to the meeting that produced it.
2
+ //
3
+ // WHY THIS IS NEEDED. De-attribution has to undo more than a label. When a voice
4
+ // is falsely attributed to someone, the identifier may ALSO have auto-enrolled
5
+ // those segments into that person's profile — so the wrong voice is now part of
6
+ // what the system thinks they sound like, and it will keep matching. Removing the
7
+ // label without removing the samples fixes the transcript and leaves the profile
8
+ // poisoned. That is the mechanism behind the phantom "Erick Hernandez": three
9
+ // mislabelled seeds compounded to eighteen samples through self-training.
10
+ //
11
+ // WHAT CAN AND CANNOT BE TRACED. Provenance strings differ in whether they name
12
+ // a session:
13
+ //
14
+ // auto:<sessionId> traceable — autoEnroll stamps it
15
+ // correction:<sessionId> traceable — the relabel ledger stamps it
16
+ // g2-training:<sessionId> traceable ONLY from 2026-08-06 onward (see below)
17
+ // g2-training NOT traceable — every sample written before that
18
+ // fireflies / manual / … NOT traceable — no session concept
19
+ //
20
+ // `train-g2` used to stamp a bare `g2-training`, discarding which meeting each
21
+ // sample came from even though the source WAV filename carries it
22
+ // (`meeting_1785190805524_uzxmn4_chunk327_sim0.57.wav`). Samples written before
23
+ // the stamp landed cannot be retracted per-meeting, and de-attribution reports
24
+ // that count rather than implying a clean sweep.
25
+
26
+ /** Session id embedded in a training-audio WAV name, or null if absent. */
27
+ export function sessionIdFromTrainingWav(filename: string): string | null {
28
+ // Session ids contain underscores (`meeting_1785190805524_uzxmn4`), so the
29
+ // split has to be on the `_chunk` marker, not on the last underscore.
30
+ const at = filename.indexOf('_chunk')
31
+ if (at <= 0) return null
32
+ const id = filename.slice(0, at)
33
+ return /^[A-Za-z0-9:_-]{3,96}$/.test(id) ? id : null
34
+ }
35
+
36
+ /** Provenance to record for a sample trained out of `filename`. */
37
+ export function trainingSourceFor(filename: string): string {
38
+ const id = sessionIdFromTrainingWav(filename)
39
+ return id ? `g2-training:${id}` : 'g2-training'
40
+ }
41
+
42
+ /**
43
+ * Did this stored sample come from `sessionId`?
44
+ *
45
+ * Exact match on the session part — a prefix test would let
46
+ * `auto:meeting_123_extra` match session `meeting_123` and retract a different
47
+ * meeting's evidence.
48
+ */
49
+ export function isSampleFromSession(source: string | undefined | null, sessionId: string): boolean {
50
+ const s = String(source ?? '')
51
+ if (!sessionId) return false
52
+ for (const prefix of ['auto:', 'correction:', 'g2-training:']) {
53
+ if (s.startsWith(prefix) && s.slice(prefix.length) === sessionId) return true
54
+ }
55
+ return false
56
+ }
57
+
58
+ /**
59
+ * Samples that belong to this speaker but cannot be tied to any meeting.
60
+ *
61
+ * Reported so a de-attribution is honest about its reach: "retracted 4, and 9
62
+ * older samples cannot be traced to a meeting" is actionable, while silence
63
+ * implies the profile was fully cleaned.
64
+ */
65
+ export function untraceableSampleCount(sources: Array<string | undefined | null>): number {
66
+ return sources.filter(s => {
67
+ const str = String(s ?? '')
68
+ return !str.includes(':')
69
+ }).length
70
+ }
@@ -9,6 +9,7 @@ import { isSileroAvailable } from '../lib/vad-silero.js'
9
9
  import { profileProvenanceSummary, speakerModelState, speakerReadiness } from '../lib/speaker-embeddings.js'
10
10
  import { chunkEmbeddingStoreStats } from '../lib/chunk-embedding-store.js'
11
11
  import { correctionStoreStats } from '../lib/meeting-corrections.js'
12
+ import { meetingAudioStats } from '../lib/meeting-audio-archive.js'
12
13
  import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
13
14
  import {
14
15
  isWhisperLocalAvailable,
@@ -124,6 +125,7 @@ healthRouter.get('/health', async (_req, res) => {
124
125
  // `pending` is the number that matters here: an intent that never closed means
125
126
  // some meeting's files may be half-rewritten.
126
127
  const speakerCorrections = correctionStoreStats()
128
+ const reviewAudio = meetingAudioStats()
127
129
  // `noHumanSample` is the one to read: a profile with no human-verified sample
128
130
  // is trained entirely on labels the system chose for itself.
129
131
  const voiceProvenance = speakerId.state === 'active' ? profileProvenanceSummary() : null
@@ -246,6 +248,7 @@ healthRouter.get('/health', async (_req, res) => {
246
248
  unsaved_captures,
247
249
  chunk_embeddings: chunkEmbeddings,
248
250
  speaker_corrections: speakerCorrections,
251
+ review_audio: reviewAudio,
249
252
  ...(voiceProvenance ? { voice_provenance: voiceProvenance } : {}),
250
253
  capabilities: {
251
254
  transcription: {