@gotcos/glasses-server 6.21.7 → 6.21.10

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,235 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'
2
+ import { basename, dirname, join, resolve, sep } from 'node:path'
3
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
4
+ import { dataPath } from './data-dir.js'
5
+
6
+ const JOB_NAME = /^[A-Za-z0-9:_-]{3,96}\.json$/
7
+
8
+ export interface MeetingFinalizationJob {
9
+ schemaVersion: 1
10
+ sessionId: string
11
+ meetingPath: string
12
+ sidecarPath: string
13
+ audioDir: string | null
14
+ streamingWordCount: number
15
+ phase: 'capture_pending' | 'batch_pending' | 'ops_pending'
16
+ claimPending: boolean
17
+ createdAt: string
18
+ updatedAt: string
19
+ lastError?: string
20
+ }
21
+
22
+ function contained(parent: string, child: string): boolean {
23
+ return child === parent || child.startsWith(`${parent}${sep}`)
24
+ }
25
+
26
+ function validJob(raw: unknown): raw is MeetingFinalizationJob {
27
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false
28
+ const job = raw as Partial<MeetingFinalizationJob>
29
+ return job.schemaVersion === 1
30
+ && typeof job.sessionId === 'string'
31
+ && /^[A-Za-z0-9:_-]{3,96}$/.test(job.sessionId)
32
+ && typeof job.meetingPath === 'string'
33
+ && typeof job.sidecarPath === 'string'
34
+ && (job.audioDir === null || typeof job.audioDir === 'string')
35
+ && typeof job.streamingWordCount === 'number'
36
+ && Number.isFinite(job.streamingWordCount)
37
+ && (job.phase === 'capture_pending' || job.phase === 'batch_pending' || job.phase === 'ops_pending')
38
+ && (job.claimPending === undefined || typeof job.claimPending === 'boolean')
39
+ && typeof job.createdAt === 'string'
40
+ && typeof job.updatedAt === 'string'
41
+ }
42
+
43
+ /** Durable replay ledger for the post-response HQ + operations handoff.
44
+ * The meeting and sidecar remain the canonical data; this store contains only
45
+ * bounded pointers and phase state so a server restart can resume safely. */
46
+ export class MeetingFinalizationJobStore {
47
+ readonly root: string
48
+
49
+ constructor(root = dataPath('meeting-finalization-jobs')) {
50
+ this.root = resolve(root)
51
+ }
52
+
53
+ private ensureRoot(): void {
54
+ mkdirSync(this.root, { recursive: true, mode: 0o700 })
55
+ }
56
+
57
+ private pathFor(sessionId: string): string {
58
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) throw new Error('Invalid finalization sessionId')
59
+ const path = resolve(this.root, `${sessionId}.json`)
60
+ if (!contained(this.root, path)) throw new Error('Unsafe finalization job path')
61
+ return path
62
+ }
63
+
64
+ private assertSafePointers(input: Pick<MeetingFinalizationJob, 'meetingPath' | 'sidecarPath' | 'audioDir'>): void {
65
+ const dataRoot = dirname(this.root)
66
+ const recordingsRoot = resolve(dataRoot, 'recordings')
67
+ const pendingRoot = resolve(dataRoot, 'pending-batch')
68
+ if (!contained(recordingsRoot, resolve(input.meetingPath))) throw new Error('Unsafe finalization meeting path')
69
+ if (!contained(recordingsRoot, resolve(input.sidecarPath))) throw new Error('Unsafe finalization sidecar path')
70
+ if (input.audioDir && !contained(pendingRoot, resolve(input.audioDir))) {
71
+ throw new Error('Unsafe finalization audio path')
72
+ }
73
+ }
74
+
75
+ save(input: Omit<MeetingFinalizationJob, 'schemaVersion' | 'createdAt' | 'updatedAt'>): MeetingFinalizationJob {
76
+ this.assertSafePointers(input)
77
+ this.ensureRoot()
78
+ const prior = this.get(input.sessionId)
79
+ const now = new Date().toISOString()
80
+ const job: MeetingFinalizationJob = {
81
+ schemaVersion: 1,
82
+ ...input,
83
+ createdAt: prior?.createdAt ?? now,
84
+ updatedAt: now,
85
+ }
86
+ durableAtomicWriteFileSync(this.pathFor(input.sessionId), `${JSON.stringify(job, null, 2)}\n`, { mode: 0o600 })
87
+ return job
88
+ }
89
+
90
+ get(sessionId: string): MeetingFinalizationJob | null {
91
+ const path = this.pathFor(sessionId)
92
+ if (!existsSync(path)) return null
93
+ try {
94
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
95
+ if (!validJob(parsed)) return null
96
+ const normalized = { ...parsed, claimPending: parsed.claimPending === true }
97
+ this.assertSafePointers(normalized)
98
+ return normalized
99
+ } catch {
100
+ return null
101
+ }
102
+ }
103
+
104
+ list(): MeetingFinalizationJob[] {
105
+ if (!existsSync(this.root)) return []
106
+ let names: string[] = []
107
+ try { names = readdirSync(this.root).filter(name => JOB_NAME.test(name)) } catch { return [] }
108
+ return names.flatMap(name => {
109
+ const sessionId = basename(name, '.json')
110
+ const job = this.get(sessionId)
111
+ return job ? [job] : []
112
+ })
113
+ }
114
+
115
+ malformedCount(): number {
116
+ if (!existsSync(this.root)) return 0
117
+ try {
118
+ return readdirSync(this.root)
119
+ .filter(name => JOB_NAME.test(name))
120
+ .filter(name => this.get(basename(name, '.json')) === null)
121
+ .length
122
+ } catch {
123
+ return 0
124
+ }
125
+ }
126
+
127
+ remove(sessionId: string): void {
128
+ try { unlinkSync(this.pathFor(sessionId)) } catch { /* already absent */ }
129
+ }
130
+
131
+ /** Find a previously moved audio directory without trusting a stored path. */
132
+ findPendingAudioDir(sessionId: string): string | null {
133
+ const pendingRoot = resolve(dirname(this.root), 'pending-batch')
134
+ if (!existsSync(pendingRoot)) return null
135
+ try {
136
+ const candidates = readdirSync(pendingRoot)
137
+ .filter(name => name === sessionId || name.startsWith(`${sessionId}_`))
138
+ .map(name => resolve(pendingRoot, name))
139
+ .filter(path => contained(pendingRoot, path) && statSync(path).isDirectory())
140
+ .sort()
141
+ return candidates[0] ?? null
142
+ } catch {
143
+ return null
144
+ }
145
+ }
146
+
147
+ /** Rebuild missing replay jobs from the canonical sidecar intent marker. */
148
+ reconcileCanonicalSidecars(): MeetingFinalizationJob[] {
149
+ const recordingsRoot = resolve(dirname(this.root), 'recordings')
150
+ if (!existsSync(recordingsRoot)) return []
151
+ const rebuilt: MeetingFinalizationJob[] = []
152
+ let months: string[] = []
153
+ try { months = readdirSync(recordingsRoot).filter(name => /^\d{4}-\d{2}$/.test(name)) } catch { return [] }
154
+ for (const month of months) {
155
+ const monthDir = resolve(recordingsRoot, month)
156
+ let names: string[] = []
157
+ try { names = readdirSync(monthDir).filter(name => name.endsWith('.g2-chunks.json')) } catch { continue }
158
+ for (const name of names) {
159
+ const sidecarPath = resolve(monthDir, name)
160
+ try {
161
+ const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf8')) as Record<string, unknown>
162
+ const sessionId = typeof sidecar.sessionId === 'string' ? sidecar.sessionId : ''
163
+ if (sidecar.finalizationState === 'complete' || !sidecar.finalizationState || this.get(sessionId)) continue
164
+ const meetingPath = sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
165
+ if (!existsSync(meetingPath)) continue
166
+ const audioDir = this.findPendingAudioDir(sessionId)
167
+ rebuilt.push(this.save({
168
+ sessionId,
169
+ meetingPath,
170
+ sidecarPath,
171
+ audioDir,
172
+ streamingWordCount: Number(sidecar.streamingWordCount ?? 0),
173
+ phase: audioDir ? 'batch_pending' : 'capture_pending',
174
+ claimPending: sidecar.claimPending === true,
175
+ }))
176
+ } catch { /* malformed canonical sidecars are not executable */ }
177
+ }
178
+ }
179
+ return rebuilt
180
+ }
181
+ }
182
+
183
+ export function readFinalizationChunkEntries(job: MeetingFinalizationJob): unknown[] | null {
184
+ if (!existsSync(job.meetingPath) || !existsSync(job.sidecarPath)) return null
185
+ try {
186
+ const sidecar = JSON.parse(readFileSync(job.sidecarPath, 'utf8')) as Record<string, unknown>
187
+ if (sidecar.sessionId !== job.sessionId || !Array.isArray(sidecar.chunkEntries)) return null
188
+ return sidecar.chunkEntries
189
+ } catch {
190
+ return null
191
+ }
192
+ }
193
+
194
+ export function canonicalFinalizationIsComplete(job: MeetingFinalizationJob): boolean {
195
+ try {
196
+ const sidecar = JSON.parse(readFileSync(job.sidecarPath, 'utf8')) as Record<string, unknown>
197
+ return sidecar.sessionId === job.sessionId && sidecar.finalizationState === 'complete'
198
+ } catch {
199
+ return false
200
+ }
201
+ }
202
+
203
+ export function markCanonicalFinalizationState(
204
+ sidecarPath: string,
205
+ state: MeetingFinalizationJob['phase'] | 'complete',
206
+ claimPending: boolean,
207
+ ): void {
208
+ if (!existsSync(sidecarPath)) return
209
+ const parsed = JSON.parse(readFileSync(sidecarPath, 'utf8')) as Record<string, unknown>
210
+ parsed.finalizationState = state
211
+ parsed.claimPending = claimPending
212
+ parsed.finalizationUpdatedAt = new Date().toISOString()
213
+ if (state === 'complete') delete parsed.finalizationError
214
+ durableAtomicWriteFileSync(sidecarPath, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 })
215
+ }
216
+
217
+ export function getMeetingFinalizationSnapshot(): {
218
+ pending: number
219
+ failed: number
220
+ oldestUpdatedAt: string | null
221
+ lastError: string | null
222
+ malformed: number
223
+ } {
224
+ const jobs = new MeetingFinalizationJobStore().list()
225
+ const failed = jobs.filter(job => Boolean(job.lastError))
226
+ const oldest = jobs.map(job => job.updatedAt).sort()[0] ?? null
227
+ const mostRecentFailure = failed.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]
228
+ return {
229
+ pending: jobs.length,
230
+ failed: failed.length,
231
+ oldestUpdatedAt: oldest,
232
+ lastError: mostRecentFailure?.lastError?.slice(0, 200) ?? null,
233
+ malformed: new MeetingFinalizationJobStore().malformedCount(),
234
+ }
235
+ }
@@ -89,6 +89,10 @@ export interface SaveMeetingInput {
89
89
  chunkEntries?: IndexedTranscriptChunk[]
90
90
  providerCandidates?: Record<string, ProviderCandidateRecord>
91
91
  transferIntegrity?: TranscriptGapReport | null
92
+ /** Durable intent used to reconstruct post-save work if the process exits
93
+ * between the canonical commit and creation of the replay job. */
94
+ finalizationRequired?: boolean
95
+ claimPending?: boolean
92
96
  }
93
97
 
94
98
  export interface SavedMeeting {
@@ -444,6 +448,11 @@ export class MeetingStore {
444
448
  transcriptionQuality: 'streaming',
445
449
  batchApplied: false,
446
450
  streamingWordCount: wordCount(transcript),
451
+ ...(input.finalizationRequired ? {
452
+ finalizationState: 'capture_pending',
453
+ claimPending: input.claimPending === true,
454
+ finalizationUpdatedAt: new Date().toISOString(),
455
+ } : {}),
447
456
  }
448
457
 
449
458
  // Sidecar first, markdown second: the markdown is the visible commit marker.
@@ -0,0 +1,86 @@
1
+ // speaker-calibration.jsonl — threshold-tuning telemetry, one row per
2
+ // identification decision. Every row carries a speaker NAME, so a person's
3
+ // trace survives deleting their voice profile unless this file is swept too.
4
+ //
5
+ // The log is append-only and written from the live identification path with a
6
+ // fire-and-forget `appendFileSync`. A rewrite therefore has a genuine (if
7
+ // millisecond-wide) race with concurrent appends. That is acceptable HERE and
8
+ // nowhere else in this feature: the file is explicitly non-critical tuning data,
9
+ // already best-effort, and losing a row written during the swap costs nothing —
10
+ // whereas leaving a deleted person's name in 21k rows is the privacy gap the
11
+ // delete exists to close. Embedding data is never touched by this module.
12
+
13
+ import { existsSync, readFileSync } from 'node:fs'
14
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
15
+
16
+ export interface CalibrationPurgeResult {
17
+ /** Rows whose `speaker` matched and were dropped. */
18
+ removed: number
19
+ /** Rows retained, including rows that could not be parsed. */
20
+ retained: number
21
+ /** Rows that were not valid JSON. Kept — a malformed row is not evidence that
22
+ * it belongs to the person being deleted, and discarding it would be a
23
+ * silent data loss dressed up as a privacy fix. */
24
+ unparsable: number
25
+ }
26
+
27
+ /**
28
+ * Filter JSONL text, dropping rows whose `speaker` field matches.
29
+ *
30
+ * Pure so the matching rule can be tested without touching the filesystem.
31
+ * Matching is exact on the `speaker` field only: a substring match would delete
32
+ * every "Miles Mallard" row when removing "Miles", and the name also appears in
33
+ * no other field.
34
+ */
35
+ export function filterCalibrationRows(
36
+ raw: string,
37
+ speakerName: string,
38
+ ): { text: string; result: CalibrationPurgeResult } {
39
+ const lines = raw.split('\n')
40
+ const kept: string[] = []
41
+ const result: CalibrationPurgeResult = { removed: 0, retained: 0, unparsable: 0 }
42
+
43
+ for (const line of lines) {
44
+ if (line.trim() === '') continue
45
+ let speaker: unknown
46
+ try {
47
+ speaker = (JSON.parse(line) as { speaker?: unknown }).speaker
48
+ } catch {
49
+ result.unparsable++
50
+ result.retained++
51
+ kept.push(line)
52
+ continue
53
+ }
54
+ if (speaker === speakerName) {
55
+ result.removed++
56
+ continue
57
+ }
58
+ result.retained++
59
+ kept.push(line)
60
+ }
61
+
62
+ return { text: kept.length > 0 ? kept.join('\n') + '\n' : '', result }
63
+ }
64
+
65
+ /** Rewrite the log without a given speaker's rows. */
66
+ export function purgeSpeakerCalibrationRows(
67
+ logPath: string,
68
+ speakerName: string,
69
+ options: { dryRun?: boolean } = {},
70
+ ): CalibrationPurgeResult {
71
+ if (!existsSync(logPath)) return { removed: 0, retained: 0, unparsable: 0 }
72
+ let raw: string
73
+ try {
74
+ raw = readFileSync(logPath, 'utf-8')
75
+ } catch {
76
+ return { removed: 0, retained: 0, unparsable: 0 }
77
+ }
78
+
79
+ const { text, result } = filterCalibrationRows(raw, speakerName)
80
+ if (result.removed === 0 || options.dryRun) return result
81
+
82
+ // Atomic: a torn rewrite of a 2 MB log would leave a half-row that every
83
+ // later parse trips over.
84
+ durableAtomicWriteFileSync(logPath, text, { mode: 0o600 })
85
+ return result
86
+ }
@@ -8,7 +8,20 @@
8
8
  import { resolve } from 'node:path'
9
9
  import { errMsg } from './utils.js'
10
10
  import { getOwnerSpeakerLabel } from './profile.js'
11
- import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync, statSync, openSync, readSync, closeSync } from 'node:fs'
11
+ import { existsSync, appendFileSync, mkdirSync, statSync, openSync, readSync, closeSync } from 'node:fs'
12
+ import {
13
+ appendEmbedding,
14
+ deleteProfileFromStore,
15
+ describeRepairs,
16
+ dropOldestEmbedding,
17
+ hasRepairs,
18
+ loadVoiceProfileStore,
19
+ modalDimension,
20
+ removeEmbeddingsBySource,
21
+ saveVoiceProfileStore,
22
+ type ProfileStore,
23
+ type VoiceProfile,
24
+ } from './voice-profile-store.js'
12
25
  import { homedir } from 'node:os'
13
26
  import { spawnSync } from 'node:child_process'
14
27
  import { fileURLToPath } from 'node:url'
@@ -92,15 +105,10 @@ const autoEnrollSessions = new Map<string, Map<string, number>>()
92
105
  // Track which speakers already auto-enrolled this session
93
106
  const autoEnrolledThisSession = new Map<string, Set<string>>()
94
107
 
95
- interface VoiceProfile {
96
- name: string
97
- embeddings: number[][] // multiple enrollments for robustness
98
- sources?: string[] // provenance: 'manual' | 'fireflies' | 'auto:sessionId'
99
- }
100
-
101
- interface ProfileStore {
102
- profiles: VoiceProfile[]
103
- }
108
+ // Shape, durability, and integrity repair all live in voice-profile-store.ts.
109
+ // Re-exported because the review surfaces (/speakers, delete-person, coherence
110
+ // audit) need the types and were previously blocked on them being file-private.
111
+ export type { ProfileStore, VoiceProfile }
104
112
 
105
113
  /** Cheap structural screen for a downloaded model.
106
114
  *
@@ -293,11 +301,13 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
293
301
  }
294
302
  }
295
303
 
296
- // FIFO cap: if at max, drop oldest before adding (always enforced)
304
+ // FIFO cap: if at max, drop oldest before adding (always enforced).
305
+ // dropOldestEmbedding keeps sources[] in lockstep — the old inline
306
+ // `sources?.shift()` no-opped whenever sources was undefined or short,
307
+ // permanently offsetting provenance from the samples it described.
297
308
  if (profile.embeddings.length >= MAX_EMBEDDINGS_PER_SPEAKER) {
298
309
  console.log(`[speaker] Profile cap reached for "${name}" (${profile.embeddings.length}/${MAX_EMBEDDINGS_PER_SPEAKER}) — dropping oldest`)
299
- profile.embeddings.shift()
300
- profile.sources?.shift()
310
+ dropOldestEmbedding(profile)
301
311
  rebuildSpeakerInManager(name, profile.embeddings)
302
312
  }
303
313
  }
@@ -448,10 +458,44 @@ export function clearSpeakerEmbeddings(name: string): boolean {
448
458
  try { manager.remove(name) } catch { /* ignore */ }
449
459
  }
450
460
 
451
- // Persist
452
- writeFileSync(PROFILES_PATH, JSON.stringify(store, null, 2))
453
- invalidateProfileCache()
454
- return true
461
+ // Persist. A "fresh training" clear legitimately empties one profile but must
462
+ // never be able to empty the whole store, so allowEmpty stays off here.
463
+ return writeProfileStore(store)
464
+ }
465
+
466
+ /** Remove a person entirely: profile, embeddings, and manager registration.
467
+ * Returns counts so a caller can report what was actually removed. */
468
+ export function removeSpeakerProfile(name: string): { removedProfiles: number; removedEmbeddings: number } {
469
+ const store = loadProfileStore()
470
+ const result = deleteProfileFromStore(store, name)
471
+ if (result.removedProfiles === 0) return result
472
+
473
+ if (manager && manager.contains(name)) {
474
+ try { manager.remove(name) } catch { /* the on-disk removal is the durable half */ }
475
+ }
476
+ // allowEmpty: deleting the only enrolled person is a legitimate reset, and the
477
+ // caller has already confirmed it explicitly.
478
+ writeProfileStore(store, { allowEmpty: true })
479
+ return result
480
+ }
481
+
482
+ /** Retract samples by provenance — e.g. every `auto:<sessionId>` embedding a
483
+ * bad session wrote into the wrong profile. `clearSpeakerEmbeddings` is
484
+ * all-or-nothing and would discard the legitimate training alongside it. */
485
+ export function retractEmbeddingsBySource(
486
+ name: string,
487
+ matches: (source: string) => boolean,
488
+ ): { removed: number; remaining: number } {
489
+ const store = loadProfileStore()
490
+ const profile = store.profiles.find(p => p.name === name)
491
+ if (!profile) return { removed: 0, remaining: 0 }
492
+
493
+ const removed = removeEmbeddingsBySource(profile, matches)
494
+ if (removed === 0) return { removed: 0, remaining: profile.embeddings.length }
495
+
496
+ rebuildSpeakerInManager(name, profile.embeddings)
497
+ writeProfileStore(store, { allowEmpty: true })
498
+ return { removed, remaining: profile.embeddings.length }
455
499
  }
456
500
 
457
501
  /** Get embedding count for a speaker */
@@ -495,6 +539,28 @@ export function speakerModelState(): {
495
539
  return { state, path, searched: speakerModelCandidates() }
496
540
  }
497
541
 
542
+ /** Map the model state onto a readiness verdict for /api/health.
543
+ *
544
+ * The distinction this encodes is the whole point of the field, so it is a
545
+ * named pure function rather than an inline ternary inside the health handler:
546
+ *
547
+ * - 'error' → degraded. A model IS installed and the runtime refused it,
548
+ * so diarization has silently collapsed to the amplitude
549
+ * fallback while every other status surface stays green.
550
+ * That is exactly how 78 trained profiles went unnoticed as
551
+ * missing across a managed cutover.
552
+ * - 'unavailable' → NOT degraded. The ~26 MB model ships outside the npm
553
+ * tarball, so most installs have never had one and are
554
+ * working as designed. Degrading them would make the field
555
+ * meaningless on every public box and train the operator to
556
+ * ignore the one channel that matters. */
557
+ export function speakerReadiness(
558
+ state: 'active' | 'unavailable' | 'error',
559
+ ): 'ready' | 'unavailable' | 'degraded' {
560
+ if (state === 'error') return 'degraded'
561
+ return state === 'active' ? 'ready' : 'unavailable'
562
+ }
563
+
498
564
  /** Compute actual cosine similarity between two raw embedding vectors */
499
565
  export function rawCosineSimilarity(a: Float32Array, b: Float32Array): number {
500
566
  if (a.length !== b.length) return 0
@@ -606,14 +672,66 @@ function logCalibration(speaker: string, similarity: number, matched: boolean, e
606
672
  } catch { /* non-critical */ }
607
673
  }
608
674
 
609
- /** Load profile store from disk (cached in memory, invalidated on write) */
675
+ /** Load profile store from disk (cached in memory, invalidated on write).
676
+ *
677
+ * Previously a bare `JSON.parse(readFileSync(...))`: a truncated file threw out
678
+ * of every caller, including the live enrollment path, and there was no backup
679
+ * to fall back to. Corruption and integrity repairs are now both reported —
680
+ * silently returning `{profiles: []}` is the one outcome that must never pass
681
+ * unremarked, because the next save would commit it over the real store. */
610
682
  function loadProfileStore(): ProfileStore {
611
683
  if (_cachedProfileStore) return _cachedProfileStore
612
- if (existsSync(PROFILES_PATH)) {
613
- _cachedProfileStore = JSON.parse(readFileSync(PROFILES_PATH, 'utf-8'))
614
- return _cachedProfileStore!
684
+
685
+ const load = loadVoiceProfileStore(PROFILES_PATH)
686
+
687
+ if (load.status === 'corrupt') {
688
+ if (load.recoveredFromBackup) {
689
+ console.error(
690
+ `[speaker] voice-profiles.json was corrupt (quarantined as ${load.quarantinedAs}) —`,
691
+ `recovered ${load.store.profiles.length} profile(s) from ${load.recoveredFromBackup}.`,
692
+ )
693
+ // Republish the recovered content so the next boot reads a clean file
694
+ // rather than repeating the recovery. The corrupt original is retained at
695
+ // the quarantine path for inspection.
696
+ try {
697
+ saveVoiceProfileStore(PROFILES_PATH, load.store)
698
+ } catch (err: unknown) {
699
+ console.error('[speaker] Failed to republish recovered profiles:', errMsg(err))
700
+ }
701
+ } else {
702
+ console.error(
703
+ `[speaker] voice-profiles.json was corrupt and NO usable backup exists.`,
704
+ `The unreadable file is retained at ${load.quarantinedAs}.`,
705
+ 'Diarization is starting with zero profiles; writes will not overwrite a populated store.',
706
+ )
707
+ }
708
+ }
709
+
710
+ if (hasRepairs(load.repairs)) {
711
+ console.warn(`[speaker] Repaired voice-profiles.json on load: ${describeRepairs(load.repairs)}`)
615
712
  }
616
- return { profiles: [] }
713
+
714
+ _cachedProfileStore = load.store
715
+ return _cachedProfileStore
716
+ }
717
+
718
+ /** Read-only view of the persisted profiles, for review/audit surfaces.
719
+ * Returns a structural copy so a caller cannot mutate the shared cache. */
720
+ export function readVoiceProfiles(): ProfileStore {
721
+ const store = loadProfileStore()
722
+ return { profiles: store.profiles.map(p => ({ ...p, embeddings: p.embeddings, sources: [...(p.sources ?? [])] })) }
723
+ }
724
+
725
+ /** Persist the shared store, reporting a refusal rather than swallowing it. */
726
+ function writeProfileStore(store: ProfileStore, options: { allowEmpty?: boolean } = {}): boolean {
727
+ const result = saveVoiceProfileStore(PROFILES_PATH, store, options)
728
+ if (!result.written) {
729
+ console.error(`[speaker] Profile store write refused: ${result.refusedReason}`)
730
+ return false
731
+ }
732
+ if (result.backup) console.log(`[speaker] Profile store backed up to ${result.backup}`)
733
+ invalidateProfileCache()
734
+ return true
617
735
  }
618
736
 
619
737
  /** Invalidate the in-memory profile store cache (call after any write to PROFILES_PATH) */
@@ -631,12 +749,9 @@ function persistProfile(name: string, embedding: Float32Array, source: string =
631
749
  profile = { name, embeddings: [], sources: [] }
632
750
  store.profiles.push(profile)
633
751
  }
634
- if (!profile.sources) profile.sources = []
635
- profile.embeddings.push(Array.from(embedding))
636
- profile.sources.push(source)
752
+ appendEmbedding(profile, Array.from(embedding), source)
637
753
 
638
- writeFileSync(PROFILES_PATH, JSON.stringify(store, null, 2))
639
- invalidateProfileCache()
754
+ writeProfileStore(store)
640
755
  } catch (err: unknown) {
641
756
  console.error('[speaker] Profile persist error:', errMsg(err))
642
757
  }
@@ -645,17 +760,24 @@ function persistProfile(name: string, embedding: Float32Array, source: string =
645
760
  /** Compute centroid (average) of multiple embeddings.
646
761
  * The centroid captures the speaker's average voice across different acoustic
647
762
  * conditions (meetings, mics, energy levels). More robust than any single embedding. */
648
- function computeCentroid(embeddings: number[][]): Float32Array {
649
- const dim = embeddings[0].length
763
+ export function computeCentroid(embeddings: number[][]): Float32Array {
764
+ // Dimension-safe: one wrong-length row used to read `undefined` past its end
765
+ // and turn EVERY component of the averaged vector into NaN, which sherpa then
766
+ // registers as the speaker's only representative vector. Skip mismatches
767
+ // instead, and take the modal dimension so a corrupt row 0 cannot define it.
768
+ const dim = modalDimension(embeddings)
650
769
  const centroid = new Float32Array(dim)
651
- for (const emb of embeddings) {
770
+ if (dim === 0) return centroid
771
+ const usable = embeddings.filter(emb => emb.length === dim)
772
+ if (usable.length === 0) return centroid
773
+ for (const emb of usable) {
652
774
  for (let i = 0; i < dim; i++) {
653
775
  centroid[i] += emb[i]
654
776
  }
655
777
  }
656
778
  // Average
657
779
  for (let i = 0; i < dim; i++) {
658
- centroid[i] /= embeddings.length
780
+ centroid[i] /= usable.length
659
781
  }
660
782
  // L2 normalize (important for cosine similarity)
661
783
  let norm = 0
@@ -695,8 +817,7 @@ function rebuildSpeakerInManager(name: string, embeddings: number[][]): void {
695
817
 
696
818
  /** Save full profile store to disk (used by trainer for bulk updates) */
697
819
  export function saveProfileStore(store: ProfileStore): void {
698
- writeFileSync(PROFILES_PATH, JSON.stringify(store, null, 2))
699
- invalidateProfileCache()
820
+ writeProfileStore(store)
700
821
  }
701
822
 
702
823
  /** Rebuild all profiles in manager from a store (used after bulk training) */
@@ -716,12 +837,17 @@ export function rebuildAllProfiles(store: ProfileStore): void {
716
837
  console.log(`[speaker] Rebuilt manager: ${loaded} speakers (centroid mode)`)
717
838
  }
718
839
 
719
- /** Load persisted profiles into manager */
840
+ /** Load persisted profiles into manager.
841
+ *
842
+ * Goes through loadProfileStore() rather than re-reading the file: a second
843
+ * independent `JSON.parse` here meant boot and the enrollment path could
844
+ * disagree about the store's contents, and it bypassed both the corrupt-file
845
+ * recovery and the integrity repairs. */
720
846
  function loadProfiles(): void {
721
- if (!manager || !existsSync(PROFILES_PATH)) return
847
+ if (!manager) return
722
848
 
723
849
  try {
724
- const store: ProfileStore = JSON.parse(readFileSync(PROFILES_PATH, 'utf-8'))
850
+ const store = loadProfileStore()
725
851
  let loaded = 0
726
852
 
727
853
  for (const profile of store.profiles) {