@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,1067 @@
1
+ // 6.96.3: name several of a saved Mac call's voices at once, with a real batch Undo.
2
+ //
3
+ // WHY. Suggest names finds voices worth naming; a person reviews the list and confirms. A wrong batch would write
4
+ // wrong samples into the same profiles the G2 uses, so the preview and a full Undo are part of the feature.
5
+ //
6
+ // ONE WRITE PATH. Each label goes through what POST /meeting/:sessionId/relabel does for one voice: relabelSidecarJson
7
+ // and relabelMeetingMarkdown over the same copy of the meeting, the same intent/applied ledger rows, then
8
+ // planMacVoiceAssignment and commitMacVoiceAssignment with no `force`. renameMeetingSpeaker is deliberately NOT used:
9
+ // on real Mac calls its position-based markdown edit resolves almost no turns, because the markdown is not an
10
+ // index-parallel rendering of the chunks, so `[Voice N]:` would stay in the transcript while the sidecar said a name.
11
+ //
12
+ // ALL OR NOTHING BEFORE THE FIRST WRITE, THEN STOP AT THE FIRST REFUSAL. Every item must be a current high-confidence
13
+ // suggestion for this session and revision. The flag is trusted as given: `score` is rounded, so it is never
14
+ // recomputed here. A restart loses suggestions on purpose, and the answer is to click Suggest names again. The check is
15
+ // repeated immediately before each write, because a profile can change during a batch. Suggestions read null once the
16
+ // store changes, which this batch's own first commit also does, so from then on the store's stamp and the meeting's
17
+ // revision, both taken right after that commit, stand in for it: any write since then that is not ours stops the batch
18
+ // as `suggestions_stale`.
19
+ //
20
+ // JOURNAL. Intent is durable before anything is touched and the outcome after. A process that dies leaves `running`,
21
+ // which reads as `interrupted` once no live batch owns it, and Undo works out what landed by inspection. Refusals that
22
+ // wrote nothing leave no journal. It holds no transcript words: turn positions and hashes are enough for an exact
23
+ // restore, and only an attendee section a step edited is copied, names only. Journals last 30 days, then Undo is gone.
24
+ //
25
+ // WHICH SAMPLES. The profile store has no sample ids, so a sample is its source plus a sha256 of its vector, taken by
26
+ // diffing the profile around the one synchronous commit. `retractEmbeddingsBySource` matches on source alone, and the
27
+ // broad session retraction would also remove earlier single namings from this call, which are a person's decisions.
28
+ // So Undo finds the exact indices, in the named profile and then in any other a merge moved them to, and passes a
29
+ // predicate that walks them in the store's index order and can only match the source recorded at that index. A sample
30
+ // no profile holds is reported: `partial`, reason `samples_not_found`, the voice listed there as well as under `reverted`.
31
+ //
32
+ // EVICTED SAMPLES ARE RESTORED WHEN THAT IS SAFE. A Mac-assigned sample only ever evicts another Mac-assigned one, and
33
+ // one label can replace a person's whole Mac allowance, so losing them on Undo would cost earlier decisions. Vectors go
34
+ // back through the normal enrolment path, skipping its near-duplicate check, only if nothing would be displaced, the
35
+ // profile still exists and that name has not since been moved or taken back in the sample's own meeting, which is what
36
+ // stops a sample of an undone batch coming back. Anything else is counted as not restored. A restored sample becomes the
37
+ // newest Mac-assigned one, so eviction order is not preserved. A vector leaves the journal once it is restored or can
38
+ // never be. One with no room yet, or whose enrolment failed, stays until a later Undo of that name retries it or the
39
+ // journal expires, so two overlapping batches end at the same samples in either Undo order.
40
+ // forgetMacVoiceNamingPerson scrubs vectors when a person is deleted, including from a batch still in memory.
41
+ //
42
+ // LATER HUMAN ACTIONS WIN. A label a person changed afterwards is left alone and the batch's samples for it are still
43
+ // retracted, because that change says the batch was wrong. A label a person CONFIRMED afterwards keeps its samples too.
44
+ //
45
+ // THE LEDGER AFTER AN UNDO. `reverted` goes on the applied row's id, and the undo's own intent closes `failed`. That is
46
+ // the only shape mac-speaker-shadow-repair's reader accepts, and the ledger is append-only, so anything else would
47
+ // block that capture's call view for good.
48
+ //
49
+ // DRAINS. Planning is read-only and holds no lease. Each write section holds its own `api_mutation` lease and the
50
+ // meeting sync lock. isMacVoiceNamingBatchPost is what index.ts needs in order to let the route answer its own refusal.
51
+
52
+ import { nfc, sameSpeakerName } from './speaker-name-nfc.js'
53
+ import { createHash, randomUUID } from 'node:crypto'
54
+ import { mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'
55
+ import { dirname, join } from 'node:path'
56
+ import type { Router } from 'express'
57
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
58
+ import { cosOperationsMeetingsConfigured, findCosOperationsMeetingBySessionId, findDirectLibraryMeetingBySessionId } from './cos-operations-meetings.js'
59
+ import { dataPath } from './data-dir.js'
60
+ import { MAC_ASSIGN_SOURCE, isMacAssign, macAssignCapFor } from './embedding-eviction.js'
61
+ import { MAC_CAPTURE_ID } from './mac-recording.js'
62
+ import { commitMacVoiceAssignment, planMacVoiceAssignment, type MacAssignInput, type MacAssignPlan, type MacAssignReport } from './mac-voice-assignment.js'
63
+ import { isVoiceLabel } from './mac-voice-labels.js'
64
+ import { currentVoiceSuggestions, neverSuggested, voiceSuggestionRecordingLease, type VoiceSuggestion } from './mac-voice-suggestions.js'
65
+ import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from './maintenance-lifecycle.js'
66
+ import { appendCorrection, appliedCorrections, pendingCorrections, readCorrections, type CorrectionRow } from './meeting-corrections.js'
67
+ import { relabelMeetingMarkdown, relabelSidecarJson } from './meeting-relabel.js'
68
+ import { acquireMeetingSyncLock } from './meeting-sync-lock.js'
69
+ import { getOwnerName, getOwnerSpeakerLabel } from './profile.js'
70
+ import { MAX_EMBEDDINGS_PER_SPEAKER, enrollEmbedding, readVoiceProfiles, retractEmbeddingsBySource } from './speaker-embeddings.js'
71
+ import { assertVoiceEvidenceSource } from './voice-evidence-guard.js'
72
+
73
+ export const MAC_VOICE_NAMING_BATCH_DIR = 'mac-voice-naming-batches'
74
+ const SESSION_ID = /^[A-Za-z0-9:_-]{3,96}$/
75
+ const BATCH_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
76
+ const MAX_ITEMS = 99
77
+ /** How long a batch can be undone. After this its journal, with any voiceprints and attendee copies in it, is deleted. */
78
+ export const MAC_VOICE_NAMING_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
79
+ const FILE_MODE = { mode: 0o600 }
80
+ /** Reasons a retry cannot change: the person's action stands. */
81
+ const PERMANENT_LEFT = new Set(['label_changed_by_person', 'confirmed_by_person'])
82
+
83
+ export interface NamingBatchItem { labels: string[]; name: string }
84
+ export interface NamingBatchRequest { sourceRevision: string; items: NamingBatchItem[]; confirm?: boolean }
85
+ export interface NamedEntry { labels: string[]; name: string; samplesAdded: number }
86
+ export interface NotNamedEntry { labels: string[]; name: string; reason: string }
87
+ export interface NamingBatchResult {
88
+ batchId: string
89
+ state: 'applied' | 'partial' | 'refused'
90
+ reason?: string
91
+ named: NamedEntry[]
92
+ notNamed: NotNamedEntry[]
93
+ sourceRevision: string
94
+ undoAvailable: boolean
95
+ dryRun?: true
96
+ }
97
+ export interface NamingBatchUndoResult {
98
+ state: 'undone' | 'partial' | 'refused'
99
+ reason?: string
100
+ reverted: Array<{ labels: string[]; name: string }>
101
+ left: Array<{ labels: string[]; name: string; reason: string }>
102
+ samplesRemoved: number
103
+ /** Samples this batch had evicted that went back, and those that could not. */
104
+ evictedRestored: number
105
+ evictedNotRestored: number
106
+ sourceRevision: string
107
+ dryRun?: true
108
+ }
109
+ export type NamingBatchListState = 'running' | 'applied' | 'partial' | 'interrupted' | 'undone' | 'undo_partial' | 'unreadable'
110
+ export interface NamingBatchListEntry { batchId: string; at: string; state: NamingBatchListState; named: NamedEntry[]; undoAvailable: boolean }
111
+
112
+ interface SampleId { source: string; hash: string }
113
+ interface SampleCopy extends SampleId { embedding: number[] }
114
+ interface StepUndo {
115
+ state: 'reverted' | 'left'
116
+ reason?: string
117
+ samplesRemoved: number
118
+ /** Samples this step added that no profile holds any more. */
119
+ samplesNotFound: number
120
+ evictedRestored: number
121
+ /** Evicted samples that can never come back: the person is gone, or the name moved in their meeting. */
122
+ evictedDropped: number
123
+ /** An interrupted step that had changed nothing: done, and not reported as reverted. */
124
+ nothingApplied?: boolean
125
+ }
126
+ interface Step {
127
+ itemIndex: number
128
+ label: string
129
+ name: string
130
+ /** `intent` left on disk means the process died inside this step. `refused` wrote nothing. */
131
+ state: 'intent' | 'named' | 'failed' | 'refused'
132
+ at: string
133
+ correctionId: string
134
+ changed: number[]
135
+ /** Null when this step did not rewrite the markdown. */
136
+ markdownBeforeHash: string | null
137
+ attendeeEdits: number
138
+ /** The section's body, only when this step edited it. Names, never words. */
139
+ attendeesBefore: string | null
140
+ attendeesAfterHash: string | null
141
+ turnCount: number
142
+ /** Which `[Name]:` turns, by order in the transcript, this step rewrote. */
143
+ turns: number[]
144
+ source: string
145
+ planned: string[]
146
+ before: string[]
147
+ /** Candidates for eviction, kept only until the commit reports what really left. */
148
+ macBefore?: SampleCopy[]
149
+ added: SampleId[]
150
+ evicted: SampleCopy[]
151
+ /** Set once Undo has worked out `added` and `evicted` for a step that died before its journal said. */
152
+ effectsKnown?: boolean
153
+ retractStarted?: boolean
154
+ evictedForgotten?: number
155
+ samplesAdded: number
156
+ undo?: StepUndo
157
+ }
158
+ interface Journal {
159
+ version: 1
160
+ batchId: string
161
+ sessionId: string
162
+ at: string
163
+ state: 'running' | 'applied' | 'partial' | 'undone' | 'undo_partial'
164
+ sourceRevisionBefore: string
165
+ items: NamingBatchItem[]
166
+ steps: Step[]
167
+ }
168
+
169
+ type Refusal = { status: number; body: Record<string, unknown> } | null
170
+
171
+ export interface NamingBatchDeps {
172
+ store: { findBySessionId(sessionId: string): { sidecarPath: string; filepath: string } | null }
173
+ captureActive: () => boolean
174
+ reviewRevision: (meetingPath: string, sidecarPath: string) => string
175
+ callViewMutationRefusal: (sessionId: string) => Refusal
176
+ finalizingRefusal: (sessionId: string) => Refusal
177
+ /** Everything below defaults to the real thing; tests override single seams. */
178
+ suggestions?: (sessionId: string) => { sourceRevision: string; suggestions: VoiceSuggestion[] } | null
179
+ recordingLease?: () => boolean
180
+ admissionsOpen?: () => boolean
181
+ acquireLock?: () => Promise<{ release: () => void }>
182
+ acquireWork?: () => { release: () => void }
183
+ plan?: (input: MacAssignInput) => Promise<MacAssignPlan>
184
+ commit?: (sessionId: string, plan: MacAssignPlan) => MacAssignReport
185
+ log?: (line: string) => void
186
+ }
187
+
188
+ // ── Samples ──────────────────────────────────────────────────────────────────
189
+
190
+ const sha256 = (text: string): string => createHash('sha256').update(text).digest('hex')
191
+ /** Stored rows are the float32 values as JSON doubles, so this hash survives the trip through the store. */
192
+ const vectorHash = (vector: ArrayLike<number>): string => createHash('sha256').update(Buffer.from(Float32Array.from(vector).buffer)).digest('hex')
193
+ const sampleKey = (sample: SampleId): string => `${sample.source}#${sample.hash}`
194
+
195
+ function profileSamples(name: string): SampleCopy[] | null {
196
+ const profile = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, name))
197
+ return profile ? profile.embeddings.map((embedding, i) => ({ source: profile.sources?.[i] ?? 'unknown', hash: vectorHash(embedding), embedding })) : null
198
+ }
199
+
200
+ /** `a` minus `b` as multisets, keeping `a`'s order. */
201
+ function subtractSamples<T extends SampleId>(a: T[], b: string[]): T[] {
202
+ const left = new Map<string, number>()
203
+ for (const key of b) left.set(key, (left.get(key) ?? 0) + 1)
204
+ return a.filter(sample => {
205
+ const key = sampleKey(sample)
206
+ const count = left.get(key) ?? 0
207
+ if (count === 0) return true
208
+ left.set(key, count - 1)
209
+ return false
210
+ })
211
+ }
212
+
213
+ /**
214
+ * Where these identities are now, by profile and index: the named profile first, then every other one, because a later
215
+ * merge moves samples with their source intact. `notFound` counts what no profile holds.
216
+ */
217
+ function locateSamples(name: string, ids: SampleId[]): { found: Array<{ owner: string; samples: SampleCopy[]; drop: Set<number> }>; notFound: number } {
218
+ const wanted = new Map<string, number>()
219
+ for (const id of ids) wanted.set(sampleKey(id), (wanted.get(sampleKey(id)) ?? 0) + 1)
220
+ let notFound = ids.length
221
+ const found: Array<{ owner: string; samples: SampleCopy[]; drop: Set<number> }> = []
222
+ if (ids.length === 0) return { found, notFound }
223
+ const owners = [name, ...readVoiceProfiles().profiles.map(p => p.name).filter(owner => owner !== name)]
224
+ for (const owner of owners) {
225
+ if (notFound === 0) break
226
+ const samples = profileSamples(owner) ?? []
227
+ const drop = new Set<number>()
228
+ samples.forEach((sample, i) => {
229
+ const count = wanted.get(sampleKey(sample)) ?? 0
230
+ if (count === 0) return
231
+ drop.add(i)
232
+ wanted.set(sampleKey(sample), count - 1)
233
+ notFound--
234
+ })
235
+ if (drop.size > 0) found.push({ owner, samples, drop })
236
+ }
237
+ return { found, notFound }
238
+ }
239
+
240
+ /** Exactly these identities, wherever they are now. Runs without awaiting, so the indices cannot go stale. */
241
+ function retractSamples(name: string, ids: SampleId[]): { removed: number; notFound: number } {
242
+ const { found, notFound } = locateSamples(name, ids)
243
+ let removed = 0
244
+ for (const { owner, samples, drop } of found) {
245
+ let index = 0
246
+ const out = retractEmbeddingsBySource(owner, source => {
247
+ const at = index++
248
+ return drop.has(at) && samples[at].source === source
249
+ })
250
+ removed += out.removed
251
+ if (out.removed !== drop.size) throw new Error(`removed ${out.removed} of ${drop.size} samples from one profile`)
252
+ }
253
+ return { removed, notFound }
254
+ }
255
+
256
+ /** True when `enrollEmbedding` would make room by dropping something. Mirrors its makeRoomForSample. */
257
+ function wouldDisplace(samples: SampleCopy[]): boolean {
258
+ const macCount = samples.filter(s => isMacAssign(s.source)).length
259
+ return samples.length >= MAX_EMBEDDINGS_PER_SPEAKER || macCount >= macAssignCapFor(samples.length, macCount)
260
+ }
261
+
262
+ /**
263
+ * Has that name been taken back or moved in the meeting this sample came from, since this step? A `reverted` row counts:
264
+ * a sample from a batch that was undone after it was evicted must not come back. `own` is this journal's corrections,
265
+ * whose rows say nothing about a person.
266
+ */
267
+ function nameMovedInItsMeeting(sample: SampleId, step: Step, own: Set<string>): boolean {
268
+ // A Mac-assigned sample only ever evicts another Mac-assigned one, so every evicted source names its meeting.
269
+ const session = sample.source.slice(MAC_ASSIGN_SOURCE.length + 1)
270
+ const later = (row: CorrectionRow) => !own.has(row.id) && row.from === step.name && row.at >= step.at
271
+ return appliedCorrections(session).some(later) || readCorrections(session).rows.some(row => row.phase === 'reverted' && later(row))
272
+ }
273
+
274
+ /** One pass. `keep` is what another pass could still restore: no room yet, or enrolment failed. `dropped` is final. */
275
+ function restoreEvicted(step: Step, evicted: SampleCopy[], own: Set<string>): { restored: number; dropped: number; keep: SampleCopy[]; enrolFailed: boolean } {
276
+ const out = { restored: 0, dropped: 0, keep: [] as SampleCopy[], enrolFailed: false }
277
+ for (const sample of evicted) {
278
+ const samples = profileSamples(step.name)
279
+ if (samples?.some(s => sampleKey(s) === sampleKey(sample))) continue
280
+ if (!samples || nameMovedInItsMeeting(sample, step, own)) { out.dropped++; continue }
281
+ if (wouldDisplace(samples)) { out.keep.push(sample); continue }
282
+ if (enrollEmbedding(step.name, Float32Array.from(sample.embedding), sample.source, true).success) out.restored++
283
+ else { out.keep.push(sample); out.enrolFailed = true }
284
+ }
285
+ return out
286
+ }
287
+
288
+ /** What this step did to the profile. A step that died inside its commit is worked out from what is there now. */
289
+ function sampleEffects(step: Step): { added: SampleId[]; evicted: SampleCopy[] } {
290
+ if (step.state === 'named' || step.effectsKnown) return { added: step.added, evicted: step.evicted }
291
+ const current = profileSamples(step.name) ?? []
292
+ const planned = new Set(step.planned)
293
+ const added = subtractSamples(current.filter(s => s.source === step.source && planned.has(s.hash)), step.before)
294
+ // No added sample means the commit never ran, so a missing sample left for some other reason.
295
+ const evicted = added.length > 0 ? subtractSamples(step.macBefore ?? [], current.map(sampleKey)) : []
296
+ return { added: added.map(({ source, hash }) => ({ source, hash })), evicted }
297
+ }
298
+
299
+ // ── Journal ──────────────────────────────────────────────────────────────────
300
+
301
+ const sessionFolder = (sessionId: string): string => dataPath(MAC_VOICE_NAMING_BATCH_DIR, sessionId.replace(/:/g, '_'))
302
+ const journalPath = (sessionId: string, batchId: string): string => join(sessionFolder(sessionId), `${batchId}.json`)
303
+
304
+ /** Names deleted in this process, and when. A batch still in memory must not write their voiceprints back. */
305
+ const forgotten = new Map<string, string>()
306
+
307
+ /** Drops what a person's deletion must not leave behind. Returns the number of voiceprints dropped. */
308
+ function scrubForName(step: Step, name: string): number {
309
+ if (step.attendeesBefore && nfc(step.attendeesBefore).includes(nfc(name))) step.attendeesBefore = null
310
+ if (!sameSpeakerName(step.name, name)) return 0
311
+ const vectors = step.evicted.length + (step.macBefore?.length ?? 0)
312
+ step.evictedForgotten = (step.evictedForgotten ?? 0) + step.evicted.length
313
+ step.evicted = []
314
+ delete step.macBefore
315
+ return vectors
316
+ }
317
+
318
+ function writeJournal(journal: Journal): void {
319
+ for (const step of journal.steps) {
320
+ for (const [name, at] of forgotten) if (step.at <= at) scrubForName(step, name)
321
+ }
322
+ const path = journalPath(journal.sessionId, journal.batchId)
323
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
324
+ durableAtomicWriteFileSync(path, `${JSON.stringify(journal)}\n`, FILE_MODE)
325
+ }
326
+
327
+ function isExpired(path: string, now = Date.now()): boolean {
328
+ try { return now - statSync(path).mtimeMs > MAC_VOICE_NAMING_RETENTION_MS } catch { return false }
329
+ }
330
+
331
+ /** `missing` covers a journal past retention: it no longer exists as far as anyone is told. */
332
+ function loadJournal(sessionId: string, batchId: string): Journal | 'missing' | 'unreadable' {
333
+ const path = journalPath(sessionId, batchId)
334
+ if (isExpired(path)) return 'missing'
335
+ let text: string
336
+ try { text = readFileSync(path, 'utf-8') } catch { return 'missing' }
337
+ try {
338
+ const parsed = JSON.parse(text) as Journal
339
+ return parsed?.version === 1 && parsed.sessionId === sessionId && parsed.batchId === batchId && Array.isArray(parsed.steps) ? parsed : 'unreadable'
340
+ } catch { return 'unreadable' }
341
+ }
342
+
343
+ /** Every journal on disk, as paths. */
344
+ function journalFiles(): Array<{ session: string; batchId: string; path: string }> {
345
+ const out: Array<{ session: string; batchId: string; path: string }> = []
346
+ let folders: string[] = []
347
+ try { folders = readdirSync(dataPath(MAC_VOICE_NAMING_BATCH_DIR)) } catch { return out }
348
+ for (const session of folders) {
349
+ let files: string[] = []
350
+ try { files = readdirSync(dataPath(MAC_VOICE_NAMING_BATCH_DIR, session)) } catch { continue }
351
+ for (const file of files) {
352
+ const batchId = file.replace(/\.json$/, '')
353
+ if (BATCH_ID.test(batchId)) out.push({ session, batchId, path: dataPath(MAC_VOICE_NAMING_BATCH_DIR, session, file) })
354
+ }
355
+ }
356
+ return out
357
+ }
358
+
359
+ /** Undo is offered for the retention window, and the voiceprints and attendee copies go with the journal after it. */
360
+ function pruneExpiredJournals(): void {
361
+ for (const { path } of journalFiles()) {
362
+ if (isExpired(path)) { try { unlinkSync(path) } catch { /* the next pass tries again */ } }
363
+ }
364
+ }
365
+
366
+ /** Person deleted: drop the voiceprints journals hold for them. Their count stays, reported as not restored. */
367
+ export function forgetMacVoiceNamingPerson(name: string): { batches: number; vectors: number } {
368
+ name = nfc(name)
369
+ forgotten.set(name, new Date().toISOString())
370
+ const out = { batches: 0, vectors: 0 }
371
+ for (const { path } of journalFiles()) {
372
+ let journal: Journal
373
+ try { journal = JSON.parse(readFileSync(path, 'utf-8')) as Journal } catch { continue }
374
+ const before = JSON.stringify(journal)
375
+ let vectors = 0
376
+ for (const step of journal.steps ?? []) vectors += scrubForName(step, name)
377
+ if (JSON.stringify(journal) === before) continue
378
+ writeJournal(journal)
379
+ out.batches++
380
+ out.vectors += vectors
381
+ }
382
+ return out
383
+ }
384
+
385
+ // ── Markdown ─────────────────────────────────────────────────────────────────
386
+
387
+ const TURN = /^\[[^\]\r\n]+\]:/
388
+
389
+ /** Line numbers of `[Name]:` turns in the Transcript section, which is what relabelMeetingMarkdown rewrites. */
390
+ function transcriptTurnLines(lines: string[]): number[] {
391
+ const out: number[] = []
392
+ let inside = false
393
+ for (let i = 0; i < lines.length; i++) {
394
+ if (/^##\s+/.test(lines[i])) inside = /^##\s+Transcript\s*$/.test(lines[i])
395
+ else if (inside && TURN.test(lines[i])) out.push(i)
396
+ }
397
+ return out
398
+ }
399
+
400
+ /** The Attendees section's body, as relabelMeetingMarkdown bounds it. Null when there is none. */
401
+ function attendeeRange(md: string): { start: number; end: number } | null {
402
+ const heading = /^##\s+Attendees\s*$/m.exec(md)
403
+ if (!heading) return null
404
+ const start = heading.index + heading[0].length
405
+ const next = /^##\s+/m.exec(md.slice(start))
406
+ return { start, end: next ? start + next.index : md.length }
407
+ }
408
+
409
+ /**
410
+ * The markdown with this step's edits taken back, or null when that cannot be done honestly. The journal keeps no words:
411
+ * turn order finds the turns and the attendee section is names only, which restores the exact bytes when nothing else
412
+ * has changed. Turn order and not turn text, because undoSpeakerMarkdown refuses a turn whose words repeat, and "Yeah." repeats on
413
+ * every real call.
414
+ */
415
+ function revertedMarkdown(current: string, step: Step): string | null {
416
+ if (step.markdownBeforeHash === null) return current
417
+ if (sha256(current) === step.markdownBeforeHash) return current
418
+ const lines = current.split('\n')
419
+ const turns = transcriptTurnLines(lines)
420
+ if (turns.length !== step.turnCount) return null
421
+ const from = `[${step.name}]:`
422
+ const to = `[${step.label}]:`
423
+ for (const ordinal of step.turns) {
424
+ const line = lines[turns[ordinal]]
425
+ if (line.startsWith(to)) continue
426
+ if (!line.startsWith(from)) return null
427
+ lines[turns[ordinal]] = to + line.slice(from.length)
428
+ }
429
+ const next = lines.join('\n')
430
+ if (step.attendeeEdits === 0) return next
431
+ const range = attendeeRange(next)
432
+ if (!range || step.attendeesBefore === null || sha256(next.slice(range.start, range.end)) !== step.attendeesAfterHash) return null
433
+ return next.slice(0, range.start) + step.attendeesBefore + next.slice(range.end)
434
+ }
435
+
436
+ // ── The batch ────────────────────────────────────────────────────────────────
437
+
438
+ const sameLabels = (a: readonly string[], b: readonly string[]): boolean => a.length === b.length && [...a].sort().join('\n') === [...b].sort().join('\n')
439
+ const readText = (path: string): string | null => { try { return readFileSync(path, 'utf-8') } catch { return null } }
440
+ const yieldToLoop = (): Promise<void> => new Promise(resolve => { setImmediate(resolve) })
441
+ /** Every store write is an atomic rename, so any write by anyone changes this. */
442
+ function profileStoreStamp(): string {
443
+ try { const info = statSync(dataPath('voice-profiles.json')); return `${info.ino}:${info.size}:${info.mtimeMs}` } catch { return 'missing' }
444
+ }
445
+ const itemIsSuggested = (current: { suggestions: VoiceSuggestion[] }, item: NamingBatchItem): boolean => current.suggestions.find(s => sameSpeakerName(s.name, item.name) && sameLabels(s.labels, item.labels))?.highConfidence === true
446
+
447
+ /** One batch or Undo per session at a time, in this process. The value is the journal it is writing, once it has one. */
448
+ const active = new Map<string, string | null>()
449
+
450
+ function isOwnerName(name: string): boolean {
451
+ let label: string | undefined
452
+ let owner: string | undefined
453
+ try { label = getOwnerSpeakerLabel() } catch { /* MU is still refused */ }
454
+ try { owner = getOwnerName() } catch { /* MU is still refused */ }
455
+ return neverSuggested(name, label) || neverSuggested(name, owner)
456
+ }
457
+
458
+ function namedEntries(journal: Journal): NamedEntry[] {
459
+ const byItem = new Map<number, NamedEntry>()
460
+ for (const step of journal.steps) {
461
+ if (step.state !== 'named') continue
462
+ const entry = byItem.get(step.itemIndex) ?? { labels: [], name: step.name, samplesAdded: 0 }
463
+ entry.labels.push(step.label)
464
+ entry.samplesAdded += step.samplesAdded
465
+ byItem.set(step.itemIndex, entry)
466
+ }
467
+ return [...byItem.values()]
468
+ }
469
+
470
+ function journalUndoAvailable(journal: Journal): boolean {
471
+ if (active.get(journal.sessionId) === journal.batchId) return false
472
+ return journal.steps.some(step => step.state !== 'refused' && step.undo?.state !== 'reverted' && !(step.undo?.state === 'left' && PERMANENT_LEFT.has(step.undo.reason ?? '')))
473
+ }
474
+
475
+ function listState(journal: Journal): NamingBatchListState {
476
+ return journal.state === 'running' && active.get(journal.sessionId) !== journal.batchId ? 'interrupted' : journal.state
477
+ }
478
+
479
+ /** The attendee section before and the hash after, when a step edited it. */
480
+ function attendeeCopies(before: string | null, after: string | null): { attendeesBefore: string | null; attendeesAfterHash: string | null } {
481
+ const beforeRange = before === null ? null : attendeeRange(before)
482
+ const afterRange = after === null ? null : attendeeRange(after)
483
+ if (before === null || after === null || !beforeRange || !afterRange) return { attendeesBefore: null, attendeesAfterHash: null }
484
+ return { attendeesBefore: before.slice(beforeRange.start, beforeRange.end), attendeesAfterHash: sha256(after.slice(afterRange.start, afterRange.end)) }
485
+ }
486
+
487
+ export interface MacVoiceNamingBatches {
488
+ run(sessionId: string, request: NamingBatchRequest): Promise<NamingBatchResult>
489
+ undo(sessionId: string, batchId: string, confirm: boolean): Promise<NamingBatchUndoResult>
490
+ list(sessionId: string): { batches: NamingBatchListEntry[] }
491
+ }
492
+
493
+ export function createMacVoiceNamingBatches(deps: NamingBatchDeps): MacVoiceNamingBatches {
494
+ const suggestions = deps.suggestions ?? currentVoiceSuggestions
495
+ const recordingLease = deps.recordingLease ?? (() => voiceSuggestionRecordingLease())
496
+ const admissionsOpen = deps.admissionsOpen ?? maintenanceAdmissionsOpen
497
+ const acquireLock = deps.acquireLock ?? acquireMeetingSyncLock
498
+ const acquireWork = deps.acquireWork ?? (() => acquireMaintenanceWork('api_mutation'))
499
+ const plan = deps.plan ?? planMacVoiceAssignment
500
+ const commit = deps.commit ?? commitMacVoiceAssignment
501
+ const log = deps.log ?? ((line: string) => console.log(`[mac-naming-batch] ${line}`))
502
+
503
+ /** The same copy of the meeting relabel writes: operations first, read-only libraries refused. */
504
+ function resolveMeeting(sessionId: string): { refused: string } | { sidecarPath: string; meetingPath: string } {
505
+ if (assertVoiceEvidenceSource([sessionId]) || !MAC_CAPTURE_ID.test(sessionId)) return { refused: 'not_saved_mac_capture' }
506
+ const operations = cosOperationsMeetingsConfigured() ? findCosOperationsMeetingBySessionId(sessionId) : null
507
+ if (operations?.mergedScribeConflict?.length) return { refused: 'record_source_mismatch' }
508
+ if (operations) return { sidecarPath: operations.sidecarPath, meetingPath: operations.meetingPath }
509
+ if (findDirectLibraryMeetingBySessionId(sessionId)) return { refused: 'direct_library_read_only' }
510
+ const saved = deps.store.findBySessionId(sessionId)
511
+ return saved ? { sidecarPath: saved.sidecarPath, meetingPath: saved.filepath } : { refused: 'not_saved_mac_capture' }
512
+ }
513
+
514
+ function blockReason(sessionId: string): string | null {
515
+ if (deps.captureActive() || recordingLease()) return 'recording_active'
516
+ if (!admissionsOpen()) return 'maintenance'
517
+ if (deps.finalizingRefusal(sessionId)) return 'meeting_finalizing'
518
+ const view = deps.callViewMutationRefusal(sessionId)
519
+ return view ? String(view.body.reason ?? 'derived_call_view') : null
520
+ }
521
+
522
+ /** Whole-batch checks. Writes nothing and awaits nothing. */
523
+ function preflight(sessionId: string, request: NamingBatchRequest, meeting: { sidecarPath: string; meetingPath: string }, revision: string): { reason: string; item?: number } | { sidecar: string } {
524
+ const blocked = blockReason(sessionId)
525
+ if (blocked) return { reason: blocked }
526
+ if (pendingCorrections(sessionId).length > 0) return { reason: 'correction_pending' }
527
+ const seen = new Set<string>()
528
+ for (const [item, { labels, name }] of request.items.entries()) {
529
+ if (isOwnerName(name)) return { reason: 'owner_refused', item }
530
+ for (const label of labels) {
531
+ if (!isVoiceLabel(label)) return { reason: 'label_not_anonymous', item }
532
+ if (seen.has(label)) return { reason: 'duplicate_label', item }
533
+ seen.add(label)
534
+ }
535
+ }
536
+ const current = suggestions(sessionId)
537
+ if (!current || current.sourceRevision !== request.sourceRevision || current.sourceRevision !== revision) return { reason: 'suggestions_stale' }
538
+ for (const [index, item] of request.items.entries()) {
539
+ if (!itemIsSuggested(current, item)) return { reason: 'not_high_confidence', item: index }
540
+ }
541
+ const sidecar = readText(meeting.sidecarPath)
542
+ if (sidecar === null) return { reason: 'sidecar_unreadable' }
543
+ let carried: Set<unknown>
544
+ try {
545
+ const rows = (JSON.parse(sidecar) as { chunks?: unknown }).chunks
546
+ if (!Array.isArray(rows)) return { reason: 'sidecar_unreadable' }
547
+ carried = new Set(rows.map(row => (row as { speaker?: unknown } | null)?.speaker))
548
+ } catch { return { reason: 'sidecar_unreadable' } }
549
+ for (const [item, { labels }] of request.items.entries()) {
550
+ if (labels.some(label => !carried.has(label))) return { reason: 'label_not_anonymous', item }
551
+ }
552
+ return { sidecar }
553
+ }
554
+
555
+ async function run(sessionId: string, request: NamingBatchRequest): Promise<NamingBatchResult> {
556
+ const confirm = request.confirm === true
557
+ const base = (reason: string, notNamed: NotNamedEntry[], sourceRevision: string): NamingBatchResult => ({
558
+ batchId: '', state: 'refused', reason, named: [], notNamed, sourceRevision, undoAvailable: false, ...(confirm ? {} : { dryRun: true as const }),
559
+ })
560
+ const allRefused = (reason: string, item?: number): NotNamedEntry[] => request.items.map((entry, i) => ({ labels: entry.labels, name: entry.name, reason: item === undefined || item === i ? reason : 'not_attempted' }))
561
+ const meeting = resolveMeeting(sessionId)
562
+ if ('refused' in meeting) return base(meeting.refused, allRefused(meeting.refused), '')
563
+ const { sidecarPath, meetingPath } = meeting
564
+ const revision = (): string => deps.reviewRevision(meetingPath, sidecarPath)
565
+ const before = revision()
566
+ if (active.has(sessionId)) return base('batch_running', allRefused('batch_running'), before)
567
+ const checked = preflight(sessionId, request, meeting, before)
568
+ if ('reason' in checked) return base(checked.reason, allRefused(checked.reason, checked.item), before)
569
+
570
+ if (confirm) pruneExpiredJournals()
571
+ active.set(sessionId, null)
572
+ const batchId = randomUUID()
573
+ let journal = null as Journal | null
574
+ let expected = { sidecar: checked.sidecar, markdown: readText(meetingPath) }
575
+ /** What this batch left, after its last commit. Null until it has written anything. */
576
+ let own = null as { store: string; revision: string } | null
577
+ const wouldName = new Map<number, NamedEntry>()
578
+ const moved = (): boolean => readText(sidecarPath) !== expected.sidecar || readText(meetingPath) !== expected.markdown
579
+
580
+ /** One label through the normal path. `named` is true when the label landed, even if the samples then failed. */
581
+ const nameLabel = async (itemIndex: number, item: NamingBatchItem, label: string): Promise<{ named: boolean; stop: string | null }> => {
582
+ const { name } = item
583
+ const refuse = (stop: string) => ({ named: false, stop })
584
+ const blocked = blockReason(sessionId)
585
+ if (blocked) return refuse(blocked)
586
+ const rename = relabelSidecarJson(expected.sidecar, label, name, [])
587
+ if (!rename.ok) return refuse('label_not_anonymous')
588
+ const ownerLabel = getOwnerSpeakerLabel()
589
+ const assignment = await plan({ sessionId, from: label, to: name, changed: rename.value.changed, sidecar: JSON.parse(expected.sidecar) as Record<string, unknown>, ownerLabel })
590
+ if (assignment.report.skipped !== null) return refuse(assignment.report.skipped)
591
+ if (assignment.report.gates.length > 0) return refuse(assignment.report.gates[0])
592
+ if (!confirm) {
593
+ const entry = wouldName.get(itemIndex) ?? { labels: [], name, samplesAdded: 0 }
594
+ entry.labels.push(label)
595
+ entry.samplesAdded += assignment.report.enrolled
596
+ wouldName.set(itemIndex, entry)
597
+ return { named: true, stop: null }
598
+ }
599
+ let work: { release: () => void }
600
+ try { work = acquireWork() } catch { return refuse('maintenance') }
601
+ let lock: { release: () => void } | undefined
602
+ try {
603
+ try { lock = await acquireLock() } catch { return refuse('sync_running') }
604
+ // The plan yielded and the lock waited, so anything may have started or moved by now.
605
+ const blockedAtLock = blockReason(sessionId)
606
+ if (blockedAtLock) return refuse(blockedAtLock)
607
+ if (pendingCorrections(sessionId).length > 0) return refuse('correction_pending')
608
+ if (moved()) return refuse('meeting_changed')
609
+ if (own === null) {
610
+ const current = suggestions(sessionId)
611
+ if (!current || current.sourceRevision !== revision() || !itemIsSuggested(current, item)) return refuse('suggestions_stale')
612
+ } else if (profileStoreStamp() !== own.store || revision() !== own.revision) return refuse('suggestions_stale')
613
+ // From here to the release nothing awaits, so the profile diff below is exactly the commit.
614
+ const md = expected.markdown === null ? null : relabelMeetingMarkdown(expected.markdown, label, name)
615
+ const edit = md?.ok ? md.value : null
616
+ const rewrites = !!edit && (edit.attendees > 0 || edit.transcript > 0)
617
+ const lines = expected.markdown?.split('\n') ?? []
618
+ const turnLines = transcriptTurnLines(lines)
619
+ const at = new Date().toISOString()
620
+ const correctionId = `${sessionId}:${label}>${name}:${Date.now().toString(36)}`
621
+ const samplesBefore = profileSamples(name) ?? []
622
+ const source = `${MAC_ASSIGN_SOURCE}:${sessionId}`
623
+ const step: Step = {
624
+ itemIndex, label, name, state: 'intent', at, correctionId, changed: rename.value.changed,
625
+ markdownBeforeHash: rewrites && expected.markdown !== null ? sha256(expected.markdown) : null,
626
+ attendeeEdits: edit?.attendees ?? 0,
627
+ ...attendeeCopies(expected.markdown, edit?.attendees ? edit.markdown : null),
628
+ turnCount: turnLines.length,
629
+ turns: turnLines.flatMap((line, ordinal) => (lines[line].startsWith(`[${label}]:`) ? [ordinal] : [])),
630
+ source,
631
+ planned: assignment.selected.map(vectorHash),
632
+ before: samplesBefore.map(sampleKey),
633
+ macBefore: samplesBefore.filter(sample => isMacAssign(sample.source)),
634
+ added: [], evicted: [], samplesAdded: 0,
635
+ }
636
+ const opened: Journal = journal ?? { version: 1, batchId, sessionId, at, state: 'running', sourceRevisionBefore: before, items: request.items, steps: [] }
637
+ opened.steps.push(step)
638
+ active.set(sessionId, batchId)
639
+ try { writeJournal(opened) } catch (error) {
640
+ opened.steps.pop()
641
+ log(`journal not writable, stopping ${batchId}: ${String(error)}`)
642
+ return refuse('journal_unwritable')
643
+ }
644
+ journal = opened
645
+ const row: CorrectionRow = { id: correctionId, phase: 'intent', at, from: label, to: name, chunks: rename.value.changed, scope: 'meeting', source: `mac-naming-batch:${batchId}` }
646
+ if (!appendCorrection(sessionId, row)) {
647
+ step.state = 'refused'
648
+ writeJournal(opened)
649
+ return refuse('ledger_unwritable')
650
+ }
651
+ try {
652
+ durableAtomicWriteFileSync(sidecarPath, rename.value.json, FILE_MODE)
653
+ if (rewrites && edit) durableAtomicWriteFileSync(meetingPath, edit.markdown, FILE_MODE)
654
+ } catch (error) {
655
+ appendCorrection(sessionId, { ...row, phase: 'failed', at: new Date().toISOString(), error: String(error) })
656
+ step.state = 'failed'
657
+ writeJournal(opened)
658
+ return refuse('write_failed')
659
+ }
660
+ appendCorrection(sessionId, { ...row, phase: 'applied', at: new Date().toISOString(), surfaces: { sidecar: rename.value.changed.length, attendees: edit?.attendees ?? 0, transcript: edit?.transcript ?? 0 }, proseStale: edit?.proseStale ?? false })
661
+ expected = { sidecar: rename.value.json, markdown: rewrites && edit ? edit.markdown : expected.markdown }
662
+ const report = commit(sessionId, assignment)
663
+ own = { store: profileStoreStamp(), revision: revision() }
664
+ const samplesAfter = profileSamples(name) ?? []
665
+ step.added = subtractSamples(samplesAfter, samplesBefore.map(sampleKey)).map(({ source: s, hash }) => ({ source: s, hash }))
666
+ step.evicted = subtractSamples(samplesBefore, samplesAfter.map(sampleKey))
667
+ step.samplesAdded = step.added.length
668
+ step.state = 'named'
669
+ delete step.macBefore
670
+ writeJournal(opened)
671
+ return { named: true, stop: report.skipped }
672
+ } finally {
673
+ lock?.release()
674
+ work.release()
675
+ }
676
+ }
677
+
678
+ const notNamedByItem = new Map<string, NotNamedEntry>()
679
+ const noteNotNamed = (itemIndex: number, label: string, name: string, reason: string): void => {
680
+ const key = `${itemIndex}|${reason}`
681
+ const entry = notNamedByItem.get(key) ?? { labels: [], name, reason }
682
+ entry.labels.push(label)
683
+ notNamedByItem.set(key, entry)
684
+ }
685
+ let stop: string | null = null
686
+ try {
687
+ for (const [itemIndex, item] of request.items.entries()) {
688
+ for (const label of item.labels) {
689
+ if (stop) { noteNotNamed(itemIndex, label, item.name, 'not_attempted'); continue }
690
+ const outcome = await nameLabel(itemIndex, item, label)
691
+ stop = outcome.stop
692
+ if (!outcome.named) noteNotNamed(itemIndex, label, item.name, outcome.stop ?? 'not_named')
693
+ }
694
+ }
695
+ if (journal) {
696
+ const finished: Journal = journal
697
+ finished.state = stop ? 'partial' : 'applied'
698
+ writeJournal(finished)
699
+ }
700
+ } catch (error) {
701
+ // Same outcome as a process dying here: nothing more is written, the journal stays `running`, and it reads
702
+ // `interrupted` once this batch lets go of the session.
703
+ log(`interrupted ${batchId}: ${error instanceof Error ? error.message : String(error)}`)
704
+ stop = 'interrupted'
705
+ } finally {
706
+ active.delete(sessionId)
707
+ }
708
+ const landed = journal
709
+ const named = landed ? namedEntries(landed) : [...wouldName.values()]
710
+ // A label that never reached a verdict because the batch died mid-item stays unnamed like the rest.
711
+ if (stop === 'interrupted') {
712
+ const done = new Set(named.flatMap(entry => entry.labels))
713
+ const listed = new Set([...notNamedByItem.values()].flatMap(entry => entry.labels))
714
+ for (const [itemIndex, item] of request.items.entries()) for (const label of item.labels) if (!done.has(label) && !listed.has(label)) noteNotNamed(itemIndex, label, item.name, 'interrupted')
715
+ }
716
+ const notNamed = [...notNamedByItem.values()]
717
+ return {
718
+ batchId: landed ? batchId : '',
719
+ state: named.length === 0 && !landed?.steps.some(step => step.state !== 'refused') ? 'refused' : stop ? 'partial' : 'applied',
720
+ ...(stop ? { reason: stop } : {}),
721
+ named,
722
+ notNamed,
723
+ sourceRevision: revision(),
724
+ undoAvailable: !!landed && journalUndoAvailable(landed),
725
+ ...(confirm ? {} : { dryRun: true as const }),
726
+ }
727
+ }
728
+
729
+ // ── Undo ───────────────────────────────────────────────────────────────────
730
+
731
+ type Verdict = 'confirmed_by_person' | 'label_changed_by_person' | 'applied' | 'not_applied'
732
+
733
+ function verdictFor(step: Step, own: Set<string>, rows: CorrectionRow[], doc: Record<string, unknown>): Verdict {
734
+ const revoked = new Set(rows.filter(r => r.phase === 'reverted').map(r => r.batchId ?? r.id))
735
+ const later = rows.filter(r => !own.has(r.id) && !revoked.has(r.batchId ?? r.id) && r.at >= step.at)
736
+ const overlaps = (r: CorrectionRow) => r.chunks.some(i => step.changed.includes(i))
737
+ if (later.some(r => (r.phase === 'confirmed' || r.phase === 'confirmed-chunks') && sameSpeakerName(r.to, step.name) && (r.chunks.length === 0 || overlaps(r)))) return 'confirmed_by_person'
738
+ if (later.some(r => r.phase === 'applied' && overlaps(r))) return 'label_changed_by_person'
739
+ const chunks = Array.isArray(doc.chunks) ? doc.chunks as Array<{ speaker?: unknown } | null> : []
740
+ const speakers = step.changed.map(i => chunks[i]?.speaker)
741
+ if (speakers.every(speaker => speaker === step.name)) return 'applied'
742
+ return speakers.every(speaker => speaker === step.label) ? 'not_applied' : 'label_changed_by_person'
743
+ }
744
+
745
+ /** Puts one step's label back: markdown, then sidecar, so a crash can never leave the sidecar reverted alone. */
746
+ function revertLabel(sessionId: string, batchId: string, step: Step, meeting: { sidecarPath: string; meetingPath: string }, sidecar: string): string | null {
747
+ const markdown = readText(meeting.meetingPath)
748
+ const nextMarkdown = markdown === null ? null : revertedMarkdown(markdown, step)
749
+ if (step.markdownBeforeHash !== null && nextMarkdown === null) return 'transcript_changed'
750
+ const inverse = relabelSidecarJson(sidecar, step.name, step.label, step.changed)
751
+ if (!inverse.ok) return 'label_changed_by_person'
752
+ const row: CorrectionRow = { id: `${step.correctionId}:undo:${Date.now().toString(36)}`, phase: 'intent', at: new Date().toISOString(), from: step.name, to: step.label, chunks: step.changed, scope: 'meeting', source: `mac-naming-batch-undo:${batchId}` }
753
+ if (!appendCorrection(sessionId, row)) return 'ledger_unwritable'
754
+ try {
755
+ if (nextMarkdown !== null && nextMarkdown !== markdown) durableAtomicWriteFileSync(meeting.meetingPath, nextMarkdown, FILE_MODE)
756
+ durableAtomicWriteFileSync(meeting.sidecarPath, inverse.value.json, FILE_MODE)
757
+ } catch (error) {
758
+ appendCorrection(sessionId, { ...row, phase: 'failed', at: new Date().toISOString(), error: String(error) })
759
+ return 'write_failed'
760
+ }
761
+ return null
762
+ }
763
+
764
+ /**
765
+ * The ledger's half of a revert. `reverted` goes on the applied row's own id, whenever one still counts, so a retry
766
+ * after a crash finishes the job: that id is what appliedCorrections and the repair reader key on, and the undo's own
767
+ * intent closes `failed` because an intent may only be followed by applied or failed. Null when all of it landed.
768
+ */
769
+ function recordRevert(sessionId: string, batchId: string, step: Step): string | null {
770
+ if (!appliedCorrections(sessionId).some(row => row.id === step.correctionId)) return null
771
+ const row: CorrectionRow = { id: step.correctionId, phase: 'reverted', at: new Date().toISOString(), from: step.name, to: step.label, chunks: step.changed, scope: 'meeting', source: `mac-naming-batch-undo:${batchId}` }
772
+ return appendCorrection(sessionId, row) ? null : 'ledger_unwritable'
773
+ }
774
+
775
+ function closeOwnIntents(sessionId: string, step: Step): string | null {
776
+ let reason: string | null = null
777
+ for (const open of pendingCorrections(sessionId)) {
778
+ if (open.id !== step.correctionId && !open.id.startsWith(`${step.correctionId}:undo:`)) continue
779
+ if (!appendCorrection(sessionId, { ...open, phase: 'failed', at: new Date().toISOString(), error: 'closed by batch undo' })) reason = 'ledger_unwritable'
780
+ }
781
+ return reason
782
+ }
783
+
784
+ function undoStep(journal: Journal, step: Step, meeting: { sidecarPath: string; meetingPath: string }): StepUndo {
785
+ const { sessionId, batchId } = journal
786
+ const own = new Set(journal.steps.map(s => s.correctionId))
787
+ const prior = step.undo
788
+ const totals = (state: StepUndo['state'], extra: { removed?: number; restored?: number; dropped?: number; notFound?: number; reason?: string | null; nothingApplied?: boolean } = {}): StepUndo => ({
789
+ state,
790
+ ...(extra.reason ? { reason: extra.reason } : {}),
791
+ samplesRemoved: (prior?.samplesRemoved ?? 0) + (extra.removed ?? 0),
792
+ samplesNotFound: (prior?.samplesNotFound ?? 0) + (extra.notFound ?? 0),
793
+ evictedRestored: (prior?.evictedRestored ?? 0) + (extra.restored ?? 0),
794
+ evictedDropped: (prior?.evictedDropped ?? 0) + (extra.dropped ?? 0),
795
+ ...(extra.nothingApplied ? { nothingApplied: true } : {}),
796
+ })
797
+ const sidecar = readText(meeting.sidecarPath)
798
+ let doc: Record<string, unknown>
799
+ try { doc = JSON.parse(sidecar ?? '') as Record<string, unknown> } catch { return totals('left', { reason: 'sidecar_unreadable' }) }
800
+ const verdict = verdictFor(step, own, readCorrections(sessionId).rows, doc)
801
+ if (verdict === 'confirmed_by_person') {
802
+ // The name and its samples stand, so what they evicted stays out. Nothing is kept for a restore that cannot happen.
803
+ const dropped = sampleEffects(step).evicted.length
804
+ step.added = []
805
+ step.evicted = []
806
+ step.effectsKnown = true
807
+ delete step.macBefore
808
+ step.attendeesBefore = null
809
+ const closing = closeOwnIntents(sessionId, step)
810
+ return totals('left', { reason: closing ?? verdict, dropped })
811
+ }
812
+ let reason: string | null = verdict === 'label_changed_by_person' ? verdict : null
813
+ if (verdict === 'applied') reason = revertLabel(sessionId, batchId, step, meeting, sidecar ?? '')
814
+ if (reason === null) reason = recordRevert(sessionId, batchId, step)
815
+ reason = closeOwnIntents(sessionId, step) ?? reason
816
+ let removed = 0
817
+ let notFound = 0
818
+ let restored = 0
819
+ let dropped = 0
820
+ try {
821
+ const effects = sampleEffects(step)
822
+ step.effectsKnown = true
823
+ step.evicted = effects.evicted
824
+ step.added = effects.added
825
+ // Durable before the store changes: a retry after a crash here must not report its own retraction as missing.
826
+ const retrying = step.retractStarted === true
827
+ step.retractStarted = true
828
+ writeJournal(journal)
829
+ const out = retractSamples(step.name, step.added)
830
+ removed = out.removed
831
+ notFound = retrying ? 0 : out.notFound
832
+ step.added = []
833
+ delete step.macBefore
834
+ const back = restoreEvicted(step, step.evicted, own)
835
+ step.evicted = back.keep
836
+ restored = back.restored
837
+ dropped = back.dropped
838
+ if (back.enrolFailed) reason = 'profile_store_unavailable'
839
+ } catch (error) {
840
+ log(`samples not reverted for ${step.label} in ${batchId}: ${String(error)}`)
841
+ reason = 'profile_store_unavailable'
842
+ }
843
+ if (!reason) step.attendeesBefore = null
844
+ return totals(reason ? 'left' : 'reverted', { removed, notFound, restored, dropped, reason, nothingApplied: verdict === 'not_applied' && step.state !== 'named' && removed === 0 })
845
+ }
846
+
847
+ /** A sample with no room when its batch was undone may have room now that a later batch is undone too. */
848
+ function retryKeptRestores(): void {
849
+ for (const { session, batchId } of journalFiles()) {
850
+ const journal = loadJournal(session, batchId)
851
+ if (typeof journal === 'string') continue
852
+ const own = new Set(journal.steps.map(s => s.correctionId))
853
+ let changed = false
854
+ for (const step of journal.steps) {
855
+ if (!step.undo || step.evicted.length === 0) continue
856
+ const back = restoreEvicted(step, step.evicted, own)
857
+ step.evicted = back.keep
858
+ step.undo.evictedRestored += back.restored
859
+ step.undo.evictedDropped += back.dropped
860
+ changed = changed || back.restored + back.dropped > 0
861
+ }
862
+ if (changed) writeJournal(journal)
863
+ }
864
+ }
865
+
866
+ function undoResult(journal: Journal, transient: Map<string, string>, revision: string, dryRun: boolean, wouldRemove = 0, wouldRestore = 0, verdicts?: Map<string, Verdict>, wouldMissNames?: Map<string, number>): NamingBatchUndoResult {
867
+ const reverted = new Map<number, { labels: string[]; name: string }>()
868
+ const left = new Map<string, { labels: string[]; name: string; reason: string }>()
869
+ let samplesRemoved = wouldRemove
870
+ let evictedRestored = wouldRestore
871
+ let evictedNotRestored = 0
872
+ const leave = (step: Step, reason: string): void => {
873
+ const key = `${step.itemIndex}|${reason}`
874
+ const entry = left.get(key) ?? { labels: [], name: step.name, reason }
875
+ entry.labels.push(step.label)
876
+ left.set(key, entry)
877
+ }
878
+ for (const step of journal.steps) {
879
+ if (step.state === 'refused') continue
880
+ samplesRemoved += step.undo?.samplesRemoved ?? 0
881
+ evictedRestored += step.undo?.evictedRestored ?? 0
882
+ if (step.undo) evictedNotRestored += step.undo.evictedDropped + step.evicted.length + (step.evictedForgotten ?? 0)
883
+ const verdict = verdicts?.get(step.correctionId)
884
+ const reason = transient.get(step.correctionId)
885
+ ?? (verdict === 'confirmed_by_person' || verdict === 'label_changed_by_person' ? verdict : undefined)
886
+ ?? (step.undo?.state === 'left' ? step.undo.reason ?? 'not_reverted' : undefined)
887
+ if (reason) leave(step, reason)
888
+ else if (!step.undo?.nothingApplied && !(verdict === 'not_applied' && step.state !== 'named')) {
889
+ const entry = reverted.get(step.itemIndex) ?? { labels: [], name: step.name }
890
+ entry.labels.push(step.label)
891
+ reverted.set(step.itemIndex, entry)
892
+ }
893
+ // The label may be back and a sample still unaccounted for: a voice can be in both lists, and the answer is partial.
894
+ if ((step.undo?.samplesNotFound ?? 0) + (wouldMissNames?.get(step.correctionId) ?? 0) > 0) leave(step, 'samples_not_found')
895
+ }
896
+ return {
897
+ state: left.size > 0 ? 'partial' : 'undone',
898
+ reverted: [...reverted.values()],
899
+ left: [...left.values()],
900
+ samplesRemoved,
901
+ evictedRestored,
902
+ evictedNotRestored,
903
+ sourceRevision: revision,
904
+ ...(dryRun ? { dryRun: true as const } : {}),
905
+ }
906
+ }
907
+
908
+ async function undo(sessionId: string, batchId: string, confirm: boolean): Promise<NamingBatchUndoResult> {
909
+ const refusal = (reason: string, sourceRevision: string): NamingBatchUndoResult => ({
910
+ state: 'refused', reason, reverted: [], left: [], samplesRemoved: 0, evictedRestored: 0, evictedNotRestored: 0, sourceRevision, ...(confirm ? {} : { dryRun: true as const }),
911
+ })
912
+ const meeting = resolveMeeting(sessionId)
913
+ if ('refused' in meeting) return refusal(meeting.refused, '')
914
+ const revision = (): string => deps.reviewRevision(meeting.meetingPath, meeting.sidecarPath)
915
+ const journal = loadJournal(sessionId, batchId)
916
+ if (journal === 'missing') return refusal('batch_not_found', revision())
917
+ if (journal === 'unreadable') return refusal('journal_unreadable', revision())
918
+ if (active.has(sessionId)) return refusal('batch_running', revision())
919
+ // Already done: answer the same thing again and touch nothing, whatever else is going on.
920
+ if (journal.state === 'undone') return undoResult(journal, new Map(), revision(), !confirm)
921
+ const blocked = blockReason(sessionId)
922
+ if (blocked) return refusal(blocked, revision())
923
+ const own = journal.steps.map(step => step.correctionId)
924
+ if (pendingCorrections(sessionId).some(open => !own.some(id => open.id === id || open.id.startsWith(`${id}:undo:`)))) return refusal('correction_pending', revision())
925
+ const steps = journal.steps.filter(step => step.state !== 'refused' && step.undo?.state !== 'reverted').reverse()
926
+
927
+ if (!confirm) {
928
+ let doc: Record<string, unknown>
929
+ try { doc = JSON.parse(readText(meeting.sidecarPath) ?? '') as Record<string, unknown> } catch { return refusal('sidecar_unreadable', revision()) }
930
+ const rows = readCorrections(sessionId).rows
931
+ const verdicts = new Map<string, Verdict>()
932
+ const misses = new Map<string, number>()
933
+ let wouldRemove = 0
934
+ let wouldRestore = 0
935
+ for (const step of steps) {
936
+ const verdict = verdictFor(step, new Set(own), rows, doc)
937
+ verdicts.set(step.correctionId, verdict)
938
+ if (verdict === 'confirmed_by_person') continue
939
+ const effects = sampleEffects(step)
940
+ const located = locateSamples(step.name, effects.added)
941
+ wouldRemove += effects.added.length - located.notFound
942
+ misses.set(step.correctionId, located.notFound)
943
+ wouldRestore += subtractSamples(effects.evicted, (profileSamples(step.name) ?? []).map(sampleKey)).length
944
+ }
945
+ return undoResult(journal, new Map(), revision(), true, wouldRemove, wouldRestore, verdicts, misses)
946
+ }
947
+
948
+ active.set(sessionId, batchId)
949
+ const transient = new Map<string, string>()
950
+ try {
951
+ for (const step of steps) {
952
+ await yieldToLoop()
953
+ let work: { release: () => void }
954
+ try { work = acquireWork() } catch { transient.set(step.correctionId, 'maintenance'); continue }
955
+ let lock: { release: () => void } | undefined
956
+ try {
957
+ try { lock = await acquireLock() } catch { transient.set(step.correctionId, 'sync_running'); continue }
958
+ // A recording may have started while this waited for the lock. Nothing awaits between here and the journal.
959
+ const stopAtLock = blockReason(sessionId)
960
+ if (stopAtLock) { transient.set(step.correctionId, stopAtLock); continue }
961
+ step.undo = undoStep(journal, step, meeting)
962
+ writeJournal(journal)
963
+ } finally {
964
+ lock?.release()
965
+ work.release()
966
+ }
967
+ }
968
+ const clean = transient.size === 0 && journal.steps.every(step => step.state === 'refused' || (step.undo?.state === 'reverted' && !step.undo.samplesNotFound))
969
+ journal.state = clean ? 'undone' : 'undo_partial'
970
+ writeJournal(journal)
971
+ // Best effort and bounded by the same checks as a write. A failure here must not turn a finished Undo into an error.
972
+ if (!blockReason(sessionId)) {
973
+ let work: { release: () => void } | undefined
974
+ try {
975
+ work = acquireWork()
976
+ retryKeptRestores()
977
+ } catch (error) {
978
+ log(`kept samples not retried after ${batchId}: ${String(error)}`)
979
+ } finally {
980
+ work?.release()
981
+ }
982
+ }
983
+ } finally {
984
+ active.delete(sessionId)
985
+ }
986
+ const reread = loadJournal(sessionId, batchId)
987
+ return undoResult(typeof reread === 'string' ? journal : reread, transient, revision(), false)
988
+ }
989
+
990
+ function list(sessionId: string): { batches: NamingBatchListEntry[] } {
991
+ let files: string[] = []
992
+ try { files = readdirSync(sessionFolder(sessionId)).filter(file => BATCH_ID.test(file.replace(/\.json$/, ''))) } catch { return { batches: [] } }
993
+ const batches = files.flatMap((file): NamingBatchListEntry[] => {
994
+ const batchId = file.replace(/\.json$/, '')
995
+ const journal = loadJournal(sessionId, batchId)
996
+ if (journal === 'missing') return []
997
+ return [typeof journal === 'string'
998
+ ? { batchId, at: '', state: 'unreadable', named: [], undoAvailable: false }
999
+ : { batchId, at: journal.at, state: listState(journal), named: namedEntries(journal), undoAvailable: journalUndoAvailable(journal) }]
1000
+ })
1001
+ return { batches: batches.sort((a, b) => b.at.localeCompare(a.at)) }
1002
+ }
1003
+
1004
+ return { run, undo, list }
1005
+ }
1006
+
1007
+ // ── Routes ───────────────────────────────────────────────────────────────────
1008
+
1009
+ /** For index.ts's `lifecycleOwned`: these own their lease per write and refuse on their own during a drain. */
1010
+ export function isMacVoiceNamingBatchPost(path: string): boolean {
1011
+ return /^\/meeting\/[A-Za-z0-9:_-]{3,96}\/voice-naming-batch(?:\/[0-9a-f-]{36}\/undo)?$/.test(path)
1012
+ }
1013
+
1014
+ function parseItems(value: unknown): NamingBatchItem[] | null {
1015
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_ITEMS) return null
1016
+ const items: NamingBatchItem[] = []
1017
+ for (const raw of value as Array<{ labels?: unknown; name?: unknown } | null>) {
1018
+ const labels = raw?.labels
1019
+ const name = raw?.name
1020
+ if (typeof name !== 'string' || name.length === 0 || name.length > 120) return null
1021
+ if (!Array.isArray(labels) || labels.length === 0 || labels.length > MAX_ITEMS || labels.some(label => typeof label !== 'string')) return null
1022
+ items.push({ labels: labels as string[], name: nfc(name) })
1023
+ }
1024
+ return items
1025
+ }
1026
+
1027
+ export function registerMacVoiceNamingBatchRoutes(router: Router, deps: NamingBatchDeps): void {
1028
+ const batches = createMacVoiceNamingBatches(deps)
1029
+ // Also at startup, so voiceprints do not outlive retention on a Mac that names nothing more.
1030
+ pruneExpiredJournals()
1031
+ const invalidSession = (sessionId: string): boolean => !SESSION_ID.test(sessionId)
1032
+
1033
+ router.get('/meeting/:sessionId/voice-naming-batch', (req, res) => {
1034
+ const sessionId = String(req.params.sessionId ?? '')
1035
+ if (invalidSession(sessionId)) { res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' }); return }
1036
+ res.set('Cache-Control', 'private, no-store')
1037
+ res.json(batches.list(sessionId))
1038
+ })
1039
+
1040
+ router.post('/meeting/:sessionId/voice-naming-batch', async (req, res) => {
1041
+ const sessionId = String(req.params.sessionId ?? '')
1042
+ res.set('Cache-Control', 'private, no-store')
1043
+ if (invalidSession(sessionId)) { res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' }); return }
1044
+ const items = parseItems(req.body?.items)
1045
+ if (!items || typeof req.body?.sourceRevision !== 'string') { res.status(400).json({ error: 'sourceRevision and items are required', reason: 'invalid_items' }); return }
1046
+ try {
1047
+ res.json(await batches.run(sessionId, { sourceRevision: req.body.sourceRevision, items, confirm: req.body?.confirm === true }))
1048
+ } catch (error) {
1049
+ console.error('[mac-naming-batch] refused:', error)
1050
+ res.status(500).json({ error: 'The batch could not run', reason: 'internal_error' })
1051
+ }
1052
+ })
1053
+
1054
+ router.post('/meeting/:sessionId/voice-naming-batch/:batchId/undo', async (req, res) => {
1055
+ const sessionId = String(req.params.sessionId ?? '')
1056
+ const batchId = String(req.params.batchId ?? '')
1057
+ res.set('Cache-Control', 'private, no-store')
1058
+ if (invalidSession(sessionId)) { res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' }); return }
1059
+ if (!BATCH_ID.test(batchId)) { res.status(400).json({ error: 'Invalid batchId', reason: 'invalid_batch_id' }); return }
1060
+ try {
1061
+ res.json(await batches.undo(sessionId, batchId, req.body?.confirm === true))
1062
+ } catch (error) {
1063
+ console.error('[mac-naming-batch] undo failed:', error)
1064
+ res.status(500).json({ error: 'The Undo could not finish. Ask again; it is safe to repeat', reason: 'internal_error' })
1065
+ }
1066
+ })
1067
+ }