@gotcos/glasses-server 6.94.1 → 6.94.3

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 (40) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +27 -0
  3. package/package.json +2 -1
  4. package/server/index.ts +6 -0
  5. package/server/lib/decision-provider.ts +4 -2
  6. package/server/lib/held-naming-batches.ts +8 -7
  7. package/server/lib/held-voice-groups.ts +5 -4
  8. package/server/lib/keyed-sessions.ts +21 -1
  9. package/server/lib/mac-speaker-machine-evidence.ts +111 -0
  10. package/server/lib/mac-speaker-profile-extractor.ts +41 -0
  11. package/server/lib/mac-speaker-profile-finalizer.ts +257 -0
  12. package/server/lib/mac-speaker-protection.ts +22 -0
  13. package/server/lib/mac-speaker-reconcile.ts +300 -0
  14. package/server/lib/mac-speaker-repair-projection.ts +301 -0
  15. package/server/lib/mac-speaker-repair-render.ts +105 -0
  16. package/server/lib/mac-speaker-windows.ts +239 -0
  17. package/server/lib/mac-voice-assignment.ts +2 -1
  18. package/server/lib/meeting-corrections.ts +2 -1
  19. package/server/lib/meeting-relabel-enrolment.ts +2 -1
  20. package/server/lib/meeting-relabel.ts +17 -10
  21. package/server/lib/meeting-scribe-content.ts +8 -1
  22. package/server/lib/meeting-speaker-review.ts +46 -7
  23. package/server/lib/nemotron-final.ts +64 -14
  24. package/server/lib/session-find-ask.ts +281 -0
  25. package/server/lib/session-find-collect.ts +715 -0
  26. package/server/lib/session-find-redact.ts +254 -0
  27. package/server/lib/session-find.ts +345 -0
  28. package/server/lib/speaker-calibration-log.ts +3 -2
  29. package/server/lib/speaker-embeddings.ts +46 -35
  30. package/server/lib/speaker-name-nfc.ts +37 -0
  31. package/server/lib/speaker-rename-fanout.ts +7 -6
  32. package/server/lib/speaker-trainer.ts +2 -1
  33. package/server/lib/voice-directory.ts +7 -2
  34. package/server/lib/voice-profile-store.ts +24 -14
  35. package/server/routes/decision-provider.ts +4 -1
  36. package/server/routes/jev.ts +5 -1
  37. package/server/routes/meeting.ts +43 -15
  38. package/server/routes/session-find.ts +73 -0
  39. package/server/routes/voice.ts +25 -18
  40. package/server/scripts/mac-speaker-repair.ts +324 -0
@@ -1,6 +1,9 @@
1
+ import { runQualifiedMacProfilePass } from '../lib/mac-speaker-profile-finalizer.js'
2
+ import { readMacSpeakerMachineEvidence } from '../lib/mac-speaker-machine-evidence.js'
1
3
  import { dataPath } from '../lib/data-dir.js'
2
4
  import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, assertPhoneProvenance, readPhoneRecording, requirePhoneRecording, closePhoneRecording } from '../lib/phone-recording.js'
3
5
  import { MAC_CAPTURE_ID, readMacRecording, requireMacRecording, macChunkReceipt, closeMacRecording } from '../lib/mac-recording.js'
6
+ import { bodyName, sameRecordId, sameSpeakerName } from '../lib/speaker-name-nfc.js'
4
7
  import { readSpeakerPolicy, writeSpeakerPolicy, isSpeakerMode } from '../lib/meeting-speaker-policy.js'
5
8
  // POST /api/meeting/save — finalize an existing transcribe-stream session into
6
9
  // the standalone public meeting store. The live transcript and chunk metadata
@@ -259,7 +262,7 @@ function markdownUnreadableReason(operations: { mergedScribeConflict?: string[]
259
262
  }
260
263
 
261
264
  function requestedRecordMatches(requested: unknown, expected: string): boolean {
262
- return requested == null || requested === '' || requested === expected
265
+ return requested == null || requested === '' || sameRecordId(requested, expected)
263
266
  }
264
267
 
265
268
  interface MeetingSessionSource {
@@ -309,6 +312,7 @@ export interface MeetingRouteDependencies {
309
312
  emit?: typeof emitDisplay
310
313
  finalizationJobs?: MeetingFinalizationJobStore
311
314
  finalDiarize?: FinalDiarize
315
+ qualifiedProfilePass?: typeof runQualifiedMacProfilePass
312
316
  /** Control's stop requests for live G2 meetings (lib/meeting-stop-requests.ts). */
313
317
  stopRequests?: StopRequestStore
314
318
  /** 6.77.0: the post-save transcript cleanup (lib/transcript-cleanup.ts). */
@@ -334,6 +338,7 @@ interface FinalizationRuntime {
334
338
  scheduleBackground: (task: Promise<void>) => void
335
339
  sessions?: MeetingSessionSource
336
340
  finalDiarize?: FinalDiarize
341
+ qualifiedProfilePass?: typeof runQualifiedMacProfilePass
337
342
  transcriptCleanup?: TranscriptCleanupRunner
338
343
  }
339
344
 
@@ -367,7 +372,7 @@ function validReplayEntries(raw: unknown[]): IndexedTranscriptChunk[] | null {
367
372
  return entries.length > 0 ? entries : null
368
373
  }
369
374
 
370
- function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: FinalizationRuntime): void {
375
+ export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: FinalizationRuntime): void {
371
376
  const key = `${runtime.finalizationJobs.root}:${job.sessionId}`
372
377
  if (activeFinalizationJobs.has(key)) return
373
378
  activeFinalizationJobs.add(key)
@@ -452,8 +457,12 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
452
457
  sessionId: current.sessionId,
453
458
  runBatch: runtime.runBatch,
454
459
  finalDiarize: runtime.finalDiarize ?? runNemotronFinalPass,
460
+ qualifiedProfilePass: runtime.qualifiedProfilePass,
455
461
  })
456
462
  } else {
463
+ // A torn profile transaction must block filing even if pending audio
464
+ // vanished. Missing audio never waives journal/proof recovery gates.
465
+ if (MAC_CAPTURE_ID.test(current.sessionId)) await (runtime.qualifiedProfilePass ?? runQualifiedMacProfilePass)({ meetingPath: current.meetingPath, sidecarPath: current.sidecarPath, audioDir: current.audioDir ?? '', resumeOnly: true })
457
466
  console.warn(`[meeting/save] Pending HQ audio missing for ${current.sessionId}; preserving streaming canonical`)
458
467
  }
459
468
  current = runtime.finalizationJobs.save({
@@ -720,6 +729,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
720
729
  const emit = deps.emit ?? emitDisplay
721
730
  const finalizationJobs = deps.finalizationJobs ?? new MeetingFinalizationJobStore()
722
731
  const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
732
+ const qualifiedProfilePass = deps.qualifiedProfilePass ?? runQualifiedMacProfilePass
723
733
  const transcriptCleanup = deps.transcriptCleanup
724
734
  const router = Router()
725
735
  const captureActive = deps.captureActive ?? (() => getTranscriptionSessionLiveness().live > 0)
@@ -1206,6 +1216,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1206
1216
  scheduleBackground,
1207
1217
  sessions,
1208
1218
  finalDiarize,
1219
+ qualifiedProfilePass,
1209
1220
  })
1210
1221
  if (origin === 'phone') notePhoneAfterAutonomousSave(sessionId, 'save_replay')
1211
1222
  respond(200, publicSaveResponse(alreadySaved, true), true)
@@ -1403,6 +1414,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1403
1414
  scheduleBackground,
1404
1415
  sessions,
1405
1416
  finalDiarize,
1417
+ qualifiedProfilePass,
1406
1418
  transcriptCleanup,
1407
1419
  })
1408
1420
  }
@@ -1659,9 +1671,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1659
1671
  const filename = operations?.filename ?? direct?.filename ?? saved!.filename
1660
1672
 
1661
1673
  let chunks: unknown
1674
+ let sidecar: Record<string, unknown>
1662
1675
  try {
1663
- const raw = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
1664
- chunks = Array.isArray(raw) ? raw : raw.chunks
1676
+ sidecar = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
1677
+ chunks = Array.isArray(sidecar) ? sidecar : sidecar.chunks
1665
1678
  } catch {
1666
1679
  // Defensive: both lookups above already read this sidecar to match the
1667
1680
  // session, so a corrupt file 404s and never reaches here. This covers the
@@ -1678,8 +1691,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1678
1691
 
1679
1692
  // Raw capture indices, so a phrase can address its own audio. Position in
1680
1693
  // `chunks` is NOT the WAV number — see attachRawChunkIndices.
1681
- const sidecar = (JSON.parse(readFileSync(sidecarPath, 'utf-8')) ?? {}) as Record<string, unknown>
1682
1694
  const withIndices = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
1695
+ const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
1683
1696
  const review = reviewMeetingSpeakers(withIndices, {
1684
1697
  owner: getOwnerSpeakerLabel(),
1685
1698
  // Labels a human already vouched for in this meeting. Without this the
@@ -1687,6 +1700,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1687
1700
  // confirms the same voice forever.
1688
1701
  confirmed: confirmedLabels(sessionId),
1689
1702
  confirmedChunks: confirmedChunks(sessionId),
1703
+ machineQualifiedPositions: machineEvidence.positions,
1704
+ machineEvidenceStatus: machineEvidence.status,
1690
1705
  phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
1691
1706
  // The sidecar's own durationMs is the meeting's true end. Deriving it from
1692
1707
  // max(elapsed) uses the START of the last chunk, which made the final
@@ -1791,10 +1806,13 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1791
1806
  }
1792
1807
  const chunks = rawChunks as ReviewChunk[]
1793
1808
 
1809
+ const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
1794
1810
  const review = reviewMeetingSpeakers(attachRawChunkIndices(chunks, sidecar.chunkEntries), {
1795
1811
  owner: getOwnerSpeakerLabel(),
1796
1812
  confirmed: confirmedLabels(sessionId),
1797
1813
  confirmedChunks: confirmedChunks(sessionId),
1814
+ machineQualifiedPositions: machineEvidence.positions,
1815
+ machineEvidenceStatus: machineEvidence.status,
1798
1816
  // This route renders no phrases; the default of 3 per voice computed and
1799
1817
  // discarded 48 transcript excerpts on a 16-voice meeting.
1800
1818
  phrasesPerVoice: 1,
@@ -1818,6 +1836,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1818
1836
  // "voice matching" for the wearer exemption and for names a human typed.
1819
1837
  isOwner: v.isOwner,
1820
1838
  confirmedByHuman: v.confirmedByHuman,
1839
+ machineQualified: v.machineQualified,
1840
+ machineQualifiedSegments: v.machineQualifiedSegments,
1821
1841
  }))
1822
1842
 
1823
1843
  // A missing .md is not fatal — the review and the sidecar still describe the
@@ -1948,7 +1968,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1948
1968
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1949
1969
  return
1950
1970
  }
1951
- const speaker = typeof req.body?.speaker === 'string' ? req.body.speaker.trim() : ''
1971
+ const speaker = bodyName(req.body?.speaker).trim()
1952
1972
  if (!speaker) {
1953
1973
  res.status(400).json({ error: 'speaker is required', reason: 'invalid_label' })
1954
1974
  return
@@ -1981,7 +2001,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1981
2001
  }
1982
2002
 
1983
2003
  // Only rows that ACTUALLY landed, and only those that named this speaker.
1984
- const rows = appliedCorrections(sessionId).filter(r => r.to === speaker && r.chunks.length > 0)
2004
+ const rows = appliedCorrections(sessionId).filter(r => sameSpeakerName(r.to, speaker) && r.chunks.length > 0)
1985
2005
  if (rows.length === 0) {
1986
2006
  res.status(404).json({ error: `No applied correction named "${speaker}" in this meeting`, reason: 'no_correction' })
1987
2007
  return
@@ -2028,8 +2048,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2028
2048
  return
2029
2049
  }
2030
2050
 
2031
- const from = typeof req.body?.from === 'string' ? req.body.from : ''
2032
- const to = typeof req.body?.to === 'string' ? req.body.to : ''
2051
+ const from = bodyName(req.body?.from)
2052
+ const to = bodyName(req.body?.to)
2033
2053
  const chunks = Array.isArray(req.body?.chunks)
2034
2054
  ? (req.body.chunks as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0)
2035
2055
  : []
@@ -2382,7 +2402,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2382
2402
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
2383
2403
  return
2384
2404
  }
2385
- const label = typeof req.body?.label === 'string' ? req.body.label : ''
2405
+ const label = bodyName(req.body?.label)
2386
2406
  const bad = invalidLabelReason(label)
2387
2407
  if (bad) {
2388
2408
  res.status(400).json({ error: `label: ${bad}`, reason: 'invalid_label' })
@@ -2430,7 +2450,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2430
2450
  const doc = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
2431
2451
  const rows = Array.isArray(doc.chunks) ? doc.chunks : []
2432
2452
  carried = rows.filter(r => r && typeof r === 'object'
2433
- && (r as Record<string, unknown>).speaker === label).length
2453
+ && sameSpeakerName((r as Record<string, unknown>).speaker, label)).length
2434
2454
  } catch {
2435
2455
  res.status(500).json({ error: 'Could not read the chunk sidecar', reason: 'sidecar_unreadable' })
2436
2456
  return
@@ -2473,7 +2493,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2473
2493
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
2474
2494
  return
2475
2495
  }
2476
- const from = typeof req.body?.from === 'string' ? req.body.from : ''
2496
+ const from = bodyName(req.body?.from)
2477
2497
  const bad = invalidLabelReason(from)
2478
2498
  if (bad) {
2479
2499
  res.status(400).json({ error: `from: ${bad}`, reason: 'invalid_label' })
@@ -2549,7 +2569,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2549
2569
  if (Array.isArray(parsed.chunks)) {
2550
2570
  for (const c of parsed.chunks) {
2551
2571
  const sp = (c as { speaker?: unknown } | null)?.speaker
2552
- if (typeof sp === 'string' && !existingSpeakers.includes(sp)) existingSpeakers.push(sp)
2572
+ if (typeof sp === 'string' && !existingSpeakers.some(x => sameSpeakerName(x, sp))) existingSpeakers.push(sp)
2553
2573
  }
2554
2574
  }
2555
2575
  } catch { /* the relabel below reports a corrupt sidecar */ }
@@ -2562,7 +2582,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2562
2582
 
2563
2583
  // What this would remove from the profile, and what it CANNOT reach.
2564
2584
  // readVoiceProfiles returns a ProfileStore, not an array.
2565
- const profile = readVoiceProfiles().profiles.find(p => p.name === from)
2585
+ const profile = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, from))
2566
2586
  const profileSources: Array<string | undefined> = profile
2567
2587
  ? Array.from({ length: profile.embeddings.length }, (_, i) => profile.sources?.[i])
2568
2588
  : []
@@ -3152,7 +3172,7 @@ function synthesizeEntriesFromChunkWavs(audioDir: string): RecoveredCapture {
3152
3172
  return { entries, startTime, durationMs }
3153
3173
  }
3154
3174
 
3155
- async function finalizeBatch(options: {
3175
+ export async function finalizeBatch(options: {
3156
3176
  audioDir: string
3157
3177
  entries: IndexedTranscriptChunk[]
3158
3178
  streamingWordCount: number
@@ -3161,7 +3181,11 @@ async function finalizeBatch(options: {
3161
3181
  sessionId?: string
3162
3182
  runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
3163
3183
  finalDiarize?: FinalDiarize
3184
+ qualifiedProfilePass?: typeof runQualifiedMacProfilePass
3164
3185
  }): Promise<void> {
3186
+ const qualifiedProfilePass = options.qualifiedProfilePass ?? runQualifiedMacProfilePass
3187
+ const mac = MAC_CAPTURE_ID.test(options.sessionId ?? '')
3188
+ if (mac && (await qualifiedProfilePass({ ...options, resumeOnly: true })).status === 'applied') return
3165
3189
  const result = await options.runBatch(
3166
3190
  options.audioDir,
3167
3191
  options.entries,
@@ -3226,6 +3250,10 @@ async function finalizeBatch(options: {
3226
3250
  }
3227
3251
  }
3228
3252
 
3253
+ // Exact qualified profile evidence runs before raw deletion and filing.
3254
+ // Prepared-transaction failures propagate: mixed generations cannot be filed.
3255
+ if (mac && metadataPersisted) await qualifiedProfilePass(options)
3256
+
3229
3257
  if (canDeletePendingBatchAudio(transcriptApplied, metadataPersisted)) {
3230
3258
  rmSync(options.audioDir, { recursive: true, force: true })
3231
3259
  } else {
@@ -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 }
@@ -5,6 +5,7 @@ import { errMsg } from '../lib/utils.js'
5
5
  import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
6
6
  import { resolve } from 'node:path'
7
7
  import { checkSpeakerName } from '../lib/speaker-name.js'
8
+ import { bodyName, nfc, sameSpeakerName } from '../lib/speaker-name-nfc.js'
8
9
  import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles, mergeSpeakerProfiles } from '../lib/speaker-embeddings.js'
9
10
  import { statSync } from 'node:fs'
10
11
  import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
@@ -51,7 +52,9 @@ voiceRouter.post('/voice/enroll', async (req, res) => {
51
52
  // Default to the configured wearer label ('Me' unless owner_speaker_label
52
53
  // is set). Hardcoding one user's initials here enrolled every other install
53
54
  // under a stranger's name.
54
- const name = (req.query.name as string) || getOwnerSpeakerLabel()
55
+ // `?name=a&name=b` is not a name; falling through to the owner's label would enroll a stranger's voice as the owner.
56
+ if (req.query.name !== undefined && typeof req.query.name !== 'string') return res.status(400).json({ error: 'name must be a single value' })
57
+ const name = nfc(typeof req.query.name === 'string' && req.query.name ? req.query.name : getOwnerSpeakerLabel())
55
58
 
56
59
  // An old client can still send a whole spoken sentence as the name (the
57
60
  // "enroll my voice" fall-through). Refuse it here: a junk profile can
@@ -115,7 +118,8 @@ voiceRouter.post('/voice/identify', async (req, res) => {
115
118
  // POST /api/voice/train — train voiceprints from Fireflies meeting audio
116
119
  voiceRouter.post('/voice/train', async (req, res) => {
117
120
  try {
118
- const { speakerNames, minSegments, minSegmentDuration, limit, maxEmbeddingsPerSpeaker, fresh } = req.body ?? {}
121
+ const { speakerNames: rawSpeakerNames, minSegments, minSegmentDuration, limit, maxEmbeddingsPerSpeaker, fresh } = req.body ?? {}
122
+ const speakerNames = Array.isArray(rawSpeakerNames) ? rawSpeakerNames.map((n: unknown) => typeof n === 'string' ? nfc(n) : n) : rawSpeakerNames
119
123
  const report = await trainFromFireflies({
120
124
  speakerNames,
121
125
  minSegments,
@@ -160,7 +164,7 @@ const DEFAULT_MAX_TRAIN_PER_SPEAKER = 10
160
164
 
161
165
  voiceRouter.post('/voice/train-g2', async (req, res) => {
162
166
  try {
163
- const targetSpeaker = req.body?.speaker as string | undefined
167
+ const targetSpeaker = typeof req.body?.speaker === 'string' ? nfc(req.body.speaker) : undefined
164
168
  const confirmAll = req.body?.confirmAllSpeakers === true
165
169
  const dryRun = req.body?.dryRun === true
166
170
  const maxPerSpeaker = Number.isFinite(req.body?.maxPerSpeaker)
@@ -361,7 +365,8 @@ voiceRouter.get('/voice/ext-audio', (_req, res) => {
361
365
  // If sessionId provided, only enroll from that session. Otherwise, enroll from all ext sessions.
362
366
  voiceRouter.post('/voice/enroll-ext', async (req, res) => {
363
367
  try {
364
- const { name, sessionId } = req.body ?? {}
368
+ const { name: rawName, sessionId } = req.body ?? {}
369
+ const name = typeof rawName === 'string' ? nfc(rawName) : rawName
365
370
  if (!name || typeof name !== 'string' || name.length < 2) {
366
371
  return res.status(400).json({ error: 'name is required (min 2 chars)' })
367
372
  }
@@ -485,7 +490,7 @@ function newestWav(dirPath: string): string | null {
485
490
  // GET /api/voice/profiles/:name/sample — hear what a stored profile sounds like.
486
491
  voiceRouter.get('/voice/profiles/:name/sample', (req, res) => {
487
492
  res.set('Cache-Control', 'private, no-store')
488
- const name = String(req.params.name ?? '')
493
+ const name = nfc(String(req.params.name ?? ''))
489
494
  const dirPath = speakerDirPath(AUDIO_SAVE_DIR, name)
490
495
  if (!dirPath) {
491
496
  res.status(400).json({ error: 'Invalid speaker name', reason: 'invalid_speaker' })
@@ -587,7 +592,7 @@ voiceRouter.post('/voice/held-groups/enroll', async (req, res) => {
587
592
  try {
588
593
  const nameCheck = checkSpeakerName(req.body?.name, { ownerLabel: getOwnerSpeakerLabel() })
589
594
  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())
595
+ const name = resolveStoredName(nfc(String(req.body.name).trim()))
591
596
  // Held samples are audio this Mac captured. A member pointing at an imported
592
597
  // or derived record is not held audio, so it never reaches the preview.
593
598
  //
@@ -679,7 +684,7 @@ voiceRouter.get('/voice/profiles', (_req, res) => {
679
684
  return {
680
685
  name: p.name,
681
686
  embeddings: p.embeddings.length,
682
- isOwner: p.name === owner,
687
+ isOwner: sameSpeakerName(p.name, owner),
683
688
  sources: bySource,
684
689
  // 6.44.15: a profile whose name on disk was a spoken sentence was
685
690
  // renamed at load; the client offers a rename.
@@ -793,21 +798,23 @@ function fanOutTotals(runs: readonly SpeakerRenameFanOut[]): {
793
798
  // knows something the audio does not, and it is logged.
794
799
  voiceRouter.post('/voice/merge-profiles', async (req, res) => {
795
800
  try {
796
- const into = typeof req.body?.into === 'string' ? req.body.into.trim() : ''
801
+ const into = bodyName(req.body?.into).trim()
797
802
  const rawFrom = req.body?.from
798
803
  const from = (Array.isArray(rawFrom) ? rawFrom : [rawFrom])
799
804
  .filter((n: unknown): n is string => typeof n === 'string' && n.trim().length > 0)
800
- .map((n: string) => n.trim())
805
+ .map((n: string) => nfc(n.trim()))
801
806
 
802
807
  if (!into || from.length === 0) {
803
808
  return res.status(400).json({ error: 'into (string) and from (string or string[]) are required' })
804
809
  }
805
- if (from.includes(into)) {
810
+ // The same name twice is refused, unless the store holds it twice (a profile written decomposed and its composed twin): those are two
811
+ // profiles and merging them is the point.
812
+ if (from.some(n => sameSpeakerName(n, into)) && readVoiceProfiles().profiles.filter(p => sameSpeakerName(p.name, into)).length < 2) {
806
813
  return res.status(400).json({ error: 'into and from must differ' })
807
814
  }
808
815
 
809
816
  const owner = getOwnerSpeakerLabel()
810
- if (from.includes(owner)) {
817
+ if (from.some(n => sameSpeakerName(n, owner))) {
811
818
  // Absorbing the owner label would delete the profile the live
812
819
  // identification path checks FIRST, on every chunk.
813
820
  return res.status(400).json({
@@ -907,11 +914,11 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
907
914
  if (!name || typeof name !== 'string' || name.trim().length < 2) {
908
915
  return res.status(400).json({ error: 'name is required (min 2 chars)' })
909
916
  }
910
- const target = name.trim()
917
+ const target = nfc(name.trim())
911
918
  const dryRun = req.body?.dryRun === true
912
919
 
913
920
  if (req.body?.confirm !== true && !dryRun) {
914
- const existing = readVoiceProfiles().profiles.find(p => p.name === target)
921
+ const existing = readVoiceProfiles().profiles.filter(p => sameSpeakerName(p.name, target))
915
922
  const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
916
923
  let wavs = 0
917
924
  if (audioDir && existsSync(audioDir)) {
@@ -922,8 +929,8 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
922
929
  error: 'confirmation required',
923
930
  message: `Deleting "${target}" is not reversible. Pass { confirm: true } to proceed.`,
924
931
  wouldRemove: {
925
- profile: existing ? 1 : 0,
926
- embeddings: existing?.embeddings.length ?? 0,
932
+ profile: existing.length,
933
+ embeddings: existing.reduce((n, p) => n + p.embeddings.length, 0),
927
934
  trainingAudioFiles: wavs,
928
935
  calibrationRows: calibration.removed,
929
936
  },
@@ -931,7 +938,7 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
931
938
  }
932
939
 
933
940
  if (dryRun) {
934
- const existing = readVoiceProfiles().profiles.find(p => p.name === target)
941
+ const existing = readVoiceProfiles().profiles.filter(p => sameSpeakerName(p.name, target))
935
942
  const audioDir = speakerDirPath(AUDIO_SAVE_DIR, target)
936
943
  let wavs = 0
937
944
  if (audioDir && existsSync(audioDir)) {
@@ -942,8 +949,8 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
942
949
  name: target,
943
950
  dryRun: true,
944
951
  removed: {
945
- profiles: existing ? 1 : 0,
946
- embeddings: existing?.embeddings.length ?? 0,
952
+ profiles: existing.length,
953
+ embeddings: existing.reduce((n, p) => n + p.embeddings.length, 0),
947
954
  trainingAudioFiles: wavs,
948
955
  calibrationRows: calibration.removed,
949
956
  },