@gotcos/glasses-server 6.94.2 → 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.
@@ -26,6 +26,7 @@ import {
26
26
  saveVoiceProfileStore,
27
27
  type ProfileStore,
28
28
  type VoiceProfile,
29
+ findStoredProfile,
29
30
  } from './voice-profile-store.js'
30
31
  import { homedir } from 'node:os'
31
32
  import { spawnSync } from 'node:child_process'
@@ -43,6 +44,7 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url))
43
44
 
44
45
  import { DATA_DIR } from './data-dir.js'
45
46
  import { AUTO_ENROLL_THRESHOLD, OWNER_VERIFY_SIMILARITY } from './voiceprint-floor.js'
47
+ import { nfc, sameSpeakerName } from './speaker-name-nfc.js'
46
48
 
47
49
  export const SPEAKER_MODEL_FILENAME = '3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx'
48
50
 
@@ -263,10 +265,23 @@ export function initSpeakerEmbeddings(): boolean {
263
265
  }
264
266
  }
265
267
 
268
+ /**
269
+ * The manager is keyed on the STORED profile name, which may be decomposed (written before names were NFC) or composed. Every lookup
270
+ * therefore tries both forms of the name asked for, and returns the key it is actually held under.
271
+ */
272
+ function managerKey(name: string): string | null {
273
+ if (!manager) return null
274
+ for (const key of new Set([name, nfc(name), name.normalize('NFD')])) if (manager.contains(key)) return key
275
+ return null
276
+ }
277
+ function removeFromManager(name: string): void {
278
+ if (!manager) return
279
+ for (const key of new Set([name, nfc(name), name.normalize('NFD')])) if (manager.contains(key)) manager.remove(key)
280
+ }
281
+
266
282
  /** Check if a speaker is enrolled */
267
283
  export function isEnrolled(name: string): boolean {
268
- if (!manager) return false
269
- return manager.contains(name)
284
+ return managerKey(name) !== null
270
285
  }
271
286
 
272
287
  /** Get all enrolled speaker names */
@@ -307,7 +322,7 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
307
322
 
308
323
  // Check diversity: skip if too similar to an existing embedding for this speaker
309
324
  const store = loadProfileStore()
310
- const profile = store.profiles.find(p => p.name === name)
325
+ const profile = store.profiles.find(p => sameSpeakerName(p.name, name))
311
326
  if (profile) {
312
327
  // Dedup check: skip if too similar to existing (unless bypassed by trainer)
313
328
  if (!skipDedupCheck) {
@@ -338,7 +353,7 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
338
353
  return { success: false, dim: extractor.dim, error: `Profile store write failed: ${errMsg(err)}` }
339
354
  }
340
355
  const updatedStore = loadProfileStore()
341
- const updatedProfile = updatedStore.profiles.find(p => p.name === name)
356
+ const updatedProfile = updatedStore.profiles.find(p => sameSpeakerName(p.name, name))
342
357
  if (updatedProfile) {
343
358
  rebuildSpeakerInManager(name, updatedProfile.embeddings)
344
359
  console.log(`[speaker] Force-enrolled "${name}" via rebuild (${updatedProfile.embeddings.length} total, source: ${source})`)
@@ -415,7 +430,7 @@ export interface BatchEnrollResult {
415
430
  export function enrollEmbeddingsBatch(name: string, items: BatchEnrollItem[]): BatchEnrollResult {
416
431
  if (!extractor || !manager) return { enrolled: 0, declined: items.length, persisted: false, error: 'Speaker embeddings not initialized' }
417
432
  const store = loadProfileStore()
418
- const profile = store.profiles.find(p => p.name === name)
433
+ const profile = store.profiles.find(p => sameSpeakerName(p.name, name))
419
434
  if (!profile) return { enrolled: 0, declined: items.length, persisted: false, error: 'No such profile' }
420
435
  let added = 0
421
436
  let declined = 0
@@ -474,13 +489,14 @@ export function identifySpeaker(
474
489
  // Phase 4: scoped search — try expected attendees first
475
490
  if (expectedSpeakers && expectedSpeakers.length > 0) {
476
491
  for (const name of expectedSpeakers) {
477
- if (name === owner) continue // wearer already checked
478
- if (!manager.contains(name)) continue
479
- const matches = manager.verify({ name, v: embedding, threshold: Math.max(SEARCH_THRESHOLD, minimum) })
492
+ if (sameSpeakerName(name, owner)) continue // wearer already checked
493
+ const held = managerKey(name)
494
+ if (!held) continue
495
+ const matches = manager.verify({ name: held, v: embedding, threshold: Math.max(SEARCH_THRESHOLD, minimum) })
480
496
  if (matches) {
481
- const similarity = computeCosineSimilarity(embedding, name)
482
- logCalibration(name, similarity, true, options.event)
483
- return { speaker: name, similarity, embedding }
497
+ const similarity = computeCosineSimilarity(embedding, held)
498
+ logCalibration(held, similarity, true, options.event)
499
+ return { speaker: held, similarity, embedding }
484
500
  }
485
501
  }
486
502
  }
@@ -518,7 +534,7 @@ export function autoEnroll(
518
534
  sessionId: string,
519
535
  ): { enrolled: boolean; reason: string } {
520
536
  if (!extractor || !manager) return { enrolled: false, reason: 'not initialized' }
521
- if (name === getOwnerSpeakerLabel() || name === 'Ext') return { enrolled: false, reason: 'skip owner/Ext' }
537
+ if (sameSpeakerName(name, getOwnerSpeakerLabel()) || name === 'Ext') return { enrolled: false, reason: 'skip owner/Ext' }
522
538
  if (similarity < AUTO_ENROLL_THRESHOLD) return { enrolled: false, reason: `similarity ${similarity.toFixed(3)} < ${AUTO_ENROLL_THRESHOLD}` }
523
539
 
524
540
  // Check if already auto-enrolled this session
@@ -565,7 +581,7 @@ export function clearAutoEnrollSession(sessionId: string): void {
565
581
  /** Clear all embeddings for a speaker (used by fresh training mode) */
566
582
  export function clearSpeakerEmbeddings(name: string): boolean {
567
583
  const store = loadProfileStore()
568
- const profile = store.profiles.find(p => p.name === name)
584
+ const profile = store.profiles.find(p => sameSpeakerName(p.name, name))
569
585
  if (!profile || profile.embeddings.length === 0) return false
570
586
 
571
587
  console.log(`[speaker] Clearing ${profile.embeddings.length} embeddings for "${name}"`)
@@ -573,9 +589,7 @@ export function clearSpeakerEmbeddings(name: string): boolean {
573
589
  profile.sources = []
574
590
 
575
591
  // Remove from manager
576
- if (manager && manager.contains(name)) {
577
- try { manager.remove(name) } catch { /* ignore */ }
578
- }
592
+ try { removeFromManager(name) } catch { /* ignore */ }
579
593
 
580
594
  // Persist. A "fresh training" clear legitimately empties one profile but must
581
595
  // never be able to empty the whole store, so allowEmpty stays off here.
@@ -621,7 +635,7 @@ export function mergeSpeakerProfiles(
621
635
  options: { force?: boolean; dryRun?: boolean } = {},
622
636
  ): MergeReport {
623
637
  const store = loadProfileStore()
624
- const target = store.profiles.find(p => p.name === into)
638
+ const target = findStoredProfile(store, into)
625
639
  const report: MergeReport = {
626
640
  into,
627
641
  merged: [],
@@ -641,10 +655,11 @@ export function mergeSpeakerProfiles(
641
655
  const eligible: string[] = []
642
656
  const refused: { name: string; similarity: number; floor: number }[] = []
643
657
  for (const name of from) {
644
- if (name === into) continue
645
- const source = store.profiles.find(p => p.name === name)
646
- if (!source) { report.missing.push(name); continue }
647
- const similarity = profileSimilarity(target, source)
658
+ // The target is never its own source, but a twin stored under the same name in the other form is a different profile and merges.
659
+ const sources = store.profiles.filter(p => p !== target && sameSpeakerName(p.name, name))
660
+ if (sources.length === 0) { if (!sameSpeakerName(name, into)) report.missing.push(name); continue }
661
+ // Every stored copy of the name is merged, so every copy has to clear the floor; the lowest one is what is reported.
662
+ const similarity = Math.min(...sources.map(source => profileSimilarity(target, source)))
648
663
  report.similarity[name] = Math.round(similarity * 1000) / 1000
649
664
  if (similarity < MERGE_SIMILARITY_FLOOR && !options.force) {
650
665
  refused.push({ name, similarity: report.similarity[name], floor: MERGE_SIMILARITY_FLOOR })
@@ -696,7 +711,7 @@ export function mergeSpeakerProfiles(
696
711
  // tests (no 26 MB model), so this side effect has no test seam — the reason
697
712
  // it is expressed as a single reused call rather than bespoke logic.
698
713
  for (const name of outcome.mergedFrom) rebuildSpeakerInManager(name, [])
699
- rebuildSpeakerInManager(into, target.embeddings)
714
+ rebuildSpeakerInManager(target.name, target.embeddings)
700
715
  writeProfileStore(store)
701
716
  console.log(
702
717
  `[speaker] Merged ${outcome.mergedFrom.join(', ')} into "${into}"`,
@@ -712,9 +727,7 @@ export function removeSpeakerProfile(name: string): { removedProfiles: number; r
712
727
  const result = deleteProfileFromStore(store, name)
713
728
  if (result.removedProfiles === 0) return result
714
729
 
715
- if (manager && manager.contains(name)) {
716
- try { manager.remove(name) } catch { /* the on-disk removal is the durable half */ }
717
- }
730
+ try { removeFromManager(name) } catch { /* the on-disk removal is the durable half */ }
718
731
  // allowEmpty: deleting the only enrolled person is a legitimate reset, and the
719
732
  // caller has already confirmed it explicitly.
720
733
  writeProfileStore(store, { allowEmpty: true })
@@ -729,7 +742,7 @@ export function retractEmbeddingsBySource(
729
742
  matches: (source: string) => boolean,
730
743
  ): { removed: number; remaining: number } {
731
744
  const store = loadProfileStore()
732
- const profile = store.profiles.find(p => p.name === name)
745
+ const profile = store.profiles.find(p => sameSpeakerName(p.name, name))
733
746
  if (!profile) return { removed: 0, remaining: 0 }
734
747
 
735
748
  const removed = removeEmbeddingsBySource(profile, matches)
@@ -743,7 +756,7 @@ export function retractEmbeddingsBySource(
743
756
  /** Get embedding count for a speaker */
744
757
  export function getEmbeddingCount(name: string): number {
745
758
  const store = loadProfileStore()
746
- const profile = store.profiles.find(p => p.name === name)
759
+ const profile = store.profiles.find(p => sameSpeakerName(p.name, name))
747
760
  return profile?.embeddings.length ?? 0
748
761
  }
749
762
 
@@ -1060,9 +1073,9 @@ function invalidateProfileCache(): void {
1060
1073
  function restoreProfileFromDisk(name: string): void {
1061
1074
  invalidateProfileCache()
1062
1075
  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)
1076
+ const fresh = loadProfileStore().profiles.find(p => sameSpeakerName(p.name, name))
1077
+ if (fresh && fresh.embeddings.length > 0) rebuildSpeakerInManager(fresh.name, fresh.embeddings)
1078
+ else removeFromManager(name)
1066
1079
  } catch (err: unknown) {
1067
1080
  console.error(`[speaker] Could not restore "${name}" from disk after a failed write:`, errMsg(err))
1068
1081
  } finally {
@@ -1077,7 +1090,7 @@ function persistProfile(name: string, embedding: Float32Array, source: string =
1077
1090
  try {
1078
1091
  const store = loadProfileStore()
1079
1092
 
1080
- let profile = store.profiles.find(p => p.name === name)
1093
+ let profile = store.profiles.find(p => sameSpeakerName(p.name, name))
1081
1094
  if (!profile) {
1082
1095
  profile = { name, embeddings: [], sources: [] }
1083
1096
  store.profiles.push(profile)
@@ -1131,10 +1144,8 @@ export function computeCentroid(embeddings: number[][]): Float32Array {
1131
1144
  function rebuildSpeakerInManager(name: string, embeddings: number[][]): void {
1132
1145
  if (!manager) return
1133
1146
  try {
1134
- // Remove existing entry
1135
- if (manager.contains(name)) {
1136
- manager.remove(name)
1137
- }
1147
+ // Remove the existing entry, whichever form it is held under
1148
+ removeFromManager(name)
1138
1149
  if (embeddings.length === 0) return
1139
1150
 
1140
1151
  // Register centroid of all embeddings
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Speaker names are compared and stored in NFC.
3
+ *
4
+ * Foundation's `Process` turns an argument into decomposed Unicode (NFD): COS Control's helper sent `Pólya` and the server
5
+ * received the bytes `50 6f cc 81 6c 79 61`, where NFC is `50 c3 b3 6c 79 61`. A sidecar written by the pipeline holds NFC, so a
6
+ * confirm of "Brigitta Pólya" answered 409 `No chunk in this meeting is labelled` while 11 chunks carried that name.
7
+ *
8
+ * Two rules, applied everywhere a name arrives in a request or is matched against stored data:
9
+ * 1. Normalize a name to NFC at the route boundary (`bodyName`, `nfc`), so nothing decomposed is ever WRITTEN to a sidecar, a
10
+ * ledger or a voice profile.
11
+ * 2. Compare with NFC on both sides (`sameSpeakerName`), so a name already stored decomposed (written before this existed)
12
+ * still matches its composed spelling. Stored data is not rewritten.
13
+ */
14
+ export function nfc(name: string): string { return name.normalize('NFC') }
15
+
16
+ export function sameSpeakerName(a: unknown, b: unknown): boolean {
17
+ return typeof a === 'string' && typeof b === 'string' && (a === b || nfc(a) === nfc(b))
18
+ }
19
+
20
+ /** A request value as a name: a string in NFC, or '' for anything else. */
21
+ export function bodyName(value: unknown): string { return typeof value === 'string' ? nfc(value) : '' }
22
+
23
+ /** A regular expression source that matches `name` in either form, for text that may hold either. */
24
+ export function nameForms(name: string, escape: (s: string) => string): string {
25
+ const composed = nfc(name), decomposed = name.normalize('NFD')
26
+ return composed === decomposed ? escape(composed) : `(?:${escape(composed)}|${escape(decomposed)})`
27
+ }
28
+
29
+ /** A record id carries a filename, and Control's helper hands it over decomposed: equal when they are the same text in NFC. */
30
+ export function sameRecordId(requested: unknown, expected: string): boolean {
31
+ return typeof requested === 'string' && (requested === expected || nfc(requested) === nfc(expected))
32
+ }
33
+
34
+ /** True when raw text holds `name` in either Unicode form (a file written before names were normalized may hold the decomposed one). */
35
+ export function textHasName(raw: string, name: string): boolean {
36
+ return raw.includes(name) || raw.includes(nfc(name)) || raw.includes(name.normalize('NFD'))
37
+ }
@@ -6,6 +6,7 @@
6
6
  import { readFileSync, readdirSync, lstatSync, realpathSync } from 'node:fs'
7
7
  import { join } from 'node:path'
8
8
  import { renameMeetingSpeaker } from './held-naming-batches.js'
9
+ import { nameForms, sameSpeakerName, textHasName } from './speaker-name-nfc.js'
9
10
 
10
11
  /** A month directory, and nothing that merely looks like one. */
11
12
  const CANONICAL_MONTH = /^\d{4}-\d{2}$/
@@ -153,7 +154,7 @@ export async function fanOutSpeakerRename(
153
154
  if (!path.endsWith(SIDECAR_SUFFIX)) continue
154
155
  const markdownPath = path.slice(0, -SIDECAR_SUFFIX.length) + '.md'
155
156
  coveredMarkdown.add(markdownPath)
156
- if (!raw.includes(from)) continue
157
+ if (!textHasName(raw, from)) continue
157
158
  try {
158
159
  const doc = JSON.parse(raw)
159
160
  if (!doc || Array.isArray(doc) || typeof doc !== 'object') throw new Error('sidecar is not a JSON object')
@@ -190,7 +191,7 @@ export async function fanOutSpeakerRename(
190
191
  // Cloud-only and archived markdown has no raw G2 position authority. Keep it
191
192
  // visible in the report rather than rewriting an attendee or quoted name.
192
193
  for (const [path, raw] of entries) {
193
- if (!path.endsWith('.md') || coveredMarkdown.has(path) || !raw.includes(from)) continue
194
+ if (!path.endsWith('.md') || coveredMarkdown.has(path) || !textHasName(raw, from)) continue
194
195
  if (hasNamedLabel(raw, from)) result.skipped.push({
195
196
  path, reason: 'no G2 session sidecar; transcript labels cannot be safely aligned',
196
197
  })
@@ -203,13 +204,13 @@ function errorText(error: unknown): string {
203
204
  }
204
205
 
205
206
  function carriesSpeaker(doc: Record<string, any>, name: string): boolean {
206
- return (Array.isArray(doc.chunks) && doc.chunks.some((chunk: any) => chunk?.speaker === name))
207
- || (Array.isArray(doc.chunkEntries) && doc.chunkEntries.some((entry: any) => entry?.chunk?.speaker === name))
207
+ return (Array.isArray(doc.chunks) && doc.chunks.some((chunk: any) => sameSpeakerName(chunk?.speaker, name)))
208
+ || (Array.isArray(doc.chunkEntries) && doc.chunkEntries.some((entry: any) => sameSpeakerName(entry?.chunk?.speaker, name)))
208
209
  || (Array.isArray(doc.batchSegments) && doc.batchSegments.some((segment: any) =>
209
- Array.isArray(segment?.speakerWords) && segment.speakerWords.some((word: any) => word?.speaker === name)))
210
+ Array.isArray(segment?.speakerWords) && segment.speakerWords.some((word: any) => sameSpeakerName(word?.speaker, name))))
210
211
  }
211
212
 
212
- function escapedName(name: string): string { return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') }
213
+ function escapedName(name: string): string { return nameForms(name, n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) }
213
214
  function hasNamedLabel(markdown: string, name: string): boolean {
214
215
  const escaped = escapedName(name)
215
216
  return new RegExp(`^(?:\\[${escaped}\\]:|\\*\\*${escaped}(?::\\*\\*|\\*\\*)|[-*] ${escaped}$)`, 'm').test(markdown)
@@ -13,6 +13,7 @@ import {
13
13
  rawCosineSimilarity, saveProfileStore, rebuildAllProfiles, clearSpeakerEmbeddings,
14
14
  } from './speaker-embeddings.js'
15
15
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
16
+ import { sameSpeakerName } from './speaker-name-nfc.js'
16
17
 
17
18
  const CACHE_DIR = '/tmp/cos-speaker-training'
18
19
  const PROGRESS_PATH = resolve(CACHE_DIR, 'training-progress.json')
@@ -520,7 +521,7 @@ export async function getTrainingStatus(): Promise<TrainingStatus> {
520
521
  }
521
522
 
522
523
  for (const name of enrolledNames) {
523
- if (!speakers.some(s => s.name === name)) {
524
+ if (!speakers.some(s => sameSpeakerName(s.name, name))) {
524
525
  speakers.push({ name, segments: 0, meetings: 0, enrolled: true })
525
526
  }
526
527
  }
@@ -26,6 +26,7 @@ import {
26
26
  } from './meeting-speaker-review.js'
27
27
  import { getOwnerSpeakerLabel } from './profile.js'
28
28
  import { readVoiceProfiles, type VoiceProfile } from './speaker-embeddings.js'
29
+ import { sameSpeakerName } from './speaker-name-nfc.js'
29
30
 
30
31
  const MONTH = /^\d{4}-(0[1-9]|1[0-2])$/
31
32
  const SIDECAR = /\.g2-chunks\.json$/
@@ -363,7 +364,7 @@ export async function buildVoiceDirectorySnapshot(): Promise<VoiceDirectorySnaps
363
364
  .slice(0, MAX_APPEARANCES_PER_VOICE)
364
365
  return {
365
366
  name: profile.name,
366
- isOwner: profile.name === owner,
367
+ isOwner: sameSpeakerName(profile.name, owner),
367
368
  embeddings: profile.embeddings.length,
368
369
  sources: sourceCounts(profile),
369
370
  sourcesAligned: (profile.sources?.length ?? 0) === profile.embeddings.length,
@@ -16,6 +16,7 @@ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:f
16
16
  import { basename, dirname, join, resolve } from 'node:path'
17
17
  import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
18
18
  import { EVICTION_ORDER, isMacAssign, macAssignCapFor, provenanceTier } from './embedding-eviction.js'
19
+ import { sameSpeakerName } from './speaker-name-nfc.js'
19
20
 
20
21
  export interface VoiceProfile {
21
22
  name: string
@@ -512,6 +513,11 @@ export interface MergeOutcome {
512
513
  * removable later, and overwriting it to record a bookkeeping event would trade
513
514
  * a useful fact for a useless one.
514
515
  */
516
+ /** The profile stored under `name`: the exact spelling first, else the one that is the same name in NFC. */
517
+ export function findStoredProfile(store: ProfileStore, name: string): VoiceProfile | undefined {
518
+ return store.profiles.find(p => p.name === name) ?? store.profiles.find(p => sameSpeakerName(p.name, name))
519
+ }
520
+
515
521
  export function mergeProfilesInStore(
516
522
  store: ProfileStore,
517
523
  into: string,
@@ -519,7 +525,7 @@ export function mergeProfilesInStore(
519
525
  options: { cap?: number } = {},
520
526
  ): MergeOutcome {
521
527
  const cap = options.cap ?? 40
522
- const target = store.profiles.find(p => p.name === into)
528
+ const target = findStoredProfile(store, into)
523
529
  const outcome: MergeOutcome = {
524
530
  similarity: {},
525
531
  samplesBefore: target?.embeddings.length ?? 0,
@@ -537,18 +543,23 @@ export function mergeProfilesInStore(
537
543
  const sources = [...(target.sources ?? [])]
538
544
  while (sources.length < embeddings.length) sources.push(UNKNOWN_SOURCE)
539
545
 
546
+ // Sources are the stored profiles themselves, never the spelling asked for: a name stored decomposed and its composed twin are two
547
+ // profiles (the twin is merged into the one that was asked for), and the target is never its own source.
548
+ const absorbed = new Set<VoiceProfile>()
540
549
  for (const name of from) {
541
- if (name === into) continue
542
- const source = store.profiles.find(p => p.name === name)
543
- if (!source) { outcome.missing.push(name); continue }
544
- outcome.similarity[name] = profileSimilarity(target, source)
545
- const sourceSources = [...(source.sources ?? [])]
546
- while (sourceSources.length < source.embeddings.length) sourceSources.push(UNKNOWN_SOURCE)
547
- for (let i = 0; i < source.embeddings.length; i++) {
548
- embeddings.push(source.embeddings[i])
549
- sources.push(sourceSources[i] ?? UNKNOWN_SOURCE)
550
+ const found = store.profiles.filter(p => p !== target && !absorbed.has(p) && sameSpeakerName(p.name, name))
551
+ if (found.length === 0) { if (!store.profiles.some(p => sameSpeakerName(p.name, name) && p !== target)) outcome.missing.push(name); continue }
552
+ for (const source of found) {
553
+ outcome.similarity[name] = profileSimilarity(target, source)
554
+ const sourceSources = [...(source.sources ?? [])]
555
+ while (sourceSources.length < source.embeddings.length) sourceSources.push(UNKNOWN_SOURCE)
556
+ for (let i = 0; i < source.embeddings.length; i++) {
557
+ embeddings.push(source.embeddings[i])
558
+ sources.push(sourceSources[i] ?? UNKNOWN_SOURCE)
559
+ }
560
+ outcome.mergedFrom.push(source.name)
561
+ absorbed.add(source)
550
562
  }
551
- outcome.mergedFrom.push(name)
552
563
  }
553
564
 
554
565
  if (outcome.mergedFrom.length === 0) return outcome
@@ -569,8 +580,7 @@ export function mergeProfilesInStore(
569
580
  target.sources = keep.map(i => sources[i])
570
581
  outcome.samplesAfter = target.embeddings.length
571
582
 
572
- const removed = new Set(outcome.mergedFrom)
573
- store.profiles = store.profiles.filter(p => !removed.has(p.name))
583
+ store.profiles = store.profiles.filter(p => !absorbed.has(p))
574
584
  return outcome
575
585
  }
576
586
 
@@ -583,7 +593,7 @@ export function deleteProfileFromStore(
583
593
  let removedProfiles = 0
584
594
  let removedEmbeddings = 0
585
595
  store.profiles = store.profiles.filter(profile => {
586
- if (profile.name !== name) return true
596
+ if (!sameSpeakerName(profile.name, name)) return true
587
597
  removedProfiles++
588
598
  removedEmbeddings += profile.embeddings.length
589
599
  return false
@@ -3,6 +3,7 @@ import { Router } from 'express'
3
3
  import { JEV_MODEL, resolveJevKey } from '../lib/jev.js'
4
4
  import { resolveSubqKey, subqKeyStatus } from '../lib/subq-key.js'
5
5
  import { SUBQ_MODEL } from '../lib/decision-provider.js'
6
+ import { sessionFindAvailable } from '../lib/session-find.js'
6
7
  import { decisionSettingsView, DecisionSettingsError, patchDecisionSettings, readDecisionSettings, writeDecisionSettings, type DecisionProvider, type DecisionSettings } from '../lib/decision-settings.js'
7
8
  export interface DecisionProviderRouteDeps {
8
9
  read: () => DecisionSettings
@@ -15,7 +16,9 @@ export function createDecisionProviderRouter(overrides: Partial<DecisionProvider
15
16
  const deps: DecisionProviderRouteDeps = { read: readDecisionSettings, write: writeDecisionSettings, configured: () => ({ jev: !!resolveJevKey(), subq: !!resolveSubqKey() }), env: process.env, keyStatus: () => { const jev = resolveJevKey(); return { jev: { configured:!!jev, source:jev?.source ?? 'none' }, subq:subqKeyStatus() } }, ...overrides }
16
17
  const router = Router()
17
18
  const view = (settings: DecisionSettings) => ({ ...decisionSettingsView(settings, deps.configured(), deps.env), models: { jev: JEV_MODEL, subq: SUBQ_MODEL }, keyStatus: deps.keyStatus(),
18
- capabilities: { choices: ['jev','subq'], workflows: ['session','completion','search'], hotReload: true, fallback: false, subqImages: true, subqKeySource: 'config-or-env' } })
19
+ capabilities: { choices: ['jev','subq'], workflows: ['session','completion','search'], hotReload: true, fallback: false, subqImages: true, subqKeySource: 'config-or-env',
20
+ // Present only while POST /api/agent-sessions/find can answer. Absent means no meaning search here, and Control does not ask (never keyed on version).
21
+ ...(sessionFindAvailable(deps.env) ? { sessionFind: true } : {}) } })
19
22
  router.use('/decision-provider', (_req,res,next) => { res.set('Cache-Control','private, no-store'); next() })
20
23
  router.get('/decision-provider', (_req,res) => {
21
24
  try { res.json(view(deps.read())) } catch { res.status(503).json({ error: { code: 'decision_settings_unavailable', message: 'Decision provider settings could not be read.' } }) }
@@ -14,6 +14,7 @@ import { CompletionChecker, readRepliesSince } from '../lib/work-completion.js'
14
14
  import { isSafeSessionId, type AgentProvider } from '../lib/agent-session-store.js'
15
15
  import { listBoard } from '../lib/task-store.js'
16
16
  import { sharedWorkSearchJevClient } from '../lib/work-search.js'
17
+ import { sharedSessionFindJevClient } from '../lib/session-find.js'
17
18
  import { isSafeDomainName } from '../lib/domains.js'
18
19
 
19
20
  /** What the route reads from a meeting review (6.57.1): the server's own record, never client text. */
@@ -34,6 +35,8 @@ export interface JevRouteDependencies {
34
35
  replies: (provider: AgentProvider, sessionId: string, afterMs: number | null) => Promise<string | null>
35
36
  /** 6.65.0: Work search keeps its own client and breaker; a newly saved key clears that breaker too. */
36
37
  searchJev: Pick<JevClient, 'resetForNewKey'>
38
+ /** Session find (Control 0.5.363) keeps its own client and breaker too. */
39
+ findJev: Pick<JevClient, 'resetForNewKey'>
37
40
  }
38
41
 
39
42
  /** Work identity first: it survives a rename, while a new card's id could equal an old identity's text hash. */
@@ -45,7 +48,7 @@ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}):
45
48
  const jev = overrides.jev ?? sharedJevClient()
46
49
  const deps: JevRouteDependencies = { jev, recommender: new SessionRecommender(createDecisionClient('session', jev)), list: listBoard, validate: validateJevKey, save: saveJevKey,
47
50
  completion: new CompletionChecker(createDecisionClient('completion', jev)), replies: (provider, sessionId, afterMs) => readRepliesSince(provider, sessionId, afterMs),
48
- searchJev: sharedWorkSearchJevClient(), ...overrides }
51
+ searchJev: sharedWorkSearchJevClient(), findJev: sharedSessionFindJevClient(), ...overrides }
49
52
  const router = Router()
50
53
  router.use(['/jev-key', '/work-board/session-recommendation', '/work-board/completion-check'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
51
54
 
@@ -67,6 +70,7 @@ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}):
67
70
  }
68
71
  deps.jev.resetForNewKey()
69
72
  deps.searchJev.resetForNewKey()
73
+ deps.findJev.resetForNewKey()
70
74
  return res.json({ ok: true, ...deps.jev.status() })
71
75
  })
72
76
 
@@ -3,6 +3,7 @@ import { readMacSpeakerMachineEvidence } from '../lib/mac-speaker-machine-eviden
3
3
  import { dataPath } from '../lib/data-dir.js'
4
4
  import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, assertPhoneProvenance, readPhoneRecording, requirePhoneRecording, closePhoneRecording } from '../lib/phone-recording.js'
5
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'
6
7
  import { readSpeakerPolicy, writeSpeakerPolicy, isSpeakerMode } from '../lib/meeting-speaker-policy.js'
7
8
  // POST /api/meeting/save — finalize an existing transcribe-stream session into
8
9
  // the standalone public meeting store. The live transcript and chunk metadata
@@ -261,7 +262,7 @@ function markdownUnreadableReason(operations: { mergedScribeConflict?: string[]
261
262
  }
262
263
 
263
264
  function requestedRecordMatches(requested: unknown, expected: string): boolean {
264
- return requested == null || requested === '' || requested === expected
265
+ return requested == null || requested === '' || sameRecordId(requested, expected)
265
266
  }
266
267
 
267
268
  interface MeetingSessionSource {
@@ -1967,7 +1968,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1967
1968
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1968
1969
  return
1969
1970
  }
1970
- const speaker = typeof req.body?.speaker === 'string' ? req.body.speaker.trim() : ''
1971
+ const speaker = bodyName(req.body?.speaker).trim()
1971
1972
  if (!speaker) {
1972
1973
  res.status(400).json({ error: 'speaker is required', reason: 'invalid_label' })
1973
1974
  return
@@ -2000,7 +2001,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2000
2001
  }
2001
2002
 
2002
2003
  // Only rows that ACTUALLY landed, and only those that named this speaker.
2003
- 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)
2004
2005
  if (rows.length === 0) {
2005
2006
  res.status(404).json({ error: `No applied correction named "${speaker}" in this meeting`, reason: 'no_correction' })
2006
2007
  return
@@ -2047,8 +2048,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2047
2048
  return
2048
2049
  }
2049
2050
 
2050
- const from = typeof req.body?.from === 'string' ? req.body.from : ''
2051
- const to = typeof req.body?.to === 'string' ? req.body.to : ''
2051
+ const from = bodyName(req.body?.from)
2052
+ const to = bodyName(req.body?.to)
2052
2053
  const chunks = Array.isArray(req.body?.chunks)
2053
2054
  ? (req.body.chunks as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0)
2054
2055
  : []
@@ -2401,7 +2402,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2401
2402
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
2402
2403
  return
2403
2404
  }
2404
- const label = typeof req.body?.label === 'string' ? req.body.label : ''
2405
+ const label = bodyName(req.body?.label)
2405
2406
  const bad = invalidLabelReason(label)
2406
2407
  if (bad) {
2407
2408
  res.status(400).json({ error: `label: ${bad}`, reason: 'invalid_label' })
@@ -2449,7 +2450,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2449
2450
  const doc = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
2450
2451
  const rows = Array.isArray(doc.chunks) ? doc.chunks : []
2451
2452
  carried = rows.filter(r => r && typeof r === 'object'
2452
- && (r as Record<string, unknown>).speaker === label).length
2453
+ && sameSpeakerName((r as Record<string, unknown>).speaker, label)).length
2453
2454
  } catch {
2454
2455
  res.status(500).json({ error: 'Could not read the chunk sidecar', reason: 'sidecar_unreadable' })
2455
2456
  return
@@ -2492,7 +2493,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2492
2493
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
2493
2494
  return
2494
2495
  }
2495
- const from = typeof req.body?.from === 'string' ? req.body.from : ''
2496
+ const from = bodyName(req.body?.from)
2496
2497
  const bad = invalidLabelReason(from)
2497
2498
  if (bad) {
2498
2499
  res.status(400).json({ error: `from: ${bad}`, reason: 'invalid_label' })
@@ -2568,7 +2569,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2568
2569
  if (Array.isArray(parsed.chunks)) {
2569
2570
  for (const c of parsed.chunks) {
2570
2571
  const sp = (c as { speaker?: unknown } | null)?.speaker
2571
- if (typeof sp === 'string' && !existingSpeakers.includes(sp)) existingSpeakers.push(sp)
2572
+ if (typeof sp === 'string' && !existingSpeakers.some(x => sameSpeakerName(x, sp))) existingSpeakers.push(sp)
2572
2573
  }
2573
2574
  }
2574
2575
  } catch { /* the relabel below reports a corrupt sidecar */ }
@@ -2581,7 +2582,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2581
2582
 
2582
2583
  // What this would remove from the profile, and what it CANNOT reach.
2583
2584
  // readVoiceProfiles returns a ProfileStore, not an array.
2584
- const profile = readVoiceProfiles().profiles.find(p => p.name === from)
2585
+ const profile = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, from))
2585
2586
  const profileSources: Array<string | undefined> = profile
2586
2587
  ? Array.from({ length: profile.embeddings.length }, (_, i) => profile.sources?.[i])
2587
2588
  : []
@@ -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 }