@gotcos/glasses-server 6.21.13 → 6.21.17

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.
@@ -0,0 +1,218 @@
1
+ // The record of every speaker correction a human has made, per meeting.
2
+ //
3
+ // WHY A LEDGER AND NOT JUST A REWRITE. A relabel mutates files in place: the
4
+ // chunk sidecar, the attendee list, the transcript turn labels. If that is all
5
+ // that happens, three things become impossible:
6
+ //
7
+ // 1. UNDO. Once "Luke H" has become "Luke Henry" in the sidecar, nothing
8
+ // remembers it was ever anything else. A mistaken correction is permanent.
9
+ // 2. CRASH RECOVERY. A rewrite touches several files. Die between them and the
10
+ // meeting is half-corrected with no trace of what was intended.
11
+ // 3. TRAINING. Piece 3 turns a correction into an enrollment. It needs to know
12
+ // WHICH chunks a human vouched for, and to be able to retract that vouching
13
+ // later if the correction is undone.
14
+ //
15
+ // So the ledger is written FIRST, as intent, and the rewrite follows. A row with
16
+ // an intent and no outcome is a correction that did not finish — visible rather
17
+ // than silent.
18
+ //
19
+ // WHAT IS DELIBERATELY NOT CORRECTED. The meeting markdown's Summary, Topics,
20
+ // Decisions and Action Items are LLM prose that refers to people by BARE FIRST
21
+ // NAME ("Jeremy pushed back", "Chris raised Beamer sentiment"). Verified on a
22
+ // real scribe: 6 of 12 speakers appear that way. This org has two Kyles, two
23
+ // Jacobuses and two Chrises, so a find/replace on a first name in narrative text
24
+ // would silently rewrite a sentence about a different person. Prose is left
25
+ // alone and flagged stale instead — `proseStale` on the applied row.
26
+
27
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
28
+ import { join, resolve } from 'node:path'
29
+ import { dataPath } from './data-dir.js'
30
+
31
+ export const CORRECTIONS_DIR = 'meeting-corrections'
32
+
33
+ /**
34
+ * A correction is recorded in two phases sharing one id.
35
+ * `intent` goes down before any file is touched; `applied` or `failed` closes it.
36
+ * An unclosed intent is an incomplete correction, not a successful one.
37
+ */
38
+ export type CorrectionPhase = 'intent' | 'applied' | 'failed'
39
+
40
+ export interface CorrectionSurfaces {
41
+ /** Chunks whose `speaker` changed in the sidecar. */
42
+ sidecar: number
43
+ /** Lines changed in the markdown attendee list. */
44
+ attendees: number
45
+ /** `[Name]:` turn labels changed in the markdown transcript. */
46
+ transcript: number
47
+ }
48
+
49
+ export interface CorrectionRow {
50
+ id: string
51
+ phase: CorrectionPhase
52
+ /** ISO timestamp, supplied by the caller so this module stays deterministic. */
53
+ at: string
54
+ from: string
55
+ to: string
56
+ /**
57
+ * The exact chunk indices this correction covers. Explicit rather than derived,
58
+ * because piece 3 enrolls precisely these and must be able to retract
59
+ * precisely these. An empty array means "every chunk carrying `from`", which
60
+ * is resolved at apply time and written back onto the applied row.
61
+ */
62
+ chunks: number[]
63
+ /**
64
+ * 'meeting' is the only scope. Corrections are per-meeting BY DESIGN: the
65
+ * identifier mishearing one voice in one room does not mean every past chunk
66
+ * was wrong, and rewriting history on a single correction is how a small
67
+ * mistake becomes an unrecoverable one.
68
+ */
69
+ scope: 'meeting'
70
+ surfaces?: CorrectionSurfaces
71
+ /** True when narrative prose still carries the old label. See the header. */
72
+ proseStale?: boolean
73
+ /** Why an attempt failed, on a `failed` row. */
74
+ error?: string
75
+ }
76
+
77
+ function sessionFile(sessionId: string): string | null {
78
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
79
+ const dir = dataPath(CORRECTIONS_DIR)
80
+ const path = join(dir, `${sessionId.replace(/:/g, '_')}.jsonl`)
81
+ return resolve(path).startsWith(resolve(dir) + '/') ? path : null
82
+ }
83
+
84
+ /**
85
+ * Append one row. Returns false rather than throwing on a bad session id or an
86
+ * unwritable directory — but note the caller's contract: if the INTENT row
87
+ * cannot be written, the rewrite must not proceed. An unrecorded mutation is
88
+ * exactly what this file exists to prevent.
89
+ */
90
+ export function appendCorrection(sessionId: string, row: CorrectionRow): boolean {
91
+ const path = sessionFile(sessionId)
92
+ if (!path) return false
93
+ try {
94
+ mkdirSync(dataPath(CORRECTIONS_DIR), { recursive: true, mode: 0o700 })
95
+ appendFileSync(path, JSON.stringify(row) + '\n', { mode: 0o600 })
96
+ return true
97
+ } catch {
98
+ return false
99
+ }
100
+ }
101
+
102
+ export interface CorrectionReadResult {
103
+ rows: CorrectionRow[]
104
+ /** Lines that could not be parsed. Surfaced, because a correction history read
105
+ * partially is a correction history that lies about what a human decided. */
106
+ unusable: number
107
+ missing: boolean
108
+ }
109
+
110
+ export function readCorrections(sessionId: string): CorrectionReadResult {
111
+ const path = sessionFile(sessionId)
112
+ if (!path || !existsSync(path)) return { rows: [], unusable: 0, missing: true }
113
+ let raw: string
114
+ try {
115
+ raw = readFileSync(path, 'utf-8')
116
+ } catch {
117
+ return { rows: [], unusable: 0, missing: true }
118
+ }
119
+ const rows: CorrectionRow[] = []
120
+ let unusable = 0
121
+ for (const line of raw.split('\n')) {
122
+ if (line.trim() === '') continue
123
+ try {
124
+ const o = JSON.parse(line) as Record<string, unknown>
125
+ 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 }
127
+ rows.push({
128
+ id: o.id,
129
+ phase: o.phase,
130
+ at: typeof o.at === 'string' ? o.at : '',
131
+ from: o.from,
132
+ to: o.to,
133
+ chunks: Array.isArray(o.chunks) ? o.chunks.filter((n): n is number => typeof n === 'number') : [],
134
+ scope: 'meeting',
135
+ surfaces: isSurfaces(o.surfaces) ? o.surfaces : undefined,
136
+ proseStale: typeof o.proseStale === 'boolean' ? o.proseStale : undefined,
137
+ error: typeof o.error === 'string' ? o.error : undefined,
138
+ })
139
+ } catch {
140
+ unusable++
141
+ }
142
+ }
143
+ return { rows, unusable, missing: false }
144
+ }
145
+
146
+ function isSurfaces(v: unknown): v is CorrectionSurfaces {
147
+ if (!v || typeof v !== 'object') return false
148
+ const o = v as Record<string, unknown>
149
+ return typeof o.sidecar === 'number' && typeof o.attendees === 'number' && typeof o.transcript === 'number'
150
+ }
151
+
152
+ /**
153
+ * Corrections that recorded an intent and never closed it.
154
+ *
155
+ * This is the crash signal. A process that died mid-rewrite leaves exactly this,
156
+ * and a meeting with a pending correction should be treated as possibly
157
+ * half-written rather than clean.
158
+ */
159
+ export function pendingCorrections(sessionId: string): CorrectionRow[] {
160
+ const { rows } = readCorrections(sessionId)
161
+ const closed = new Set(rows.filter(r => r.phase !== 'intent').map(r => r.id))
162
+ return rows.filter(r => r.phase === 'intent' && !closed.has(r.id))
163
+ }
164
+
165
+ /** Only the corrections that actually landed — the ones piece 3 may train on. */
166
+ export function appliedCorrections(sessionId: string): CorrectionRow[] {
167
+ return readCorrections(sessionId).rows.filter(r => r.phase === 'applied')
168
+ }
169
+
170
+ /**
171
+ * Follow a chain of applied corrections to the label a voice now carries.
172
+ *
173
+ * Chains happen: a voice labelled 'Ext' is corrected to 'Luke H', then later to
174
+ * 'Luke Henry'. Asking "what is Ext now?" must answer 'Luke Henry', not stop at
175
+ * the first hop. Cycles are possible if a human corrects A→B then B→A, so the
176
+ * walk is bounded by the number of corrections and returns the last label
177
+ * reached rather than looping.
178
+ */
179
+ export function currentLabelFor(sessionId: string, originalLabel: string): string {
180
+ const applied = appliedCorrections(sessionId)
181
+ let label = originalLabel
182
+ const seen = new Set<string>([label])
183
+ for (let hop = 0; hop < applied.length; hop++) {
184
+ // Latest applied correction FROM the current label wins: a later human
185
+ // decision supersedes an earlier one.
186
+ const next = [...applied].reverse().find(r => r.from === label)
187
+ if (!next || seen.has(next.to)) break
188
+ label = next.to
189
+ seen.add(label)
190
+ }
191
+ return label
192
+ }
193
+
194
+ /** Counts for /api/health — pending is the number that matters. */
195
+ export function correctionStoreStats(): {
196
+ sessions: number
197
+ applied: number
198
+ pending: number
199
+ failed: number
200
+ } {
201
+ const dir = dataPath(CORRECTIONS_DIR)
202
+ if (!existsSync(dir)) return { sessions: 0, applied: 0, pending: 0, failed: 0 }
203
+ let sessions = 0, applied = 0, pending = 0, failed = 0
204
+ try {
205
+ for (const name of readdirSync(dir).filter(n => n.endsWith('.jsonl'))) {
206
+ try {
207
+ statSync(join(dir, name))
208
+ sessions++
209
+ const id = name.replace(/\.jsonl$/, '')
210
+ const { rows } = readCorrections(id)
211
+ applied += rows.filter(r => r.phase === 'applied').length
212
+ failed += rows.filter(r => r.phase === 'failed').length
213
+ pending += pendingCorrections(id).length
214
+ } catch { /* skip unreadable */ }
215
+ }
216
+ } catch { /* report what we have */ }
217
+ return { sessions, applied, pending, failed }
218
+ }
@@ -0,0 +1,267 @@
1
+ // Rewriting a speaker label inside one meeting's files.
2
+ //
3
+ // Pure over strings: every function takes file content and returns file content,
4
+ // so each rule below is testable by execution rather than by reading it.
5
+ //
6
+ // THE CONSTRAINT THAT SHAPES THIS FILE. The markdown transcript is NOT an
7
+ // index-parallel rendering of the chunk sidecar. Measured on a real meeting
8
+ // (2026-08-06 Health Score V2): 135 sidecar chunks collapse to 46 speaker runs,
9
+ // while the markdown carries 70 turns — and they disagree on who spoke, with the
10
+ // markdown's 4th turn attributed to Joe Karbowski where the sidecar's 4th run
11
+ // says Richard Jenkins. The transcript was rendered from a different
12
+ // segmentation pass. So:
13
+ //
14
+ // * There is NO mapping from a chunk index to a transcript turn.
15
+ // * A relabel of EVERY chunk carrying a label can still be applied to the
16
+ // markdown, because "all X becomes Y" needs no index alignment.
17
+ // * A relabel of a SUBSET of chunks cannot touch the markdown transcript at
18
+ // all. Rewriting by label would relabel turns the human never selected.
19
+ //
20
+ // `coveredAllWithLabel` on the sidecar result is how the caller knows which case
21
+ // it is. It is returned as DATA rather than left as a rule to remember.
22
+ //
23
+ // AND WHAT IS NEVER REWRITTEN: the Summary / Topics / Decisions / Action Items
24
+ // prose. It refers to people by bare first name — 6 of 12 speakers did so on the
25
+ // meeting above — and this org has two Kyles, two Jacobuses and two Chrises, so
26
+ // a first-name substitution in narrative text would rewrite sentences about
27
+ // somebody else. Reported as `proseStale` instead.
28
+
29
+ /** A label that would corrupt the file formats it gets written into. */
30
+ export function invalidLabelReason(label: string): string | null {
31
+ if (label.trim() === '') return 'label is empty'
32
+ if (label !== label.trim()) return 'label has leading or trailing whitespace'
33
+ if (label.length > 120) return 'label is longer than 120 characters'
34
+ // `[Name]:` delimits transcript turns and `- Name` delimits attendees; a
35
+ // label containing these would make the file unparseable by its own readers.
36
+ if (/[\[\]\n\r]/.test(label)) return 'label contains a bracket or newline'
37
+ return null
38
+ }
39
+
40
+ function escapeRegExp(s: string): string {
41
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
42
+ }
43
+
44
+ export interface SidecarRelabelResult {
45
+ /** Serialized in the same shape the pipeline writes: 2-space indent, trailing newline. */
46
+ json: string
47
+ /** Chunk indices whose speaker actually changed. */
48
+ changed: number[]
49
+ /**
50
+ * True when no chunk still carries `from` after this relabel. ONLY then may the
51
+ * caller rewrite the markdown transcript by label. See the file header.
52
+ */
53
+ coveredAllWithLabel: boolean
54
+ /** The top-level `speakers` array after the relabel. */
55
+ speakers: string[]
56
+ /** Chunks still carrying `from` — non-zero for a deliberate partial relabel. */
57
+ remainingWithFrom: number
58
+ }
59
+
60
+ export type RelabelOutcome<T> = { ok: true; value: T } | { ok: false; error: string }
61
+
62
+ /**
63
+ * Relabel chunks in a `.g2-chunks.json` sidecar.
64
+ *
65
+ * `chunks` empty means every chunk carrying `from`. An explicit list restricts
66
+ * the change to those indices — the partial case, which exists because the
67
+ * identifier can mishear one stretch of a meeting without being wrong about the
68
+ * rest.
69
+ */
70
+ export function relabelSidecarJson(
71
+ raw: string,
72
+ from: string,
73
+ to: string,
74
+ chunks: number[] = [],
75
+ ): RelabelOutcome<SidecarRelabelResult> {
76
+ if (from === to) return { ok: false, error: 'from and to are the same label' }
77
+ for (const [which, label] of [['from', from], ['to', to]] as const) {
78
+ const bad = invalidLabelReason(label)
79
+ if (bad) return { ok: false, error: `${which}: ${bad}` }
80
+ }
81
+
82
+ let parsed: unknown
83
+ try {
84
+ parsed = JSON.parse(raw)
85
+ } catch {
86
+ return { ok: false, error: 'sidecar is not valid JSON' }
87
+ }
88
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
89
+ return { ok: false, error: 'sidecar is not an object' }
90
+ }
91
+ const doc = parsed as Record<string, unknown>
92
+ const rows = doc.chunks
93
+ if (!Array.isArray(rows)) return { ok: false, error: 'sidecar has no chunks array' }
94
+
95
+ const restrict = chunks.length > 0 ? new Set(chunks) : null
96
+ // A caller naming indices that do not carry `from` is confused about what it
97
+ // is correcting; failing loudly beats silently relabelling nothing.
98
+ if (restrict) {
99
+ const mismatched = chunks.filter(i => {
100
+ const row = rows[i]
101
+ return !row || typeof row !== 'object' || (row as Record<string, unknown>).speaker !== from
102
+ })
103
+ if (mismatched.length > 0) {
104
+ return { ok: false, error: `chunks do not carry "${from}": ${mismatched.slice(0, 8).join(', ')}` }
105
+ }
106
+ }
107
+
108
+ const changed: number[] = []
109
+ let remainingWithFrom = 0
110
+ rows.forEach((row, i) => {
111
+ // The `typeof` half guards the cast below rather than any behaviour — a
112
+ // truthy non-object can never satisfy `.speaker === from`, so it is
113
+ // deliberately not claimed as tested.
114
+ if (!row || typeof row !== 'object') return
115
+ const r = row as Record<string, unknown>
116
+ if (r.speaker !== from) return
117
+ if (restrict && !restrict.has(i)) { remainingWithFrom++; return }
118
+ r.speaker = to
119
+ changed.push(i)
120
+ })
121
+
122
+ if (changed.length === 0) return { ok: false, error: `no chunk carries "${from}"` }
123
+
124
+ // Keep the top-level speaker list truthful: it is what the review panel and
125
+ // the attendee renderer read, so a stale entry shows a person who is no longer
126
+ // attributed anything.
127
+ const existing = Array.isArray(doc.speakers) ? doc.speakers.filter((s): s is string => typeof s === 'string') : []
128
+ const speakers = [...existing]
129
+ if (!speakers.includes(to)) {
130
+ // Replace in place when `from` is being fully retired, so the list keeps its
131
+ // original ordering rather than moving the person to the end.
132
+ const at = speakers.indexOf(from)
133
+ if (at >= 0 && remainingWithFrom === 0) speakers[at] = to
134
+ else speakers.push(to)
135
+ } else if (remainingWithFrom === 0) {
136
+ // `to` already listed and `from` fully retired — merging a split identity.
137
+ // Drop the old entry rather than leaving a name nothing is attributed to.
138
+ const at = speakers.indexOf(from)
139
+ if (at >= 0) speakers.splice(at, 1)
140
+ }
141
+ doc.speakers = speakers
142
+
143
+ return {
144
+ ok: true,
145
+ value: {
146
+ json: `${JSON.stringify(doc, null, 2)}\n`,
147
+ changed,
148
+ coveredAllWithLabel: remainingWithFrom === 0,
149
+ speakers,
150
+ remainingWithFrom,
151
+ },
152
+ }
153
+ }
154
+
155
+ export interface MarkdownRelabelResult {
156
+ markdown: string
157
+ /** Attendee bullet lines changed or removed. */
158
+ attendees: number
159
+ /** `[Name]:` turn labels rewritten. */
160
+ transcript: number
161
+ /**
162
+ * True when the old label still appears in narrative prose, which is left
163
+ * untouched on purpose. The caller records this so a human can be told the
164
+ * summary predates their correction.
165
+ */
166
+ proseStale: boolean
167
+ /** The prose forms found, for an honest message rather than a bare boolean. */
168
+ proseHits: string[]
169
+ }
170
+
171
+ /** Byte range of a `## Heading` section, or null when absent. */
172
+ function sectionRange(md: string, heading: string): { start: number; end: number } | null {
173
+ const re = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'm')
174
+ const m = re.exec(md)
175
+ if (!m) return null
176
+ const start = m.index + m[0].length
177
+ const next = /^##\s+/m.exec(md.slice(start))
178
+ return { start, end: next ? start + next.index : md.length }
179
+ }
180
+
181
+ /**
182
+ * Rewrite the structured label positions in a meeting markdown file.
183
+ *
184
+ * ONLY sound when every chunk carrying `from` is being relabelled — this works
185
+ * by label, not by index, and the markdown's turn segmentation does not match
186
+ * the sidecar's. See the file header.
187
+ */
188
+ export function relabelMeetingMarkdown(md: string, from: string, to: string): RelabelOutcome<MarkdownRelabelResult> {
189
+ if (from === to) return { ok: false, error: 'from and to are the same label' }
190
+ for (const [which, label] of [['from', from], ['to', to]] as const) {
191
+ const bad = invalidLabelReason(label)
192
+ if (bad) return { ok: false, error: `${which}: ${bad}` }
193
+ }
194
+
195
+ let out = md
196
+ let attendees = 0
197
+ let transcript = 0
198
+
199
+ // --- Attendees: exact bullet lines only, inside the Attendees section only.
200
+ const att = sectionRange(out, 'Attendees')
201
+ if (att) {
202
+ const body = out.slice(att.start, att.end)
203
+ // Anchored at BOTH ends, and `[ \t]` rather than `\s` so a pattern can never
204
+ // run past its own line. Without the end anchor, `- Luke H` matches the
205
+ // PREFIX of `- Luke Henry` and deleting it leaves the fragment `enry`.
206
+ const line = (label: string) => `^-[ \t]+${escapeRegExp(label)}[ \t]*$`
207
+ const alreadyListed = new RegExp(line(to), 'm').test(body)
208
+ let rewritten: string
209
+ if (alreadyListed) {
210
+ // Both names present: drop the old bullet instead of creating a duplicate
211
+ // attendee. This is the normal case when merging a split identity.
212
+ rewritten = body.replace(new RegExp(`${line(from)}\\n?`, 'gm'), () => { attendees++; return '' })
213
+ } else {
214
+ rewritten = body.replace(new RegExp(line(from), 'gm'), () => { attendees++; return `- ${to}` })
215
+ }
216
+ out = out.slice(0, att.start) + rewritten + out.slice(att.end)
217
+ }
218
+
219
+ // --- Transcript: `[Name]:` at line start only. A name appearing inside spoken
220
+ // text is a quote, not a label, and must not be touched.
221
+ const tr = sectionRange(out, 'Transcript')
222
+ if (tr) {
223
+ const body = out.slice(tr.start, tr.end)
224
+ const rewritten = body.replace(
225
+ new RegExp(`^\\[${escapeRegExp(from)}\\]:`, 'gm'),
226
+ () => { transcript++; return `[${to}]:` },
227
+ )
228
+ out = out.slice(0, tr.start) + rewritten + out.slice(tr.end)
229
+ }
230
+
231
+ // --- Prose: detect, never rewrite.
232
+ const proseHits = detectProseReferences(out, from)
233
+
234
+ return {
235
+ ok: true,
236
+ value: { markdown: out, attendees, transcript, proseStale: proseHits.length > 0, proseHits },
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Forms of `label` still present in narrative prose.
242
+ *
243
+ * Searches everything except the attendee list and the transcript — the
244
+ * transcript's body is spoken words, and a name said aloud is a quote rather
245
+ * than a stale attribution.
246
+ */
247
+ export function detectProseReferences(md: string, label: string): string[] {
248
+ const ranges = [sectionRange(md, 'Attendees'), sectionRange(md, 'Transcript')].filter(
249
+ (r): r is { start: number; end: number } => r !== null,
250
+ )
251
+ let prose = ''
252
+ let cursor = 0
253
+ for (const r of ranges.sort((a, b) => a.start - b.start)) {
254
+ prose += md.slice(cursor, r.start)
255
+ cursor = Math.max(cursor, r.end)
256
+ }
257
+ prose += md.slice(cursor)
258
+
259
+ const hits: string[] = []
260
+ const full = new RegExp(`\\b${escapeRegExp(label)}\\b`)
261
+ if (full.test(prose)) hits.push(label)
262
+ const first = label.split(/\s+/)[0]
263
+ // Only a multi-word label has a distinct bare-first-name form. 'MU' or 'Ext'
264
+ // would otherwise be reported twice for the same match.
265
+ if (first && first !== label && new RegExp(`\\b${escapeRegExp(first)}\\b`).test(prose)) hits.push(first)
266
+ return hits
267
+ }
@@ -1,4 +1,5 @@
1
1
  // Speaker embedding extraction and verification using sherpa-onnx
2
+ import { chooseEviction, tierBreakdown } from './embedding-eviction.js'
2
3
  // Wraps ECAPA-TDNN model for voiceprint-based speaker classification.
3
4
  // Falls back gracefully if model is missing — amplitude classification continues.
4
5
  //
@@ -15,6 +16,8 @@ import {
15
16
  mergeProfilesInStore,
16
17
  profileSimilarity,
17
18
  describeRepairs,
19
+ alignedSources,
20
+ dropEmbeddingAt,
18
21
  dropOldestEmbedding,
19
22
  hasRepairs,
20
23
  loadVoiceProfileStore,
@@ -87,7 +90,11 @@ const VERIFY_THRESHOLD = 0.65
87
90
  const SEARCH_THRESHOLD = 0.55
88
91
  const AUTO_ENROLL_THRESHOLD = 0.88 // High bar — must be very confident before auto-enrolling
89
92
  const AUTO_ENROLL_CONSENSUS = 2 // Must match N times in same session before enrolling
90
- const MAX_EMBEDDINGS_PER_SPEAKER = 20 // FIFO cap oldest drops when full
93
+ // Raised 20 -> 40 on 2026-08-06. Measured, not guessed: search latency is 1 us
94
+ // at 20, 40 AND 80 samples per speaker (77 speakers, sherpa SpeakerEmbeddingManager),
95
+ // so the old cap defended nothing — while 61 of 77 profiles sat AT it, meaning
96
+ // every correction cost a sample. 20 extra slots per speaker is ~1.2 MB.
97
+ const MAX_EMBEDDINGS_PER_SPEAKER = 40
91
98
  const SAMPLE_RATE = 16000
92
99
 
93
100
  // Module-level state — sherpa-onnx-node is CJS with no TS types (SDK v0.0.7 interop)
@@ -308,8 +315,24 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
308
315
  // `sources?.shift()` no-opped whenever sources was undefined or short,
309
316
  // permanently offsetting provenance from the samples it described.
310
317
  if (profile.embeddings.length >= MAX_EMBEDDINGS_PER_SPEAKER) {
311
- console.log(`[speaker] Profile cap reached for "${name}" (${profile.embeddings.length}/${MAX_EMBEDDINGS_PER_SPEAKER}) dropping oldest`)
312
- dropOldestEmbedding(profile)
318
+ // Weakest PROVENANCE goes, not the oldest sample. Age is the wrong axis:
319
+ // four profiles at cap would lose their only human-supplied sample to
320
+ // FIFO while unverified attendee-metadata samples sat untouched.
321
+ // alignedSources is defence-in-depth here, not load-bearing: loadProfileStore
322
+ // already pads sources[] to match embeddings[], so a ragged array cannot
323
+ // reach this line (mutation-verified — swapping it for profile.sources is
324
+ // unobservable through this path). It is kept for any future caller that
325
+ // builds a profile without going through the loader, and is unit-tested
326
+ // directly in voice-profile-store.test.ts.
327
+ const choice = chooseEviction(alignedSources(profile), source, MAX_EMBEDDINGS_PER_SPEAKER)
328
+ const { droppedSource } = choice
329
+ ? dropEmbeddingAt(profile, choice.index)
330
+ : dropOldestEmbedding(profile)
331
+ console.log(
332
+ `[speaker] Profile full for "${name}" (${MAX_EMBEDDINGS_PER_SPEAKER}) — `
333
+ + `${choice ? choice.reason : 'no provenance available, dropping the oldest'} `
334
+ + `[dropped ${droppedSource ?? 'unknown'}, incoming ${source}]`,
335
+ )
313
336
  rebuildSpeakerInManager(name, profile.embeddings)
314
337
  }
315
338
  }
@@ -342,7 +365,7 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
342
365
  export function identifySpeaker(
343
366
  wavBuffer: Buffer,
344
367
  expectedSpeakers?: string[],
345
- ): { speaker: string; similarity: number } | null {
368
+ ): { speaker: string; similarity: number; embedding?: Float32Array } | null {
346
369
  if (!extractor || !manager) return null
347
370
 
348
371
  try {
@@ -356,7 +379,7 @@ export function identifySpeaker(
356
379
  if (isOwner) {
357
380
  const similarity = computeCosineSimilarity(embedding, owner)
358
381
  logCalibration(owner, similarity, true)
359
- return { speaker: owner, similarity }
382
+ return { speaker: owner, similarity, embedding }
360
383
  }
361
384
  }
362
385
 
@@ -369,7 +392,7 @@ export function identifySpeaker(
369
392
  if (matches) {
370
393
  const similarity = computeCosineSimilarity(embedding, name)
371
394
  logCalibration(name, similarity, true)
372
- return { speaker: name, similarity }
395
+ return { speaker: name, similarity, embedding }
373
396
  }
374
397
  }
375
398
  }
@@ -379,12 +402,14 @@ export function identifySpeaker(
379
402
  if (found && found.length > 0) {
380
403
  const similarity = computeCosineSimilarity(embedding, found)
381
404
  logCalibration(found, similarity, true)
382
- return { speaker: found, similarity }
405
+ return { speaker: found, similarity, embedding }
383
406
  }
384
407
 
385
- // No match — external speaker
408
+ // No match — external speaker. The embedding still goes back: an
409
+ // unidentified voice's vector is the most valuable thing to retain, because
410
+ // naming it later is exactly the correction that has no other evidence.
386
411
  logCalibration('Ext', 0, false)
387
- return { speaker: 'Ext', similarity: 0 }
412
+ return { speaker: 'Ext', similarity: 0, embedding }
388
413
  } catch (err: unknown) {
389
414
  console.error('[speaker] Identification error:', errMsg(err))
390
415
  return null
@@ -665,6 +690,35 @@ export function speakerReadiness(
665
690
  return state === 'active' ? 'ready' : 'unavailable'
666
691
  }
667
692
 
693
+ /**
694
+ * Provenance composition across every stored profile, plus the profiles with no
695
+ * human-verified sample at all.
696
+ *
697
+ * Surfaced because the number that mattered here was invisible: the owner's own
698
+ * profile — the one driving owner detection — was 10 attendee-metadata samples,
699
+ * 9 identifier-labelled ones and 1 unlabelled, with nothing a human had ever
700
+ * confirmed. Nothing in any status output said so.
701
+ */
702
+ export function profileProvenanceSummary(): {
703
+ profiles: number
704
+ atCap: number
705
+ cap: number
706
+ tiers: Record<string, number>
707
+ noHumanSample: string[]
708
+ } {
709
+ const store = loadProfileStore()
710
+ const tiers: Record<string, number> = {}
711
+ const noHumanSample: string[] = []
712
+ let atCap = 0
713
+ for (const p of store.profiles) {
714
+ const breakdown = tierBreakdown(alignedSources(p))
715
+ for (const [k, v] of Object.entries(breakdown)) tiers[k] = (tiers[k] ?? 0) + v
716
+ if (p.embeddings.length >= MAX_EMBEDDINGS_PER_SPEAKER) atCap++
717
+ if (breakdown.human === 0) noHumanSample.push(p.name)
718
+ }
719
+ return { profiles: store.profiles.length, atCap, cap: MAX_EMBEDDINGS_PER_SPEAKER, tiers, noHumanSample }
720
+ }
721
+
668
722
  /** Compute actual cosine similarity between two raw embedding vectors */
669
723
  export function rawCosineSimilarity(a: Float32Array, b: Float32Array): number {
670
724
  if (a.length !== b.length) return 0
@@ -69,7 +69,10 @@ export function describeRepairs(r: StoreRepairs): string {
69
69
  return parts.join(', ')
70
70
  }
71
71
 
72
- const UNKNOWN_SOURCE = 'unknown'
72
+ /** Placeholder provenance for a sample whose source was never recorded.
73
+ * Exported so callers and tests reference the same value the store writes;
74
+ * 3 samples in the live store carry it. */
75
+ export const UNKNOWN_SOURCE = 'unknown'
73
76
 
74
77
  function isUsableEmbedding(row: unknown): row is number[] {
75
78
  return Array.isArray(row) && row.length > 0 && row.every(v => typeof v === 'number' && Number.isFinite(v))
@@ -317,6 +320,38 @@ export function dropOldestEmbedding(profile: VoiceProfile): { droppedSource: str
317
320
  return { droppedSource }
318
321
  }
319
322
 
323
+ /**
324
+ * Drop the sample at `index`, keeping sources[] in lockstep.
325
+ *
326
+ * Alignment happens BEFORE the splice: a sources[] shorter than embeddings[]
327
+ * would otherwise make index N refer to two different samples in the two
328
+ * arrays, permanently offsetting provenance from the samples it describes —
329
+ * the same class of bug as the old inline `sources?.shift()`.
330
+ */
331
+ export function dropEmbeddingAt(profile: VoiceProfile, index: number): { droppedSource: string | null } {
332
+ if (!Number.isInteger(index) || index < 0 || index >= profile.embeddings.length) {
333
+ return { droppedSource: null }
334
+ }
335
+ if (!Array.isArray(profile.sources)) profile.sources = []
336
+ while (profile.sources.length < profile.embeddings.length) profile.sources.push(UNKNOWN_SOURCE)
337
+ profile.embeddings.splice(index, 1)
338
+ const droppedSource = profile.sources.splice(index, 1)[0] ?? null
339
+ profile.sources.length = profile.embeddings.length
340
+ return { droppedSource }
341
+ }
342
+
343
+ /**
344
+ * Provenance of each stored sample, index-aligned with embeddings[].
345
+ *
346
+ * Holes and a short array both read as undefined, which the eviction policy
347
+ * classifies as `unknown` — the weakest tier. That is the safe direction: a
348
+ * sample whose provenance was lost must never inherit the protection given to
349
+ * one a human supplied.
350
+ */
351
+ export function alignedSources(profile: VoiceProfile): Array<string | undefined> {
352
+ return Array.from({ length: profile.embeddings.length }, (_, i) => profile.sources?.[i])
353
+ }
354
+
320
355
  /** Append a sample to both arrays together. */
321
356
  export function appendEmbedding(profile: VoiceProfile, embedding: number[], source: string): void {
322
357
  if (!Array.isArray(profile.sources)) profile.sources = []