@gotcos/glasses-server 6.95.0 → 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 (123) hide show
  1. package/CHANGELOG.md +157 -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 +453 -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 +570 -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,548 @@
1
+ // 6.96.3: on-click voice suggestions for a saved Mac recording's anonymous voices.
2
+ //
3
+ // WHY. A 6 s Mac chunk almost never clears the .88 bar automatic names use, so Mac recordings stay full of
4
+ // `Voice N`. The fresh-ear study (speaker-diarization-claude-20261030/fresh-ear-192-v1) judged whole voices
5
+ // against the shared 192d profiles instead: on 12 voices Miles judged by ear, the rule below gave 10 right,
6
+ // 0 wrong and 2 no suggestion, with 0 false names when the person was removed from the profiles.
7
+ //
8
+ // A SUGGESTION, NEVER A NAME. Every result carries `requiresConfirmation`. A person confirms through the
9
+ // normal naming path, which is the only thing that writes. Automatic names keep their own rule untouched:
10
+ // this file does not read or change meeting-speaker-policy.ts, identifySpeaker, SEARCH_THRESHOLD or
11
+ // AUTO_ENROLL_THRESHOLD.
12
+ //
13
+ // ON CLICK ONLY. POST /meeting/:sessionId/voice-suggestions is the one caller of `start`. Nothing here runs
14
+ // from GET /speakers, at save, on a sweep or at startup.
15
+ //
16
+ // WRITES NOTHING. No profile, label, sidecar or vector file. Results live in memory, bound to the meeting's
17
+ // sourceRevision, and vectors are dropped as soon as a voice is decided. A restart means clicking again.
18
+ //
19
+ // UNITS AND GATES ARE THE STUDY'S. The per-chunk rule is shared with mac-voice-assignment.ts. Parity with the
20
+ // study's predictions.json is proven by a canary on copied audio; see the 6.96.3 report.
21
+ //
22
+ // EVERY LABEL IS SCORED ON ITS OWN. The study scored single labels and ran no groups. A pooled group lets a large
23
+ // voice carry a small one: 37 units of one person and 3 of a stranger pass as one voice, and a batch would then
24
+ // name the stranger and write wrong samples. So a display group's labels merge into one suggestion only after each
25
+ // passed the rule alone for the same person, at the lowest score among them. Nothing else is ever listed.
26
+ //
27
+ // A RESULT EXPIRES. It counts only for the meeting revision and the profile store it was computed from. A chunk a
28
+ // person renamed is skipped: POST /relabel patches `chunks[]`, not `chunkEntries`, so an entry's label can be stale.
29
+ //
30
+ // DOES NOT STALL THE SERVER. Embedding is a synchronous native call (about 130 ms a unit). The job yields to
31
+ // the event loop between units, as mac-assign does, and stops at a 6 minute deadline. A recording or a
32
+ // maintenance drain that starts mid-run ends it as `failed` with that reason. Dictation only pauses it, because a
33
+ // person dictates often and a job that dies each time could never finish.
34
+ //
35
+ // REFUSED IS AN ANSWER, NOT A STATE. A click that cannot start gets `refused` from POST and leaves nothing behind,
36
+ // so GET never reports a recording that ended an hour ago. GET is idle, running, done or failed.
37
+ //
38
+ // HOW IT STOPS. One job at a time machine-wide, one user click per job, the deadline, and the live-work
39
+ // probes. No LLM, no network, no retained data.
40
+
41
+ import { readFileSync, statSync } from 'node:fs'
42
+ import { chunkEmbeddingsEnabled } from './chunk-embedding-store.js'
43
+ import { dataPath } from './data-dir.js'
44
+ import { maintenanceLifecycle } from './maintenance-lifecycle.js'
45
+ import {
46
+ MAC_ASSIGN_MAX_UNITS,
47
+ MAC_ASSIGN_MIN_SINGLE_SEC,
48
+ MAC_ASSIGN_MIN_UNITS,
49
+ MAC_ASSIGN_MIXED_FRACTION,
50
+ macVoiceChunkUnits,
51
+ macVoiceTurnsOf,
52
+ macVoiceUnitAudio,
53
+ spreadMacVoiceUnits,
54
+ type MacVoiceSlice,
55
+ } from './mac-voice-assignment.js'
56
+ import { meetingAudioChunkPath } from './meeting-audio-archive.js'
57
+ import { attachRawChunkIndices, type ReviewChunk } from './meeting-speaker-review.js'
58
+ import { getOwnerSpeakerLabel } from './profile.js'
59
+ import { extractEmbedding, isEmbeddingAvailable, readVoiceProfiles } from './speaker-embeddings.js'
60
+ import { SPEAKER_SHADOW_LIVE_LEASES } from './speaker-shadow.js'
61
+ import { dominantCoherentCluster } from './voice-enrolment-selection.js'
62
+ import { VOICEPRINT_MIN_AUDIO_SEC } from './voiceprint-floor.js'
63
+
64
+ /** Frozen in fresh-ear-192-v1/policy.json before the fresh run. Not a tuning knob. */
65
+ export const VOICE_SUGGESTION_FLOOR = 0.7
66
+ export const VOICE_SUGGESTION_MARGIN = 0.05
67
+ /** Marks a suggestion the batch naming flow may offer. Scores from .70 to .80 stay plain suggestions. Names nothing by itself. */
68
+ export const HIGH_CONFIDENCE_FLOOR = 0.8
69
+ export const HIGH_CONFIDENCE_MARGIN = 0.05
70
+ /** Never suggested, though they stay in the bank: their score still has to lose to the suggested person's. */
71
+ export const VOICE_SUGGESTION_NEVER_NAMES: readonly string[] = ['MU']
72
+ export const VOICE_SUGGESTION_DEADLINE_MS = 6 * 60_000
73
+ export const VOICE_SUGGESTION_MODEL_DIM = 192
74
+ const RESULT_CACHE_MAX = 16
75
+ const PLAN_YIELD_EVERY = 16
76
+ const PAUSE_POLL_MS = 250
77
+
78
+ export type VoiceSuggestionState = 'idle' | 'running' | 'done' | 'refused' | 'failed'
79
+ export type VoiceSuggestionGate = 'mixed_voice' | 'too_little_audio' | 'no_dominant_voice' | 'no_usable_audio' | 'below_rule'
80
+ export type VoiceSuggestionBlock = 'recording_active' | 'maintenance' | 'meeting_finalizing'
81
+
82
+ export interface VoiceSuggestion {
83
+ labels: string[]
84
+ name: string
85
+ score: number
86
+ margin: number
87
+ units: number
88
+ kept: number
89
+ requiresConfirmation: true
90
+ /** Unrounded score and margin both clear the high-confidence bar. Only a voice past every gate has a suggestion at all. */
91
+ highConfidence: boolean
92
+ }
93
+ export interface VoiceSuggestionUnmatched { labels: string[]; gate: VoiceSuggestionGate }
94
+
95
+ export interface VoiceSuggestionsBody {
96
+ state: VoiceSuggestionState
97
+ reason?: string
98
+ progress?: { done: number; total: number }
99
+ rule: { floor: number; margin: number }
100
+ modelSpace: '192d'
101
+ sourceRevision: string
102
+ suggestions?: VoiceSuggestion[]
103
+ unmatched?: VoiceSuggestionUnmatched[]
104
+ }
105
+
106
+ // ── The bank ─────────────────────────────────────────────────────────────────
107
+
108
+ export interface StoredVoiceProfile { name: string; embeddings: ArrayLike<number>[]; sources?: string[] }
109
+ export interface VoiceBankEntry { name: string; centroid: number[]; samples: number }
110
+
111
+ function toUnit(vector: ArrayLike<number>): number[] | null {
112
+ const out = new Array<number>(vector.length)
113
+ let sum = 0
114
+ for (let i = 0; i < vector.length; i++) {
115
+ const value = vector[i]
116
+ if (!Number.isFinite(value)) return null
117
+ out[i] = value
118
+ sum += value * value
119
+ }
120
+ if (!(sum > 0)) return null
121
+ const norm = Math.sqrt(sum)
122
+ for (let i = 0; i < out.length; i++) out[i] /= norm
123
+ return out
124
+ }
125
+
126
+ function meanDirection(vectors: number[][]): number[] | null {
127
+ if (vectors.length === 0) return null
128
+ const sum = new Array<number>(vectors[0].length).fill(0)
129
+ for (const vector of vectors) for (let i = 0; i < sum.length; i++) sum[i] += vector[i]
130
+ return toUnit(sum)
131
+ }
132
+
133
+ /**
134
+ * Unit-normalized mean per profile, from 192d samples only. Samples whose source contains this capture's id
135
+ * are dropped first, so a name given on this very call cannot vouch for itself. A profile with nothing left
136
+ * leaves the bank; MU stays in it.
137
+ */
138
+ export function buildVoiceBank(profiles: readonly StoredVoiceProfile[], captureId: string): VoiceBankEntry[] {
139
+ const bank: VoiceBankEntry[] = []
140
+ for (const profile of profiles) {
141
+ const units: number[][] = []
142
+ profile.embeddings.forEach((embedding, i) => {
143
+ if (embedding.length !== VOICE_SUGGESTION_MODEL_DIM) return
144
+ if (String(profile.sources?.[i] ?? '').includes(captureId)) return
145
+ const unit = toUnit(embedding)
146
+ if (unit) units.push(unit)
147
+ })
148
+ const centroid = meanDirection(units)
149
+ if (centroid) bank.push({ name: profile.name, centroid, samples: units.length })
150
+ }
151
+ return bank
152
+ }
153
+
154
+ function dot(a: readonly number[], b: readonly number[]): number {
155
+ let sum = 0
156
+ for (let i = 0; i < a.length; i++) sum += a[i] * b[i]
157
+ return sum
158
+ }
159
+
160
+ // ── One voice's decision ─────────────────────────────────────────────────────
161
+
162
+ export interface VoiceUnitVector { unit: number[]; sec: number }
163
+
164
+ export interface VoiceDecision {
165
+ labels: string[]
166
+ units: number
167
+ kept: number
168
+ keptSec: number
169
+ /** Null means this voice earned a suggestion. */
170
+ gate: VoiceSuggestionGate | null
171
+ /** Unrounded, for tests and the parity canary. Present whenever any audio survived the coherence gate. */
172
+ top: { name: string; score: number } | null
173
+ margin: number | null
174
+ }
175
+
176
+ const sameName = (a: string, b: string): boolean => a.normalize('NFKC').trim().toLowerCase() === b.normalize('NFKC').trim().toLowerCase()
177
+
178
+ /** MU, and whoever this Mac's owner label says the wearer is. A Mac recording has no microphone on the owner. */
179
+ export function neverSuggested(name: string, ownerLabel?: string): boolean {
180
+ return VOICE_SUGGESTION_NEVER_NAMES.some(never => sameName(never, name)) || (!!ownerLabel && sameName(ownerLabel, name))
181
+ }
182
+
183
+ /** The frozen rule, on unrounded values. */
184
+ export function passesVoiceSuggestionRule(score: number, margin: number): boolean {
185
+ return score >= VOICE_SUGGESTION_FLOOR && margin >= VOICE_SUGGESTION_MARGIN
186
+ }
187
+
188
+ /** On unrounded values, so a .7996 that displays as .800 is not high confidence. */
189
+ export function isHighConfidenceVoiceSuggestion(score: number, margin: number): boolean {
190
+ return score >= HIGH_CONFIDENCE_FLOOR && margin >= HIGH_CONFIDENCE_MARGIN
191
+ }
192
+
193
+ /** Gates in the study's order, then the frozen rule. Pure. */
194
+ export function decideVoiceSuggestion(labels: string[], vectors: readonly VoiceUnitVector[], bank: readonly VoiceBankEntry[], ownerLabel?: string): VoiceDecision {
195
+ const none = (gate: VoiceSuggestionGate): VoiceDecision => ({ labels, units: vectors.length, kept: 0, keptSec: 0, gate, top: null, margin: null })
196
+ if (vectors.length === 0) return none('no_usable_audio')
197
+ const cluster = dominantCoherentCluster(vectors.map(v => Float32Array.from(v.unit)))
198
+ if (cluster.members.length === 0) return none('no_dominant_voice')
199
+ const kept = cluster.members.map(i => vectors[i])
200
+ const keptSec = kept.reduce((sum, v) => sum + v.sec, 0)
201
+ const voice = meanDirection(kept.map(v => v.unit))
202
+ const ranked = voice ? bank.map(entry => ({ name: entry.name, score: dot(voice, entry.centroid) })).sort((a, b) => b.score - a.score) : []
203
+ const top = ranked[0] ?? null
204
+ const margin = ranked.length >= 2 ? ranked[0].score - ranked[1].score : null
205
+ let gate: VoiceSuggestionGate | null = null
206
+ if (kept.length < vectors.length && kept.length / vectors.length < MAC_ASSIGN_MIXED_FRACTION) gate = 'mixed_voice'
207
+ else if (kept.length < MAC_ASSIGN_MIN_UNITS && keptSec < MAC_ASSIGN_MIN_SINGLE_SEC) gate = 'too_little_audio'
208
+ else if (!top || margin === null || !passesVoiceSuggestionRule(top.score, margin) || neverSuggested(top.name, ownerLabel)) gate = 'below_rule'
209
+ return { labels, units: vectors.length, kept: kept.length, keptSec, gate, top, margin }
210
+ }
211
+
212
+ // ── Which audio ──────────────────────────────────────────────────────────────
213
+
214
+ export interface VoiceSidecarEntry { chunkIndex: number; chunk: Record<string, unknown> }
215
+ export interface VoiceUnitRef { raw: number; slice: MacVoiceSlice | null }
216
+
217
+ /**
218
+ * The `chunkEntries` rows that still speak for their audio. Nothing else addresses retained audio: a chunk's array
219
+ * position is not its WAV number. POST /relabel patches `chunks[]` and leaves these rows alone, so a row whose current
220
+ * chunk says otherwise is stale and is skipped for every voice: left in, a chunk renamed away from Voice 3 stays a
221
+ * Voice 3 unit, and what is left of Voice 3 can be suggested as the person it was just split from. A silent row has no
222
+ * chunk to disagree with and stays. Null when the two lists cannot be lined up: a shifted mapping would be worse.
223
+ */
224
+ export function voiceSidecarEntries(sidecar: Record<string, unknown>): VoiceSidecarEntry[] | null {
225
+ const chunks = sidecar.chunks
226
+ if (!Array.isArray(chunks)) return null
227
+ const mapped = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
228
+ if (mapped === chunks) return null
229
+ const current = new Map(mapped.map(chunk => [chunk.chunkIndex, chunk.speaker]))
230
+ const rows = sidecar.chunkEntries as Array<Record<string, unknown>>
231
+ return rows
232
+ .filter((row): row is Record<string, unknown> & VoiceSidecarEntry => typeof row?.chunkIndex === 'number' && !!row.chunk && typeof row.chunk === 'object')
233
+ .filter(row => !current.has(row.chunkIndex) || current.get(row.chunkIndex) === row.chunk.speaker)
234
+ }
235
+
236
+ /** Every candidate unit of one voice, in call order, before the duration filter and the spread. */
237
+ export function voiceUnitCandidates(entries: readonly VoiceSidecarEntry[], label: string): VoiceUnitRef[] {
238
+ const out: VoiceUnitRef[] = []
239
+ for (const entry of entries) {
240
+ const turns = macVoiceTurnsOf(entry.chunk)
241
+ if (entry.chunk.speaker !== label && !turns.some(t => t.speaker === label)) continue
242
+ const units = macVoiceChunkUnits(turns, label)
243
+ if (units === 'whole') out.push({ raw: entry.chunkIndex, slice: null })
244
+ else for (const slice of units) out.push({ raw: entry.chunkIndex, slice })
245
+ }
246
+ return out.sort((a, b) => a.raw - b.raw || (a.slice?.startSec ?? 0) - (b.slice?.startSec ?? 0))
247
+ }
248
+
249
+ // ── Probes ───────────────────────────────────────────────────────────────────
250
+
251
+ /** Live work that pauses a job rather than ending it. Everything else in SPEAKER_SHADOW_LIVE_LEASES ends it. */
252
+ export const VOICE_SUGGESTION_PAUSE_LEASES: readonly string[] = ['one_shot_transcription', 'prompt_draft_write', 'prompt_draft_warm', 'prompt_draft_finalize']
253
+
254
+ const defaultActiveByKind = (): Record<string, number> => maintenanceLifecycle.snapshot().activeByKind as Record<string, number>
255
+
256
+ export function voiceSuggestionRecordingLease(activeByKind: () => Record<string, number> = defaultActiveByKind): boolean {
257
+ const byKind = activeByKind()
258
+ return SPEAKER_SHADOW_LIVE_LEASES.some(kind => !VOICE_SUGGESTION_PAUSE_LEASES.includes(kind) && (byKind[kind] ?? 0) > 0)
259
+ }
260
+
261
+ export function voiceSuggestionPauseLease(activeByKind: () => Record<string, number> = defaultActiveByKind): boolean {
262
+ const byKind = activeByKind()
263
+ return VOICE_SUGGESTION_PAUSE_LEASES.some(kind => (byKind[kind] ?? 0) > 0)
264
+ }
265
+
266
+ // ── The job ──────────────────────────────────────────────────────────────────
267
+
268
+ export interface VoiceSuggestionDeps {
269
+ now: () => number
270
+ yieldToLoop: () => Promise<void>
271
+ sleep: (ms: number) => Promise<void>
272
+ /** The speaker runtime is up and the embedding master switch is on. */
273
+ available: () => boolean
274
+ /** Changes whenever the profile store does: delete-person, merge-profiles, any enrolment. */
275
+ storeFingerprint: () => string
276
+ extract: (audio: Buffer, unit: VoiceUnitRef) => Float32Array | null
277
+ readProfiles: () => { profiles: StoredVoiceProfile[] }
278
+ audioPath: (sessionId: string, raw: number) => string | null
279
+ ownerLabel: () => string | undefined
280
+ log: (line: string) => void
281
+ /** Sees each voice's unrounded decision. For the parity canary; the server passes nothing. */
282
+ observe?: (decision: VoiceDecision) => void
283
+ }
284
+
285
+ export function defaultVoiceSuggestionDeps(): VoiceSuggestionDeps {
286
+ return {
287
+ now: Date.now,
288
+ yieldToLoop: () => new Promise<void>(resolve => setImmediate(resolve)),
289
+ sleep: ms => new Promise<void>(resolve => { setTimeout(resolve, ms) }),
290
+ available: () => chunkEmbeddingsEnabled() && isEmbeddingAvailable(),
291
+ storeFingerprint: () => { try { const info = statSync(dataPath('voice-profiles.json')); return `${info.ino}:${info.size}:${info.mtimeMs}` } catch { return 'none' } },
292
+ extract: audio => extractEmbedding(audio),
293
+ readProfiles: readVoiceProfiles,
294
+ audioPath: meetingAudioChunkPath,
295
+ ownerLabel: () => { try { return getOwnerSpeakerLabel() } catch { return undefined } },
296
+ log: line => console.log(`[voice-suggestions] ${line}`),
297
+ }
298
+ }
299
+
300
+ export interface VoiceSuggestionStartInput {
301
+ sessionId: string
302
+ sourceRevision: string
303
+ entries: VoiceSidecarEntry[]
304
+ /** Read once the job has started, so the click answers before a large review is built. */
305
+ loadScopes: () => Promise<string[][]>
306
+ /** Why a job may not start or continue right now. Called before every unit. */
307
+ block: () => VoiceSuggestionBlock | null
308
+ /** Live work a job waits out instead of ending. */
309
+ pause: () => boolean
310
+ }
311
+
312
+ interface Job {
313
+ sessionId: string
314
+ sourceRevision: string
315
+ storeFingerprint: string
316
+ state: 'running' | 'done' | 'failed'
317
+ reason?: string
318
+ progress: { done: number; total: number }
319
+ suggestions?: VoiceSuggestion[]
320
+ unmatched?: VoiceSuggestionUnmatched[]
321
+ cancelled: boolean
322
+ }
323
+
324
+ class JobStop extends Error {
325
+ constructor(readonly state: 'failed' | 'cancelled', readonly reason: string) { super(reason) }
326
+ }
327
+
328
+ const round3 = (value: number): number => Math.round(value * 1000) / 1000
329
+
330
+ /**
331
+ * What a job says, from each label's own decision. Within one scope, labels that each passed the rule for the same
332
+ * person become one suggestion at their lowest score and margin, high confidence only if all of them are. A label that
333
+ * passed for someone else gets its own suggestion. A label that did not pass is unmatched with its own gate, and is
334
+ * never listed on a suggestion. Pure.
335
+ */
336
+ export function combineVoiceDecisions(scopes: readonly string[][], decisions: ReadonlyMap<string, VoiceDecision>): { suggestions: VoiceSuggestion[]; unmatched: VoiceSuggestionUnmatched[] } {
337
+ const suggestions: VoiceSuggestion[] = []
338
+ const unmatched: VoiceSuggestionUnmatched[] = []
339
+ for (const scope of scopes) {
340
+ const passed = new Map<string, Array<VoiceDecision & { top: { name: string; score: number }; margin: number }>>()
341
+ for (const label of scope) {
342
+ const decision = decisions.get(label)
343
+ if (!decision || decision.gate !== null || !decision.top || decision.margin === null) {
344
+ unmatched.push({ labels: [label], gate: decision?.gate ?? 'no_usable_audio' })
345
+ continue
346
+ }
347
+ const name = decision.top.name
348
+ passed.set(name, [...(passed.get(name) ?? []), { ...decision, top: decision.top, margin: decision.margin }])
349
+ }
350
+ for (const [name, members] of passed) {
351
+ suggestions.push({
352
+ labels: members.flatMap(member => member.labels),
353
+ name,
354
+ score: round3(Math.min(...members.map(member => member.top.score))),
355
+ margin: round3(Math.min(...members.map(member => member.margin))),
356
+ units: members.reduce((sum, member) => sum + member.units, 0),
357
+ kept: members.reduce((sum, member) => sum + member.kept, 0),
358
+ requiresConfirmation: true,
359
+ highConfidence: members.every(member => isHighConfidenceVoiceSuggestion(member.top.score, member.margin)),
360
+ })
361
+ }
362
+ }
363
+ return { suggestions, unmatched }
364
+ }
365
+
366
+ export class VoiceSuggestionRunner {
367
+ private readonly deps: VoiceSuggestionDeps
368
+ private readonly jobs = new Map<string, Job>()
369
+ private active: Job | null = null
370
+
371
+ constructor(deps: Partial<VoiceSuggestionDeps> = {}) {
372
+ this.deps = { ...defaultVoiceSuggestionDeps(), ...deps }
373
+ }
374
+
375
+ private idle(sourceRevision: string): VoiceSuggestionsBody {
376
+ return { state: 'idle', rule: { floor: VOICE_SUGGESTION_FLOOR, margin: VOICE_SUGGESTION_MARGIN }, modelSpace: '192d', sourceRevision }
377
+ }
378
+
379
+ private body(job: Job): VoiceSuggestionsBody {
380
+ return {
381
+ ...this.idle(job.sourceRevision),
382
+ state: job.state,
383
+ ...(job.reason ? { reason: job.reason } : {}),
384
+ ...(job.state === 'running' || job.state === 'done' ? { progress: { ...job.progress } } : {}),
385
+ ...(job.suggestions ? { suggestions: job.suggestions } : {}),
386
+ ...(job.unmatched ? { unmatched: job.unmatched } : {}),
387
+ }
388
+ }
389
+
390
+ /** POST's answer when a click cannot start. Remembers nothing, so GET does not repeat it. */
391
+ refusal(reason: string, sourceRevision: string): VoiceSuggestionsBody {
392
+ return { ...this.idle(sourceRevision), state: 'refused', reason }
393
+ }
394
+
395
+ private remember(job: Job): void {
396
+ this.jobs.delete(job.sessionId)
397
+ this.jobs.set(job.sessionId, job)
398
+ while (this.jobs.size > RESULT_CACHE_MAX) this.jobs.delete(this.jobs.keys().next().value as string)
399
+ }
400
+
401
+ /** The state of a job in flight, so polling does not re-hash a large meeting. Null when none is running. */
402
+ running(sessionId: string): VoiceSuggestionsBody | null {
403
+ const job = this.jobs.get(sessionId)
404
+ return job?.state === 'running' && !job.cancelled ? this.body(job) : null
405
+ }
406
+
407
+ /** A result counts only for the revision it was computed from. Starts nothing. */
408
+ read(sessionId: string, sourceRevision: string): VoiceSuggestionsBody {
409
+ const job = this.jobs.get(sessionId)
410
+ if (!job || job.cancelled || job.sourceRevision !== sourceRevision) return this.idle(sourceRevision)
411
+ if (job.state !== 'running' && job.storeFingerprint !== this.deps.storeFingerprint()) return this.idle(sourceRevision)
412
+ return this.body(job)
413
+ }
414
+
415
+ /** The result of a finished job, as a copy. Null for anything else, including a job still running. */
416
+ finished(sessionId: string): { sourceRevision: string; suggestions: VoiceSuggestion[] } | null {
417
+ const job = this.jobs.get(sessionId)
418
+ if (job?.state !== 'done' || job.cancelled || !job.suggestions || job.storeFingerprint !== this.deps.storeFingerprint()) return null
419
+ return { sourceRevision: job.sourceRevision, suggestions: job.suggestions.map(suggestion => ({ ...suggestion, labels: [...suggestion.labels] })) }
420
+ }
421
+
422
+ /** Called by POST and nothing else. Returns the current job when there is one for this revision and profile store. */
423
+ start(input: VoiceSuggestionStartInput): VoiceSuggestionsBody {
424
+ const storeFingerprint = this.deps.storeFingerprint()
425
+ const existing = this.jobs.get(input.sessionId)
426
+ if (existing && !existing.cancelled && existing.sourceRevision === input.sourceRevision && existing.storeFingerprint === storeFingerprint && (existing.state === 'running' || existing.state === 'done')) return this.body(existing)
427
+ const refuse = (reason: string): VoiceSuggestionsBody => this.refusal(reason, input.sourceRevision)
428
+ const blocked = input.block()
429
+ if (blocked) return refuse(blocked)
430
+ if (!this.deps.available()) return refuse('speaker_model_unavailable')
431
+ if (!input.entries.some(entry => this.deps.audioPath(input.sessionId, entry.chunkIndex))) return refuse('audio_not_retained')
432
+ let bank: VoiceBankEntry[]
433
+ try { bank = buildVoiceBank(this.deps.readProfiles().profiles, input.sessionId) } catch { return refuse('profile_store_unreadable') }
434
+ if (bank.length < 2) return refuse('not_enough_voice_profiles')
435
+ // The same meeting at a newer revision or profile store replaces its own stale job; another meeting has to wait.
436
+ if (this.active && this.active.sessionId !== input.sessionId) return refuse('job_running_elsewhere')
437
+ if (this.active) this.active.cancelled = true
438
+
439
+ const job: Job = { sessionId: input.sessionId, sourceRevision: input.sourceRevision, storeFingerprint, state: 'running', progress: { done: 0, total: 0 }, cancelled: false }
440
+ this.active = job
441
+ this.remember(job)
442
+ void this.run(job, input, bank).catch(error => this.settle(job, 'internal_error', error))
443
+ return this.body(job)
444
+ }
445
+
446
+ private settle(job: Job, reason: string, error?: unknown): void {
447
+ if (job.cancelled) return
448
+ job.state = 'failed'
449
+ job.reason = reason
450
+ if (this.active === job) this.active = null
451
+ this.deps.log(`failed ${job.sessionId} (${reason})${error ? `: ${error instanceof Error ? error.message : String(error)}` : ''}`)
452
+ }
453
+
454
+ private async run(job: Job, input: VoiceSuggestionStartInput, bank: VoiceBankEntry[]): Promise<void> {
455
+ const { now, yieldToLoop, sleep } = this.deps
456
+ const started = now()
457
+ const deadline = started + VOICE_SUGGESTION_DEADLINE_MS
458
+ /** Between units: end on anything that says stop, wait out dictation, then let the server breathe. */
459
+ const checkpoint = async (): Promise<void> => {
460
+ for (;;) {
461
+ if (job.cancelled) throw new JobStop('cancelled', 'superseded')
462
+ if (now() >= deadline) throw new JobStop('failed', 'deadline')
463
+ const blocked = input.block()
464
+ if (blocked) throw new JobStop('failed', blocked)
465
+ if (!input.pause()) break
466
+ await sleep(PAUSE_POLL_MS)
467
+ }
468
+ await yieldToLoop()
469
+ }
470
+ try {
471
+ await yieldToLoop()
472
+ const scopes = await input.loadScopes()
473
+ const ownerLabel = this.deps.ownerLabel()
474
+
475
+ // Plan first: each label's 40 units are spread over audio that can actually be used, as in the study.
476
+ const planned: Array<{ label: string; units: Array<VoiceUnitRef & { sec: number }> }> = []
477
+ let reads = 0
478
+ for (const label of new Set(scopes.flat())) {
479
+ const usable: Array<VoiceUnitRef & { sec: number }> = []
480
+ for (const unit of voiceUnitCandidates(input.entries, label)) {
481
+ if (++reads % PLAN_YIELD_EVERY === 0) await checkpoint()
482
+ const path = this.deps.audioPath(input.sessionId, unit.raw)
483
+ if (!path) continue
484
+ let cut: { sec: number } | null = null
485
+ try { cut = macVoiceUnitAudio(readFileSync(path), unit.slice) } catch { continue }
486
+ if (cut && cut.sec >= VOICEPRINT_MIN_AUDIO_SEC) usable.push({ ...unit, sec: cut.sec })
487
+ }
488
+ planned.push({ label, units: spreadMacVoiceUnits(usable, MAC_ASSIGN_MAX_UNITS) })
489
+ }
490
+ job.progress.total = planned.reduce((sum, plan) => sum + plan.units.length, 0)
491
+
492
+ const decisions = new Map<string, VoiceDecision>()
493
+ for (const plan of planned) {
494
+ const vectors: VoiceUnitVector[] = []
495
+ for (const unit of plan.units) {
496
+ await checkpoint()
497
+ const path = this.deps.audioPath(input.sessionId, unit.raw)
498
+ let embedding: Float32Array | null = null
499
+ if (path) {
500
+ let cut: { audio: Buffer; sec: number } | null = null
501
+ try { cut = macVoiceUnitAudio(readFileSync(path), unit.slice) } catch { /* evicted mid-run */ }
502
+ if (cut) embedding = this.deps.extract(cut.audio, { raw: unit.raw, slice: unit.slice })
503
+ }
504
+ job.progress.done += 1
505
+ if (embedding?.length !== VOICE_SUGGESTION_MODEL_DIM) continue
506
+ const asUnit = toUnit(embedding)
507
+ if (asUnit) vectors.push({ unit: asUnit, sec: unit.sec })
508
+ }
509
+ const decision = decideVoiceSuggestion([plan.label], vectors, bank, ownerLabel)
510
+ this.deps.observe?.(decision)
511
+ decisions.set(plan.label, decision)
512
+ }
513
+
514
+ if (job.cancelled) throw new JobStop('cancelled', 'superseded')
515
+ const result = combineVoiceDecisions(scopes, decisions)
516
+ job.suggestions = result.suggestions
517
+ job.unmatched = result.unmatched
518
+ job.state = 'done'
519
+ if (this.active === job) this.active = null
520
+ this.deps.log(`done ${job.sessionId} in ${now() - started} ms: units=${job.progress.done} suggestions=${job.suggestions.length} unmatched=${job.unmatched.length}`)
521
+ } catch (error) {
522
+ if (error instanceof JobStop) {
523
+ if (error.state === 'cancelled') { if (this.active === job) this.active = null; return }
524
+ this.settle(job, error.reason)
525
+ return
526
+ }
527
+ throw error
528
+ }
529
+ }
530
+ }
531
+
532
+ let shared: VoiceSuggestionRunner | null = null
533
+ /** One per server process, which is what makes it one job at a time machine-wide. */
534
+ export function getVoiceSuggestionRunner(): VoiceSuggestionRunner {
535
+ shared ??= new VoiceSuggestionRunner()
536
+ return shared
537
+ }
538
+ export function resetVoiceSuggestionRunnerForTests(runner: VoiceSuggestionRunner | null = null): void {
539
+ shared = runner
540
+ }
541
+
542
+ /**
543
+ * The in-memory result of this session's finished job, or null. Batch naming reads this to confirm each item is a
544
+ * current high-confidence suggestion; it must still compare `sourceRevision` with the meeting as it is now.
545
+ */
546
+ export function currentVoiceSuggestions(sessionId: string): { sourceRevision: string; suggestions: VoiceSuggestion[] } | null {
547
+ return shared?.finished(sessionId) ?? null
548
+ }
@@ -44,6 +44,8 @@ export type MaintenanceWorkKind =
44
44
  | 'query_attachment_write'
45
45
  | 'one_shot_transcription'
46
46
  | 'recording_chunk'
47
+ | 'mac_speaker_live'
48
+ | 'mac_speaker_call'
47
49
  | 'meeting_save'
48
50
  | 'meeting_batch_finalization'
49
51
  | 'prompt_draft_write'
@@ -623,7 +623,8 @@ export class MediaStore {
623
623
  : prepared.mime === 'application/pdf' ? 'pdf'
624
624
  : prepared.mime === 'text/markdown' ? 'md'
625
625
  : prepared.mime === 'text/csv' ? 'csv'
626
- : prepared.mime === 'application/json' ? 'json' : 'txt'
626
+ : prepared.mime === 'application/json' ? 'json'
627
+ : prepared.mime === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ? 'xlsx' : 'txt'
627
628
  const derivatives = prepared.category === 'video' ? prepared.frames : prepared.pageImages
628
629
  const ref: MediaAttachmentRef = {
629
630
  id,