@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
@@ -0,0 +1,307 @@
1
+ /** Pure PREPARATION only. Hashes bind supplied bytes; they do not authenticate native
2
+ * execution, reconstructed PCM, model provenance, or a caller's complete export.
3
+ * A future trusted caller must verify those before any durable transaction and
4
+ * run this bounded but potentially expensive full-cohort computation off-thread
5
+ * under a wall-time/memory budget. This is not an HTTP/main-thread entry point. */
6
+ import { createHash } from 'node:crypto'
7
+ import { decide, exactOverlap, nativeTurns, type ShadowEmbeddingRow } from './speaker-shadow-decide.js'
8
+ import { shadowEntries, shadowProductionSegments, type ShadowTimelineRow } from './speaker-shadow-timeline.js'
9
+ import { hasMacSpeakerProtectedRows, isMacSpeakerHumanProtected } from './mac-speaker-protection.js'
10
+ import { hashMacSpeakerDocument, projectMacSpeakerRepair, type MacSpeakerRepairPlan, type MacSpeakerWordBinding } from './mac-speaker-repair-projection.js'
11
+
12
+ type Doc = Record<string, any>
13
+ export interface MacShadowRepairSource {
14
+ originalJSON: string
15
+ currentJSON: string
16
+ ledgerJSONL: string
17
+ rawAudio: Array<{ rawChunkIndex: number; wavSHA256: string; pcmSHA256: string; sampleCount: number }>
18
+ timeline: { rows: ShadowTimelineRow[]; total: number; pcmSHA256: string }
19
+ cohort: { nativeJSON: string; embeddingsJSON: string; modelSHA256: string; cliSHA256: string }
20
+ }
21
+ export interface MacShadowRepairInput extends MacShadowRepairSource { sourceBindingSHA256: string }
22
+ export interface MacShadowRepairPreview {
23
+ plan: MacSpeakerRepairPlan | null
24
+ provenance: { productionQualified: false; nativeAuthenticated: false; sourceBindingSHA256: string; originalSHA256: string; currentSHA256: string; ledgerSHA256: string }
25
+ protectedRawChunkIndices: number[]
26
+ withheld: Array<{ rawChunkIndex: number; reason: string }>
27
+ counts: { raw: number; spoken: number; embeddings: number; acousticAcceptedMerges: number; candidateChunks: number; assignedChunks: number; protectedChunks: number; withheldChunks: number; beforeLabels: number; afterLabels: number; beforeRootLabels: number; afterRootLabels: number }
28
+ }
29
+ const HASH = /^[a-f0-9]{64}$/
30
+ const VOICE = /^Voice (?:[1-9]|[1-9]\d)$/
31
+ const sha = (value: string) => createHash('sha256').update(value).digest('hex')
32
+ const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
33
+ const fail = (message: string): never => { throw new Error(`Mac shadow repair: ${message}`) }
34
+ /** Integrity helper, NOT a native execution/qualification certificate. */
35
+ export function bindMacSpeakerShadowRepair(source: MacShadowRepairSource): string { return sha(JSON.stringify(source)) }
36
+ function boundedJSON(text: string, max: number): any {
37
+ if (typeof text !== 'string' || Buffer.byteLength(text) > max) fail('JSON byte bound')
38
+ return JSON.parse(text)
39
+ }
40
+ function closed(doc: Doc, immutableView = false) {
41
+ if (!doc || !Array.isArray(doc.chunks) || !Array.isArray(doc.chunkEntries) || doc.finalizationState !== 'complete' || doc.sourceType !== 'mac_system_audio' || !/^mac_/.test(doc.sessionId) || doc.active || doc.isActive || doc.claimPending || doc.status === 'recording' || doc.mergedFrom || doc.sourceSessionIds || doc.isMerged) fail('closed original Mac capture required')
42
+ // Even a valid prior reader proof binds the complete speech document. A new
43
+ // label patch would invalidate it. This adapter cannot compose those receipts.
44
+ if (!immutableView && ['speakerRepair', 'speakerProfilePass'].some(key => Object.hasOwn(doc, key))) fail('prior repair/profile receipt requires proof composition')
45
+ }
46
+ const ANNOTATION_KEYS = new Set(['speaker', 'originalSpeaker', 'speakerCorrectionBatchId', 'speakerCorrectionId', 'manualSpeaker', 'speakerManuallyAssigned', 'confirmedByHuman', 'manuallyAssigned', 'manuallyCorrected', 'speakerOrigin', 'speakerSource', 'speakerPolicy', 'speakerProvenance', 'source'])
47
+ /** Compare the complete source row recursively, allowing only speaker annotation
48
+ * changes. Annotation differences are handled by protection, never by copying
49
+ * labels back from ORIGINAL. Array presence, text, clocks and diagnostics remain
50
+ * exact on every support/competitor row, not just eventual assignments. */
51
+ function sourceGeometry(value: any): any {
52
+ if (Array.isArray(value)) return value.map(sourceGeometry)
53
+ if (!value || typeof value !== 'object') return value
54
+ return Object.fromEntries(Object.keys(value).filter(key => !ANNOTATION_KEYS.has(key)).sort().map(key => [key, sourceGeometry(value[key])]))
55
+ }
56
+ function rows(chunk: Doc): Doc[] {
57
+ const out = [chunk]
58
+ for (const key of ['segments', 'finalSegments', 'words']) {
59
+ if (chunk[key] !== undefined && !Array.isArray(chunk[key])) fail('malformed label surface')
60
+ for (const row of chunk[key] ?? []) {
61
+ if (!row || typeof row !== 'object') fail('malformed label row')
62
+ if (key !== 'words' || 'speaker' in row) out.push(row)
63
+ }
64
+ }
65
+ return out
66
+ }
67
+ function mapping(doc: Doc): Array<{ raw: number; chunk: Doc; entry: Doc }> {
68
+ const entries = shadowEntries(doc)
69
+ if (entries.length > 6000 || entries.some((e, i) => e.chunkIndex !== i)) fail('complete ordered raw map required')
70
+ const spoken = entries.filter(e => e.chunk.text)
71
+ if (spoken.length !== doc.chunks.length) fail('compacted map count')
72
+ return spoken.map((e, i) => {
73
+ const chunk = doc.chunks[i]
74
+ if (!chunk || ['text', 'elapsed', 'audioSha256', 'audioDurationMs'].some(k => !equal(chunk[k], e.chunk[k]))) fail('ambiguous compacted raw join')
75
+ if (typeof chunk.text !== 'string' || !HASH.test(chunk.audioSha256)) fail('unbound spoken source')
76
+ return { raw: e.chunkIndex, chunk, entry: e.chunk }
77
+ })
78
+ }
79
+ function ledgerProtection(text: string, map: ReturnType<typeof mapping>, original: ReturnType<typeof mapping>): Set<number> {
80
+ if (Buffer.byteLength(text) > 8 * 1024 * 1024) fail('ledger byte bound')
81
+ const history = new Map<string, Doc[]>()
82
+ const replay = original.map(m => m.chunk.speaker)
83
+ const lines = text.split('\n').filter(x => x.trim())
84
+ if (lines.length > 20000) fail('ledger row bound')
85
+ for (const line of lines) {
86
+ const row = JSON.parse(line)
87
+ if (!row || typeof row.id !== 'string' || !row.id || !['intent', 'applied', 'failed', 'confirmed', 'confirmed-chunks', 'reverted'].includes(row.phase) || !Array.isArray(row.chunks) || row.chunks.some((i: number) => !Number.isInteger(i) || !map[i]) || new Set(row.chunks).size !== row.chunks.length) fail('ambiguous correction ledger')
88
+ const prior = history.get(row.id) ?? []
89
+ if (prior.at(-1)?.phase === 'intent' && !['applied', 'failed'].includes(row.phase)) fail('incomplete correction intent')
90
+ const intended = prior.at(-1)
91
+ const resolvedIntent = intended?.chunks.length ? intended.chunks : replay.flatMap((label, i) => label === intended?.from ? [i] : [])
92
+ // CorrectionRow documents an explicit empty intent as all rows carrying from;
93
+ // applied rows normally carry the resolved list. Missing chunks is not that
94
+ // explicit contract and was rejected above.
95
+ if (row.phase === 'intent' && prior.length || row.phase === 'applied' && (intended?.phase !== 'intent' || row.chunks.length && !equal(resolvedIntent, row.chunks) || intended?.from !== row.from || intended?.to !== row.to) || row.phase === 'failed' && intended?.phase !== 'intent' || row.phase === 'reverted' && !prior.some(r => r.phase === 'applied')) fail('ambiguous correction history')
96
+ if (row.phase === 'applied' && row.chunks.length) {
97
+ if (typeof row.from !== 'string' || typeof row.to !== 'string' || !row.to || row.chunks.some((i: number) => replay[i] !== row.from)) fail('correction history source label mismatch')
98
+ for (const i of row.chunks) replay[i] = row.to
99
+ }
100
+ if (row.phase === 'reverted') {
101
+ const applied = prior.find(e => e.phase === 'applied')!
102
+ if (!applied.chunks.length || applied.chunks.some((i: number) => replay[i] !== applied.to)) fail('ambiguous correction revert')
103
+ for (const i of applied.chunks) replay[i] = applied.from
104
+ }
105
+ history.set(row.id, [...prior, row])
106
+ }
107
+ const protectedRaw = new Set<number>()
108
+ for (const events of history.values()) {
109
+ if (events.at(-1)?.phase === 'intent') fail('incomplete correction intent')
110
+ // Retain protection even for a historical reverted decision. No machine write
111
+ // should silently reinterpret human history. Empty confirmations keep the
112
+ // existing offline CLI's conservative protect-all behavior.
113
+ for (const e of events.filter(e => ['applied', 'confirmed', 'confirmed-chunks'].includes(e.phase))) {
114
+ if (!e.chunks.length) for (const m of map) protectedRaw.add(m.raw)
115
+ else for (const i of e.chunks) protectedRaw.add(map[i].raw)
116
+ }
117
+ }
118
+ return protectedRaw
119
+ }
120
+ function batchBindings(original: Doc, current: Doc, entries: ReturnType<typeof shadowEntries>, samples: Record<string, number>, protection: Set<number>) {
121
+ const wordSpans: Array<{ binding: MacSpeakerWordBinding; raw: number[]; protectedRaw: number[]; protected: boolean }> = []
122
+ if (current.batchSegments !== undefined && !Array.isArray(current.batchSegments)) fail('batch schema')
123
+ if (!equal((original.batchSegments ?? []).map((b: Doc) => [b.startChunkIdx, b.endChunkIdx, b.speakerWords?.length]), (current.batchSegments ?? []).map((b: Doc) => [b.startChunkIdx, b.endChunkIdx, b.speakerWords?.length]))) fail('current batch topology drift')
124
+ for (const [si, batch] of (current.batchSegments ?? []).entries()) {
125
+ if (!Number.isInteger(batch.startChunkIdx) || !Number.isInteger(batch.endChunkIdx) || batch.startChunkIdx < 0 || batch.endChunkIdx < batch.startChunkIdx || batch.endChunkIdx >= entries.length || !Array.isArray(batch.speakerWords)) fail('batch raw clock range')
126
+ let offset = 0
127
+ const intervals = []
128
+ for (let raw = batch.startChunkIdx; raw <= batch.endChunkIdx; raw++) { const end = offset + samples[String(raw)] / 16000; intervals.push({ raw, start: offset, end }); offset = end }
129
+ for (const [wi, word] of batch.speakerWords.entries()) {
130
+ if (!word || typeof word.word !== 'string' || !Number.isFinite(word.start) || !Number.isFinite(word.end) || word.start < 0 || word.end < word.start || word.end > offset) fail('batch word clock')
131
+ const raw = intervals.filter(r => word.end === word.start ? word.start >= r.start && word.start < r.end : word.start < r.end && word.end > r.start).map(r => r.raw)
132
+ const oldWord = original.batchSegments[si].speakerWords[wi], changedWord = !equal(oldWord, word)
133
+ // Preserve even an anonymous, unmarked edit. An altered word clock protects
134
+ // both its original and current source intersections; never normalize it.
135
+ const priorRaw = changedWord ? intervals.filter(r => oldWord?.end === oldWord?.start ? oldWord.start >= r.start && oldWord.start < r.end : oldWord?.start < r.end && oldWord?.end > r.start).map(r => r.raw) : []
136
+ if (changedWord) for (const r of priorRaw) protection.add(r)
137
+ wordSpans.push({ binding: { segmentIndex: si, wordIndex: wi, word: word.word, start: word.start, end: word.end, from: word.speaker }, raw, protectedRaw: [...new Set([...raw, ...priorRaw])], protected: changedWord || isMacSpeakerHumanProtected(word) || !/^(?:Voice [1-9]\d?|Ext|Unknown|Unidentified(?: \d+)?|Speaker \d+)$/.test(word.speaker) })
138
+ }
139
+ }
140
+ for (const word of wordSpans) if (word.protected) for (const raw of word.protectedRaw) protection.add(raw)
141
+ return wordSpans
142
+ }
143
+ /** No remap/proposal input: recompute unchanged floor15/.8/1.5 decision from the
144
+ * entire bound export and production segments reconstructed from ORIGINAL rows. */
145
+ function inspectSource(input: MacShadowRepairInput, immutableView: boolean) {
146
+ if (Object.keys(input).some(k => !['originalJSON', 'currentJSON', 'ledgerJSONL', 'rawAudio', 'timeline', 'cohort', 'sourceBindingSHA256'].includes(k))) fail('unsupported proposal/remap input')
147
+ for (const [value, maximum] of [[input.originalJSON, 32], [input.currentJSON, 32], [input.ledgerJSONL, 8], [input.cohort?.nativeJSON, 64], [input.cohort?.embeddingsJSON, 96]] as const) if (typeof value !== 'string' || Buffer.byteLength(value) > maximum * 1024 * 1024) fail('input byte bound')
148
+ if (!Array.isArray(input.rawAudio) || input.rawAudio.length > 6000 || !Array.isArray(input.timeline?.rows) || input.timeline.rows.length > 6000) fail('raw/timeline schema bound')
149
+ const { sourceBindingSHA256, ...source } = input
150
+ if (!HASH.test(sourceBindingSHA256) || bindMacSpeakerShadowRepair(source) !== sourceBindingSHA256) fail('full source binding drift')
151
+ const original = boundedJSON(input.originalJSON, 32 * 1024 * 1024), current = boundedJSON(input.currentJSON, 32 * 1024 * 1024)
152
+ closed(original, immutableView); closed(current, immutableView)
153
+ if (original.sessionId !== current.sessionId) fail('capture mismatch')
154
+ const oldMap = mapping(original), map = mapping(current)
155
+ if (oldMap.some(m => !equal(sourceGeometry(m.chunk), sourceGeometry(m.entry)))) fail('original root/raw source geometry disagreement')
156
+ if (['startTime', 'durationMs'].some(key => !equal(original[key], current[key]))) fail('current capture clock drift')
157
+ if (oldMap.length !== map.length || oldMap.some((m, i) => m.raw !== map[i].raw || ['text', 'elapsed', 'audioSha256', 'audioDurationMs'].some(k => !equal(m.chunk[k], map[i].chunk[k])))) fail('current speech source drift')
158
+ if (oldMap.some((m, i) => !equal(sourceGeometry(m.chunk), sourceGeometry(map[i].chunk)))) fail('current compacted source geometry drift')
159
+ const entries = shadowEntries(original), currentEntries = shadowEntries(current)
160
+ if (entries.length !== currentEntries.length || entries.some((e, i) => ['text', 'elapsed', 'audioSha256', 'audioDurationMs'].some(k => !equal(e.chunk[k], currentEntries[i].chunk[k])))) fail('current raw source drift')
161
+ for (const entry of [...entries, ...currentEntries]) rows(entry.chunk)
162
+ if (entries.some((e, i) => !equal(sourceGeometry(e.chunk), sourceGeometry(currentEntries[i].chunk)))) fail('current raw source geometry drift')
163
+ if (!Array.isArray(input.rawAudio) || input.rawAudio.length !== entries.length || !input.rawAudio.length) fail('all raw WAV bindings required')
164
+ const samples: Record<string, number> = {}
165
+ const placed = input.rawAudio.map((raw, i) => {
166
+ const chunk = entries[i].chunk
167
+ if (raw.rawChunkIndex !== i || !HASH.test(raw.wavSHA256) || !HASH.test(raw.pcmSHA256) || chunk.audioSha256 && raw.wavSHA256 !== chunk.audioSha256 || !Number.isSafeInteger(raw.sampleCount) || raw.sampleCount <= 0 || raw.sampleCount > 480000 || !Number.isFinite(chunk.elapsed) || (chunk.elapsed as number) < 0) fail('raw WAV source binding')
168
+ samples[String(i)] = raw.sampleCount
169
+ return { rawChunkIndex: i, startSample: Math.floor((chunk.elapsed as number) * 16 + .5), sampleCount: raw.sampleCount, usedSampleCount: 0 }
170
+ }).sort((a, b) => a.startSample - b.startSample || a.rawChunkIndex - b.rawChunkIndex)
171
+ for (let i = 0; i < placed.length; i++) {
172
+ const row = placed[i]
173
+ row.usedSampleCount = i + 1 < placed.length ? Math.min(row.sampleCount, placed[i + 1].startSample - row.startSample) : row.sampleCount
174
+ if (row.usedSampleCount <= 0) fail('shadowed timeline row')
175
+ }
176
+ const total = Math.max(...placed.map(r => r.startSample + r.sampleCount))
177
+ if (total > 4 * 3600 * 16000 || !equal(placed, input.timeline.rows) || total !== input.timeline.total || !HASH.test(input.timeline.pcmSHA256)) fail('timeline geometry drift')
178
+ if (!HASH.test(input.cohort.modelSHA256) || !HASH.test(input.cohort.cliSHA256)) fail('model/CLI binding required')
179
+ // Validate every export row, including short confusers that decide will reject.
180
+ const native = boundedJSON(input.cohort.nativeJSON, 64 * 1024 * 1024), embeddings = boundedJSON(input.cohort.embeddingsJSON, 96 * 1024 * 1024)
181
+ if (!native || !Array.isArray(native.segments) || native.segments.length > 20000 || !Array.isArray(embeddings) || embeddings.length > 10000) fail('cohort schema bound')
182
+ const vector = (v: unknown) => Array.isArray(v) && v.length === 256 && v.every(x => typeof x === 'number' && Number.isFinite(x) && Math.abs(x) <= 1e10) && v.some(x => x !== 0)
183
+ for (const s of native.segments) {
184
+ if (!s || typeof s.speakerId !== 'string' || !s.speakerId || s.speakerId.length > 100 || !Number.isFinite(s.startTimeSeconds) || !Number.isFinite(s.endTimeSeconds) || s.startTimeSeconds < 0 || s.endTimeSeconds <= s.startTimeSeconds || s.endTimeSeconds > total / 16000 + 1) fail('native turn bound')
185
+ if (s.embedding !== undefined && !vector(s.embedding)) fail('native vector must be finite nonzero256d')
186
+ }
187
+ const excludedEmbeddingWindows: Array<{ originalIndex: number; startTime: number; endTime: number; windowSHA256: string; reason: 'window-crosses-capture-end' }> = []
188
+ const effectiveEmbeddings: ShadowEmbeddingRow[] = []
189
+ for (const [originalIndex, e] of embeddings.entries()) {
190
+ if (!e || !vector(e.embedding256) || !Number.isInteger(e.cluster) || e.cluster < 0 || e.cluster > 9999 || !Number.isFinite(e.startTime) || !Number.isFinite(e.endTime) || e.startTime < 0 || e.endTime <= e.startTime) fail('embedding must be bounded finite nonzero256d')
191
+ if (e.endTime > total / 16000 + 1e-6) {
192
+ // A vector covering source AND terminal model padding is not a vector for
193
+ // a cropped source window. Never clip its time or reuse its vector. The
194
+ // immutable-view protocol can exclude the ENTIRE terminal window while
195
+ // retaining the complete export in its source binding and audit receipt.
196
+ // The physical writer remains strict. Windows outside the capture or an
197
+ // overrun beyond the native export's one-second envelope remain fatal.
198
+ if (!immutableView || e.startTime >= total / 16000 || e.endTime > total / 16000 + 1) fail('embedding must be bounded finite nonzero256d')
199
+ excludedEmbeddingWindows.push({ originalIndex, startTime: e.startTime, endTime: e.endTime, windowSHA256: sha(JSON.stringify(e)), reason: 'window-crosses-capture-end' })
200
+ } else effectiveEmbeddings.push(e)
201
+ }
202
+ const { turns, dropped } = nativeTurns(native.segments, total / 16000)
203
+ if (dropped) fail('malformed/dropped native turn')
204
+ const ledgerRawProtection = ledgerProtection(input.ledgerJSONL, map, oldMap)
205
+ const currentRoots = new Map(map.map(m => [m.raw, m.chunk]))
206
+ // Historical ASR occasionally extends a generic Ext segment past its own WAV.
207
+ // An immutable view can quarantine that WHOLE row, never clip it or synthesize
208
+ // replacement support. Keep every native/confuser row and bind the unmodified
209
+ // original JSON; physical writers and malformed Voice/human rows still refuse.
210
+ const excludedProductionSegments: Array<{ rawChunkIndex: number; segmentIndex: number; rowSHA256: string; startSec: number; endSec: number; sourceSamples: number; overrunSamples: number; reason: 'generic-segment-crosses-source-end' }> = []
211
+ const productionEntries = entries.map(e => ({ ...e, chunk: { ...e.chunk, segments: ((e.chunk.segments as Doc[] | undefined) ?? []).filter((s, segmentIndex) => {
212
+ if (!s || typeof s.speaker !== 'string' || !Number.isFinite(s.startSec) || !Number.isFinite(s.endSec) || s.startSec < 0 || s.endSec <= s.startSec) fail('production segment coordinates')
213
+ const sourceSamples = samples[String(e.chunkIndex)], overrunSamples = s.endSec * 16000 - sourceSamples
214
+ if (overrunSamples <= 1e-6) return true
215
+ const currentRow = currentEntries[e.chunkIndex].chunk
216
+ const currentRoot = currentRoots.get(e.chunkIndex)
217
+ if (!immutableView || s.speaker !== 'Ext' || e.chunk.speaker !== 'Ext' || currentRow.speaker !== 'Ext'
218
+ || currentRoot && currentRoot.speaker !== 'Ext' || s.startSec * 16000 >= sourceSamples || overrunSamples > 16000
219
+ || ledgerRawProtection.has(e.chunkIndex)
220
+ || [e.chunk, currentRow, ...(currentRoot ? [currentRoot] : [])].some(c => hasMacSpeakerProtectedRows(c) || !equal(rows(c), rows(e.chunk)) && c !== e.chunk)) fail('production segment coordinates')
221
+ excludedProductionSegments.push({ rawChunkIndex: e.chunkIndex, segmentIndex, rowSHA256: sha(JSON.stringify(s)), startSec: s.startSec, endSec: s.endSec, sourceSamples, overrunSamples, reason: 'generic-segment-crosses-source-end' })
222
+ return false
223
+ }) } }))
224
+ const production = shadowProductionSegments(productionEntries, placed, total)
225
+ if (production.segs.length > 40000) fail('production segment bound')
226
+ const decision = decide(production.segs, total, turns, effectiveEmbeddings)
227
+ const protection = new Set(ledgerRawProtection)
228
+ for (const row of excludedProductionSegments) protection.add(row.rawChunkIndex)
229
+ for (const m of map) if ([m.chunk, m.entry].some(c => hasMacSpeakerProtectedRows(c) || rows(c).some(r => !VOICE.test(r.speaker)))) protection.add(m.raw)
230
+ const wordSpans = batchBindings(original, current, entries, samples, protection)
231
+ // Anonymous estimate aliases inherit the original machine localization; they
232
+ // must not confuse a structural Ext/Voice boundary with a human correction.
233
+ // Keep this separate from the stronger interval-projection exclusion above.
234
+ // No tail or mixed boundary admitted here becomes new acoustic support.
235
+ const humanProtection = new Set(ledgerRawProtection)
236
+ for (const row of excludedProductionSegments) humanProtection.add(row.rawChunkIndex)
237
+ const named = (row: Doc) => typeof row.speaker === 'string' && row.speaker.trim() !== ''
238
+ && !/^(?:Voice [1-9]\d?|Ext|Unknown|Unidentified(?: \d+)?|Speaker \d+)$/.test(row.speaker)
239
+ for (const [i, m] of map.entries()) {
240
+ const before = oldMap[i]
241
+ if (!equal(rows(before.chunk), rows(m.chunk)) || !equal(rows(before.entry), rows(m.entry))
242
+ || [m.chunk, m.entry].some(c => hasMacSpeakerProtectedRows(c) || rows(c).some(named))) humanProtection.add(m.raw)
243
+ }
244
+ for (const word of wordSpans) if (word.protected) for (const raw of word.protectedRaw) humanProtection.add(raw)
245
+ // A view keeps all base bytes and previous proof receipts intact. It may use
246
+ // this inspection, but it is not permission to feed an old receipt into the
247
+ // physical-patch writer below.
248
+ return { original, current, oldMap, map, entries, currentEntries, samples, placed, total, native, embeddings: effectiveEmbeddings, excludedEmbeddingWindows, excludedProductionSegments, turns, production, decision, protection, humanProtection, wordSpans }
249
+ }
250
+
251
+ /** Complete immutable-source inspection for the call-wide derived view. This
252
+ * accepts existing proof metadata because consumers MUST validate it against
253
+ * the unmodified base, and MUST NOT patch that base. No native execution is
254
+ * authenticated by a caller-supplied hash. */
255
+ export function inspectMacSpeakerShadowCallSource(input: MacShadowRepairInput) {
256
+ return inspectSource(input, true)
257
+ }
258
+
259
+ export function buildMacSpeakerShadowRepair(input: MacShadowRepairInput): MacShadowRepairPreview {
260
+ const { sourceBindingSHA256 } = input
261
+ const { original, current, oldMap, map, entries, currentEntries, samples, placed, total, native, embeddings, turns, production, decision, protection, wordSpans } = inspectSource(input, false)
262
+ const withheld = new Map<number, string>(), chosen = new Map<number, { from: string; to: string }>()
263
+ let candidateChunks = 0
264
+ for (let i = 0; i < oldMap.length; i++) {
265
+ const before = oldMap[i], now = map[i], to = decision.remap.get(before.chunk.speaker)
266
+ if (!to) { withheld.set(now.raw, VOICE.test(before.chunk.speaker) ? 'no-qualified-merge' : 'non-Voice'); continue }
267
+ candidateChunks++
268
+ if (!VOICE.test(to) || !VOICE.test(before.chunk.speaker)) { withheld.set(now.raw, 'non-Voice'); continue }
269
+ if (protection.has(now.raw)) { withheld.set(now.raw, 'human-or-named-protected'); continue }
270
+ if (!equal(rows(before.chunk), rows(now.chunk)) || !equal(rows(now.chunk), rows(now.entry)) || rows(now.chunk).some(r => r.speaker !== before.chunk.speaker)) { withheld.set(now.raw, 'mixed-or-current-surfaces-differ'); continue }
271
+ const row = placed.find(r => r.rawChunkIndex === now.raw)!, segs = production.segs.filter((_, j) => production.sources[j].chunkIndex === now.raw)
272
+ const cluster = decision.rows.get(before.chunk.speaker)?.topCluster
273
+ if (row.usedSampleCount !== row.sampleCount || !segs.length || !cluster || segs.some(([lo, hi, label]) => label !== before.chunk.speaker || [...exactOverlap(lo, hi, decision.index)].some(([key]) => key !== cluster))) { withheld.set(now.raw, 'outside-fully-qualified-chunk'); continue }
274
+ chosen.set(now.raw, { from: now.chunk.speaker, to })
275
+ }
276
+ for (const raw of chosen.keys()) if (protection.has(raw)) { chosen.delete(raw); withheld.set(raw, 'batch-protected-or-crosses-unqualified-boundary') }
277
+ // Remove entire chunks to a fixed point; never write a partly unqualified word.
278
+ let changed = true
279
+ while (changed) {
280
+ changed = false
281
+ for (const word of wordSpans) {
282
+ const touched = word.raw.filter(r => chosen.has(r))
283
+ if (touched.length && (word.protected || word.raw.some(r => !chosen.has(r)) || new Set(touched.map(r => chosen.get(r)!.to)).size !== 1)) for (const raw of touched) { chosen.delete(raw); withheld.set(raw, 'batch-protected-or-crosses-unqualified-boundary'); changed = true }
284
+ }
285
+ }
286
+ const protectedRawChunkIndices = [...protection].sort((a, b) => a - b)
287
+ const assignments = [...chosen].map(([rawChunkIndex, pair]) => ({ rawChunkIndex, ...pair, evidenceId: `shadow256_${sourceBindingSHA256}`, batchWords: wordSpans.filter(w => w.raw[0] === rawChunkIndex).map(w => w.binding) }))
288
+ let plan: MacSpeakerRepairPlan | null = assignments.length ? { id: `shadow256_${sourceBindingSHA256.slice(0, 24)}`, evidenceHash: sourceBindingSHA256, expectedDocumentHash: hashMacSpeakerDocument(current), protectedRawChunkIndices, rawAudioSampleCounts: samples, assignments } : null
289
+ let after = current
290
+ if (plan) {
291
+ try { after = projectMacSpeakerRepair(current, plan).doc }
292
+ catch { for (const a of assignments) withheld.set(a.rawChunkIndex, 'strict-projector-refused'); plan = null }
293
+ }
294
+ const rootLabels = (d: Doc) => new Set(d.chunks.map((c: Doc) => c.speaker)).size
295
+ const labels = (d: Doc) => new Set([
296
+ ...d.chunks.flatMap((c: Doc) => rows(c).map(r => r.speaker)),
297
+ ...d.chunkEntries.flatMap((e: Doc) => rows(e.chunk).map(r => r.speaker)),
298
+ ...(d.batchSegments ?? []).flatMap((b: Doc) => b.speakerWords.map((w: Doc) => w.speaker)),
299
+ ].filter(v => typeof v === 'string' && v)).size
300
+ // An acoustic proposal alone is not the requested fragmentation repair. The
301
+ // CURRENT root display must improve without increasing any stored label set.
302
+ if (plan && (rootLabels(after) >= rootLabels(current) || labels(after) > labels(current))) {
303
+ for (const assignment of plan.assignments) withheld.set(assignment.rawChunkIndex, 'non-reducing-plan')
304
+ plan = null; after = current
305
+ }
306
+ return { plan, provenance: { productionQualified: false, nativeAuthenticated: false, sourceBindingSHA256, originalSHA256: sha(input.originalJSON), currentSHA256: sha(input.currentJSON), ledgerSHA256: sha(input.ledgerJSONL) }, protectedRawChunkIndices, withheld: [...withheld].map(([rawChunkIndex, reason]) => ({ rawChunkIndex, reason })), counts: { raw: entries.length, spoken: map.length, embeddings: embeddings.length, acousticAcceptedMerges: decision.accepted, candidateChunks, assignedChunks: plan?.assignments.length ?? 0, protectedChunks: protection.size, withheldChunks: withheld.size, beforeLabels: labels(current), afterLabels: labels(after), beforeRootLabels: rootLabels(current), afterRootLabels: rootLabels(after) } }
307
+ }
@@ -77,6 +77,7 @@ import {
77
77
  greedyDiversitySelect,
78
78
  } from './voice-enrolment-selection.js'
79
79
  import { VOICEPRINT_MIN_AUDIO_SEC } from './voiceprint-floor.js'
80
+ import { sameSpeakerName } from './speaker-name-nfc.js'
80
81
 
81
82
  /** A turn shorter than this is not sliced out of a mixed chunk. */
82
83
  export const MAC_ASSIGN_MIN_SLICE_SEC = 3.0
@@ -196,24 +197,55 @@ function emptyReport(person: string, skipped: MacAssignSkip): MacAssignReport {
196
197
  }
197
198
  }
198
199
 
199
- interface Turn { speaker: string; startSec?: number; endSec?: number }
200
- interface Unit { raw: number; slice: { startSec: number; endSec: number } | null }
200
+ export interface MacVoiceTurn { speaker: string; startSec?: number; endSec?: number }
201
+ export interface MacVoiceSlice { startSec: number; endSec: number }
202
+ interface Unit { raw: number; slice: MacVoiceSlice | null }
201
203
 
202
- function turnsOf(chunk: Record<string, unknown> | undefined): Turn[] {
204
+ export function macVoiceTurnsOf(chunk: Record<string, unknown> | undefined): MacVoiceTurn[] {
203
205
  if (!chunk) return []
204
206
  const final = chunk.finalSegments
205
207
  const live = chunk.segments
206
208
  const list = Array.isArray(final) && final.length > 0 ? final : Array.isArray(live) ? live : []
207
- return list.filter((t): t is Turn => !!t && typeof t === 'object' && typeof (t as Turn).speaker === 'string')
209
+ return list.filter((t): t is MacVoiceTurn => !!t && typeof t === 'object' && typeof (t as MacVoiceTurn).speaker === 'string')
208
210
  }
209
211
 
210
- function spread<T>(items: T[], max: number): T[] {
212
+ /** At most `max` items, evenly spread across the list. */
213
+ export function spreadMacVoiceUnits<T>(items: T[], max: number): T[] {
211
214
  if (items.length <= max) return items
212
215
  const out: T[] = []
213
216
  for (let k = 0; k < max; k++) out.push(items[Math.floor((k * items.length) / max)])
214
217
  return out
215
218
  }
216
219
 
220
+ /**
221
+ * What one chunk contributes for a voice: `whole` when every turn is that voice or the chunk has no turns,
222
+ * otherwise that voice's turns long enough to slice, inset at each end. Empty means nothing here is usable.
223
+ * Voice suggestions (mac-voice-suggestions.ts) use the same per-chunk rule. They do not pick the same units
224
+ * overall: this plan spreads to the cap before the 2 s filter, suggestions spread after it, as the study did.
225
+ */
226
+ export function macVoiceChunkUnits(turns: MacVoiceTurn[], voice: string): 'whole' | MacVoiceSlice[] {
227
+ if (turns.length === 0 || turns.every(t => t.speaker === voice)) return 'whole'
228
+ return turns
229
+ .filter(t => t.speaker === voice
230
+ && Number.isFinite(t.startSec) && Number.isFinite(t.endSec)
231
+ && (t.endSec as number) - (t.startSec as number) >= MAC_ASSIGN_MIN_SLICE_SEC)
232
+ .map(t => ({ startSec: (t.startSec as number) + MAC_ASSIGN_SLICE_INSET_SEC, endSec: (t.endSec as number) - MAC_ASSIGN_SLICE_INSET_SEC }))
233
+ }
234
+
235
+ /** The audio a unit addresses and its length. Null when the WAV is unusable or the slice is empty. */
236
+ export function macVoiceUnitAudio(wav: Buffer, slice: MacVoiceSlice | null): { audio: Buffer; sec: number } | null {
237
+ const pcm = readPcm16Mono(wav)
238
+ if (!pcm || pcm.sampleRate !== SAMPLE_RATE) return null
239
+ let audio = wav
240
+ if (slice) {
241
+ const from16 = Math.max(0, Math.round(slice.startSec * SAMPLE_RATE)) * 2
242
+ const to16 = Math.min(pcm.pcm.length, Math.round(slice.endSec * SAMPLE_RATE) * 2)
243
+ if (to16 <= from16) return null
244
+ audio = pcm16ToWav(pcm.pcm.subarray(from16, to16))
245
+ }
246
+ return { audio, sec: (audio.length - 44) / (SAMPLE_RATE * 2) }
247
+ }
248
+
217
249
  function centroid(vectors: Float32Array[]): Float32Array | null {
218
250
  if (vectors.length === 0) return null
219
251
  const out = new Float32Array(vectors[0].length)
@@ -239,7 +271,7 @@ export async function planMacVoiceAssignment(input: MacAssignInput): Promise<Mac
239
271
  if (isPlaceholderLabel(to)) return none('unknown_profile')
240
272
  if (input.ownerLabel && sameName(to, input.ownerLabel)) return none('owner_profile')
241
273
  let profile: { name: string; embeddings: number[][]; sources?: string[] } | undefined
242
- try { profile = readVoiceProfiles().profiles.find(p => p.name === to) } catch { return none('store_unavailable') }
274
+ try { profile = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, to)) } catch { return none('store_unavailable') }
243
275
  if (!profile) return none('unknown_profile')
244
276
  if (!chunkEmbeddingsEnabled()) return none('disabled')
245
277
  if (!isEmbeddingAvailable()) return none('store_unavailable')
@@ -261,22 +293,16 @@ export async function planMacVoiceAssignment(input: MacAssignInput): Promise<Mac
261
293
  const report = emptyReport(to, null)
262
294
  const units: Unit[] = []
263
295
  for (const raw of [...new Set(rawIndices)].sort((a, b) => a - b)) {
264
- const turns = turnsOf(chunksByRaw.get(raw))
265
- if (turns.length === 0 || turns.every(t => t.speaker === from)) { units.push({ raw, slice: null }); continue }
266
- const usable = turns.filter(t => t.speaker === from
267
- && Number.isFinite(t.startSec) && Number.isFinite(t.endSec)
268
- && (t.endSec as number) - (t.startSec as number) >= MAC_ASSIGN_MIN_SLICE_SEC)
269
- if (usable.length === 0) { report.multiVoiceSkipped += 1; continue }
270
- for (const t of usable) units.push({
271
- raw,
272
- slice: { startSec: (t.startSec as number) + MAC_ASSIGN_SLICE_INSET_SEC, endSec: (t.endSec as number) - MAC_ASSIGN_SLICE_INSET_SEC },
273
- })
296
+ const chunkUnits = macVoiceChunkUnits(macVoiceTurnsOf(chunksByRaw.get(raw)), from)
297
+ if (chunkUnits === 'whole') { units.push({ raw, slice: null }); continue }
298
+ if (chunkUnits.length === 0) { report.multiVoiceSkipped += 1; continue }
299
+ for (const slice of chunkUnits) units.push({ raw, slice })
274
300
  }
275
301
 
276
302
  const embeddings: Float32Array[] = []
277
303
  const secs: number[] = []
278
304
  let foundAudio = 0
279
- const picked = spread(units, MAC_ASSIGN_MAX_UNITS)
305
+ const picked = spreadMacVoiceUnits(units, MAC_ASSIGN_MAX_UNITS)
280
306
  stats.units = picked.length
281
307
  const deadline = started + (input.deadlineMs ?? deadlineMsFromEnv())
282
308
  for (const unit of picked) {
@@ -302,16 +328,9 @@ export async function planMacVoiceAssignment(input: MacAssignInput): Promise<Mac
302
328
  if (hit) { stats.reused += 1; embeddings.push(hit.vec); secs.push(hit.sec); continue }
303
329
  let wav: Buffer
304
330
  try { wav = readFileSync(path) } catch { report.missingAudio += 1; continue }
305
- const pcm = readPcm16Mono(wav)
306
- if (!pcm || pcm.sampleRate !== SAMPLE_RATE) continue
307
- let audio = wav
308
- if (unit.slice) {
309
- const from16 = Math.max(0, Math.round(unit.slice.startSec * SAMPLE_RATE)) * 2
310
- const to16 = Math.min(pcm.pcm.length, Math.round(unit.slice.endSec * SAMPLE_RATE) * 2)
311
- if (to16 <= from16) continue
312
- audio = pcm16ToWav(pcm.pcm.subarray(from16, to16))
313
- }
314
- const sec = (audio.length - 44) / (SAMPLE_RATE * 2)
331
+ const cut = macVoiceUnitAudio(wav, unit.slice)
332
+ if (!cut) continue
333
+ const { audio, sec } = cut
315
334
  if (sec < VOICEPRINT_MIN_AUDIO_SEC) { report.shortSkipped += 1; continue }
316
335
  const embedding = extractEmbedding(audio)
317
336
  stats.embedded += 1
@@ -0,0 +1,195 @@
1
+ // 6.96.3: how a saved Mac recording's anonymous voices are DISPLAYED in speaker review.
2
+ //
3
+ // WHY. One real call returned 61 rows from GET /meeting/:sessionId/speakers: 58 `Voice N`, Ext and two
4
+ // names. Speaker shadow already logged, for that call, which of those voices its gates would merge
5
+ // (speaker-shadow.ts, proposals.accepted). Miles approved showing that at read time: merged groups
6
+ // first, then one expandable row for voices under 15 s after merging. Display only.
7
+ //
8
+ // WHAT THIS IS NOT. It writes nothing and applies nothing: no label, name, sample, packet or sidecar
9
+ // changes, and the words stay one click away under the same labels. There is no flag. The field exists
10
+ // only when this Mac has a `proposed` shadow record for this session, so a Mac with shadow off answers
11
+ // exactly what it answered before.
12
+ //
13
+ // THE RESPONSE WINS OVER THE RECORD. The record is a log from when the meeting finished; the labels in
14
+ // the current response are the truth. A voice named since then leaves its group, and a current voice the
15
+ // record never saw still gets a row. Every anonymous label in the response lands in exactly one bucket.
16
+ //
17
+ // FAILS TO ABSENT. Any malformed record, unreadable store or unexpected value omits the field, which
18
+ // means Control shows what it shows today. Nothing here throws.
19
+
20
+ import { readdir, stat } from 'node:fs/promises'
21
+ import { join } from 'node:path'
22
+ import { isVoiceLabel } from './mac-voice-labels.js'
23
+ import { readSpeakerShadowRecords, SPEAKER_SHADOW_RECORDS } from './speaker-shadow.js'
24
+ import { speakerShadowRoot } from './speaker-shadow-models.js'
25
+
26
+ /** A voice with less known speech than this, after merging, folds into the one expandable row. */
27
+ export const VOICE_DISPLAY_FOLD_UNDER_SECONDS = 15 as const
28
+ /** Past this the store is not read at all. At 12 runs a day it is about two years of records. */
29
+ const MAX_STORE_BYTES = 256 * 1024 * 1024
30
+ const MAX_RECORDS_PER_SESSION = 100
31
+ const EVIDENCE_CACHE_MAX = 32
32
+
33
+ export interface VoiceDisplay {
34
+ source: 'speaker-shadow'
35
+ recordedAt: string
36
+ foldUnderSeconds: typeof VOICE_DISPLAY_FOLD_UNDER_SECONDS
37
+ /** Two or more labels, largest first. */
38
+ groups: Array<{ id: string; labels: string[]; seconds: number | null }>
39
+ /** 15 s or more, or unknown duration. */
40
+ singles: Array<{ label: string; seconds: number | null }>
41
+ /** Known duration under 15 s after merging. */
42
+ short: Array<{ label: string; seconds: number }>
43
+ }
44
+
45
+ /** The parts of one shadow record display needs. Validated once, so building a view cannot fail. */
46
+ export interface VoiceDisplayEvidence {
47
+ recordedAt: string
48
+ groups: Array<{ id: string; labels: string[] }>
49
+ seconds: ReadonlyMap<string, number>
50
+ }
51
+
52
+ const isRecord = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value)
53
+ const validSeconds = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0
54
+
55
+ /** Null on any problem at all. A label may appear once across the whole record. */
56
+ export function voiceDisplayEvidence(record: unknown): VoiceDisplayEvidence | null {
57
+ if (!isRecord(record) || record.status !== 'proposed' || typeof record.recordedAt !== 'string' || !record.recordedAt) return null
58
+ const proposals = record.proposals
59
+ if (!isRecord(proposals)) return null
60
+ const { accepted, rejected, untouched } = proposals
61
+ if (!Array.isArray(accepted) || !Array.isArray(rejected) || !Array.isArray(untouched)) return null
62
+
63
+ const seconds = new Map<string, number>()
64
+ const seen = new Set<string>()
65
+ const claim = (label: unknown): label is string => {
66
+ if (typeof label !== 'string' || seen.has(label)) return false
67
+ seen.add(label)
68
+ return true
69
+ }
70
+ const groups: VoiceDisplayEvidence['groups'] = []
71
+ const ids = new Set<string>()
72
+ for (const group of accepted) {
73
+ if (!isRecord(group) || typeof group.cluster !== 'string' || !group.cluster || ids.has(group.cluster)) return null
74
+ ids.add(group.cluster)
75
+ if (!Array.isArray(group.merge) || !Array.isArray(group.voices)) return null
76
+ const labels: string[] = []
77
+ for (const label of [group.keepLabel, ...group.merge]) {
78
+ if (!claim(label)) return null
79
+ labels.push(label)
80
+ }
81
+ for (const row of group.voices) {
82
+ if (!isRecord(row) || typeof row.voice !== 'string' || !labels.includes(row.voice) || !validSeconds(row.seconds)) return null
83
+ seconds.set(row.voice, row.seconds)
84
+ }
85
+ groups.push({ id: `shadow-${group.cluster}`, labels })
86
+ }
87
+ for (const row of [...rejected, ...untouched]) {
88
+ if (!isRecord(row) || !claim(row.voice) || !validSeconds(row.seconds)) return null
89
+ seconds.set(row.voice, row.seconds)
90
+ }
91
+ return { recordedAt: record.recordedAt, groups, seconds }
92
+ }
93
+
94
+ const bySecondsThenLabel = (a: { seconds: number | null; key: string }, b: { seconds: number | null; key: string }): number => {
95
+ if (a.seconds === null && b.seconds !== null) return 1
96
+ if (a.seconds !== null && b.seconds === null) return -1
97
+ if (a.seconds !== null && b.seconds !== null && a.seconds !== b.seconds) return b.seconds - a.seconds
98
+ return a.key.localeCompare(b.key, 'en', { numeric: true })
99
+ }
100
+
101
+ /** Pure. `currentLabels` is the label of every row in the response being answered. */
102
+ export function buildVoiceDisplay(evidence: VoiceDisplayEvidence, currentLabels: readonly string[]): VoiceDisplay {
103
+ const current = new Set(currentLabels.filter(isVoiceLabel))
104
+ const secondsOf = (label: string): number | null => evidence.seconds.get(label) ?? null
105
+ const groups: VoiceDisplay['groups'] = []
106
+ const singles: VoiceDisplay['singles'] = []
107
+ const short: VoiceDisplay['short'] = []
108
+ const place = (label: string): void => {
109
+ const seconds = secondsOf(label)
110
+ if (seconds !== null && seconds < VOICE_DISPLAY_FOLD_UNDER_SECONDS) short.push({ label, seconds })
111
+ else singles.push({ label, seconds })
112
+ }
113
+
114
+ const grouped = new Set<string>()
115
+ for (const group of evidence.groups) {
116
+ const labels = group.labels.filter(label => current.has(label))
117
+ for (const label of labels) grouped.add(label)
118
+ if (labels.length === 1) place(labels[0])
119
+ else if (labels.length >= 2) {
120
+ const known = labels.map(secondsOf)
121
+ const seconds = known.every((s): s is number => s !== null)
122
+ ? Math.round(known.reduce((sum, s) => sum + s, 0) * 100) / 100
123
+ : null
124
+ groups.push({ id: group.id, labels, seconds })
125
+ }
126
+ }
127
+ for (const label of current) if (!grouped.has(label)) place(label)
128
+
129
+ groups.sort((a, b) => bySecondsThenLabel({ seconds: a.seconds, key: a.labels[0] }, { seconds: b.seconds, key: b.labels[0] }))
130
+ singles.sort((a, b) => bySecondsThenLabel({ seconds: a.seconds, key: a.label }, { seconds: b.seconds, key: b.label }))
131
+ short.sort((a, b) => bySecondsThenLabel({ seconds: a.seconds, key: a.label }, { seconds: b.seconds, key: b.label }))
132
+ return { source: 'speaker-shadow', recordedAt: evidence.recordedAt, foldUnderSeconds: VOICE_DISPLAY_FOLD_UNDER_SECONDS, groups, singles, short }
133
+ }
134
+
135
+ /**
136
+ * Who a voice suggestion is for: one entry per group, single and short row when there is a display,
137
+ * otherwise one per anonymous label. Ext and names never appear, on either path.
138
+ */
139
+ export function voiceDisplayScopes(display: VoiceDisplay | null, currentLabels: readonly string[]): string[][] {
140
+ if (!display) return [...new Set(currentLabels.filter(isVoiceLabel))].map(label => [label])
141
+ return [
142
+ ...display.groups.map(group => group.labels),
143
+ ...display.singles.map(row => [row.label]),
144
+ ...display.short.map(row => [row.label]),
145
+ ]
146
+ }
147
+
148
+ // Keyed on every proposal file's name, size and mtime. /speakers is opened again and again on one
149
+ // meeting; a session with no record would otherwise scan the whole store each time.
150
+ const evidenceCache = new Map<string, { signature: string; evidence: VoiceDisplayEvidence | null }>()
151
+
152
+ export function __clearVoiceDisplayCacheForTests(): void { evidenceCache.clear() }
153
+
154
+ async function storeSignature(root: string): Promise<string | null> {
155
+ const names = (await readdir(root)).filter(name => name === SPEAKER_SHADOW_RECORDS || /^proposals-[0-9TZ-]+\.jsonl$/.test(name)).sort()
156
+ if (names.length === 0) return null
157
+ let total = 0
158
+ const parts: string[] = []
159
+ for (const name of names) {
160
+ const info = await stat(join(root, name))
161
+ total += info.size
162
+ parts.push(`${name}:${info.size}:${info.mtimeMs}`)
163
+ }
164
+ return total > MAX_STORE_BYTES ? null : parts.join('|')
165
+ }
166
+
167
+ /** The latest `proposed` record for a session. Null when there is none or anything about it is off. Never throws. */
168
+ export async function readVoiceDisplayEvidence(sessionId: string, root: string = speakerShadowRoot()): Promise<VoiceDisplayEvidence | null> {
169
+ try {
170
+ const signature = await storeSignature(root)
171
+ if (signature === null) return null
172
+ const key = `${root}\u0000${sessionId}`
173
+ const cached = evidenceCache.get(key)
174
+ if (cached?.signature === signature) return cached.evidence
175
+ const records = await readSpeakerShadowRecords(root, { sessionId, limit: MAX_RECORDS_PER_SESSION })
176
+ const latest = records.find(record => record.status === 'proposed')
177
+ const evidence = latest ? voiceDisplayEvidence(latest) : null
178
+ evidenceCache.delete(key)
179
+ evidenceCache.set(key, { signature, evidence })
180
+ while (evidenceCache.size > EVIDENCE_CACHE_MAX) evidenceCache.delete(evidenceCache.keys().next().value as string)
181
+ return evidence
182
+ } catch {
183
+ return null
184
+ }
185
+ }
186
+
187
+ /** The optional `voiceDisplay` field. Null means leave it out. Never throws. */
188
+ export async function readVoiceDisplay(sessionId: string, currentLabels: readonly string[], root: string = speakerShadowRoot()): Promise<VoiceDisplay | null> {
189
+ try {
190
+ const evidence = await readVoiceDisplayEvidence(sessionId, root)
191
+ return evidence ? buildVoiceDisplay(evidence, currentLabels) : null
192
+ } catch {
193
+ return null
194
+ }
195
+ }