@gotcos/glasses-server 6.21.10 → 6.21.13

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,55 @@
1
+ ## 6.21.13
2
+
3
+ - Carry each meeting's `sessionId` on the meetings list, so a Control row can
4
+ open the per-meeting speaker review. The review is keyed on the session
5
+ because that is what lets the store's own hardened lookup find the chunk
6
+ sidecar again; without this field the two surfaces could not be joined.
7
+ - Read only the head of a sidecar to lift that one field. Sidecars run to
8
+ megabytes, and reading them whole would make listing cost scale with total
9
+ transcript size — and silently drop the id on any sidecar above the
10
+ whole-file size cap.
11
+ - Omit the field rather than invent one when a sidecar is absent, corrupt,
12
+ symlinked outside its month directory, or carries an implausible id. A
13
+ meeting with no readable sidecar is still listed.
14
+
15
+ ## 6.21.12
16
+
17
+ - Add `GET /api/meeting/:sessionId/speakers`, the read surface behind COS
18
+ Control's speaker-naming panel. Read-only: it reports what a saved meeting's
19
+ chunk sidecar already holds and never writes. Naming, merging, and rebuilding
20
+ stay on the `/api/voice/*` routes, each with its own confirmation.
21
+ - Return two to three representative verbatim lines per voice, spread across the
22
+ meeting and timestamped. These are the primary output: a similarity score
23
+ cannot tell you who someone is, and a remembered sentence can.
24
+ - Report per-voice reliability from run length rather than similarity alone. Two
25
+ labels that swap every few segments are the identifier oscillating mid-turn,
26
+ which means those profiles cannot be told apart and any name applied to either
27
+ would be a guess. A high similarity score does not override that verdict.
28
+ - Restrict the run-length comparison to each pair of speakers, so a third person
29
+ interjecting cannot make two others look like they are swapping.
30
+ - Report a recovered meeting as unattributed rather than as a meeting with no
31
+ speakers, and still return phrases for it — on those meetings the phrases are
32
+ the only way in.
33
+
34
+ ## 6.21.11
35
+
36
+ - Add `POST /api/voice/merge-profiles` for the case where one person holds two
37
+ profiles. The sherpa manager registers one centroid per name, so a split
38
+ identity has both halves competing on every search and each capped at twenty
39
+ samples independently — a weaker representation than either half deserves.
40
+ - Refuse a merge whose centroid similarity falls below the search-accept
41
+ threshold, since two profiles further apart than the value at which
42
+ identification would match them are not one voice. `force` overrides and is
43
+ recorded in the response.
44
+ - Preserve provenance through a merge and select the surviving samples for
45
+ acoustic diversity, so capping the union keeps both profiles represented
46
+ rather than silently discarding the absorbed one.
47
+ - Relabel the absorbed name's calibration history instead of deleting it: after
48
+ a merge it is one person's history, and it is the only evidence for whether
49
+ the merge improved identification.
50
+ - Refuse to absorb the owner label, which the live identification path checks
51
+ first on every chunk.
52
+
1
53
  ## 6.21.10
2
54
 
3
55
  - Make the voice profile store durable. `voice-profiles.json` is written
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.10",
3
+ "version": "6.21.13",
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,268 @@
1
+ // Per-meeting speaker review — the data behind COS Control's naming panel.
2
+ //
3
+ // The panel's job is to let a human name voices from what they REMEMBER, so the
4
+ // primary output here is verbatim phrases, not similarity scores. A score cannot
5
+ // tell you who someone is; "I'll follow up with both Andrews on music targeting"
6
+ // can.
7
+ //
8
+ // It also carries the diagnostic that actually catches a broken profile.
9
+ // Similarity between profile centroids does NOT distinguish "two people who
10
+ // sound alike" from "one voice split across two labels" — measured on the live
11
+ // store, Chris Krubeck and Luke Henry sat at 0.85 while genuinely being
12
+ // separate rows. What discriminates is RUN LENGTH within a single meeting:
13
+ //
14
+ // MU + Chris Krubeck (known two people) : flip rate 0.069, mean run 30.0
15
+ // Luke H + Luke Henry : flip rate 0.361, mean run 2.79
16
+ // Chris Krubeck + Luke Henry : flip rate 0.381, mean run 3.47
17
+ // Scott Taylor + Dylan Jackson : flip rate 0.344, mean run 2.80
18
+ //
19
+ // Real conversation holds the floor for ~30 consecutive segments. A pair that
20
+ // swaps every ~3 is the identifier oscillating mid-turn, which means those two
21
+ // profiles cannot be told apart and any name applied to either is a guess.
22
+ //
23
+ // Everything here is pure over a chunk array so it can be tested by execution.
24
+
25
+ /** One transcript chunk as stored in the `.g2-chunks.json` sidecar. */
26
+ export interface ReviewChunk {
27
+ text?: string
28
+ speaker?: string
29
+ /** MILLISECONDS from meeting start. Confirmed against the writer:
30
+ * meeting.ts accumulates `elapsed += row.durationMs`. Treating this as
31
+ * seconds reports a 32-minute meeting as 30,936 minutes. */
32
+ elapsed?: number
33
+ similarity?: number
34
+ }
35
+
36
+ /** Labels that mean "nobody was identified", not a person. */
37
+ export const UNATTRIBUTED = new Set(['Unknown', 'Ext', '', 'Speaker 1', 'Speaker 2', 'Speaker 3'])
38
+
39
+ /** Calibrated from the control pair above. A pair must be BOTH flip-happy and
40
+ * short-run to be called unreliable — either alone has honest explanations
41
+ * (a rapid-fire exchange is flip-happy; a brief interjection is short-run). */
42
+ export const THRASH_FLIP_RATE = 0.20
43
+ export const THRASH_MEAN_RUN = 8
44
+ /** Below the search-accept threshold a name was never asserted with confidence. */
45
+ export const CONFIDENT_SIMILARITY = 0.65
46
+
47
+ export type Reliability = 'confident' | 'weak' | 'unreliable' | 'unattributed'
48
+
49
+ export interface ThrashPair {
50
+ speaker: string
51
+ flipRate: number
52
+ meanRun: number
53
+ sharedSegments: number
54
+ }
55
+
56
+ export interface Phrase {
57
+ text: string
58
+ /** Milliseconds from meeting start, matching the sidecar. */
59
+ atMs: number
60
+ similarity: number | null
61
+ }
62
+
63
+ export interface VoiceReview {
64
+ label: string
65
+ segments: number
66
+ meanSimilarity: number | null
67
+ /** Mean consecutive-run length across the WHOLE meeting. Not comparable to
68
+ * the pair-scoped run in `thrashesWith`: in a 13-voice meeting every
69
+ * speaker's whole-sequence run is short (measured: 3.36 for the owner), so
70
+ * this is a talkativeness texture, never a reliability signal. */
71
+ meanRun: number
72
+ longestRun: number
73
+ isOwner: boolean
74
+ reliability: Reliability
75
+ thrashesWith: ThrashPair[]
76
+ phrases: Phrase[]
77
+ }
78
+
79
+ export interface MeetingSpeakerReview {
80
+ segments: number
81
+ /** False when no chunk carries a real speaker — a recovered capture. */
82
+ attributed: boolean
83
+ durationMs: number
84
+ voices: VoiceReview[]
85
+ }
86
+
87
+ function mean(xs: number[]): number {
88
+ return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0
89
+ }
90
+
91
+ /** Consecutive-run lengths for one speaker across the whole meeting. */
92
+ export function speakerRuns(sequence: string[], speaker: string): number[] {
93
+ const runs: number[] = []
94
+ let cur = 0
95
+ for (const s of sequence) {
96
+ if (s === speaker) cur++
97
+ else if (cur > 0) { runs.push(cur); cur = 0 }
98
+ }
99
+ if (cur > 0) runs.push(cur)
100
+ return runs
101
+ }
102
+
103
+ /**
104
+ * How often the label flips between exactly two speakers, ignoring everyone
105
+ * else. Restricting to the pair is the point: a third person interjecting
106
+ * should not make two other speakers look like they are thrashing.
107
+ */
108
+ export function pairFlipRate(
109
+ sequence: string[],
110
+ a: string,
111
+ b: string,
112
+ ): { flipRate: number; meanRun: number; sharedSegments: number } | null {
113
+ const pair = sequence.filter(s => s === a || s === b)
114
+ if (pair.length < 6) return null // too little to characterise
115
+ if (!pair.includes(a) || !pair.includes(b)) return null
116
+
117
+ let flips = 0
118
+ const runs: number[] = []
119
+ let cur = 1
120
+ for (let i = 1; i < pair.length; i++) {
121
+ if (pair[i] === pair[i - 1]) cur++
122
+ else { flips++; runs.push(cur); cur = 1 }
123
+ }
124
+ runs.push(cur)
125
+ return {
126
+ flipRate: flips / (pair.length - 1),
127
+ meanRun: mean(runs),
128
+ sharedSegments: pair.length,
129
+ }
130
+ }
131
+
132
+ /** Words that carry no identifying information. */
133
+ const FILLER = /^(?:yeah|yep|okay|ok|right|sure|mm+|uh+|um+|hmm+|got it|exactly|thanks?|no|yes)[.!?]?$/i
134
+
135
+ /**
136
+ * Score a line by how much it would help a human recognise the speaker.
137
+ *
138
+ * Length matters (a longer line carries more voice), but so does specificity:
139
+ * a proper noun, a number, or a domain term is what makes someone say "that's
140
+ * Graham." A long line of pleasantries scores lower than a short line naming a
141
+ * brand and a figure.
142
+ */
143
+ export function phraseScore(text: string): number {
144
+ const trimmed = text.trim()
145
+ if (!trimmed || FILLER.test(trimmed)) return 0
146
+ const words = trimmed.split(/\s+/)
147
+ if (words.length < 6) return 0
148
+
149
+ let score = Math.min(words.length, 28)
150
+ // Mid-sentence capitals — names, brands, products.
151
+ const propers = words.slice(1).filter(w => /^[A-Z][a-zA-Z]{2,}/.test(w)).length
152
+ score += Math.min(propers, 4) * 6
153
+ // Figures, percentages, money — highly memorable.
154
+ if (/\d/.test(trimmed)) score += 5
155
+ // Penalise a line that is mostly filler even if long.
156
+ const fillerWords = words.filter(w => FILLER.test(w)).length
157
+ score -= fillerWords * 3
158
+ return Math.max(score, 0)
159
+ }
160
+
161
+ /**
162
+ * Pick up to `limit` representative lines, spread across the meeting.
163
+ *
164
+ * Spread is enforced deliberately: the three best-scoring lines often sit
165
+ * seconds apart in one monologue, which tells you far less about a speaker than
166
+ * three lines from the beginning, middle, and end. Every phrase carries its
167
+ * timestamp so a reviewer can place it against their memory of the meeting.
168
+ */
169
+ export function selectPhrases(
170
+ chunks: ReviewChunk[],
171
+ speaker: string,
172
+ limit = 3,
173
+ durationMs = 0,
174
+ ): Phrase[] {
175
+ const owned = chunks
176
+ .map((c, i) => ({ c, i }))
177
+ .filter(({ c }) => (c.speaker ?? '') === speaker)
178
+ .map(({ c }) => ({
179
+ text: (c.text ?? '').trim(),
180
+ atMs: typeof c.elapsed === 'number' ? c.elapsed : 0,
181
+ similarity: typeof c.similarity === 'number' ? c.similarity : null,
182
+ score: phraseScore(c.text ?? ''),
183
+ }))
184
+ .filter(p => p.score > 0)
185
+
186
+ if (owned.length === 0) return []
187
+
188
+ const span = durationMs > 0 ? durationMs : Math.max(...owned.map(p => p.atMs), 1)
189
+ const minGap = span / (limit * 2) // no two picks from the same stretch
190
+
191
+ const picked: typeof owned = []
192
+ for (const cand of [...owned].sort((a, b) => b.score - a.score)) {
193
+ if (picked.length >= limit) break
194
+ if (picked.some(p => Math.abs(p.atMs - cand.atMs) < minGap)) continue
195
+ picked.push(cand)
196
+ }
197
+ // Backfill on a short meeting where the gap rule excluded everything.
198
+ if (picked.length < limit) {
199
+ for (const cand of [...owned].sort((a, b) => b.score - a.score)) {
200
+ if (picked.length >= limit) break
201
+ if (!picked.includes(cand)) picked.push(cand)
202
+ }
203
+ }
204
+ return picked
205
+ .sort((a, b) => a.atMs - b.atMs)
206
+ .map(({ text, atMs, similarity }) => ({ text, atMs, similarity }))
207
+ }
208
+
209
+ /** Build the whole review for one meeting's chunks. */
210
+ export function reviewMeetingSpeakers(
211
+ chunks: ReviewChunk[],
212
+ options: { owner?: string; phrasesPerVoice?: number } = {},
213
+ ): MeetingSpeakerReview {
214
+ const owner = options.owner ?? 'Me'
215
+ const limit = options.phrasesPerVoice ?? 3
216
+ 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)
218
+
219
+ const labels = [...new Set(sequence)].filter(s => s.length > 0)
220
+ const named = labels.filter(l => !UNATTRIBUTED.has(l))
221
+
222
+ const voices: VoiceReview[] = labels.map(label => {
223
+ const own = chunks.filter(c => (c.speaker ?? '') === label)
224
+ const sims = own.map(c => c.similarity).filter((s): s is number => typeof s === 'number' && s > 0)
225
+ const runs = speakerRuns(sequence, label)
226
+ const unattributed = UNATTRIBUTED.has(label)
227
+
228
+ const thrashesWith: ThrashPair[] = []
229
+ if (!unattributed) {
230
+ for (const other of named) {
231
+ if (other === label) continue
232
+ const p = pairFlipRate(sequence, label, other)
233
+ if (!p) continue
234
+ if (p.flipRate > THRASH_FLIP_RATE && p.meanRun < THRASH_MEAN_RUN) {
235
+ thrashesWith.push({
236
+ speaker: other,
237
+ flipRate: Math.round(p.flipRate * 1000) / 1000,
238
+ meanRun: Math.round(p.meanRun * 100) / 100,
239
+ sharedSegments: p.sharedSegments,
240
+ })
241
+ }
242
+ }
243
+ thrashesWith.sort((a, b) => b.flipRate - a.flipRate)
244
+ }
245
+
246
+ const meanSim = sims.length ? Math.round(mean(sims) * 1000) / 1000 : null
247
+ const reliability: Reliability = unattributed
248
+ ? 'unattributed'
249
+ : thrashesWith.length > 0
250
+ ? 'unreliable'
251
+ : (meanSim ?? 0) >= CONFIDENT_SIMILARITY ? 'confident' : 'weak'
252
+
253
+ return {
254
+ label,
255
+ segments: own.length,
256
+ meanSimilarity: meanSim,
257
+ meanRun: Math.round(mean(runs) * 100) / 100,
258
+ longestRun: runs.length ? Math.max(...runs) : 0,
259
+ isOwner: label === owner,
260
+ reliability,
261
+ thrashesWith,
262
+ phrases: selectPhrases(chunks, label, limit, durationMs),
263
+ }
264
+ })
265
+
266
+ voices.sort((a, b) => b.segments - a.segments)
267
+ return { segments: chunks.length, attributed: named.length > 0, durationMs, voices }
268
+ }
@@ -8,6 +8,7 @@ import {
8
8
  mkdirSync,
9
9
  openSync,
10
10
  readFileSync,
11
+ readSync,
11
12
  readdirSync,
12
13
  realpathSync,
13
14
  unlinkSync,
@@ -43,6 +44,11 @@ export class MeetingStoreError extends Error {
43
44
 
44
45
  export interface MeetingMeta {
45
46
  filename: string
47
+ /** From the chunk sidecar, absent on meetings saved without one. Carried so a
48
+ * list row can open the per-meeting speaker review, which is keyed on the
49
+ * session rather than the filename — the session is what lets the store's
50
+ * own hardened lookup find the sidecar again. */
51
+ sessionId?: string
46
52
  title: string
47
53
  date: string
48
54
  domain: string
@@ -314,7 +320,7 @@ export function boundedMeetingSource(content: string): { sourceContent: string;
314
320
  return { sourceContent: bytes.subarray(0, end).toString('utf8'), sourceTruncated: true }
315
321
  }
316
322
 
317
- function toMeta(detail: MeetingDetail): MeetingMeta {
323
+ function toMeta(detail: MeetingDetail, sessionId?: string): MeetingMeta {
318
324
  const detailCharEstimate = [
319
325
  detail.title,
320
326
  detail.date,
@@ -327,6 +333,7 @@ function toMeta(detail: MeetingDetail): MeetingMeta {
327
333
  ].join('\n\n').trim().length
328
334
  return {
329
335
  filename: detail.filename,
336
+ ...(sessionId ? { sessionId } : {}),
330
337
  title: detail.title,
331
338
  date: detail.date,
332
339
  domain: detail.domain,
@@ -344,6 +351,9 @@ function toMeta(detail: MeetingDetail): MeetingMeta {
344
351
  }
345
352
  }
346
353
 
354
+ /** Enough to clear the sidecar's leading metadata keys whatever their order. */
355
+ const SIDECAR_HEAD_BYTES = 4096
356
+
347
357
  function isContained(parent: string, child: string): boolean {
348
358
  return child === parent || child.startsWith(`${parent}${sep}`)
349
359
  }
@@ -542,7 +552,7 @@ export class MeetingStore {
542
552
  if (content === null) continue
543
553
  const detail = parseMeeting(content, filename, month)
544
554
  if (domain !== 'all' && detail.domain !== domain) continue
545
- meetings.push(toMeta(detail))
555
+ meetings.push(toMeta(detail, this.sidecarSessionId(monthDir, monthReal, filename)))
546
556
  } catch {
547
557
  // One unreadable/corrupt entry must not hide the rest of the store.
548
558
  }
@@ -601,6 +611,51 @@ export class MeetingStore {
601
611
  return this.safeReadFile(monthDir, monthReal, filename)
602
612
  }
603
613
 
614
+ /** Read only the first `bytes` of a file, with the same symlink,
615
+ * containment, and O_NOFOLLOW guards as safeReadFile.
616
+ *
617
+ * Exists so `list()` can lift one field out of a chunk sidecar without
618
+ * reading it whole: sidecars run to megabytes (1.3 MB for a 32-minute
619
+ * meeting) and would also trip safeReadFile's MAX_MEETING_BYTES cap, which
620
+ * is sized for markdown. */
621
+ private safeReadFileHead(monthDir: string, monthReal: string, filename: string, bytes: number): string | null {
622
+ const filepath = join(monthDir, filename)
623
+ let fd: number | null = null
624
+ try {
625
+ const linkStat = lstatSync(filepath)
626
+ if (linkStat.isSymbolicLink() || !linkStat.isFile()) return null
627
+ const real = realpathSync(filepath)
628
+ if (!isContained(monthReal, real) || dirname(real) !== monthReal) return null
629
+ fd = openSync(filepath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
630
+ const stat = fstatSync(fd)
631
+ if (!stat.isFile()) return null
632
+ const buffer = Buffer.alloc(Math.min(bytes, stat.size))
633
+ if (buffer.length === 0) return ''
634
+ const read = readSync(fd, buffer, 0, buffer.length, 0)
635
+ return buffer.subarray(0, read).toString('utf8')
636
+ } catch {
637
+ return null
638
+ } finally {
639
+ if (fd !== null) {
640
+ try { closeSync(fd) } catch { /* already closed */ }
641
+ }
642
+ }
643
+ }
644
+
645
+ /** The sessionId recorded in a meeting's chunk sidecar, if it has one.
646
+ *
647
+ * Matched with a regex over the file's head rather than JSON.parse: the goal
648
+ * is one small field, and parsing a megabyte of chunks per list row to reach
649
+ * it would make listing cost scale with total transcript size. */
650
+ private sidecarSessionId(monthDir: string, monthReal: string, meetingFilename: string): string | undefined {
651
+ const sidecarName = meetingFilename.replace(/\.md$/, '.g2-chunks.json')
652
+ if (sidecarName === meetingFilename) return undefined
653
+ const head = this.safeReadFileHead(monthDir, monthReal, sidecarName, SIDECAR_HEAD_BYTES)
654
+ if (!head) return undefined
655
+ const match = head.match(/"sessionId"\s*:\s*"([A-Za-z0-9:_-]{3,96})"/)
656
+ return match ? match[1] : undefined
657
+ }
658
+
604
659
  private safeReadFile(monthDir: string, monthReal: string, filename: string): string | null {
605
660
  const filepath = join(monthDir, filename)
606
661
  let fd: number | null = null
@@ -62,6 +62,66 @@ export function filterCalibrationRows(
62
62
  return { text: kept.length > 0 ? kept.join('\n') + '\n' : '', result }
63
63
  }
64
64
 
65
+ /**
66
+ * Rename a speaker across the log, for a profile merge.
67
+ *
68
+ * Relabel rather than delete: a merge means the two names were always one
69
+ * person, so their calibration history is one history. Dropping the absorbed
70
+ * name's rows would silently discard exactly the evidence needed to tell
71
+ * whether the merge improved identification.
72
+ */
73
+ export function relabelCalibrationRows(
74
+ raw: string,
75
+ fromName: string,
76
+ toName: string,
77
+ ): { text: string; relabeled: number; retained: number; unparsable: number } {
78
+ const out: string[] = []
79
+ let relabeled = 0, retained = 0, unparsable = 0
80
+ for (const line of raw.split('\n')) {
81
+ if (line.trim() === '') continue
82
+ let row: Record<string, unknown>
83
+ try {
84
+ row = JSON.parse(line) as Record<string, unknown>
85
+ } catch {
86
+ unparsable++; retained++; out.push(line); continue
87
+ }
88
+ // `retained` counts every row that survives, relabeled or not, so
89
+ // `retained === total input rows` is the invariant a caller can assert: a
90
+ // merge must never lose history. (Contrast filterCalibrationRows, where
91
+ // removed + retained = total.)
92
+ retained++
93
+ if (row.speaker === fromName) {
94
+ row.speaker = toName
95
+ relabeled++
96
+ out.push(JSON.stringify(row))
97
+ } else {
98
+ out.push(line)
99
+ }
100
+ }
101
+ return { text: out.length > 0 ? out.join('\n') + '\n' : '', relabeled, retained, unparsable }
102
+ }
103
+
104
+ /** Apply a merge relabel to the log on disk. */
105
+ export function relabelSpeakerCalibrationRows(
106
+ logPath: string,
107
+ fromName: string,
108
+ toName: string,
109
+ options: { dryRun?: boolean } = {},
110
+ ): { relabeled: number; retained: number; unparsable: number } {
111
+ if (!existsSync(logPath)) return { relabeled: 0, retained: 0, unparsable: 0 }
112
+ let raw: string
113
+ try {
114
+ raw = readFileSync(logPath, 'utf-8')
115
+ } catch {
116
+ return { relabeled: 0, retained: 0, unparsable: 0 }
117
+ }
118
+ const { text, relabeled, retained, unparsable } = relabelCalibrationRows(raw, fromName, toName)
119
+ if (relabeled > 0 && !options.dryRun) {
120
+ durableAtomicWriteFileSync(logPath, text, { mode: 0o600 })
121
+ }
122
+ return { relabeled, retained, unparsable }
123
+ }
124
+
65
125
  /** Rewrite the log without a given speaker's rows. */
66
126
  export function purgeSpeakerCalibrationRows(
67
127
  logPath: string,
@@ -12,6 +12,8 @@ import { existsSync, appendFileSync, mkdirSync, statSync, openSync, readSync, cl
12
12
  import {
13
13
  appendEmbedding,
14
14
  deleteProfileFromStore,
15
+ mergeProfilesInStore,
16
+ profileSimilarity,
15
17
  describeRepairs,
16
18
  dropOldestEmbedding,
17
19
  hasRepairs,
@@ -463,6 +465,108 @@ export function clearSpeakerEmbeddings(name: string): boolean {
463
465
  return writeProfileStore(store)
464
466
  }
465
467
 
468
+ /** The floor a merge must clear on centroid cosine.
469
+ *
470
+ * Set at the search-accept threshold on purpose: if two profiles are further
471
+ * apart than the value at which identification would accept a match between
472
+ * them, they are not the same voice and merging them would poison both. */
473
+ export const MERGE_SIMILARITY_FLOOR = SEARCH_THRESHOLD
474
+
475
+ export interface MergeReport {
476
+ into: string
477
+ merged: string[]
478
+ missing: string[]
479
+ similarity: Record<string, number>
480
+ samplesBefore: number
481
+ samplesAfter: number
482
+ droppedToCap: number
483
+ refused?: { name: string; similarity: number; floor: number }[]
484
+ }
485
+
486
+ /**
487
+ * Fold profiles together — two names for one person, e.g. "Luke H" and
488
+ * "Luke Henry" at 0.843.
489
+ *
490
+ * Fails closed below MERGE_SIMILARITY_FLOOR unless `force`. That guard is the
491
+ * whole safety story: a wrong merge destroys BOTH identities at once, and the
492
+ * only evidence that two names are one person is acoustic, never the names
493
+ * themselves.
494
+ */
495
+ export function mergeSpeakerProfiles(
496
+ into: string,
497
+ from: string[],
498
+ options: { force?: boolean; dryRun?: boolean } = {},
499
+ ): MergeReport {
500
+ const store = loadProfileStore()
501
+ const target = store.profiles.find(p => p.name === into)
502
+ const report: MergeReport = {
503
+ into,
504
+ merged: [],
505
+ missing: [],
506
+ similarity: {},
507
+ samplesBefore: target?.embeddings.length ?? 0,
508
+ samplesAfter: target?.embeddings.length ?? 0,
509
+ droppedToCap: 0,
510
+ }
511
+ if (!target) {
512
+ report.missing = [into]
513
+ return report
514
+ }
515
+
516
+ // Score every candidate first so a dry run and a refusal report the same
517
+ // numbers the real merge would act on.
518
+ const eligible: string[] = []
519
+ const refused: { name: string; similarity: number; floor: number }[] = []
520
+ for (const name of from) {
521
+ if (name === into) continue
522
+ const source = store.profiles.find(p => p.name === name)
523
+ if (!source) { report.missing.push(name); continue }
524
+ const similarity = profileSimilarity(target, source)
525
+ report.similarity[name] = Math.round(similarity * 1000) / 1000
526
+ if (similarity < MERGE_SIMILARITY_FLOOR && !options.force) {
527
+ refused.push({ name, similarity: report.similarity[name], floor: MERGE_SIMILARITY_FLOOR })
528
+ continue
529
+ }
530
+ eligible.push(name)
531
+ }
532
+ if (refused.length > 0) report.refused = refused
533
+ if (eligible.length === 0) return report
534
+
535
+ if (options.dryRun) {
536
+ // Report on a throwaway copy so nothing is mutated by a preview.
537
+ const preview = JSON.parse(JSON.stringify(store)) as ProfileStore
538
+ const outcome = mergeProfilesInStore(preview, into, eligible, { cap: MAX_EMBEDDINGS_PER_SPEAKER })
539
+ report.merged = outcome.mergedFrom
540
+ report.samplesAfter = outcome.samplesAfter
541
+ report.droppedToCap = outcome.droppedToCap
542
+ return report
543
+ }
544
+
545
+ const outcome = mergeProfilesInStore(store, into, eligible, { cap: MAX_EMBEDDINGS_PER_SPEAKER })
546
+ report.merged = outcome.mergedFrom
547
+ report.samplesAfter = outcome.samplesAfter
548
+ report.droppedToCap = outcome.droppedToCap
549
+
550
+ // Manager first: drop the absorbed names so a stale centroid cannot keep
551
+ // winning searches after its profile is gone, then re-register the target
552
+ // from its new combined sample set.
553
+ //
554
+ // Routed through rebuildSpeakerInManager rather than an inline
555
+ // contains()/remove() pair: that helper already removes-then-returns on an
556
+ // empty sample list, so this is the one manager-mutation path in the file
557
+ // instead of a second one that could drift. NOTE: the manager is absent in
558
+ // tests (no 26 MB model), so this side effect has no test seam — the reason
559
+ // it is expressed as a single reused call rather than bespoke logic.
560
+ for (const name of outcome.mergedFrom) rebuildSpeakerInManager(name, [])
561
+ rebuildSpeakerInManager(into, target.embeddings)
562
+ writeProfileStore(store)
563
+ console.log(
564
+ `[speaker] Merged ${outcome.mergedFrom.join(', ')} into "${into}"`,
565
+ `(${report.samplesBefore} + absorbed → ${report.samplesAfter}, ${report.droppedToCap} dropped to cap)`,
566
+ )
567
+ return report
568
+ }
569
+
466
570
  /** Remove a person entirely: profile, embeddings, and manager registration.
467
571
  * Returns counts so a caller can report what was actually removed. */
468
572
  export function removeSpeakerProfile(name: string): { removedProfiles: number; removedEmbeddings: number } {
@@ -347,6 +347,148 @@ export function removeEmbeddingsBySource(
347
347
  return removed
348
348
  }
349
349
 
350
+ /** Cosine similarity between two raw rows. Local so this module stays free of
351
+ * the sherpa runtime and can be unit-tested without a 26 MB model. */
352
+ export function rowCosine(a: number[], b: number[]): number {
353
+ if (a.length !== b.length || a.length === 0) return 0
354
+ let dot = 0, na = 0, nb = 0
355
+ for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i] }
356
+ const denom = Math.sqrt(na) * Math.sqrt(nb)
357
+ return denom > 0 ? dot / denom : 0
358
+ }
359
+
360
+ /** L2-normalized mean of the rows that share the modal dimension. */
361
+ export function profileCentroid(embeddings: number[][]): number[] {
362
+ const dim = modalDimension(embeddings)
363
+ if (dim === 0) return []
364
+ const usable = embeddings.filter(row => row.length === dim)
365
+ if (usable.length === 0) return []
366
+ const c = new Array<number>(dim).fill(0)
367
+ for (const row of usable) for (let i = 0; i < dim; i++) c[i] += row[i]
368
+ for (let i = 0; i < dim; i++) c[i] /= usable.length
369
+ const norm = Math.sqrt(c.reduce((sum, v) => sum + v * v, 0))
370
+ return norm > 0 ? c.map(v => v / norm) : c
371
+ }
372
+
373
+ /** How close two profiles' centroids are. This is the number a merge must be
374
+ * justified by: names are not evidence, and `Miles Mallard` / `Manoj Kumar`
375
+ * are different humans who would merge happily on a name heuristic. */
376
+ export function profileSimilarity(a: VoiceProfile, b: VoiceProfile): number {
377
+ return rowCosine(profileCentroid(a.embeddings), profileCentroid(b.embeddings))
378
+ }
379
+
380
+ /** Pick the N most acoustically diverse samples, carrying provenance along.
381
+ *
382
+ * A merge routinely produces more samples than the per-speaker cap (two
383
+ * capped profiles make 40 against a cap of 20), and which 20 survive matters:
384
+ * taking the first N would keep one profile's acoustic conditions and discard
385
+ * the other's, which is the opposite of what merging is for. Greedy
386
+ * max-min-distance keeps the spread.
387
+ *
388
+ * Returns indices so the caller can slice embeddings and sources together. */
389
+ export function selectDiverseIndices(embeddings: number[][], maxN: number): number[] {
390
+ if (embeddings.length <= maxN) return embeddings.map((_, i) => i)
391
+
392
+ // Seed with the two most dissimilar rows.
393
+ let worstPair = Number.POSITIVE_INFINITY, seedA = 0, seedB = 1
394
+ for (let i = 0; i < embeddings.length; i++) {
395
+ for (let j = i + 1; j < embeddings.length; j++) {
396
+ const sim = rowCosine(embeddings[i], embeddings[j])
397
+ if (sim < worstPair) { worstPair = sim; seedA = i; seedB = j }
398
+ }
399
+ }
400
+ const chosen = [seedA, seedB]
401
+ const taken = new Set(chosen)
402
+ while (chosen.length < maxN) {
403
+ let best = -1, bestMinDist = -Infinity
404
+ for (let i = 0; i < embeddings.length; i++) {
405
+ if (taken.has(i)) continue
406
+ let minDist = Infinity
407
+ for (const c of chosen) {
408
+ const dist = 1 - rowCosine(embeddings[i], embeddings[c])
409
+ if (dist < minDist) minDist = dist
410
+ }
411
+ if (minDist > bestMinDist) { bestMinDist = minDist; best = i }
412
+ }
413
+ if (best === -1) break
414
+ chosen.push(best)
415
+ taken.add(best)
416
+ }
417
+ // Ascending so the surviving order still reflects enrollment order.
418
+ return chosen.sort((x, y) => x - y)
419
+ }
420
+
421
+ export interface MergeOutcome {
422
+ /** Centroid cosine between the target and each source, before merging. */
423
+ similarity: Record<string, number>
424
+ samplesBefore: number
425
+ samplesAfter: number
426
+ /** Samples discarded by the cap, not by the merge itself. */
427
+ droppedToCap: number
428
+ mergedFrom: string[]
429
+ missing: string[]
430
+ }
431
+
432
+ /**
433
+ * Fold one or more profiles into another.
434
+ *
435
+ * Provenance is deliberately PRESERVED rather than restamped `merged:*`: the
436
+ * per-source retraction path is what makes a poisoned `auto:<sessionId>` sample
437
+ * removable later, and overwriting it to record a bookkeeping event would trade
438
+ * a useful fact for a useless one.
439
+ */
440
+ export function mergeProfilesInStore(
441
+ store: ProfileStore,
442
+ into: string,
443
+ from: string[],
444
+ options: { cap?: number } = {},
445
+ ): MergeOutcome {
446
+ const cap = options.cap ?? 20
447
+ const target = store.profiles.find(p => p.name === into)
448
+ const outcome: MergeOutcome = {
449
+ similarity: {},
450
+ samplesBefore: target?.embeddings.length ?? 0,
451
+ samplesAfter: target?.embeddings.length ?? 0,
452
+ droppedToCap: 0,
453
+ mergedFrom: [],
454
+ missing: [],
455
+ }
456
+ if (!target) {
457
+ outcome.missing = [into, ...from]
458
+ return outcome
459
+ }
460
+
461
+ const embeddings = [...target.embeddings]
462
+ const sources = [...(target.sources ?? [])]
463
+ while (sources.length < embeddings.length) sources.push(UNKNOWN_SOURCE)
464
+
465
+ for (const name of from) {
466
+ if (name === into) continue
467
+ const source = store.profiles.find(p => p.name === name)
468
+ if (!source) { outcome.missing.push(name); continue }
469
+ outcome.similarity[name] = profileSimilarity(target, source)
470
+ const sourceSources = [...(source.sources ?? [])]
471
+ while (sourceSources.length < source.embeddings.length) sourceSources.push(UNKNOWN_SOURCE)
472
+ for (let i = 0; i < source.embeddings.length; i++) {
473
+ embeddings.push(source.embeddings[i])
474
+ sources.push(sourceSources[i] ?? UNKNOWN_SOURCE)
475
+ }
476
+ outcome.mergedFrom.push(name)
477
+ }
478
+
479
+ if (outcome.mergedFrom.length === 0) return outcome
480
+
481
+ const keep = selectDiverseIndices(embeddings, cap)
482
+ outcome.droppedToCap = embeddings.length - keep.length
483
+ target.embeddings = keep.map(i => embeddings[i])
484
+ target.sources = keep.map(i => sources[i])
485
+ outcome.samplesAfter = target.embeddings.length
486
+
487
+ const removed = new Set(outcome.mergedFrom)
488
+ store.profiles = store.profiles.filter(p => !removed.has(p.name))
489
+ return outcome
490
+ }
491
+
350
492
  /** Delete a person's profile outright. Returns what was removed so the caller
351
493
  * can report a per-store count instead of a bare success. */
352
494
  export function deleteProfileFromStore(
@@ -2,7 +2,7 @@
2
2
  // the standalone public meeting store. The live transcript and chunk metadata
3
3
  // are durable before the session is closed; batch improvement runs afterward.
4
4
 
5
- import { existsSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
5
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'
6
6
  import { resolve } from 'node:path'
7
7
  import { Router } from 'express'
8
8
  import { emitDisplay } from '../lib/display-bus.js'
@@ -64,6 +64,8 @@ import {
64
64
  type TranscriptGapReport,
65
65
  } from './transcribe-stream.js'
66
66
  import { getServerInstanceId } from '../lib/server-instance-id.js'
67
+ import { getOwnerSpeakerLabel } from '../lib/profile.js'
68
+ import { reviewMeetingSpeakers, type ReviewChunk } from '../lib/meeting-speaker-review.js'
67
69
  import {
68
70
  acquireMaintenanceWork,
69
71
  maintenanceAdmissionsOpen,
@@ -613,6 +615,59 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
613
615
  }
614
616
  })
615
617
 
618
+ // ── Speaker review (6.21.12) ──────────────────────────────────────────
619
+ // Backs COS Control's naming panel. Read-only: it reports what a saved
620
+ // meeting's sidecar already contains and never writes. Naming, merging, and
621
+ // rebuilding are the /api/voice/* routes, each with its own confirmation.
622
+ //
623
+ // Keyed on sessionId so it can reuse the store's traversal-hardened readers
624
+ // (safeDirectoryRealpath / safeReadFile) instead of reassembling a path from
625
+ // client-supplied domain and filename components.
626
+ router.get('/meeting/:sessionId/speakers', (req, res) => {
627
+ const sessionId = String(req.params.sessionId ?? '')
628
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
629
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
630
+ return
631
+ }
632
+ const saved = store.findBySessionId(sessionId)
633
+ if (!saved) {
634
+ res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
635
+ return
636
+ }
637
+
638
+ let chunks: unknown
639
+ try {
640
+ const raw = JSON.parse(readFileSync(saved.sidecarPath, 'utf-8')) as Record<string, unknown>
641
+ chunks = Array.isArray(raw) ? raw : raw.chunks
642
+ } catch {
643
+ // Defensive: findBySessionId already parsed this sidecar to match the
644
+ // session, so a corrupt file 404s above and never reaches here. This
645
+ // covers the narrow race where it becomes unreadable in between. Either
646
+ // way the answer is never 200-with-no-voices, which would read as
647
+ // "nobody spoke" and invite naming voices that were never analysed.
648
+ res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
649
+ return
650
+ }
651
+ if (!Array.isArray(chunks)) {
652
+ res.status(422).json({ error: 'Chunk sidecar holds no chunk array', reason: 'sidecar_empty' })
653
+ return
654
+ }
655
+
656
+ const review = reviewMeetingSpeakers(chunks as ReviewChunk[], {
657
+ owner: getOwnerSpeakerLabel(),
658
+ phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
659
+ })
660
+ res.set('Cache-Control', 'private, no-store')
661
+ res.json({
662
+ sessionId,
663
+ title: saved.title,
664
+ domain: saved.domain,
665
+ filename: saved.filename,
666
+ durationMin: saved.durationMin,
667
+ ...review,
668
+ })
669
+ })
670
+
616
671
  // ── Unsaved-capture recovery (6.19.0) ─────────────────────────────────
617
672
  // Surface-only by decision (Miles, 2026-08-02): the server NEVER drives
618
673
  // recovery on its own. It lists what the quarantine holds, and one
@@ -4,12 +4,12 @@ import { Router } from 'express'
4
4
  import { errMsg } from '../lib/utils.js'
5
5
  import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
6
6
  import { resolve } from 'node:path'
7
- import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles } from '../lib/speaker-embeddings.js'
7
+ import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles, mergeSpeakerProfiles } from '../lib/speaker-embeddings.js'
8
8
  import { statSync } from 'node:fs'
9
9
  import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
10
10
  import { getOwnerSpeakerLabel } from '../lib/profile.js'
11
11
  import { dataPath } from '../lib/data-dir.js'
12
- import { purgeSpeakerCalibrationRows } from '../lib/speaker-calibration-log.js'
12
+ import { purgeSpeakerCalibrationRows, relabelSpeakerCalibrationRows } from '../lib/speaker-calibration-log.js'
13
13
 
14
14
  // These MUST match the writer in transcribe-stream.ts, which saves under
15
15
  // dataPath(). They previously resolved relative to __dirname — i.e. inside the
@@ -444,6 +444,85 @@ voiceRouter.get('/voice/profiles', (_req, res) => {
444
444
  }
445
445
  })
446
446
 
447
+ // POST /api/voice/merge-profiles — fold two names for one person together.
448
+ // Body: { into, from: string[]|string, confirm: true, dryRun?, force? }
449
+ //
450
+ // Two profiles for one voice is worse than it looks: the sherpa manager holds
451
+ // one centroid per NAME, so both compete on every search and each is capped at
452
+ // 20 samples independently — 40 samples of one person, split, each half a
453
+ // weaker representation of them than the union would be.
454
+ //
455
+ // Fails closed below the search-accept threshold. A wrong merge destroys BOTH
456
+ // identities at once and cannot be undone from the store alone, so the only
457
+ // acceptable evidence is acoustic. `force` exists for the case where Miles
458
+ // knows something the audio does not, and it is logged.
459
+ voiceRouter.post('/voice/merge-profiles', (req, res) => {
460
+ try {
461
+ const into = typeof req.body?.into === 'string' ? req.body.into.trim() : ''
462
+ const rawFrom = req.body?.from
463
+ const from = (Array.isArray(rawFrom) ? rawFrom : [rawFrom])
464
+ .filter((n: unknown): n is string => typeof n === 'string' && n.trim().length > 0)
465
+ .map((n: string) => n.trim())
466
+
467
+ if (!into || from.length === 0) {
468
+ return res.status(400).json({ error: 'into (string) and from (string or string[]) are required' })
469
+ }
470
+ if (from.includes(into)) {
471
+ return res.status(400).json({ error: 'into and from must differ' })
472
+ }
473
+
474
+ const owner = getOwnerSpeakerLabel()
475
+ if (from.includes(owner)) {
476
+ // Absorbing the owner label would delete the profile the live
477
+ // identification path checks FIRST, on every chunk.
478
+ return res.status(400).json({
479
+ error: `refusing to absorb the owner label "${owner}" — merge INTO it instead`,
480
+ })
481
+ }
482
+
483
+ const dryRun = req.body?.dryRun === true
484
+ const force = req.body?.force === true
485
+
486
+ if (req.body?.confirm !== true && !dryRun) {
487
+ const preview = mergeSpeakerProfiles(into, from, { force, dryRun: true })
488
+ return res.status(400).json({
489
+ error: 'confirmation required',
490
+ message: `Merging is not reversible from the store alone. Review the similarity scores, then pass { confirm: true }.`,
491
+ preview,
492
+ })
493
+ }
494
+
495
+ const report = mergeSpeakerProfiles(into, from, { force, dryRun })
496
+
497
+ if (report.missing.length > 0 && report.merged.length === 0) {
498
+ return res.status(404).json({ error: 'no such profile(s)', missing: report.missing, report })
499
+ }
500
+ if (report.refused && report.merged.length === 0) {
501
+ return res.status(409).json({
502
+ error: 'similarity below the merge floor',
503
+ message: 'These centroids are further apart than the threshold at which identification would '
504
+ + 'accept a match between them, so they are probably different people. Pass { force: true } '
505
+ + 'only if you know they are the same person.',
506
+ report,
507
+ })
508
+ }
509
+
510
+ // Relabel rather than drop the absorbed name's calibration history: after a
511
+ // merge it is one person's history, and it is the only evidence for whether
512
+ // the merge improved identification.
513
+ const calibration: Record<string, number> = {}
514
+ if (!dryRun) {
515
+ for (const name of report.merged) {
516
+ calibration[name] = relabelSpeakerCalibrationRows(CALIBRATION_LOG, name, into).relabeled
517
+ }
518
+ }
519
+
520
+ res.json({ ...report, dryRun, forced: force, calibrationRowsRelabeled: calibration })
521
+ } catch (err: unknown) {
522
+ res.status(500).json({ error: errMsg(err) })
523
+ }
524
+ })
525
+
447
526
  // POST /api/voice/delete-person — remove one person from every store that
448
527
  // carries their name. Body: { name, confirm: true, dryRun? }
449
528
  //