@gotcos/glasses-server 6.95.0 → 6.98.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 (123) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +4 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +12 -0
  6. package/server/lib/attached-provider-adapter.ts +35 -1
  7. package/server/lib/claude-bridge.ts +47 -0
  8. package/server/lib/codex-bridge.ts +11 -1
  9. package/server/lib/codex-rollout-watermark.ts +42 -0
  10. package/server/lib/cursor-bridge.ts +2 -1
  11. package/server/lib/decision-provider.ts +4 -2
  12. package/server/lib/dictation-clean-models.ts +2 -0
  13. package/server/lib/dictation-clean.ts +2 -2
  14. package/server/lib/dictation-polish-pipeline.ts +22 -2
  15. package/server/lib/dictation-prewarm.ts +16 -0
  16. package/server/lib/direct-bridge.ts +14 -2
  17. package/server/lib/direct-sse.ts +6 -1
  18. package/server/lib/display-bus.ts +1 -1
  19. package/server/lib/fork-thread.ts +32 -1
  20. package/server/lib/held-naming-batches.ts +44 -7
  21. package/server/lib/held-voice-groups.ts +5 -4
  22. package/server/lib/job-audit-caller.ts +20 -0
  23. package/server/lib/keyed-sessions.ts +21 -1
  24. package/server/lib/lens-gist-engines.ts +17 -5
  25. package/server/lib/live-cos-ask.ts +29 -6
  26. package/server/lib/live-cue-verdicts.ts +43 -8
  27. package/server/lib/live-cues-engine.ts +4 -1
  28. package/server/lib/live-cues-filter.ts +50 -0
  29. package/server/lib/live-cues-general-prompts.ts +137 -0
  30. package/server/lib/live-cues-general-topic.ts +245 -0
  31. package/server/lib/live-cues-general.ts +715 -0
  32. package/server/lib/live-cues-v2-data.ts +453 -0
  33. package/server/lib/live-cues-v2-grounded.ts +262 -0
  34. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  35. package/server/lib/live-cues-v2-names.ts +49 -0
  36. package/server/lib/live-cues-v2-notes.ts +8 -4
  37. package/server/lib/live-cues-v2-open-items.ts +132 -0
  38. package/server/lib/live-cues-v2-pacing.ts +45 -0
  39. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  40. package/server/lib/live-cues-v2-settings.ts +89 -10
  41. package/server/lib/live-cues-v2.ts +570 -47
  42. package/server/lib/mac-recording-asr.ts +6 -3
  43. package/server/lib/mac-speaker-call-human.ts +371 -0
  44. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  45. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  46. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  47. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  48. package/server/lib/mac-speaker-call-producer.ts +539 -0
  49. package/server/lib/mac-speaker-call-store.ts +264 -0
  50. package/server/lib/mac-speaker-call-view.ts +373 -0
  51. package/server/lib/mac-speaker-live-compute.ts +72 -0
  52. package/server/lib/mac-speaker-live-memory.ts +414 -0
  53. package/server/lib/mac-speaker-live-projection.ts +126 -0
  54. package/server/lib/mac-speaker-live-save.ts +90 -0
  55. package/server/lib/mac-speaker-live-store.ts +71 -0
  56. package/server/lib/mac-speaker-live-worker.ts +58 -0
  57. package/server/lib/mac-speaker-live.ts +175 -0
  58. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  59. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  60. package/server/lib/mac-voice-assignment.ts +46 -27
  61. package/server/lib/mac-voice-display.ts +195 -0
  62. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  63. package/server/lib/mac-voice-suggestions.ts +548 -0
  64. package/server/lib/maintenance-lifecycle.ts +2 -0
  65. package/server/lib/media-store.ts +2 -1
  66. package/server/lib/meeting-ask-pasted.ts +135 -0
  67. package/server/lib/meeting-ask-review.ts +8 -1
  68. package/server/lib/meeting-ask-runner.ts +4 -1
  69. package/server/lib/meeting-ask-runtime.ts +189 -17
  70. package/server/lib/meeting-ask-store.ts +2 -0
  71. package/server/lib/meeting-ask-web.ts +284 -13
  72. package/server/lib/meeting-batch-progress.ts +51 -5
  73. package/server/lib/meeting-batch-transcribe.ts +35 -2
  74. package/server/lib/meeting-corrections.ts +2 -1
  75. package/server/lib/meeting-fact-check.ts +3 -0
  76. package/server/lib/meeting-finalization-jobs.ts +69 -2
  77. package/server/lib/meeting-public-topic.ts +6 -1
  78. package/server/lib/meeting-relabel-enrolment.ts +2 -1
  79. package/server/lib/meeting-relabel.ts +17 -10
  80. package/server/lib/meeting-speaker-review.ts +187 -21
  81. package/server/lib/meeting-store.ts +6 -1
  82. package/server/lib/parakeet.ts +51 -5
  83. package/server/lib/pending-batch-retention.ts +58 -0
  84. package/server/lib/private-reference.ts +38 -0
  85. package/server/lib/provider-usage.ts +561 -0
  86. package/server/lib/public-https-url.ts +32 -0
  87. package/server/lib/query-job-runtime.ts +3 -0
  88. package/server/lib/rich-media-safety.ts +23 -2
  89. package/server/lib/session-find-ask.ts +288 -0
  90. package/server/lib/session-find-collect.ts +715 -0
  91. package/server/lib/session-find-redact.ts +254 -0
  92. package/server/lib/session-find.ts +345 -0
  93. package/server/lib/speaker-calibration-log.ts +3 -2
  94. package/server/lib/speaker-embeddings.ts +46 -35
  95. package/server/lib/speaker-name-nfc.ts +37 -0
  96. package/server/lib/speaker-rename-fanout.ts +7 -6
  97. package/server/lib/speaker-shadow-decide.ts +59 -22
  98. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  99. package/server/lib/speaker-shadow-paired.ts +268 -0
  100. package/server/lib/speaker-shadow-report.ts +2 -2
  101. package/server/lib/speaker-shadow-worker.ts +50 -15
  102. package/server/lib/speaker-shadow.ts +80 -19
  103. package/server/lib/speaker-trainer.ts +2 -1
  104. package/server/lib/token-audit.ts +36 -6
  105. package/server/lib/transcript-cleanup.ts +3 -2
  106. package/server/lib/voice-directory.ts +10 -5
  107. package/server/lib/voice-profile-store.ts +24 -14
  108. package/server/lib/web-lookup-ledger.ts +151 -0
  109. package/server/lib/work-review-runtime.ts +2 -0
  110. package/server/lib/work-search.ts +3 -1
  111. package/server/lib/xlsx-text.ts +277 -0
  112. package/server/routes/decision-provider.ts +4 -1
  113. package/server/routes/jev.ts +5 -1
  114. package/server/routes/live-cues.ts +36 -14
  115. package/server/routes/meeting.ts +515 -61
  116. package/server/routes/session-find.ts +73 -0
  117. package/server/routes/transcribe-stream.ts +114 -33
  118. package/server/routes/voice.ts +28 -18
  119. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  120. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  121. package/shared/live-cues-v2.ts +38 -5
  122. package/shared/media-attachment.ts +2 -0
  123. package/shared/meeting-ask.ts +11 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * POST /api/agent-sessions/find (Control 0.5.363): which of this Mac's agent sessions the person means, by meaning.
3
+ * Auth comes from the parent /api mount. See lib/session-find.ts for what leaves the Mac, the cost controls and the gates.
4
+ *
5
+ * Body `{ query, consentedProvider, stage }`, exactly those keys. `query` is a string; the words are redacted and cut at 200
6
+ * code points before use. `consentedProvider` is `"jev"`, `"subq"` or `null`: the provider the person agreed to send to.
7
+ * `stage` is `"first"` (the newest 254 sessions) or `"full"` (the next 254 too, unless the first already settles it).
8
+ *
9
+ * A search that cannot run answers HTTP 200 with `available: false`, a `reason` and a `message`, so Control keeps its word
10
+ * search and says one line about why. A malformed body is 400. A walk that fails (the session store, the keyed-sessions file) is
11
+ * also a 200 with `reason: failed`, so Control keeps its word search. 503 `session_find_unavailable` is left for an unexpected
12
+ * error outside the finder's own handling: try later.
13
+ * An older server answers 404, which Control reads as "no meaning search here".
14
+ *
15
+ * A closed connection stops the walk and any stage that has not started. A model request already sent is not aborted (it is
16
+ * billed either way). The query and the sessions never reach a log line.
17
+ */
18
+ import { Router, type Response } from 'express'
19
+ import { isAbortError } from '../lib/session-find-collect.js'
20
+ import { SESSION_FIND_LIMITS, SessionFinder, unavailableAnswer, type SessionFindAnswer } from '../lib/session-find.js'
21
+
22
+ export interface SessionFindRouteDependencies {
23
+ finder: Pick<SessionFinder, 'find'>
24
+ now: () => number
25
+ }
26
+
27
+ const BODY_KEYS = new Set(['query', 'consentedProvider', 'stage'])
28
+ /** One log line per degrade reason per 10 minutes: Control falls back quietly, so the server says why. Fixed words only, never the query or a session. */
29
+ export const SESSION_FIND_LOG_EVERY_MS = 10 * 60_000
30
+
31
+ export function createSessionFindRouter(overrides: Partial<SessionFindRouteDependencies> = {}): Router {
32
+ let finder: Pick<SessionFinder, 'find'> | null = overrides.finder ?? null
33
+ const now = overrides.now ?? Date.now
34
+ const lastLogged = new Map<string, number>()
35
+ const logDegrade = (reason: string) => {
36
+ const at = now()
37
+ const last = lastLogged.get(reason)
38
+ if (last !== undefined && at - last < SESSION_FIND_LOG_EVERY_MS) return
39
+ lastLogged.set(reason, at)
40
+ console.warn(`[session-find] unavailable: ${reason}`)
41
+ }
42
+ const router = Router()
43
+ router.use('/agent-sessions/find', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
44
+
45
+ router.post('/agent-sessions/find', async (req, res: Response) => {
46
+ const body = req.body
47
+ const bad = () => res.status(400).json({ error: { code: 'invalid_find_request',
48
+ message: 'Send a query, the provider you consented to (jev, subq or null) and a stage of first or full.' } })
49
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(key => !BODY_KEYS.has(key))) return bad()
50
+ if (typeof body.query !== 'string' || body.query.length > SESSION_FIND_LIMITS.queryRawMax) return bad()
51
+ if (body.consentedProvider !== null && body.consentedProvider !== undefined && body.consentedProvider !== 'jev' && body.consentedProvider !== 'subq') return bad()
52
+ if (body.stage !== 'first' && body.stage !== 'full') return bad()
53
+
54
+ const controller = new AbortController()
55
+ // `close` also fires when a response has finished normally; only a connection that closed before the answer counts.
56
+ res.on('close', () => { if (!res.writableEnded) controller.abort() })
57
+ let answer: SessionFindAnswer
58
+ try {
59
+ finder ??= new SessionFinder()
60
+ answer = await finder.find({ query: body.query, consentedProvider: body.consentedProvider ?? null, stage: body.stage }, controller.signal)
61
+ } catch (error) {
62
+ if (isAbortError(error) || controller.signal.aborted) return
63
+ console.error('[session-find] search failed')
64
+ return res.status(503).json({ error: { code: 'session_find_unavailable', message: 'Meaning search could not read your sessions. Try again shortly.' } })
65
+ }
66
+ if (controller.signal.aborted) return
67
+ if (!answer.available && answer.reason) logDegrade(answer.reason)
68
+ return res.json(answer)
69
+ })
70
+ return router
71
+ }
72
+
73
+ export { unavailableAnswer }
@@ -1,3 +1,8 @@
1
+ import { takeMacSpeakerLivePublication } from '../lib/mac-speaker-live-compute.js'
2
+ import type { MacSpeakerLivePreparedPublication } from '../lib/mac-speaker-live-worker.js'
3
+ import { createMacSpeakerLiveScheduler, macSpeakerLiveEnabled, assertMacSpeakerLivePublicationBindings, type MacSpeakerLiveOutcome, type MacSpeakerLiveFileBinding } from '../lib/mac-speaker-live.js'
4
+ import { hashMacSpeakerLive, type MacSpeakerLiveState } from '../lib/mac-speaker-live-memory.js'
5
+ import { macSpeakerLiveOriginalOnly, macSpeakerLiveVoiceHighWater } from '../lib/mac-speaker-live-store.js'
1
6
  import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, assertPhoneProvenance, admitPhoneRecording, phoneQueryInteger, requirePhoneRecording, readPhoneRecording, validatePhoneWav, persistPhoneChunkReceipt } from '../lib/phone-recording.js'
2
7
  import { matchMacLiveVoice, macAnonymousProjection, type MacLiveVoiceState } from '../lib/mac-live-voices.js'
3
8
  import { chunkShowsVoiceLabel, macLiveVoicesMode } from '../lib/mac-voice-labels.js'
@@ -15,7 +20,7 @@ import { readSpeakerPolicy, policyLabel, type SpeakerMode } from '../lib/meeting
15
20
  // Streams speaker-labeled transcript chunks for live meeting capture.
16
21
 
17
22
  import { Router } from 'express'
18
- import { createHash } from 'node:crypto'
23
+ import { createHash, randomUUID } from 'node:crypto'
19
24
  import { chmodSync, readFileSync, writeFileSync, existsSync, lstatSync, mkdirSync, readdirSync, unlinkSync, rmSync, renameSync, statSync } from 'node:fs'
20
25
  import { writeFile } from 'node:fs/promises'
21
26
  import { resolve } from 'node:path'
@@ -86,6 +91,7 @@ import {
86
91
  type EvenSpeakerRoleHistogram,
87
92
  } from '../lib/even-hub-speaker-role.js'
88
93
  import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
94
+ import { sweepStalePendingBatch } from '../lib/pending-batch-retention.js'
89
95
  import {
90
96
  DISCARDED_RETENTION_MS,
91
97
  countChunkWavs,
@@ -339,6 +345,18 @@ export interface ProviderCandidateRecord {
339
345
  }
340
346
 
341
347
  interface TranscriptSession {
348
+ /** Capture-frozen opt-in; original ASR chunks remain immutable inputs. */
349
+ speakerIdentityPolicy?: 'mac-native-v1'
350
+ speakerGeneration?: string
351
+ speakerRevision?: number
352
+ speakerProjectionSHA256?: string
353
+ macSpeakerLive?: MacSpeakerLiveState
354
+ macSpeakerLiveQuarantineSHA256?: string
355
+ /** Ephemeral: recovered evidence cannot publish before asynchronous revalidation. */
356
+ macSpeakerLiveValidated?: boolean
357
+ macSpeakerLiveSourceBindingSHA256?: string
358
+ macSpeakerLiveFileBindings?: MacSpeakerLiveFileBinding[]
359
+ macSpeakerLiveStatus?: {reason:string;latencyMs:number;pendingWindows:number;completeEligibleSnapshot:boolean}
342
360
  chunks: TranscriptChunk[]
343
361
  /** Bounded anonymous acoustic anchors; active capture only, never training. */
344
362
  macLiveVoices?: MacLiveVoiceState
@@ -370,6 +388,44 @@ interface TranscriptSession {
370
388
  }
371
389
 
372
390
  const sessions = new Map<string, TranscriptSession>()
391
+ function liveIndexed(session:TranscriptSession){return session.chunks.flatMap((c,i)=>c?.text?[{i,c}]:[])}
392
+ function liveSnapshot(captureId:string){
393
+ const s=sessions.get(captureId)
394
+ if(!maintenanceAdmissionsOpen()||!macSpeakerLiveEnabled()||!s||s.speakerIdentityPolicy!=='mac-native-v1'||!s.speakerGeneration||isSessionDeleted(captureId)||macChunkLocks.has(captureId))return null
395
+ return{captureId,generation:s.speakerGeneration,audioDir:resolve(SESSION_AUDIO_DIR,captureId),ledgerPath:dataPath('meeting-corrections',`${captureId}.jsonl`),
396
+ chunksIndexed:structuredClone(liveIndexed(s)) as unknown as {i:number;c:Record<string,unknown>}[],state:s.macSpeakerLive?structuredClone(s.macSpeakerLive):undefined,
397
+ voiceHighWater:macSpeakerLiveVoiceHighWater(s.chunks,Math.max(s.macWindowVoices?.voices??0,Number.isSafeInteger(s.macSpeakerLive?.voiceHighWater)?s.macSpeakerLive!.voiceHighWater:0)),revision:s.speakerRevision??0,previousDigest:s.speakerProjectionSHA256}
398
+ }
399
+ function commitMacSpeakerLive(captureId:string,outcome:MacSpeakerLiveOutcome):boolean{
400
+ const s=sessions.get(captureId)
401
+ if(!s||s.speakerIdentityPolicy!=='mac-native-v1'||!s.speakerGeneration||isSessionDeleted(captureId)||macChunkLocks.has(captureId)||hashMacSpeakerLive(liveIndexed(s))!==outcome.sourceBindingSHA256)return false
402
+ if(outcome.state&&(outcome.state.captureId!==captureId||outcome.state.generation!==s.speakerGeneration))return false
403
+ try{if(outcome.completeEligibleSnapshot)assertMacSpeakerLivePublicationBindings(outcome.fileBindings)}catch{
404
+ s.macSpeakerLiveValidated=false;s.macSpeakerLiveStatus={reason:'publication_binding_changed',latencyMs:outcome.latencyMs,pendingWindows:0,completeEligibleSnapshot:false};persistSessionBestEffort(captureId);return false
405
+ }
406
+ const certified=takeMacSpeakerLivePublication(outcome.capability,{captureId,generation:s.speakerGeneration,sourceBindingSHA256:outcome.sourceBindingSHA256,fileBindingsSHA256:hashMacSpeakerLive(outcome.fileBindings),revision:s.speakerRevision??0,
407
+ voiceHighWater:macSpeakerLiveVoiceHighWater(s.chunks,Math.max(s.macWindowVoices?.voices??0,Number.isSafeInteger(s.macSpeakerLive?.voiceHighWater)?s.macSpeakerLive!.voiceHighWater:0)),mode:'mac-native-v1'})
408
+ if(outcome.completeEligibleSnapshot&&!certified)return false
409
+ const previous={source:s.macSpeakerLiveSourceBindingSHA256,files:s.macSpeakerLiveFileBindings,state:s.macSpeakerLive,validated:s.macSpeakerLiveValidated,status:s.macSpeakerLiveStatus,window:s.macWindowVoices?structuredClone(s.macWindowVoices):undefined,revision:s.speakerRevision,digest:s.speakerProjectionSHA256}
410
+ if(certified)s.macSpeakerLive=certified.macSpeakerLive??outcome.state
411
+ s.macSpeakerLiveSourceBindingSHA256=outcome.sourceBindingSHA256;s.macSpeakerLiveFileBindings=outcome.fileBindings
412
+ s.macSpeakerLiveValidated=maintenanceAdmissionsOpen()&&macSpeakerLiveEnabled()&&!!certified&&!!outcome.state&&outcome.completeEligibleSnapshot
413
+ s.macSpeakerLiveStatus={reason:outcome.reason,latencyMs:outcome.latencyMs,pendingWindows:outcome.pendingWindows,completeEligibleSnapshot:outcome.completeEligibleSnapshot}
414
+ if(s.macWindowVoices&&s.macSpeakerLive)s.macWindowVoices.voices=Math.max(s.macWindowVoices.voices,s.macSpeakerLive.voiceHighWater)
415
+ try{persistSessionRequired(captureId,certified??undefined)}catch{Object.assign(s,{macSpeakerLiveSourceBindingSHA256:previous.source,macSpeakerLiveFileBindings:previous.files,macSpeakerLive:previous.state,macSpeakerLiveValidated:previous.validated,macSpeakerLiveStatus:previous.status,macWindowVoices:previous.window,speakerRevision:previous.revision,speakerProjectionSHA256:previous.digest});return false}
416
+ // Dedicated invalidation cursor; consumers fetch the durable full replacement.
417
+ emitDisplay({type:'mac_speaker_live_revision',data:{sessionId:captureId,speakerIdentityPolicy:'mac-native-v1',generation:s.speakerGeneration,speakerRevision:s.speakerRevision,speakerProjectionSHA256:s.speakerProjectionSHA256}})
418
+ return true
419
+ }
420
+ const macSpeakerLiveScheduler=createMacSpeakerLiveScheduler({snapshot:liveSnapshot,commit:commitMacSpeakerLive,lease:()=>acquireMaintenanceWork('mac_speaker_live'),runtime:{admit:captureId=>maintenanceAdmissionsOpen()&&macSpeakerLiveEnabled()&&sessions.has(captureId)&&!isSessionDeleted(captureId)&&!macChunkLocks.has(captureId)}})
421
+ export const __macSpeakerLiveForTests={snapshot:liveSnapshot,commit:commitMacSpeakerLive,persist:(id:string)=>persistSessionRequired(id),drain:()=>macSpeakerLiveScheduler.drain()}
422
+ /** Save consumers receive only the durable, fully validated exact-source snapshot. */
423
+ export function getMacSpeakerLiveSaveSnapshot(captureId:string):{state:MacSpeakerLiveState;sourceBindingSHA256:string}|null{
424
+ const s=sessions.get(captureId);if(!macSpeakerLiveEnabled()||!s?.macSpeakerLiveValidated||!s.macSpeakerLive||s.macSpeakerLiveStatus?.completeEligibleSnapshot!==true||s.macSpeakerLiveSourceBindingSHA256!==hashMacSpeakerLive(liveIndexed(s)))return null
425
+ try{assertMacSpeakerLivePublicationBindings(s.macSpeakerLiveFileBindings??[])}catch{return null}
426
+ return{state:structuredClone(s.macSpeakerLive),sourceBindingSHA256:hashMacSpeakerLive(liveIndexed(s))}
427
+ }
428
+
373
429
  const CLOSED_SESSION_TTL_MS = LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
374
430
  const CLOSED_SESSIONS_FILE = dataPath('closed-transcript-sessions.json')
375
431
 
@@ -704,7 +760,7 @@ function recoverClosedSessions(): void {
704
760
 
705
761
  /** Persist a session before acknowledging any chunk. Throws on failure so a
706
762
  * client never interprets a non-durable index as accepted. */
707
- function persistSessionRequired(sessionId: string): void {
763
+ function persistSessionRequired(sessionId: string, certified?:MacSpeakerLivePreparedPublication): void {
708
764
  const session = sessions.get(sessionId)
709
765
  if (!session) throw makeHttpError(404, 'session not found', 'session_not_found')
710
766
  const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
@@ -715,8 +771,23 @@ function persistSessionRequired(sessionId: string): void {
715
771
  const c = session.chunks[i]
716
772
  if (c && c.text) chunksIndexed.push({ i, c })
717
773
  }
774
+ if(session.macSpeakerLiveValidated){
775
+ let reason:string|undefined
776
+ if(!macSpeakerLiveEnabled())reason='feature_disabled'
777
+ else if(session.macSpeakerLiveSourceBindingSHA256!==hashMacSpeakerLive(chunksIndexed))reason='new_source_pending'
778
+ else try{assertMacSpeakerLivePublicationBindings(session.macSpeakerLiveFileBindings??[])}catch{reason='resource_binding_changed'}
779
+ if(reason){session.macSpeakerLiveValidated=false;session.macSpeakerLiveStatus={reason,latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}}
780
+ }
781
+ const live = session.speakerIdentityPolicy === 'mac-native-v1' && session.speakerGeneration
782
+ ? (session.macSpeakerLiveValidated?certified:undefined)??macSpeakerLiveOriginalOnly({captureId:sessionId,generation:session.speakerGeneration,chunksIndexed:chunksIndexed as unknown as {i:number;c:Record<string,unknown>}[],state:session.macSpeakerLive,revision:session.speakerRevision??0,previousDigest:session.speakerProjectionSHA256}) : null
783
+ const projectionWithdrawn=!!session.macSpeakerLive?.projections.length&&!live?.macSpeakerLive?.projections.length
784
+ const liveStatus=live?.macSpeakerLive?.withheldReason?{reason:live.macSpeakerLive.withheldReason,latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}
785
+ :projectionWithdrawn&&session.macSpeakerLiveValidated?{reason:'projection_binding_changed',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}
786
+ :!live?.macSpeakerLive?{reason:!macSpeakerLiveEnabled()?'feature_disabled':session.macSpeakerLiveStatus?.reason??'awaiting_qualified_evidence',latencyMs:session.macSpeakerLiveStatus?.latencyMs??0,pendingWindows:session.macSpeakerLiveStatus?.pendingWindows??0,completeEligibleSnapshot:false}
787
+ :session.macSpeakerLiveStatus??{reason:'awaiting_qualified_evidence',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}
718
788
  const data = JSON.stringify({
719
789
  sessionId,
790
+ ...(live ? {speakerIdentityPolicy:live.speakerIdentityPolicy,sourceType:live.sourceType,speakerGeneration:live.speakerGeneration,speakerRevision:live.speakerRevision,speakerProjectionSHA256:live.speakerProjectionSHA256,speakerProjectedChunksIndexed:live.speakerProjectedChunksIndexed,...(live.macSpeakerLive?{macSpeakerLive:live.macSpeakerLive}:{}),...('macSpeakerLiveRetainedState'in live?{macSpeakerLiveRetainedState:live.macSpeakerLiveRetainedState}:{}),...(('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined)?{macSpeakerLiveQuarantineSHA256:('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined)}:{}),...(session.macSpeakerLiveQuarantineSHA256?{macSpeakerLiveQuarantineSHA256:session.macSpeakerLiveQuarantineSHA256}:{}),macSpeakerLiveStatus:liveStatus} : {}),
720
791
  ...(PHONE_CAPTURE_ID.test(sessionId) ? { captureSource: requirePhoneRecording(sessionId), sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous' } : {}),
721
792
  startTime: session.startTime,
722
793
  lastActivityAt: session.lastActivityAt,
@@ -736,7 +807,11 @@ function persistSessionRequired(sessionId: string): void {
736
807
  ...(session.macWindowVoices !== undefined ? { macWindowVoices: session.macWindowVoices } : {}),
737
808
  })
738
809
  try {
810
+ if(certified&&session.macSpeakerLiveValidated){try{assertMacSpeakerLivePublicationBindings(session.macSpeakerLiveFileBindings??[])}catch{session.macSpeakerLiveValidated=false;session.macSpeakerLiveStatus={reason:'resource_binding_changed',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false};return persistSessionRequired(sessionId)}}
739
811
  durableAtomicWriteFileSync(filePath, data, { mode: 0o600 })
812
+ if(live){session.macSpeakerLiveStatus=liveStatus;if(projectionWithdrawn)session.macSpeakerLiveValidated=false;session.macSpeakerLive=live.macSpeakerLive??('macSpeakerLiveRetainedState'in live?live.macSpeakerLiveRetainedState as MacSpeakerLiveState:undefined);session.speakerRevision=live.speakerRevision;session.speakerProjectionSHA256=live.speakerProjectionSHA256
813
+ if(('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined))session.macSpeakerLiveQuarantineSHA256=('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined)
814
+ if(live.invalid)session.macSpeakerLiveValidated=false}
740
815
  } catch (error) {
741
816
  console.error(`[transcribe-stream] Durable session write failed for ${sessionId}: ${errMsg(error)}`)
742
817
  throw makeHttpError(503, 'meeting session persistence unavailable', 'session_persistence_failed')
@@ -918,6 +993,12 @@ function recoverSessions(): void {
918
993
  for (const key of Object.keys(emptyCompletions)) insertSortedUnique(asrCompletedIndices, Number(key))
919
994
  const session: TranscriptSession = {
920
995
  chunks,
996
+ ...(MAC_CAPTURE_ID.test(data.sessionId)&&data.speakerIdentityPolicy==='mac-native-v1'?{
997
+ speakerIdentityPolicy:'mac-native-v1' as const,speakerGeneration:typeof data.speakerGeneration==='string'&&data.speakerGeneration.length>0&&data.speakerGeneration.length<=128?data.speakerGeneration:randomUUID(),
998
+ speakerRevision:typeof data.speakerRevision==='number'?data.speakerRevision:Number.NaN,
999
+ speakerProjectionSHA256:data.speakerProjectionSHA256,macSpeakerLive:data.macSpeakerLiveRetainedState??data.macSpeakerLive,macSpeakerLiveValidated:false,
1000
+ macSpeakerLiveStatus:{reason:'recovery_revalidation_pending',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false},
1001
+ }:{}),
921
1002
  startTime: data.startTime,
922
1003
  title: data.title || '',
923
1004
  lastActivityAt,
@@ -957,6 +1038,20 @@ function recoverSessions(): void {
957
1038
  }
958
1039
  sessions.set(data.sessionId, session)
959
1040
  recoveredIds.add(data.sessionId)
1041
+ if(session.speakerIdentityPolicy==='mac-native-v1'){
1042
+ // Clear previously exposed claims durably before asynchronous model,
1043
+ // profile, ledger and every retained raw binding are revalidated.
1044
+ try{persistSessionRequired(data.sessionId)}catch(error){
1045
+ sessions.delete(data.sessionId);recoveredIds.delete(data.sessionId);rememberDeletedSession(data.sessionId)
1046
+ // Preserve evidence outside the directory read as active claims.
1047
+ // Do not adopt a capture whose mandatory withdrawal was not durable.
1048
+ ensurePrivateDirectory(STALE_ACTIVE_SESSION_DIR)
1049
+ renameSync(resolve(CHUNK_PERSIST_DIR,file),resolve(STALE_ACTIVE_SESSION_DIR,`${data.sessionId}.speaker-revalidation-${Date.now()}.json`))
1050
+ console.error(`[session-recovery] Speaker revalidation clear failed; capture quarantined: ${data.sessionId}`)
1051
+ continue
1052
+ }
1053
+ if(macSpeakerLiveEnabled())setImmediate(()=>macSpeakerLiveScheduler.schedule(data.sessionId))
1054
+ }
960
1055
  if (cleaned > 0) {
961
1056
  console.log(`[session-recovery] Cleaned inline hallucinations from ${cleaned} chunks`)
962
1057
  persistSessionBestEffort(data.sessionId) // re-persist cleaned data to disk
@@ -1269,30 +1364,7 @@ setInterval(() => {
1269
1364
  try {
1270
1365
  const batchBusy = ((maintenanceLifecycle.snapshot().activeByKind as Record<string, number>)
1271
1366
  .meeting_batch_finalization ?? 0) > 0
1272
- for (const dir of readdirSync(PENDING_BATCH_DIR)) {
1273
- const dirPath = resolve(PENDING_BATCH_DIR, dir)
1274
- try {
1275
- const files = readdirSync(dirPath)
1276
- if (files.length === 0) { rmSync(dirPath, { recursive: true, force: true }); continue }
1277
- // Prefer the marker file mtime over first chunk mtime
1278
- const markerPath = resolve(dirPath, '_batch_pending.marker')
1279
- let ageSource: number
1280
- if (existsSync(markerPath)) {
1281
- ageSource = statSync(markerPath).mtimeMs
1282
- } else {
1283
- // Fallback for pre-v5.4.3 dirs: use directory ctime (changes on rename)
1284
- ageSource = statSync(dirPath).ctimeMs
1285
- }
1286
- if (Date.now() - ageSource > 12 * 60 * 60 * 1000) {
1287
- if (batchBusy) {
1288
- console.warn(`[cleanup] Retaining stale pending-batch while batch lease held: ${dir}`)
1289
- continue
1290
- }
1291
- rmSync(dirPath, { recursive: true, force: true })
1292
- console.log(`[cleanup] Purged stale pending-batch: ${dir}`)
1293
- }
1294
- } catch {}
1295
- }
1367
+ sweepStalePendingBatch(PENDING_BATCH_DIR, { now: Date.now(), batchBusy })
1296
1368
  } catch {}
1297
1369
 
1298
1370
  // Meeting audio for review: 7-day window, then an 8 GB budget as a backstop.
@@ -1385,13 +1457,19 @@ setInterval(() => {
1385
1457
  }, 60_000)
1386
1458
 
1387
1459
  /** Get or create a transcript session */
1388
- export function getSession(sessionId: string): TranscriptSession {
1460
+ export function getSession(sessionId: string, admission: {newMacRegistry?:boolean} = {}): TranscriptSession {
1389
1461
  const phone = sessionId.startsWith('phone_') ? requirePhoneRecording(sessionId) : null
1390
1462
  let session = sessions.get(sessionId)
1391
1463
  if (!session) {
1464
+ // The durable registry proves this capture was already admitted. A missing
1465
+ // or unreadable active snapshot is not permission to reset its generation,
1466
+ // acoustic evidence or Voice namespace. Only the initial registry creator
1467
+ // may create a new Mac session; replayed starts must recover existing state.
1468
+ if(MAC_CAPTURE_ID.test(sessionId)&&readMacRecording(sessionId)&&!admission.newMacRegistry)throw macError(409,'mac_speaker_state_missing','Capture state is unavailable; preserve this recording and start a new capture')
1392
1469
  const now = Date.now()
1393
1470
  session = {
1394
1471
  chunks: [],
1472
+ ...(MAC_CAPTURE_ID.test(sessionId)&&macSpeakerLiveEnabled()?{speakerIdentityPolicy:'mac-native-v1' as const,speakerGeneration:randomUUID(),speakerRevision:0,macSpeakerLiveValidated:false}:{}),
1395
1473
  startTime: phone?.startTime ?? now,
1396
1474
  lastActivityAt: now,
1397
1475
  title: '',
@@ -2478,6 +2556,7 @@ async function processStreamChunk(opts: {
2478
2556
  const macWindowWithheld = macWindowOn ? macWindowWithheldReason(session.macWindowVoices, session.chunks) : null
2479
2557
  if (macWindowWithheld) noteMacWindowWithheld(sessionId, macWindowWithheld)
2480
2558
  const macWindowState = macWindowOn ? macWindowStateFor(session.macWindowVoices, session.chunks) : null
2559
+ if(macWindowState&&session.speakerIdentityPolicy==='mac-native-v1')macWindowState.voices=Math.max(macWindowState.voices,session.macSpeakerLive?.voiceHighWater??0)
2481
2560
  const macWindow = macWindowState
2482
2561
  ? startMacWindowDiarize({
2483
2562
  sessionId,
@@ -2510,7 +2589,8 @@ async function processStreamChunk(opts: {
2510
2589
  const whisperContext = recentWhisperContext(sessionId, session)
2511
2590
  console.log(`[perf] whisperContext: ${whisperContext.length}b | session.chunks: ${session.chunks.length}`)
2512
2591
 
2513
- const speakerPromise = Promise.resolve(identifyChunkSpeaker(audioBuffer, sessionId, chunkIndex, clientSpeaker, capturePolicy))
2592
+ const nativeIdentity = session.speakerIdentityPolicy === 'mac-native-v1'
2593
+ const speakerPromise = Promise.resolve(nativeIdentity ? {speaker:'Ext',similarity:0} : identifyChunkSpeaker(audioBuffer, sessionId, chunkIndex, clientSpeaker, capturePolicy))
2514
2594
 
2515
2595
  let rawText = ''
2516
2596
  let words: WhisperWord[] | undefined
@@ -2603,7 +2683,7 @@ async function processStreamChunk(opts: {
2603
2683
  owner: ownerLabelOrNull(),
2604
2684
  ...(macWindow ? {
2605
2685
  anonymousChannel: (channel: number) => macWindow.result()?.labelOf(channel) ?? null,
2606
- } : MAC_CAPTURE_ID.test(sessionId) && macLiveVoicesMode() === 'anchors' ? {
2686
+ } : !nativeIdentity && MAC_CAPTURE_ID.test(sessionId) && macLiveVoicesMode() === 'anchors' ? {
2607
2687
  anonymousVoice: (wav: Buffer) => {
2608
2688
  // Fail closed after an upgrade/recovery lacking acoustic anchors:
2609
2689
  // never reuse Voice 1 for an unrelated person from a later chunk.
@@ -2614,7 +2694,7 @@ async function processStreamChunk(opts: {
2614
2694
  } : {}),
2615
2695
  identify: wav => {
2616
2696
  const policy = capturePolicy
2617
- if (policy === 'anonymous') return { speaker: 'Ext', similarity: 0 }
2697
+ if (nativeIdentity || policy === 'anonymous') return { speaker: 'Ext', similarity: 0 }
2618
2698
  // 6.90.0: a Mac track is never checked against the wearer first (the wearer
2619
2699
  // is not in Mac playback) and is tagged so its calibration rows are separable.
2620
2700
  const mac = MAC_CAPTURE_ID.test(sessionId)
@@ -2715,6 +2795,7 @@ async function processStreamChunk(opts: {
2715
2795
  console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
2716
2796
 
2717
2797
  scheduleSessionProgressiveHq(sessionId)
2798
+ if(nativeIdentity)macSpeakerLiveScheduler.schedule(sessionId)
2718
2799
 
2719
2800
  emitDisplay({
2720
2801
  type: 'transcript_chunk',
@@ -2725,7 +2806,7 @@ async function processStreamChunk(opts: {
2725
2806
  // bare `void` on a rejecting promise is an unhandled rejection, which Node
2726
2807
  // throws on by default. All gates live inside the feed.
2727
2808
  // The ASR await above lets a late chunk land after Stop or save; a closed session never feeds cues.
2728
- if (!isSessionDeleted(sessionId)) void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
2809
+ if (!isSessionDeleted(sessionId)) void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason), [speaker, ...(chunk.segments ?? []).map(segment => segment.speaker)]).catch(() => {})
2729
2810
 
2730
2811
  // 6.85.0: timing and the character count only. This line runs for every chunk, so the meeting
2731
2812
  // text itself never goes to the server log (transcribe-stream-log-privacy.test.ts).
@@ -2884,7 +2965,7 @@ transcribeStreamRouter.post('/mac-recording/start', (req, res) => {
2884
2965
  if (sessions.has(req.body.captureId) && !readMacRecording(req.body.captureId)) throw macError(409, 'mac_capture_conflict', 'Capture ID already exists')
2885
2966
  lease = acquireRecordingChunkLease({ allowDuringDrain: sessions.has(req.body.captureId) })
2886
2967
  const { recording, replayed } = registerMacRecording(req.body)
2887
- const session = getSession(recording.sessionId)
2968
+ const session = getSession(recording.sessionId,{newMacRegistry:!replayed})
2888
2969
  if (!session.chunks.some(Boolean)) session.startTime = recording.startTime
2889
2970
  persistSessionRequired(recording.sessionId)
2890
2971
  // Arming happens only after the recording is admitted, and its answer never fails the start (armMacLiveCues returns a
@@ -2920,7 +3001,7 @@ transcribeStreamRouter.post('/mac-recording/:captureId/chunks', async (req, res)
2920
3001
  const prior = session.chunks[chunkIndex] ?? session.emptyCompletions?.[String(chunkIndex)]
2921
3002
  if (prior?.audioSha256 && prior.audioSha256 !== sha256Hex(audio)) throw macError(409, 'mac_chunk_conflict', 'Chunk index already holds different audio')
2922
3003
  res.json(await processStreamChunk({ sessionId: id, chunkIndex, clientSpeaker: 'Ext', audioBuffer: audio, clientElapsed: elapsed - duration, audioDurationMs: duration, startTimeOverride: recording.startTime }))
2923
- } catch (error) { sendStreamError(res, error) } finally { if (locked) macChunkLocks.delete(id); lease?.release() }
3004
+ } catch (error) { sendStreamError(res, error) } finally { if (locked) {macChunkLocks.delete(id);if(sessions.get(id)?.speakerIdentityPolicy==='mac-native-v1')macSpeakerLiveScheduler.schedule(id)}; lease?.release() }
2924
3005
  })
2925
3006
 
2926
3007
  const phoneChunkLocks = new Set<string>()
@@ -2,9 +2,11 @@
2
2
 
3
3
  import express, { Router } from 'express'
4
4
  import { errMsg } from '../lib/utils.js'
5
+ import { forgetMacVoiceNamingPerson } from '../lib/mac-voice-naming-batch.js'
5
6
  import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
6
7
  import { resolve } from 'node:path'
7
8
  import { checkSpeakerName } from '../lib/speaker-name.js'
9
+ import { bodyName, nfc, sameSpeakerName } from '../lib/speaker-name-nfc.js'
8
10
  import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles, mergeSpeakerProfiles } from '../lib/speaker-embeddings.js'
9
11
  import { statSync } from 'node:fs'
10
12
  import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
@@ -51,7 +53,9 @@ voiceRouter.post('/voice/enroll', async (req, res) => {
51
53
  // Default to the configured wearer label ('Me' unless owner_speaker_label
52
54
  // is set). Hardcoding one user's initials here enrolled every other install
53
55
  // under a stranger's name.
54
- const name = (req.query.name as string) || getOwnerSpeakerLabel()
56
+ // `?name=a&name=b` is not a name; falling through to the owner's label would enroll a stranger's voice as the owner.
57
+ if (req.query.name !== undefined && typeof req.query.name !== 'string') return res.status(400).json({ error: 'name must be a single value' })
58
+ const name = nfc(typeof req.query.name === 'string' && req.query.name ? req.query.name : getOwnerSpeakerLabel())
55
59
 
56
60
  // An old client can still send a whole spoken sentence as the name (the
57
61
  // "enroll my voice" fall-through). Refuse it here: a junk profile can
@@ -115,7 +119,8 @@ voiceRouter.post('/voice/identify', async (req, res) => {
115
119
  // POST /api/voice/train — train voiceprints from Fireflies meeting audio
116
120
  voiceRouter.post('/voice/train', async (req, res) => {
117
121
  try {
118
- const { speakerNames, minSegments, minSegmentDuration, limit, maxEmbeddingsPerSpeaker, fresh } = req.body ?? {}
122
+ const { speakerNames: rawSpeakerNames, minSegments, minSegmentDuration, limit, maxEmbeddingsPerSpeaker, fresh } = req.body ?? {}
123
+ const speakerNames = Array.isArray(rawSpeakerNames) ? rawSpeakerNames.map((n: unknown) => typeof n === 'string' ? nfc(n) : n) : rawSpeakerNames
119
124
  const report = await trainFromFireflies({
120
125
  speakerNames,
121
126
  minSegments,
@@ -160,7 +165,7 @@ const DEFAULT_MAX_TRAIN_PER_SPEAKER = 10
160
165
 
161
166
  voiceRouter.post('/voice/train-g2', async (req, res) => {
162
167
  try {
163
- const targetSpeaker = req.body?.speaker as string | undefined
168
+ const targetSpeaker = typeof req.body?.speaker === 'string' ? nfc(req.body.speaker) : undefined
164
169
  const confirmAll = req.body?.confirmAllSpeakers === true
165
170
  const dryRun = req.body?.dryRun === true
166
171
  const maxPerSpeaker = Number.isFinite(req.body?.maxPerSpeaker)
@@ -361,7 +366,8 @@ voiceRouter.get('/voice/ext-audio', (_req, res) => {
361
366
  // If sessionId provided, only enroll from that session. Otherwise, enroll from all ext sessions.
362
367
  voiceRouter.post('/voice/enroll-ext', async (req, res) => {
363
368
  try {
364
- const { name, sessionId } = req.body ?? {}
369
+ const { name: rawName, sessionId } = req.body ?? {}
370
+ const name = typeof rawName === 'string' ? nfc(rawName) : rawName
365
371
  if (!name || typeof name !== 'string' || name.length < 2) {
366
372
  return res.status(400).json({ error: 'name is required (min 2 chars)' })
367
373
  }
@@ -485,7 +491,7 @@ function newestWav(dirPath: string): string | null {
485
491
  // GET /api/voice/profiles/:name/sample — hear what a stored profile sounds like.
486
492
  voiceRouter.get('/voice/profiles/:name/sample', (req, res) => {
487
493
  res.set('Cache-Control', 'private, no-store')
488
- const name = String(req.params.name ?? '')
494
+ const name = nfc(String(req.params.name ?? ''))
489
495
  const dirPath = speakerDirPath(AUDIO_SAVE_DIR, name)
490
496
  if (!dirPath) {
491
497
  res.status(400).json({ error: 'Invalid speaker name', reason: 'invalid_speaker' })
@@ -587,7 +593,7 @@ voiceRouter.post('/voice/held-groups/enroll', async (req, res) => {
587
593
  try {
588
594
  const nameCheck = checkSpeakerName(req.body?.name, { ownerLabel: getOwnerSpeakerLabel() })
589
595
  if (!nameCheck.ok) return res.status(400).json({ success:false,error:nameCheck.message,reason:nameCheck.reason })
590
- const name = resolveStoredName(String(req.body.name).trim())
596
+ const name = resolveStoredName(nfc(String(req.body.name).trim()))
591
597
  // Held samples are audio this Mac captured. A member pointing at an imported
592
598
  // or derived record is not held audio, so it never reaches the preview.
593
599
  //
@@ -679,7 +685,7 @@ voiceRouter.get('/voice/profiles', (_req, res) => {
679
685
  return {
680
686
  name: p.name,
681
687
  embeddings: p.embeddings.length,
682
- isOwner: p.name === owner,
688
+ isOwner: sameSpeakerName(p.name, owner),
683
689
  sources: bySource,
684
690
  // 6.44.15: a profile whose name on disk was a spoken sentence was
685
691
  // renamed at load; the client offers a rename.
@@ -793,21 +799,23 @@ function fanOutTotals(runs: readonly SpeakerRenameFanOut[]): {
793
799
  // knows something the audio does not, and it is logged.
794
800
  voiceRouter.post('/voice/merge-profiles', async (req, res) => {
795
801
  try {
796
- const into = typeof req.body?.into === 'string' ? req.body.into.trim() : ''
802
+ const into = bodyName(req.body?.into).trim()
797
803
  const rawFrom = req.body?.from
798
804
  const from = (Array.isArray(rawFrom) ? rawFrom : [rawFrom])
799
805
  .filter((n: unknown): n is string => typeof n === 'string' && n.trim().length > 0)
800
- .map((n: string) => n.trim())
806
+ .map((n: string) => nfc(n.trim()))
801
807
 
802
808
  if (!into || from.length === 0) {
803
809
  return res.status(400).json({ error: 'into (string) and from (string or string[]) are required' })
804
810
  }
805
- if (from.includes(into)) {
811
+ // The same name twice is refused, unless the store holds it twice (a profile written decomposed and its composed twin): those are two
812
+ // profiles and merging them is the point.
813
+ if (from.some(n => sameSpeakerName(n, into)) && readVoiceProfiles().profiles.filter(p => sameSpeakerName(p.name, into)).length < 2) {
806
814
  return res.status(400).json({ error: 'into and from must differ' })
807
815
  }
808
816
 
809
817
  const owner = getOwnerSpeakerLabel()
810
- if (from.includes(owner)) {
818
+ if (from.some(n => sameSpeakerName(n, owner))) {
811
819
  // Absorbing the owner label would delete the profile the live
812
820
  // identification path checks FIRST, on every chunk.
813
821
  return res.status(400).json({
@@ -907,11 +915,11 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
907
915
  if (!name || typeof name !== 'string' || name.trim().length < 2) {
908
916
  return res.status(400).json({ error: 'name is required (min 2 chars)' })
909
917
  }
910
- const target = name.trim()
918
+ const target = nfc(name.trim())
911
919
  const dryRun = req.body?.dryRun === true
912
920
 
913
921
  if (req.body?.confirm !== true && !dryRun) {
914
- const existing = readVoiceProfiles().profiles.find(p => p.name === target)
922
+ const existing = readVoiceProfiles().profiles.filter(p => sameSpeakerName(p.name, target))
915
923
  const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
916
924
  let wavs = 0
917
925
  if (audioDir && existsSync(audioDir)) {
@@ -922,8 +930,8 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
922
930
  error: 'confirmation required',
923
931
  message: `Deleting "${target}" is not reversible. Pass { confirm: true } to proceed.`,
924
932
  wouldRemove: {
925
- profile: existing ? 1 : 0,
926
- embeddings: existing?.embeddings.length ?? 0,
933
+ profile: existing.length,
934
+ embeddings: existing.reduce((n, p) => n + p.embeddings.length, 0),
927
935
  trainingAudioFiles: wavs,
928
936
  calibrationRows: calibration.removed,
929
937
  },
@@ -931,7 +939,7 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
931
939
  }
932
940
 
933
941
  if (dryRun) {
934
- const existing = readVoiceProfiles().profiles.find(p => p.name === target)
942
+ const existing = readVoiceProfiles().profiles.filter(p => sameSpeakerName(p.name, target))
935
943
  const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
936
944
  let wavs = 0
937
945
  if (audioDir && existsSync(audioDir)) {
@@ -942,8 +950,8 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
942
950
  name: target,
943
951
  dryRun: true,
944
952
  removed: {
945
- profiles: existing ? 1 : 0,
946
- embeddings: existing?.embeddings.length ?? 0,
953
+ profiles: existing.length,
954
+ embeddings: existing.reduce((n, p) => n + p.embeddings.length, 0),
947
955
  trainingAudioFiles: wavs,
948
956
  calibrationRows: calibration.removed,
949
957
  },
@@ -952,6 +960,8 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
952
960
 
953
961
  // 1. Voice profile + sherpa manager registration.
954
962
  const profileResult = removeSpeakerProfile(target)
963
+ // A deleted person's voiceprints must not survive in a naming batch's Undo journal.
964
+ forgetMacVoiceNamingPerson(target)
955
965
 
956
966
  // 2. Saved G2 training audio for this person.
957
967
  let trainingAudioFiles = 0