@gotcos/glasses-server 6.95.0 → 6.98.1

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 +166 -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 +763 -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 +572 -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
@@ -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)
@@ -1,4 +1,4 @@
1
- // Speaker merge shadow mode (6.95.0): the gates, ported EXACTLY from the research tool.
1
+ // Speaker merge shadow mode: the research gates plus the Wave-1 anchor gate.
2
2
  //
3
3
  // SOURCE OF TRUTH. `community-1-fluidaudio-v1/scripts/hybrid.py` `decide()` (round 2) and
4
4
  // the report assembly in `round3/propose.py`, on CPython 3.15. Production `Voice N` labels
@@ -9,21 +9,31 @@
9
9
  // (b) it has at least `floorSeconds` (15 s) of production speech;
10
10
  // (c) its window-embedding centroid lies inside the target cluster's own spread,
11
11
  // measured leave-fragment-out against the 95th percentile.
12
+ // (d) a nonanchor's centroid distance from the kept voice must be <=1.5 times
13
+ // that anchor's own p95 spread. Missing evidence fails closed. The anchor
14
+ // remains the longest voice passing (a)-(c); a rejected fragment never
15
+ // causes selection of another anchor.
12
16
  // Named labels, `Ext` and every non-Voice label are frozen: never merged into or out of.
13
- // The post-hoc anchor ratio is computed and REPORTED ONLY. It decides nothing.
14
17
  //
15
18
  // LOG ONLY. Nothing here writes, and nothing calls a writer: the output is a proposal record.
16
19
  //
17
20
  // Order and rounding are part of the contract: Python dicts iterate in insertion order,
18
- // max()/most_common(1) keep the FIRST maximum, sorted() is stable, gates compare ROUNDED
19
- // values and reason strings use Python %-formatting. Every one of those is kept below,
21
+ // max()/most_common(1) keep the FIRST maximum, sorted() is stable, research gates compare
22
+ // ROUNDED values and reason strings use Python %-formatting. Gate(d) uses the raw ratio.
23
+ // The research numerics remain unchanged below,
20
24
  // with the Python-compatible numerics in speaker-shadow-numeric.ts.
21
25
 
22
26
  import { PY_VOICE_LABEL, codePointCompare, pyClock, pyFirstInt, pyFixed, pyRound, pyRoundInt, pySum } from './speaker-shadow-numeric.js'
23
27
 
24
28
  export const SHADOW_SAMPLE_RATE = 16_000
25
- /** The round-2 gates, fixed before scoring (RESULT-ROUND2.md). */
26
- export const SHADOW_GATES = Object.freeze({ majority: 0.8, minCoveredFraction: 0.5, floorSeconds: 15, anchorRatioPostHoc: 1.5 })
29
+ /** Existing round-2 gates plus the separately enforced Wave-1 anchor ratio. */
30
+ export const SHADOW_GATES = Object.freeze({ majority: 0.8, minCoveredFraction: 0.5, floorSeconds: 15, anchorRatioMax: 1.5,
31
+ /** Compatibility alias for older report consumers; this is now enforced. */
32
+ anchorRatioPostHoc: 1.5 })
33
+
34
+ export function passesShadowAnchorRatio(ratio: number | null): boolean {
35
+ return ratio !== null && Number.isFinite(ratio) && ratio >= 0 && ratio <= SHADOW_GATES.anchorRatioMax
36
+ }
27
37
 
28
38
  /** One production segment on the reconstructed timeline: [lo, hi) samples and its label. */
29
39
  export type ShadowSegment = readonly [lo: number, hi: number, label: string]
@@ -154,6 +164,10 @@ export interface ShadowVoiceRow {
154
164
  fragmentEmbeddings: number
155
165
  otherClusterMembers: number
156
166
  outlier: ShadowOutlier
167
+ /** Unrounded ratio used by gate (d); null for the anchor or missing evidence. */
168
+ anchorRatio: number | null
169
+ /** null means this voice is the anchor or is not in a proposed group. */
170
+ anchorRatioPass: boolean | null
157
171
  eligible: boolean
158
172
  reasons: string[]
159
173
  }
@@ -161,6 +175,20 @@ export interface ShadowVoiceRow {
161
175
  export interface ShadowItem { vec: number[]; exportCluster: string; outputCluster: string | null; voice: string | null }
162
176
  export interface ShadowGroup { cluster: string; voices: string[]; anchor: string | null; mergedIntoAnchor: string[] }
163
177
 
178
+ /** The anchor spread is independent of the fragment; do not inflate it with
179
+ * candidate fragments, and do not accept a missing/zero spread as agreement. */
180
+ function anchorRatio(fragment: readonly number[][], anchor: readonly number[][]): number | null {
181
+ if (fragment.length < 3 || anchor.length < 3 || [...fragment, ...anchor].some(v =>
182
+ v.length !== 256 || v.some(x => !Number.isFinite(x)) || !v.some(x => x !== 0))) return null
183
+ const centroid = meanDir(anchor)
184
+ const spread = p95(anchor.map(v => cosdist(v, centroid)))
185
+ const distance = cosdist(meanDir(fragment), centroid)
186
+ if (!Number.isFinite(spread) || !(spread > 0) || !Number.isFinite(distance) || distance < -1e-12) return null
187
+ // Unit-vector dot products can exceed one by a rounding ulp.
188
+ const ratio = Math.max(0, distance) / spread
189
+ return Number.isFinite(ratio) ? ratio : null
190
+ }
191
+
164
192
  export interface ShadowDecision {
165
193
  voices: Map<string, Array<[number, number]>>
166
194
  rows: Map<string, ShadowVoiceRow>
@@ -233,7 +261,7 @@ export function decide(
233
261
  topCluster: top,
234
262
  majority: covered ? pyRound(topN / covered, 3) : 0,
235
263
  fragmentEmbeddings: 0, otherClusterMembers: 0,
236
- outlier: { pass: false, reason: '' }, eligible: false, reasons: [],
264
+ outlier: { pass: false, reason: '' }, anchorRatio: null, anchorRatioPass: null, eligible: false, reasons: [],
237
265
  })
238
266
  }
239
267
 
@@ -278,13 +306,30 @@ export function decide(
278
306
  const remap = new Map<string, string>()
279
307
  const rejected: ShadowDecision['rejected'] = []
280
308
  const groups: ShadowGroup[] = []
309
+ const vecs = new Map<string, number[][]>()
310
+ for (const item of items) if (item.voice) {
311
+ const list = vecs.get(item.voice)
312
+ if (list) list.push(item.vec); else vecs.set(item.voice, [item.vec])
313
+ }
281
314
  let proposed = 0, accepted = 0
282
315
  for (const [k, vs] of [...byCluster].sort((x, y) => codePointCompare(x[0], y[0]))) {
283
316
  if (vs.length < 2) continue
284
317
  proposed += vs.length - 1
285
- const ok = longestFirst(vs.filter(v => rows.get(v)!.eligible))
286
- const anchor = ok[0] ?? null
287
- const merged = ok.slice(1)
318
+ const baseEligible = longestFirst(vs.filter(v => rows.get(v)!.eligible))
319
+ const anchor = baseEligible[0] ?? null
320
+ if (anchor) for (const v of vs) {
321
+ if (v === anchor) continue
322
+ const row = rows.get(v)!
323
+ row.anchorRatio = anchorRatio(vecs.get(v) ?? [], vecs.get(anchor) ?? [])
324
+ row.anchorRatioPass = passesShadowAnchorRatio(row.anchorRatio)
325
+ if (row.eligible && !row.anchorRatioPass) {
326
+ row.eligible = false
327
+ row.reasons.push(row.anchorRatio === null ? '(d) anchor ratio evidence unavailable'
328
+ : `(d) anchor ratio ${pyFixed(row.anchorRatio, 4)} > ${pyFixed(SHADOW_GATES.anchorRatioMax, 2)}`)
329
+ }
330
+ }
331
+ const ok = baseEligible.filter(v => rows.get(v)!.eligible)
332
+ const merged = ok.filter(v => v !== anchor)
288
333
  accepted += merged.length
289
334
  for (const v of merged) remap.set(v, anchor!)
290
335
  for (const v of vs) {
@@ -315,7 +360,9 @@ export interface ShadowVoiceEntry {
315
360
  outlierDistance: number | null
316
361
  outlierP95: number | null
317
362
  outlierNote: string | null
318
- /** UNVALIDATED post-hoc check, reported only (fragment centroid vs the kept voice's spread). */
363
+ /** Applied unrounded ratio. Optional only for older persisted proposal rows. */
364
+ anchorRatio?: number | null
365
+ /** Rounded compatibility field; gate (d) uses the row's UNROUNDED ratio. */
319
366
  anchorRatioPostHoc: number | null
320
367
  eligible: boolean
321
368
  reasons: string[]
@@ -368,16 +415,6 @@ export function buildShadowProposals(
368
415
  const byLabel = new Map<string, ShadowSegmentSource[]>()
369
416
  segs.forEach(([, , label], i) => { const list = byLabel.get(label); if (list) list.push(sources[i]); else byLabel.set(label, [sources[i]]) })
370
417
 
371
- const vecs = new Map<string, number[][]>()
372
- for (const it of D.items) if (it.voice) { const list = vecs.get(it.voice); if (list) list.push(it.vec); else vecs.set(it.voice, [it.vec]) }
373
- const ratio = (v: string, anchor: string): number | null => {
374
- const A = vecs.get(anchor) ?? [], B = vecs.get(v) ?? []
375
- if (v === anchor || A.length < 3 || B.length < 3) return null
376
- const ca = meanDir(A)
377
- const thr = p95(A.map(x => cosdist(x, ca)))
378
- return thr > 0 ? pyRound(cosdist(meanDir(B), ca) / thr, 2) : null
379
- }
380
-
381
418
  // Named / non-Voice labels sharing each FluidAudio cluster (caution only; never merged).
382
419
  const named = new Map<string, Map<string, number>>()
383
420
  for (const [lo, hi, label] of segs) {
@@ -399,7 +436,7 @@ export function buildShadowProposals(
399
436
  outlierDistance: 'reason' in o ? null : o.fragmentCentroidDistance,
400
437
  outlierP95: 'reason' in o ? null : o.clusterP95Distance,
401
438
  outlierNote: 'reason' in o ? o.reason : null,
402
- anchorRatioPostHoc: ratio(v, anchor), eligible: r.eligible, reasons: r.reasons,
439
+ anchorRatio: r.anchorRatio, anchorRatioPostHoc: r.anchorRatio === null ? null : pyRound(r.anchorRatio, 2), eligible: r.eligible, reasons: r.reasons,
403
440
  listen: listenPointers(v, byLabel),
404
441
  }
405
442
  }
@@ -0,0 +1,91 @@
1
+ // Bounded read-only clock diagnostics. File dates and timestamps are not alignment proof.
2
+ import { open, readdir, lstat } from 'node:fs/promises'
3
+ import { constants } from 'node:fs'
4
+ import { basename, dirname, join } from 'node:path'
5
+ import { createHash } from 'node:crypto'
6
+
7
+ export interface ShadowPairSource {
8
+ status: 'time_only' | 'ambiguous' | 'unpaired'
9
+ reason: string
10
+ sessionId?: string
11
+ sourceSha256?: string
12
+ g2?: { startTime: number; chunks: Array<{ speaker: string; elapsed: number; audioDurationMs: number }> }
13
+ }
14
+ const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)
15
+ function bounds(value: Record<string, unknown>, durationOverride?: number): [number, number] | null {
16
+ if (!finite(value.startTime) || !Array.isArray(value.chunks)) return null
17
+ const duration = durationOverride ?? value.durationMs
18
+ if (!finite(duration) || duration <= 0 || duration > 4 * 3600_000) return null
19
+ return [value.startTime, value.startTime + duration]
20
+ }
21
+ const day = (ms: number) => { const d = new Date(ms); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` }
22
+ /** Only the current recording directory is searched; absent cross-month pairs remain unpaired. */
23
+ export async function findShadowPairSource(sidecarPath: string, mac: Record<string, unknown>, durationMs?: number): Promise<ShadowPairSource> {
24
+ const target = bounds(mac, durationMs)
25
+ if (!target) return { status: 'unpaired', reason: 'invalid Mac clock or duration' }
26
+ const dir = dirname(sidecarPath)
27
+ // Official sidecars use this Mac's local calendar-day prefix. Include a four-hour
28
+ // lookback for a G2 capture that started before this Mac capture (maximum call length).
29
+ const days = new Set([day(target[0] - 4 * 3600_000), day(target[0]), day(target[1])])
30
+ const all = await readdir(dir)
31
+ const names = all.filter(n => n.endsWith('.g2-chunks.json') && n !== basename(sidecarPath))
32
+ .filter(n => { const prefix = n.match(/^(\d{4}-\d{2}-\d{2})_/); return !prefix || days.has(prefix[1]) }).sort()
33
+ if (names.length > 256) return { status: 'ambiguous', reason: 'day-window discovery file limit exceeded; no pair selected' }
34
+ let bytes = 0, estimated = false
35
+ const candidates: ShadowPairSource[] = []
36
+ for (const name of names) {
37
+ const path = join(dir, name)
38
+ let raw: string, value: Record<string, unknown>
39
+ try {
40
+ const stat = await lstat(path)
41
+ if (!stat.isFile() || stat.isSymbolicLink()) return { status: 'ambiguous', reason: 'unreadable day-window candidate; no pair selected' }
42
+ if (stat.size > 16 * 1024 * 1024 || bytes + stat.size > 128 * 1024 * 1024) return { status: 'ambiguous', reason: 'day-window discovery byte limit exceeded; no pair selected' }
43
+ const parts: Buffer[] = []
44
+ let size = 0
45
+ // A candidate can change between lstat and open. Never wait on a FIFO/device
46
+ // substituted in that interval; verify the actual descriptor before reading.
47
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
48
+ try {
49
+ if (!(await handle.stat()).isFile()) throw new Error('candidate is no longer a regular file')
50
+ const buffer = Buffer.alloc(64 * 1024)
51
+ while (true) {
52
+ const { bytesRead } = await handle.read(buffer)
53
+ if (!bytesRead) break
54
+ size += bytesRead
55
+ if (size > 16 * 1024 * 1024 || bytes + size > 128 * 1024 * 1024) throw new Error('candidate grew beyond byte budget')
56
+ parts.push(Buffer.from(buffer.subarray(0, bytesRead)))
57
+ }
58
+ } finally { await handle.close() }
59
+ raw = Buffer.concat(parts).toString('utf8')
60
+ bytes += size
61
+ if (Buffer.byteLength(raw) > 16 * 1024 * 1024 || bytes > 128 * 1024 * 1024) return { status: 'ambiguous', reason: 'day-window discovery changed beyond byte limit; no pair selected' }
62
+ const parsed: unknown = JSON.parse(raw)
63
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid sidecar')
64
+ value = parsed as Record<string, unknown>
65
+ if (typeof value.sessionId !== 'string') throw new Error('missing source identity')
66
+ } catch { return { status: 'ambiguous', reason: 'unreadable or malformed day-window candidate; no pair selected' } }
67
+ if (typeof value.sessionId !== 'string' || !value.sessionId.startsWith('meeting_') || value.finalizationState !== 'complete') continue
68
+ const span = bounds(value)
69
+ if (!span) return { status: 'ambiguous', reason: 'complete G2 candidate lacks valid clock/duration; no pair selected' }
70
+ const overlap = Math.max(0, Math.min(target[1], span[1]) - Math.max(target[0], span[0]))
71
+ const macDuration = target[1] - target[0], g2Duration = span[1] - span[0]
72
+ if (overlap / Math.max(macDuration, g2Duration) < .5 || Math.min(macDuration, g2Duration) / Math.max(macDuration, g2Duration) < .5) continue
73
+ const chunks: NonNullable<ShadowPairSource['g2']>['chunks'] = []
74
+ for (const item of value.chunks as unknown[]) {
75
+ if (!item || typeof item !== 'object') return { status: 'ambiguous', reason: 'invalid G2 chunk; no pair selected' }
76
+ const row = item as Record<string, unknown>
77
+ if (typeof row.speaker !== 'string' || !finite(row.elapsed) || row.elapsed < 0) return { status: 'ambiguous', reason: 'invalid G2 timing or label; no pair selected' }
78
+ const exactDuration = row.audioDurationMs ?? row.durationMs
79
+ if (exactDuration !== undefined && (!finite(exactDuration) || exactDuration <= 0 || (row.audioDurationMs !== undefined && row.durationMs !== undefined && row.audioDurationMs !== row.durationMs))) return { status: 'ambiguous', reason: 'invalid G2 duration; no pair selected' }
80
+ // Historical G2 sidecars lack chunk durations: disclose the fixed-window
81
+ // approximation. This function can NEVER qualify alignment or transfer a name.
82
+ const duration = exactDuration === undefined ? 6000 : exactDuration as number
83
+ estimated ||= exactDuration === undefined
84
+ chunks.push({ speaker: row.speaker, elapsed: row.elapsed, audioDurationMs: duration })
85
+ }
86
+ candidates.push({ status: 'time_only', reason: '', sessionId: value.sessionId, sourceSha256: createHash('sha256').update(raw).digest('hex'), g2: { startTime: span[0], chunks } })
87
+ }
88
+ const scope = `Day-window scan: ${names.length} files, ${bytes} bytes, current directory only.`
89
+ return candidates.length === 1 ? { ...candidates[0], reason: `Clock overlap only; text offset/drift alignment has not been qualified. G2 names are estimates.${estimated ? ' G2 chunk durations use an approximate 6-second window where absent.' : ''} ${scope}` }
90
+ : { status: candidates.length ? 'ambiguous' : 'unpaired', reason: `${candidates.length ? `${candidates.length} overlapping G2 candidates; no pair chosen` : 'no complete overlapping G2 capture'}. ${scope}` }
91
+ }