@gotcos/glasses-server 6.92.0 → 6.93.0

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.
Files changed (60) hide show
  1. package/.env.example +30 -1
  2. package/CHANGELOG.md +57 -29
  3. package/README.md +58 -0
  4. package/docs/active-query-jobs.md +1 -1
  5. package/docs/test-plan-6.71.7.md +1 -1
  6. package/managed-runtime-contract.json +2 -0
  7. package/package.json +2 -1
  8. package/server/lib/ask-session-id.ts +2 -1
  9. package/server/lib/embedding-eviction.ts +60 -2
  10. package/server/lib/fuzzy-correct.ts +1 -1
  11. package/server/lib/hallucination-filter.ts +1 -1
  12. package/server/lib/imported-library-rows.ts +4 -2
  13. package/server/lib/live-cos-ask.ts +3 -2
  14. package/server/lib/mac-live-voices.ts +57 -0
  15. package/server/lib/mac-recording-asr.ts +16 -8
  16. package/server/lib/mac-speaker-projection.ts +211 -0
  17. package/server/lib/mac-stream-diarizer.ts +185 -0
  18. package/server/lib/mac-voice-assignment.ts +361 -0
  19. package/server/lib/mac-voice-labels.ts +70 -8
  20. package/server/lib/mac-window-replay.ts +271 -0
  21. package/server/lib/mac-window-voices.ts +390 -0
  22. package/server/lib/meeting-actions.ts +1 -1
  23. package/server/lib/meeting-ask-owner.ts +4 -4
  24. package/server/lib/meeting-ask-transcript-sources.ts +41 -3
  25. package/server/lib/meeting-audio-archive.ts +4 -0
  26. package/server/lib/meeting-batch-transcribe.ts +7 -1
  27. package/server/lib/meeting-context-keys.ts +6 -3
  28. package/server/lib/meeting-corrections.ts +4 -4
  29. package/server/lib/meeting-fact-check.ts +3 -3
  30. package/server/lib/meeting-parse.ts +1 -1
  31. package/server/lib/meeting-relabel-enrolment.ts +8 -8
  32. package/server/lib/meeting-relabel.ts +3 -3
  33. package/server/lib/meeting-scribe-content.ts +1 -1
  34. package/server/lib/meeting-speaker-review.ts +8 -8
  35. package/server/lib/meeting-store.ts +19 -9
  36. package/server/lib/nemotron-final.ts +114 -9
  37. package/server/lib/nemotron-live.ts +58 -6
  38. package/server/lib/phone-recording.ts +114 -0
  39. package/server/lib/prompt-tail-guard.ts +1 -1
  40. package/server/lib/speaker-embeddings.ts +154 -28
  41. package/server/lib/speaker-name.ts +1 -1
  42. package/server/lib/stranded-session-actions.ts +7 -2
  43. package/server/lib/training-audio-provenance.ts +3 -2
  44. package/server/lib/transcript-cleanup-context.ts +3 -3
  45. package/server/lib/transcript-cleanup-model.ts +1 -1
  46. package/server/lib/transcript-cleanup-spans.ts +10 -10
  47. package/server/lib/transcript-cleanup-text.ts +4 -4
  48. package/server/lib/transcript-cleanup.ts +1 -1
  49. package/server/lib/unsaved-audio-quarantine.ts +10 -1
  50. package/server/lib/voice-directory.ts +1 -1
  51. package/server/lib/voice-evidence-guard.ts +2 -1
  52. package/server/lib/voice-profile-store.ts +11 -5
  53. package/server/lib/whisper-local.ts +1 -1
  54. package/server/routes/health.ts +8 -0
  55. package/server/routes/meeting.ts +189 -19
  56. package/server/routes/meetings.ts +9 -5
  57. package/server/routes/transcribe-stream.ts +140 -15
  58. package/server/routes/voice.ts +12 -4
  59. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
  60. package/server/scripts/mac-window-voices-6930-mutation-gate.mjs +193 -0
@@ -85,6 +85,14 @@ export interface LiveNamingInput {
85
85
  /** The wearer's label (`owner_speaker_label`), for the owner guard. */
86
86
  owner?: string | null
87
87
  identify: (wav: Buffer) => VoiceprintResult | null
88
+ /** Mac-only capture-local continuity; never an identity or a channel ID. */
89
+ anonymousVoice?: (wav: Buffer) => string | null
90
+ /**
91
+ * 6.93.0 window replay (mac-window-voices.ts): the confirmed `Voice N` for an
92
+ * activity channel, already carried across chunk boundaries, or null. When set it
93
+ * replaces `anonymousVoice`; a real name from `identify` still wins.
94
+ */
95
+ anonymousChannel?: (channel: number) => string | null
88
96
  }
89
97
 
90
98
  export type LiveNamingResult =
@@ -122,7 +130,7 @@ function isRealLabel(label: string): boolean {
122
130
  * - in a split chunk the WEARER is named only at owner-verify confidence
123
131
  * (OWNER_VERIFY_SIMILARITY). A full search can return the wearer from 0.55,
124
132
  * and the client's label is the glasses' default wearer label, not identity
125
- * evidence: on 2026-10-02 both named other people (Jeremy, Kyle) as the
133
+ * evidence: on 2026-10-02 both named other people (Jasper, Corin) as the
126
134
  * wearer. Either becomes Ext and is counted (`ownerGuard`).
127
135
  * A name is never invented.
128
136
  * The segments' texts joined with one space equal `text` exactly.
@@ -173,7 +181,7 @@ export function nameChunkSegments(input: LiveNamingInput): LiveNamingResult {
173
181
  }
174
182
  if (track.channel === dominant) { names.set(track.channel, ownerSafe(input.chunkVoiceprint)); continue }
175
183
  // The client's label is the glasses' default wearer label, not evidence of
176
- // who this voice is (6.61.0 named Jeremy Sokolic as MU through it).
184
+ // who this voice is (6.61.0 named Jasper Quillen as MU through it).
177
185
  const dominantName = names.get(dominant)?.speaker ?? input.chunkVoiceprint.speaker
178
186
  const client = input.clientSpeaker.trim()
179
187
  if (isRealLabel(client) && client !== dominantName) ownerGuard.client_label++
@@ -181,6 +189,30 @@ export function nameChunkSegments(input: LiveNamingInput): LiveNamingResult {
181
189
  }
182
190
  }
183
191
 
192
+ // Live anonymous labels, one of two paths (never both, window wins):
193
+ // - anonymousChannel (6.93.0 window replay): every track takes the label its
194
+ // channel was already proven to hold, short turns included, because the
195
+ // crosswalk (not this gate) required the clean proof;
196
+ // - anonymousVoice (6.92.1 anchors, below): only a clean, sufficiently long
197
+ // isolated track may establish continuity; short or mixed tracks cannot
198
+ // inherit the dominant chunk's anonymous identity.
199
+ if (input.anonymousChannel) for (const track of ordered) {
200
+ // The window crosswalk already required clean overlap and two clean proof
201
+ // chunks for this track, so a short turn by a confirmed voice keeps its label.
202
+ const current = names.get(track.channel)
203
+ if (current && isRealLabel(current.speaker)) continue
204
+ const label = input.anonymousChannel(track.channel)
205
+ if (label && isVoiceLabel(label)) names.set(track.channel, { speaker: label, similarity: 0 })
206
+ } else if (input.anonymousVoice && pcm) for (const track of ordered) {
207
+ const current = names.get(track.channel)
208
+ if (current && isRealLabel(current.speaker)) continue
209
+ if (track.exclusiveSec < NAME_TRACK_MIN_SEC || track.exclusiveSec / track.activeSec < 0.9) continue
210
+ const isolated = exclusiveTrackWav(activity, track.channel, pcm)
211
+ if (!isolated) continue
212
+ const label = input.anonymousVoice(isolated)
213
+ if (label && isVoiceLabel(label)) names.set(track.channel, { speaker: label, similarity: 0 })
214
+ }
215
+
184
216
  const segments: ChunkSpeakerSegment[] = []
185
217
  const nameOf = (channel: number): VoiceprintResult => names.get(channel) ?? input.chunkVoiceprint
186
218
  for (const run of runsOf(labels)) {
@@ -269,6 +301,10 @@ export interface LiveLabelInput {
269
301
  /** The wearer's label, for the owner guard on split chunks. */
270
302
  owner?: string | null
271
303
  identify: (wav: Buffer) => VoiceprintResult | null
304
+ /** Mac-only capture-local continuity; never an identity or a channel ID. */
305
+ anonymousVoice?: (wav: Buffer) => string | null
306
+ /** 6.93.0 window replay; see LiveNamingInput. */
307
+ anonymousChannel?: (channel: number) => string | null
272
308
  clock?: () => number
273
309
  }
274
310
 
@@ -334,6 +370,8 @@ export async function resolveLiveLabels(input: LiveLabelInput): Promise<LiveLabe
334
370
  clientSpeaker: input.clientSpeaker,
335
371
  owner: input.owner,
336
372
  identify: input.identify,
373
+ anonymousVoice: input.anonymousVoice,
374
+ anonymousChannel: input.anonymousChannel,
337
375
  })
338
376
  if (!named.ok) return voiceprintOnly(named.reason, ms)
339
377
  const changed = named.speaker !== input.voiceprint.speaker
@@ -409,6 +447,15 @@ export interface NemotronLiveDeps {
409
447
  }
410
448
 
411
449
  export const LIVE_BUDGET_MS = 1500
450
+ /** The audio length LIVE_BUDGET_MS was sized for: one 6 s chunk (both transports cut 6 s chunks). */
451
+ export const LIVE_BUDGET_REFERENCE_AUDIO_SEC = 6
452
+ /**
453
+ * The budget for a run over `audioSec` of audio: LIVE_BUDGET_MS per reference chunk,
454
+ * never less than one chunk's budget. A 30 s window is five chunks' worth, not one.
455
+ */
456
+ export function liveBudgetForAudioSec(audioSec: number): number {
457
+ return Math.max(LIVE_BUDGET_MS, Math.ceil(LIVE_BUDGET_MS * (Number.isFinite(audioSec) ? audioSec : 0) / LIVE_BUDGET_REFERENCE_AUDIO_SEC))
458
+ }
412
459
  export const LIVE_MAX_CONCURRENT = 2
413
460
  export const WARM_TIMEOUT_MS = 180_000
414
461
  export const WARM_RETRY_DELAYS_MS = [5_000, 30_000]
@@ -600,8 +647,13 @@ export class NemotronLiveRuntime {
600
647
  return null
601
648
  }
602
649
 
603
- /** Run Nemotron on one chunk WAV within the live budget. Never throws. */
604
- async diarize(wavPath: string): Promise<LiveDiarizeOutcome> {
650
+ /**
651
+ * Run Nemotron on one chunk WAV within the live budget. Never throws.
652
+ * `timeoutMs` replaces the per-chunk budget (a longer clip). `timeoutTripsBreaker: false`
653
+ * keeps a timeout of THIS run out of the shared breaker (6.93.0: a window timeout has a
654
+ * per-chunk fallback and must not cool Nemotron for G2 and every other capture).
655
+ */
656
+ async diarize(wavPath: string, options: { timeoutMs?: number; timeoutTripsBreaker?: boolean } = {}): Promise<LiveDiarizeOutcome> {
605
657
  const blocked = this.gate()
606
658
  if (blocked) return { ok: false, reason: blocked }
607
659
  this.inFlight++
@@ -611,7 +663,7 @@ export class NemotronLiveRuntime {
611
663
  cli: this.cli!,
612
664
  modelsDir: this.modelsDir!,
613
665
  wavPath,
614
- timeoutMs: this.budget(),
666
+ timeoutMs: options.timeoutMs ?? this.budget(),
615
667
  computeUnits: 'ane',
616
668
  niceness: 10,
617
669
  tmpRoot: this.deps.tmpRoot?.(),
@@ -621,7 +673,7 @@ export class NemotronLiveRuntime {
621
673
  return { ok: true, run }
622
674
  } catch (error) {
623
675
  const reason: LiveFallbackReason = error instanceof NemotronRunError ? error.reason : 'error'
624
- if (RUN_FAILURES.has(reason)) {
676
+ if (RUN_FAILURES.has(reason) && !(reason === 'timeout' && options.timeoutTripsBreaker === false)) {
625
677
  this.consecutiveFailures++
626
678
  if (this.consecutiveFailures >= BREAKER_FAILURES && this.state === 'ready') {
627
679
  // The breaker (QA 6.61.0): stop spawning. Every chunk takes the voiceprint
@@ -0,0 +1,114 @@
1
+ /** Explicit iPhone microphone provenance. Default-off future protocol; never G2 or Mac audio. */
2
+ import { constants, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { dataPath } from './data-dir.js'
5
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
6
+ import { macError, validateMacWav } from './mac-recording.js'
7
+ import { readSpeakerPolicy, writeSpeakerPolicy } from './meeting-speaker-policy.js'
8
+ import { readSttSettings } from './stt-settings.js'
9
+ import { parakeetInstalled } from './parakeet.js'
10
+
11
+ export const PHONE_SOURCE_TYPE = 'iphone_microphone' as const
12
+ export const PHONE_CAPTURE_ID = /^phone_(?:[0-9]{13}_)?[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/
13
+ export interface PhoneRecording { version: 1; captureId: string; sessionId: string; sourceType: typeof PHONE_SOURCE_TYPE; speakerPolicy: 'anonymous'; startTime: number; closedAt?: number }
14
+ export function phoneMicrophoneMeetingsCapability(serverInstanceId: string | null) {
15
+ let reason = 'disabled'
16
+ if (process.env.COS_IPHONE_MICROPHONE_MEETINGS === '1') {
17
+ try { reason = !serverInstanceId ? 'server_identity_unavailable' : readSttSettings().hq !== 'parakeet-ultra' ? 'parakeet_not_selected' : !parakeetInstalled().installed ? 'parakeet_not_installed' : 'ready' }
18
+ catch { reason = 'parakeet_unavailable' }
19
+ }
20
+ return { enabled: reason === 'ready', protocolVersion: 1 as const, sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous' as const, asrModel: 'parakeet-ultra', dictionary: 'server-profile-corrections', reason }
21
+ }
22
+ /** Phone IDs and the new source marker must agree, including on rejected/replayed requests. */
23
+ export function assertPhoneProvenance(id: unknown, fields: Record<string, unknown> | undefined): boolean {
24
+ // A repeated query key arrives as an array; it is never a phone ID and never a 500.
25
+ if (typeof id !== 'string') {
26
+ if (fields?.sourceType === PHONE_SOURCE_TYPE) throw macError(400, 'invalid_phone_capture_id', 'Invalid iPhone capture ID')
27
+ return false
28
+ }
29
+ const phone = id.startsWith('phone_')
30
+ // Anonymous is a shared speaker policy, not evidence of a phone source.
31
+ if (!phone && fields?.sourceType !== PHONE_SOURCE_TYPE) return false
32
+ if (!PHONE_CAPTURE_ID.test(id) || fields?.sourceType !== PHONE_SOURCE_TYPE || fields?.speakerPolicy !== 'anonymous') throw macError(400, 'phone_provenance_required', 'iPhone microphone capture requires its phone ID, source type and anonymous policy')
33
+ return true
34
+ }
35
+ function pathFor(id: string): string {
36
+ if (!PHONE_CAPTURE_ID.test(id)) throw macError(400, 'invalid_phone_capture_id', 'Invalid iPhone capture ID')
37
+ return join(dataPath('phone-recordings'), id + '.json')
38
+ }
39
+ export function readPhoneRecording(id: string): PhoneRecording | null {
40
+ if (!id.startsWith('phone_')) return null
41
+ const path = pathFor(id)
42
+ try {
43
+ if (lstatSync(path).isSymbolicLink()) throw Error('symbolic link')
44
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
45
+ try {
46
+ const stat = fstatSync(fd)
47
+ if (!stat.isFile() || stat.size > 4096) throw Error('invalid metadata')
48
+ const value = JSON.parse(readFileSync(fd, 'utf8'))
49
+ if (value.version !== 1 || value.captureId !== id || value.sessionId !== id || value.sourceType !== PHONE_SOURCE_TYPE || value.speakerPolicy !== 'anonymous' || !Number.isSafeInteger(value.startTime) || value.startTime <= 0 || (value.closedAt !== undefined && (!Number.isSafeInteger(value.closedAt) || value.closedAt <= 0))) throw Error('invalid metadata')
50
+ return value
51
+ } finally { closeSync(fd) }
52
+ } catch (error) {
53
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
54
+ throw macError(409, 'phone_capture_metadata_unreadable', 'iPhone capture provenance is unreadable; audio is retained')
55
+ }
56
+ }
57
+ export function requirePhoneRecording(id: string): PhoneRecording {
58
+ const value = readPhoneRecording(id)
59
+ if (!value) throw macError(409, 'phone_capture_provenance_missing', 'iPhone capture provenance is missing; audio is retained')
60
+ if (readSpeakerPolicy(id).mode !== 'anonymous' || readSpeakerPolicy(id).revision === 'unreadable') throw macError(409, 'phone_capture_policy_changed', 'iPhone microphone capture is anonymous')
61
+ return value
62
+ }
63
+ export function admitPhoneRecording(id: string, startTime: number | undefined, serverInstanceId: string): PhoneRecording {
64
+ const capability = phoneMicrophoneMeetingsCapability(serverInstanceId)
65
+ if (!capability.enabled) throw macError(503, 'phone_microphone_' + capability.reason, 'iPhone microphone recording is unavailable: ' + capability.reason)
66
+ const prior = readPhoneRecording(id)
67
+ if (prior) {
68
+ if (prior.closedAt) throw macError(410, 'session_closed', 'Capture is already saved')
69
+ if (startTime !== undefined && Number(startTime) !== prior.startTime) throw macError(409, 'phone_capture_conflict', 'Capture ID already belongs to another start time')
70
+ return requirePhoneRecording(id)
71
+ }
72
+ // Never retroactively turn an old/unlabelled recording into a phone capture.
73
+ if (existsSync(dataPath('active-sessions', id + '.json')) || existsSync(dataPath('session-audio', id)) || existsSync(dataPath('meeting-audio', id)) || existsSync(dataPath('pending-batch', id)) || existsSync(dataPath('unsaved-audio', id))) throw macError(409, 'phone_capture_provenance_missing', 'Existing audio has no admitted iPhone provenance')
74
+ const at = Number(startTime)
75
+ const epoch = /^phone_([0-9]{13})_/.exec(id)?.[1]
76
+ if (epoch && Number(epoch) !== at) throw macError(409, 'phone_capture_conflict', 'Capture ID does not match its original start time')
77
+ if (!Number.isSafeInteger(at) || at <= 0 || at > Date.now() + 300000) throw macError(400, 'phone_start_time_required', 'Original iPhone capture start time required')
78
+ const policy = readSpeakerPolicy(id)
79
+ if (policy.revision !== 'unset' && policy.mode !== 'anonymous') throw macError(409, 'phone_capture_policy_conflict', 'Capture has an incompatible speaker policy')
80
+ if (policy.revision === 'unset') writeSpeakerPolicy(id, 'anonymous', policy.revision)
81
+ const value: PhoneRecording = { version: 1, captureId: id, sessionId: id, sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous', startTime: at }
82
+ mkdirSync(dataPath('phone-recordings'), { recursive: true, mode: 0o700 })
83
+ durableAtomicWriteFileSync(pathFor(id), JSON.stringify(value), { mode: 0o600 })
84
+ return value
85
+ }
86
+ export function closePhoneRecording(id: string): void {
87
+ const value = requirePhoneRecording(id)
88
+ if (!value.closedAt) durableAtomicWriteFileSync(pathFor(id), JSON.stringify({ ...value, closedAt: Date.now() }), { mode: 0o600 })
89
+ }
90
+ export function validatePhoneWav(audio: Buffer): number {
91
+ try { return validateMacWav(audio) } catch { throw macError(400, 'invalid_phone_audio', 'Expected a bounded canonical 16-kHz mono PCM16 WAV') }
92
+ }
93
+ /** Phone ingress numbers are canonical decimal strings, never coerced arrays, hex, exponents or blanks. */
94
+ const PHONE_DECIMAL = /^(?:0|[1-9][0-9]{0,15})$/
95
+ export function phoneQueryInteger(value: unknown, reason: string, message: string): number {
96
+ if (typeof value !== 'string' || !PHONE_DECIMAL.test(value) || !Number.isSafeInteger(Number(value))) throw macError(400, reason, message)
97
+ return Number(value)
98
+ }
99
+ /** A receipt precedes ASR so retry after decode failure cannot overwrite accepted raw audio. */
100
+ export function persistPhoneChunkReceipt(id: string, chunkIndex: number, audioSha256: string, elapsed: number, durationMs: number): void {
101
+ requirePhoneRecording(id)
102
+ const dir = dataPath('phone-recording-chunks', id), path = join(dir, chunkIndex + '.json')
103
+ const value = { chunkIndex, audioSha256, elapsed, durationMs }
104
+ try {
105
+ const stat = lstatSync(path)
106
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024) throw Error('invalid receipt')
107
+ if (JSON.stringify(JSON.parse(readFileSync(path, 'utf8'))) !== JSON.stringify(value)) throw Error('conflict')
108
+ return
109
+ } catch (error) {
110
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw macError(409, 'phone_chunk_conflict', 'Chunk index holds different audio/timing or unreadable metadata')
111
+ }
112
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
113
+ durableAtomicWriteFileSync(path, JSON.stringify(value), { mode: 0o600 })
114
+ }
@@ -55,7 +55,7 @@ export const TAIL_WINDOW_SEC = 10
55
55
  * no free-text tail — so it matches a sentence that IS the filler and nothing
56
56
  * that merely starts with its words. The re-validation of 2026-09-12 caught
57
57
  * `.{0,30}` tails letting "Thanks for watching the demo.", "Transcript from the
58
- * Silas call is in the folder." and "Subscribe me to the newsletter." through.
58
+ * Elliot call is in the folder." and "Subscribe me to the newsletter." through.
59
59
  * "See you later", "the end", "transcribed by" and "translated by" are
60
60
  * deliberately absent: each occurs in real dictation ("Translated by Friday."),
61
61
  * and whisper's inventions are the caption-credit shapes below. A filler this
@@ -1,5 +1,5 @@
1
1
  // Speaker embedding extraction and verification using sherpa-onnx
2
- import { chooseEviction, tierBreakdown } from './embedding-eviction.js'
2
+ import { chooseEviction, isMacAssign, macAssignCapFor, tierBreakdown } from './embedding-eviction.js'
3
3
  // Wraps ECAPA-TDNN model for voiceprint-based speaker classification.
4
4
  // Falls back gracefully if model is missing — amplitude classification continues.
5
5
  //
@@ -323,27 +323,8 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
323
323
  // dropOldestEmbedding keeps sources[] in lockstep — the old inline
324
324
  // `sources?.shift()` no-opped whenever sources was undefined or short,
325
325
  // permanently offsetting provenance from the samples it described.
326
- if (profile.embeddings.length >= MAX_EMBEDDINGS_PER_SPEAKER) {
327
- // Weakest PROVENANCE goes, not the oldest sample. Age is the wrong axis:
328
- // four profiles at cap would lose their only human-supplied sample to
329
- // FIFO while unverified attendee-metadata samples sat untouched.
330
- // alignedSources is defence-in-depth here, not load-bearing: loadProfileStore
331
- // already pads sources[] to match embeddings[], so a ragged array cannot
332
- // reach this line (mutation-verified — swapping it for profile.sources is
333
- // unobservable through this path). It is kept for any future caller that
334
- // builds a profile without going through the loader, and is unit-tested
335
- // directly in voice-profile-store.test.ts.
336
- const choice = chooseEviction(alignedSources(profile), source, MAX_EMBEDDINGS_PER_SPEAKER)
337
- const { droppedSource } = choice
338
- ? dropEmbeddingAt(profile, choice.index)
339
- : dropOldestEmbedding(profile)
340
- console.log(
341
- `[speaker] Profile full for "${name}" (${MAX_EMBEDDINGS_PER_SPEAKER}) — `
342
- + `${choice ? choice.reason : 'no provenance available, dropping the oldest'} `
343
- + `[dropped ${droppedSource ?? 'unknown'}, incoming ${source}]`,
344
- )
345
- rebuildSpeakerInManager(name, profile.embeddings)
346
- }
326
+ const room = makeRoomForSample(name, profile, source)
327
+ if (!room.ok) return { success: false, dim: extractor.dim, error: room.error }
347
328
  }
348
329
 
349
330
  // Add to manager — if manager rejects (internal dedup), force via remove+readd
@@ -351,7 +332,11 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
351
332
  if (!added) {
352
333
  // Manager's internal dedup rejected it — force-add by rebuilding
353
334
  // Persist first so we have the complete embedding list
354
- persistProfile(name, embedding, source)
335
+ try {
336
+ persistProfile(name, embedding, source)
337
+ } catch (err: unknown) {
338
+ return { success: false, dim: extractor.dim, error: `Profile store write failed: ${errMsg(err)}` }
339
+ }
355
340
  const updatedStore = loadProfileStore()
356
341
  const updatedProfile = updatedStore.profiles.find(p => p.name === name)
357
342
  if (updatedProfile) {
@@ -363,12 +348,99 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
363
348
  }
364
349
 
365
350
  // Persist to disk with provenance
366
- persistProfile(name, embedding, source)
351
+ try {
352
+ persistProfile(name, embedding, source)
353
+ } catch (err: unknown) {
354
+ return { success: false, dim: extractor.dim, error: `Profile store write failed: ${errMsg(err)}` }
355
+ }
367
356
 
368
357
  console.log(`[speaker] Enrolled "${name}" (${extractor.dim}-dim, source: ${source})`)
369
358
  return { success: true, dim: extractor.dim }
370
359
  }
371
360
 
361
+ /**
362
+ * Make room in a full profile for one incoming sample, by the provenance policy.
363
+ * Also enforces the per-profile cap on Mac-assigned samples (6.93.0), which can bind
364
+ * below the profile cap. Returns not-ok only when a Mac-assigned sample would have to
365
+ * displace something other than another Mac-assigned sample.
366
+ */
367
+ function makeRoomForSample(name: string, profile: VoiceProfile, source: string): { ok: true } | { ok: false; error: string } {
368
+ const macCount = isMacAssign(source) ? alignedSources(profile).filter(s => isMacAssign(s)).length : 0
369
+ const macAtCap = isMacAssign(source) && macCount >= macAssignCapFor(profile.embeddings.length, macCount)
370
+ if (profile.embeddings.length < MAX_EMBEDDINGS_PER_SPEAKER && !macAtCap) return { ok: true }
371
+ // Weakest PROVENANCE goes, not the oldest sample. Age is the wrong axis:
372
+ // four profiles at cap would lose their only human-supplied sample to
373
+ // FIFO while unverified attendee-metadata samples sat untouched.
374
+ // alignedSources is defence-in-depth here, not load-bearing: loadProfileStore
375
+ // already pads sources[] to match embeddings[], so a ragged array cannot
376
+ // reach this line (mutation-verified — swapping it for profile.sources is
377
+ // unobservable through this path). It is kept for any future caller that
378
+ // builds a profile without going through the loader, and is unit-tested
379
+ // directly in voice-profile-store.test.ts.
380
+ const choice = chooseEviction(alignedSources(profile), source, MAX_EMBEDDINGS_PER_SPEAKER)
381
+ if (choice?.refuse) {
382
+ console.log(`[speaker] Profile "${name}" full (${profile.embeddings.length}/${MAX_EMBEDDINGS_PER_SPEAKER}): ${choice.reason} [incoming ${source}]`)
383
+ return { ok: false, error: 'Profile is full and holds no Mac-assigned sample to replace' }
384
+ }
385
+ const { droppedSource } = choice
386
+ ? dropEmbeddingAt(profile, choice.index)
387
+ : dropOldestEmbedding(profile)
388
+ console.log(
389
+ `[speaker] Profile full for "${name}" (${MAX_EMBEDDINGS_PER_SPEAKER}) — `
390
+ + `${choice ? choice.reason : 'no provenance available, dropping the oldest'} `
391
+ + `[dropped ${droppedSource ?? 'unknown'}, incoming ${source}]`,
392
+ )
393
+ rebuildSpeakerInManager(name, profile.embeddings)
394
+ return { ok: true }
395
+ }
396
+
397
+ export interface BatchEnrollItem { embedding: Float32Array; source: string }
398
+ export interface BatchEnrollResult {
399
+ /** Samples that landed AND were written to disk. Zero when the write failed. */
400
+ enrolled: number
401
+ /** Samples dropped before the write (too close to an existing one, or a Mac-assigned sample with nothing it may replace). */
402
+ declined: number
403
+ /** False when the store refused or failed the single write; nothing from this batch is then in memory or on disk. */
404
+ persisted: boolean
405
+ error?: string
406
+ }
407
+
408
+ /**
409
+ * 6.93.0: add several samples to an EXISTING profile with ONE read-modify-write of the store.
410
+ * Calling `enrollEmbedding` per sample re-parsed and durably rewrote the whole store each
411
+ * time (about 10 MB, up to 20 times, on the event loop). Same dedup, same provenance
412
+ * eviction, same Mac-assigned cap. A write failure is reported, never swallowed, and the
413
+ * in-memory store and the speaker manager are put back to what is on disk.
414
+ */
415
+ export function enrollEmbeddingsBatch(name: string, items: BatchEnrollItem[]): BatchEnrollResult {
416
+ if (!extractor || !manager) return { enrolled: 0, declined: items.length, persisted: false, error: 'Speaker embeddings not initialized' }
417
+ const store = loadProfileStore()
418
+ const profile = store.profiles.find(p => p.name === name)
419
+ if (!profile) return { enrolled: 0, declined: items.length, persisted: false, error: 'No such profile' }
420
+ let added = 0
421
+ let declined = 0
422
+ for (const item of items) {
423
+ if (profile.embeddings.some(existing => rawCosineSimilarity(item.embedding, new Float32Array(existing)) > 0.95)) { declined++; continue }
424
+ const room = makeRoomForSample(name, profile, item.source)
425
+ if (!room.ok) { declined++; continue }
426
+ appendEmbedding(profile, Array.from(item.embedding), item.source)
427
+ added++
428
+ }
429
+ if (added === 0) {
430
+ // Nothing was appended, but makeRoom may have rebuilt the manager; disk is still the truth.
431
+ return { enrolled: 0, declined, persisted: true }
432
+ }
433
+ try {
434
+ if (!writeProfileStore(store)) throw new Error('the store refused the write')
435
+ } catch (err: unknown) {
436
+ restoreProfileFromDisk(name)
437
+ return { enrolled: 0, declined: items.length, persisted: false, error: `Profile store write failed: ${errMsg(err)}` }
438
+ }
439
+ rebuildSpeakerInManager(name, profile.embeddings)
440
+ console.log(`[speaker] Batch-enrolled ${added} sample(s) for "${name}" (${profile.embeddings.length} total, one write)`)
441
+ return { enrolled: added, declined, persisted: true }
442
+ }
443
+
372
444
  /** Identify speaker from WAV audio buffer.
373
445
  * Phase 4: optionally scope search to expected attendees for fewer false positives. */
374
446
  export function identifySpeaker(
@@ -526,11 +598,17 @@ export interface MergeReport {
526
598
  samplesAfter: number
527
599
  droppedToCap: number
528
600
  refused?: { name: string; similarity: number; floor: number }[]
601
+ /** Set when the merge was refused because it would leave the target with no
602
+ * samples at all. Nothing is written when this is present. */
603
+ refusal?: { code: 'merge_would_empty_profile'; message: string }
529
604
  }
530
605
 
606
+ /** The code a merge reports when it would leave an empty profile. */
607
+ export const MERGE_WOULD_EMPTY_PROFILE = 'merge_would_empty_profile'
608
+
531
609
  /**
532
- * Fold profiles together — two names for one person, e.g. "Luke H" and
533
- * "Luke Henry" at 0.843.
610
+ * Fold profiles together — two names for one person, e.g. "Theo M" and
611
+ * "Theo Marsh" at 0.843.
534
612
  *
535
613
  * Fails closed below MERGE_SIMILARITY_FLOOR unless `force`. That guard is the
536
614
  * whole safety story: a wrong merge destroys BOTH identities at once, and the
@@ -577,6 +655,21 @@ export function mergeSpeakerProfiles(
577
655
  if (refused.length > 0) report.refused = refused
578
656
  if (eligible.length === 0) return report
579
657
 
658
+ // A merge may never leave the target with nothing. Mac-assigned samples are
659
+ // trimmed to the person's other samples, so two profiles that hold only Mac
660
+ // samples would merge down to zero and the person would stop being recognised.
661
+ // Checked on a throwaway copy so a dry run and a real merge refuse identically.
662
+ const wouldBe = mergeProfilesInStore(
663
+ JSON.parse(JSON.stringify(store)) as ProfileStore, into, eligible, { cap: MAX_EMBEDDINGS_PER_SPEAKER },
664
+ )
665
+ if (wouldBe.mergedFrom.length > 0 && wouldBe.samplesAfter === 0) {
666
+ report.refusal = {
667
+ code: MERGE_WOULD_EMPTY_PROFILE,
668
+ message: `Merging into "${into}" would leave it with no voice samples. Nothing was changed.`,
669
+ }
670
+ return report
671
+ }
672
+
580
673
  if (options.dryRun) {
581
674
  // Report on a throwaway copy so nothing is mutated by a preview.
582
675
  const preview = JSON.parse(JSON.stringify(store)) as ProfileStore
@@ -659,6 +752,17 @@ export function extractEmbedding(wavBuffer: Buffer): Float32Array | null {
659
752
  return extractEmbeddingFromWav(wavBuffer)
660
753
  }
661
754
 
755
+ /**
756
+ * Test seam: install a fake runtime (see __fixtures__/fake-speaker-runtime.ts) so the real enrolment, eviction and store-write
757
+ * code runs without the model. Pass null to remove it. Never called by the server.
758
+ */
759
+ export function __installSpeakerRuntimeForTests(runtime: { extractor: unknown; manager: unknown } | null): void {
760
+ extractor = runtime ? runtime.extractor : null
761
+ manager = runtime ? runtime.manager : null
762
+ initialized = runtime !== null
763
+ _cachedProfileStore = null
764
+ }
765
+
662
766
  /** Check if the embedding system is available */
663
767
  export function isEmbeddingAvailable(): boolean {
664
768
  return extractor !== null && manager !== null
@@ -948,7 +1052,27 @@ function invalidateProfileCache(): void {
948
1052
  _cachedProfileStore = null
949
1053
  }
950
1054
 
951
- /** Persist a speaker profile to disk with provenance tracking */
1055
+ /**
1056
+ * Put the in-memory store and the speaker manager back to what is on disk after a failed write.
1057
+ * Without this the cache keeps samples the disk never received: `enrolled` would overstate
1058
+ * what survives a restart, and the identifier would match on voiceprints that are not stored.
1059
+ */
1060
+ function restoreProfileFromDisk(name: string): void {
1061
+ invalidateProfileCache()
1062
+ try {
1063
+ const fresh = loadProfileStore().profiles.find(p => p.name === name)
1064
+ if (fresh && fresh.embeddings.length > 0) rebuildSpeakerInManager(name, fresh.embeddings)
1065
+ else if (manager?.contains(name)) manager.remove(name)
1066
+ } catch (err: unknown) {
1067
+ console.error(`[speaker] Could not restore "${name}" from disk after a failed write:`, errMsg(err))
1068
+ } finally {
1069
+ // Whatever that load saw (a store that was unreadable at that instant included) must not be pinned: the next reader reloads.
1070
+ invalidateProfileCache()
1071
+ }
1072
+ }
1073
+
1074
+ /** Persist a speaker profile to disk with provenance tracking. THROWS when the store cannot be written
1075
+ * (6.93.0: it used to log and swallow, so `enrollEmbedding` reported success for a sample only in memory). */
952
1076
  function persistProfile(name: string, embedding: Float32Array, source: string = 'manual'): void {
953
1077
  try {
954
1078
  const store = loadProfileStore()
@@ -960,9 +1084,11 @@ function persistProfile(name: string, embedding: Float32Array, source: string =
960
1084
  }
961
1085
  appendEmbedding(profile, Array.from(embedding), source)
962
1086
 
963
- writeProfileStore(store)
1087
+ if (!writeProfileStore(store)) throw new Error('the store refused the write')
964
1088
  } catch (err: unknown) {
965
1089
  console.error('[speaker] Profile persist error:', errMsg(err))
1090
+ restoreProfileFromDisk(name)
1091
+ throw err instanceof Error ? err : new Error(errMsg(err))
966
1092
  }
967
1093
  }
968
1094
 
@@ -19,7 +19,7 @@
19
19
 
20
20
  /** Longest plausible human name we will store. */
21
21
  export const MAX_SPEAKER_NAME_CHARS = 40
22
- /** Most words a name may have ("Maria del Carmen Ruiz" is four). */
22
+ /** Most words a name may have ("Maria del Cora Ruiz" is four). */
23
23
  export const MAX_SPEAKER_NAME_WORDS = 4
24
24
 
25
25
  /** Words that mean "the wearer", never a third party's name. */
@@ -1,3 +1,5 @@
1
+ import { requirePhoneRecording, PHONE_SOURCE_TYPE } from './phone-recording.js'
2
+ import { getServerInstanceId } from './server-instance-id.js'
1
3
  // What the sweeper DOES about a stranded session: draft it, then promote it.
2
4
  //
3
5
  // Two actions, deliberately asymmetric.
@@ -295,10 +297,13 @@ export async function promoteStrandedSession(
295
297
  const controller = new AbortController()
296
298
  const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 600_000)
297
299
  try {
300
+ const phone = sessionId.startsWith('phone_') ? requirePhoneRecording(sessionId) : null
301
+ const pin = phone ? getServerInstanceId() : null
302
+ if (phone && !pin) return { ok: false, status: 0, reason: 'server_identity_unavailable' }
298
303
  const res = await doFetch(`http://127.0.0.1:${options.port}/api/meeting/save`, {
299
304
  method: 'POST',
300
- headers: { 'Content-Type': 'application/json', 'X-Cos-Token': options.token },
301
- body: JSON.stringify({ sessionId, title: STRANDED_PROMOTE_TITLE }),
305
+ headers: { 'Content-Type': 'application/json', 'X-Cos-Token': options.token, ...(pin ? { 'X-COS-Server-Instance': pin, 'X-COS-Recording-Origin': 'stranded-sweep' } : {}) },
306
+ body: JSON.stringify({ sessionId, title: STRANDED_PROMOTE_TITLE, ...(phone ? { sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous' } : {}) }),
302
307
  signal: controller.signal,
303
308
  })
304
309
  let payload: Record<string, unknown> = {}
@@ -5,7 +5,7 @@
5
5
  // those segments into that person's profile — so the wrong voice is now part of
6
6
  // what the system thinks they sound like, and it will keep matching. Removing the
7
7
  // label without removing the samples fixes the transcript and leaves the profile
8
- // poisoned. That is the mechanism behind the phantom "Erick Hernandez": three
8
+ // poisoned. That is the mechanism behind the phantom "Evan Hartwell": three
9
9
  // mislabelled seeds compounded to eighteen samples through self-training.
10
10
  //
11
11
  // WHAT CAN AND CANNOT BE TRACED. Provenance strings differ in whether they name
@@ -13,6 +13,7 @@
13
13
  //
14
14
  // auto:<sessionId> traceable — autoEnroll stamps it
15
15
  // correction:<sessionId> traceable — the relabel ledger stamps it
16
+ // mac-assign:<sessionId> traceable, a Mac recording voice assigned to a profile (6.93.0)
16
17
  // g2-training:<sessionId> traceable ONLY from 2026-08-06 onward (see below)
17
18
  // g2-training NOT traceable — every sample written before that
18
19
  // fireflies / manual / … NOT traceable — no session concept
@@ -49,7 +50,7 @@ export function trainingSourceFor(filename: string): string {
49
50
  export function isSampleFromSession(source: string | undefined | null, sessionId: string): boolean {
50
51
  const s = String(source ?? '')
51
52
  if (!sessionId) return false
52
- for (const prefix of ['auto:', 'correction:', 'g2-training:', 'ext-group:']) {
53
+ for (const prefix of ['auto:', 'correction:', 'g2-training:', 'ext-group:', 'mac-assign:']) {
53
54
  if (s.startsWith(prefix) && s.slice(prefix.length) === sessionId) return true
54
55
  }
55
56
  return false
@@ -10,8 +10,8 @@
10
10
  // SCREEN AWARENESS. On-screen text lines COS Control recorded during the meeting
11
11
  // (transcript-cleanup-screen.ts), shown beside each passage by time.
12
12
  //
13
- // TERMS. Every source contributes terms: capitalised words and runs of them ("Grace
14
- // Ukaoma", "Jewel360"). Terms are the per-meeting vocabulary: deterministic correction
13
+ // TERMS. Every source contributes terms: capitalised words and runs of them ("Faith
14
+ // Ukamya", "Jewel360"). Terms are the per-meeting vocabulary: deterministic correction
15
15
  // may write a term from this meeting's screen or files, the model may write any term,
16
16
  // and a term the model writes that came from the meeting (not the dictionary) becomes a
17
17
  // dictionary SUGGESTION, never an automatic addition.
@@ -86,7 +86,7 @@ export function extractTerms(text: string, max: number = CONTEXT_LIMITS.termsPer
86
86
  const w = words[i].w
87
87
  const capital = /^\p{Lu}/u.test(w)
88
88
  if (looksLikeName(w)) out.add(w)
89
- // "Grace Ukaoma": a capitalised word directly before a name-like word, with nothing but a space between.
89
+ // "Faith Ukamya": a capitalised word directly before a name-like word, with nothing but a space between.
90
90
  if (capital && i + 1 < words.length && text.slice(words[i].end, words[i + 1].at) === ' ' && looksLikeName(words[i + 1].w)) {
91
91
  out.add(`${w} ${words[i + 1].w}`)
92
92
  }
@@ -5,7 +5,7 @@
5
5
  // servers, no hooks, an empty scratch workspace, a pinned model id, low effort, a
6
6
  // bounded output budget. Never a direct SDK. The doubtful spans of a meeting go in ONE
7
7
  // batched prompt; the answer is strict JSON, one entry per span id:
8
- // {"edits":[{"id":"S1","action":"keep"},{"id":"S2","action":"replace","text":"Grace Ukaoma"}]}
8
+ // {"edits":[{"id":"S1","action":"keep"},{"id":"S2","action":"replace","text":"Faith Ukamya"}]}
9
9
  //
10
10
  // THE GUARDS. Every replacement is checked before anything is applied. It is rejected if
11
11
  // unknown_span its id was not sent