@gotcos/glasses-server 6.94.4 → 6.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +52 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +19 -1
  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/dictation-clean-models.ts +2 -0
  12. package/server/lib/dictation-clean.ts +2 -2
  13. package/server/lib/dictation-polish-pipeline.ts +22 -2
  14. package/server/lib/dictation-prewarm.ts +16 -0
  15. package/server/lib/direct-bridge.ts +14 -2
  16. package/server/lib/direct-sse.ts +6 -1
  17. package/server/lib/display-bus.ts +1 -1
  18. package/server/lib/fork-thread.ts +32 -1
  19. package/server/lib/held-naming-batches.ts +36 -0
  20. package/server/lib/job-audit-caller.ts +20 -0
  21. package/server/lib/lens-gist-engines.ts +17 -5
  22. package/server/lib/live-cos-ask.ts +29 -6
  23. package/server/lib/live-cue-verdicts.ts +43 -8
  24. package/server/lib/live-cues-engine.ts +4 -1
  25. package/server/lib/live-cues-filter.ts +50 -0
  26. package/server/lib/live-cues-general-prompts.ts +137 -0
  27. package/server/lib/live-cues-general-topic.ts +245 -0
  28. package/server/lib/live-cues-general.ts +715 -0
  29. package/server/lib/live-cues-v2-data.ts +453 -0
  30. package/server/lib/live-cues-v2-grounded.ts +262 -0
  31. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  32. package/server/lib/live-cues-v2-names.ts +49 -0
  33. package/server/lib/live-cues-v2-notes.ts +8 -4
  34. package/server/lib/live-cues-v2-open-items.ts +132 -0
  35. package/server/lib/live-cues-v2-pacing.ts +45 -0
  36. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  37. package/server/lib/live-cues-v2-settings.ts +89 -10
  38. package/server/lib/live-cues-v2.ts +570 -47
  39. package/server/lib/mac-recording-asr.ts +6 -3
  40. package/server/lib/mac-speaker-call-human.ts +371 -0
  41. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  42. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  43. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  44. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  45. package/server/lib/mac-speaker-call-producer.ts +539 -0
  46. package/server/lib/mac-speaker-call-store.ts +264 -0
  47. package/server/lib/mac-speaker-call-view.ts +373 -0
  48. package/server/lib/mac-speaker-live-compute.ts +72 -0
  49. package/server/lib/mac-speaker-live-memory.ts +414 -0
  50. package/server/lib/mac-speaker-live-projection.ts +126 -0
  51. package/server/lib/mac-speaker-live-save.ts +90 -0
  52. package/server/lib/mac-speaker-live-store.ts +71 -0
  53. package/server/lib/mac-speaker-live-worker.ts +58 -0
  54. package/server/lib/mac-speaker-live.ts +175 -0
  55. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  56. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  57. package/server/lib/mac-voice-assignment.ts +44 -26
  58. package/server/lib/mac-voice-display.ts +195 -0
  59. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  60. package/server/lib/mac-voice-suggestions.ts +548 -0
  61. package/server/lib/maintenance-lifecycle.ts +2 -0
  62. package/server/lib/media-store.ts +2 -1
  63. package/server/lib/meeting-ask-pasted.ts +135 -0
  64. package/server/lib/meeting-ask-review.ts +8 -1
  65. package/server/lib/meeting-ask-runner.ts +4 -1
  66. package/server/lib/meeting-ask-runtime.ts +189 -17
  67. package/server/lib/meeting-ask-store.ts +2 -0
  68. package/server/lib/meeting-ask-web.ts +284 -13
  69. package/server/lib/meeting-batch-progress.ts +51 -5
  70. package/server/lib/meeting-batch-transcribe.ts +35 -2
  71. package/server/lib/meeting-fact-check.ts +3 -0
  72. package/server/lib/meeting-finalization-jobs.ts +69 -2
  73. package/server/lib/meeting-public-topic.ts +6 -1
  74. package/server/lib/meeting-speaker-review.ts +182 -17
  75. package/server/lib/meeting-store.ts +6 -1
  76. package/server/lib/parakeet.ts +51 -5
  77. package/server/lib/pending-batch-retention.ts +58 -0
  78. package/server/lib/private-reference.ts +38 -0
  79. package/server/lib/provider-usage.ts +561 -0
  80. package/server/lib/public-https-url.ts +32 -0
  81. package/server/lib/query-job-runtime.ts +3 -0
  82. package/server/lib/rich-media-safety.ts +23 -2
  83. package/server/lib/speaker-shadow-decide.ts +503 -0
  84. package/server/lib/speaker-shadow-models.ts +150 -0
  85. package/server/lib/speaker-shadow-numeric.ts +143 -0
  86. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  87. package/server/lib/speaker-shadow-paired.ts +268 -0
  88. package/server/lib/speaker-shadow-report.ts +113 -0
  89. package/server/lib/speaker-shadow-timeline.ts +210 -0
  90. package/server/lib/speaker-shadow-worker.ts +312 -0
  91. package/server/lib/speaker-shadow.ts +788 -0
  92. package/server/lib/token-audit.ts +36 -6
  93. package/server/lib/transcript-cleanup.ts +3 -2
  94. package/server/lib/voice-directory.ts +8 -4
  95. package/server/lib/web-lookup-ledger.ts +151 -0
  96. package/server/lib/work-review-runtime.ts +2 -0
  97. package/server/lib/work-search.ts +3 -1
  98. package/server/lib/xlsx-text.ts +277 -0
  99. package/server/routes/health.ts +5 -0
  100. package/server/routes/live-cues.ts +36 -14
  101. package/server/routes/meeting.ts +506 -49
  102. package/server/routes/speaker-shadow.ts +79 -0
  103. package/server/routes/transcribe-stream.ts +114 -33
  104. package/server/routes/voice.ts +3 -0
  105. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  106. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  107. package/shared/live-cues-v2.ts +38 -5
  108. package/shared/media-attachment.ts +2 -0
  109. 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
+ }
@@ -197,24 +197,55 @@ function emptyReport(person: string, skipped: MacAssignSkip): MacAssignReport {
197
197
  }
198
198
  }
199
199
 
200
- interface Turn { speaker: string; startSec?: number; endSec?: number }
201
- 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 }
202
203
 
203
- function turnsOf(chunk: Record<string, unknown> | undefined): Turn[] {
204
+ export function macVoiceTurnsOf(chunk: Record<string, unknown> | undefined): MacVoiceTurn[] {
204
205
  if (!chunk) return []
205
206
  const final = chunk.finalSegments
206
207
  const live = chunk.segments
207
208
  const list = Array.isArray(final) && final.length > 0 ? final : Array.isArray(live) ? live : []
208
- 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')
209
210
  }
210
211
 
211
- 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[] {
212
214
  if (items.length <= max) return items
213
215
  const out: T[] = []
214
216
  for (let k = 0; k < max; k++) out.push(items[Math.floor((k * items.length) / max)])
215
217
  return out
216
218
  }
217
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
+
218
249
  function centroid(vectors: Float32Array[]): Float32Array | null {
219
250
  if (vectors.length === 0) return null
220
251
  const out = new Float32Array(vectors[0].length)
@@ -262,22 +293,16 @@ export async function planMacVoiceAssignment(input: MacAssignInput): Promise<Mac
262
293
  const report = emptyReport(to, null)
263
294
  const units: Unit[] = []
264
295
  for (const raw of [...new Set(rawIndices)].sort((a, b) => a - b)) {
265
- const turns = turnsOf(chunksByRaw.get(raw))
266
- if (turns.length === 0 || turns.every(t => t.speaker === from)) { units.push({ raw, slice: null }); continue }
267
- const usable = turns.filter(t => t.speaker === from
268
- && Number.isFinite(t.startSec) && Number.isFinite(t.endSec)
269
- && (t.endSec as number) - (t.startSec as number) >= MAC_ASSIGN_MIN_SLICE_SEC)
270
- if (usable.length === 0) { report.multiVoiceSkipped += 1; continue }
271
- for (const t of usable) units.push({
272
- raw,
273
- slice: { startSec: (t.startSec as number) + MAC_ASSIGN_SLICE_INSET_SEC, endSec: (t.endSec as number) - MAC_ASSIGN_SLICE_INSET_SEC },
274
- })
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 })
275
300
  }
276
301
 
277
302
  const embeddings: Float32Array[] = []
278
303
  const secs: number[] = []
279
304
  let foundAudio = 0
280
- const picked = spread(units, MAC_ASSIGN_MAX_UNITS)
305
+ const picked = spreadMacVoiceUnits(units, MAC_ASSIGN_MAX_UNITS)
281
306
  stats.units = picked.length
282
307
  const deadline = started + (input.deadlineMs ?? deadlineMsFromEnv())
283
308
  for (const unit of picked) {
@@ -303,16 +328,9 @@ export async function planMacVoiceAssignment(input: MacAssignInput): Promise<Mac
303
328
  if (hit) { stats.reused += 1; embeddings.push(hit.vec); secs.push(hit.sec); continue }
304
329
  let wav: Buffer
305
330
  try { wav = readFileSync(path) } catch { report.missingAudio += 1; continue }
306
- const pcm = readPcm16Mono(wav)
307
- if (!pcm || pcm.sampleRate !== SAMPLE_RATE) continue
308
- let audio = wav
309
- if (unit.slice) {
310
- const from16 = Math.max(0, Math.round(unit.slice.startSec * SAMPLE_RATE)) * 2
311
- const to16 = Math.min(pcm.pcm.length, Math.round(unit.slice.endSec * SAMPLE_RATE) * 2)
312
- if (to16 <= from16) continue
313
- audio = pcm16ToWav(pcm.pcm.subarray(from16, to16))
314
- }
315
- const sec = (audio.length - 44) / (SAMPLE_RATE * 2)
331
+ const cut = macVoiceUnitAudio(wav, unit.slice)
332
+ if (!cut) continue
333
+ const { audio, sec } = cut
316
334
  if (sec < VOICEPRINT_MIN_AUDIO_SEC) { report.shortSkipped += 1; continue }
317
335
  const embedding = extractEmbedding(audio)
318
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
+ }