@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
@@ -1,8 +1,15 @@
1
1
  import { runQualifiedMacProfilePass } from '../lib/mac-speaker-profile-finalizer.js'
2
2
  import { readMacSpeakerMachineEvidence } from '../lib/mac-speaker-machine-evidence.js'
3
+ import { isEffectiveMacSpeakerCallView, readMacSpeakerCallView } from '../lib/mac-speaker-call-view.js'
4
+ import { commitMacCallHumanAnnotation, isMacCallHumanName, macCallHumanPaths, macCallHumanScopes, previewMacCallHumanAnnotation, readMacCallHumanFile, recoverMacCallHumanAnnotation, type MacCallHumanFiles, type MacCallHumanScope } from '../lib/mac-speaker-call-human.js'
5
+ import type { MacSpeakerCallPacket, MacSpeakerCallView } from '../lib/mac-speaker-call-view.js'
6
+ import { buildMacIntervalHumanScopes, commitMacIntervalHumanAnnotation, inspectMacIntervalHumanJournal, macIntervalHumanPaths, previewMacIntervalHumanAnnotation, recoverMacIntervalHumanAnnotation, undoMacIntervalHumanAnnotation, type MacIntervalHumanScope } from '../lib/mac-speaker-call-interval-human.js'
7
+ import { commitOriginalViewTransaction, inspectOriginalViewTransaction, originalViewPaths, previewOriginalViewTransaction, recoverOriginalViewTransaction } from '../lib/mac-speaker-call-original-view.js'
8
+ import { checkSpeakerName } from '../lib/speaker-name.js'
3
9
  import { dataPath } from '../lib/data-dir.js'
4
10
  import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, assertPhoneProvenance, readPhoneRecording, requirePhoneRecording, closePhoneRecording } from '../lib/phone-recording.js'
5
11
  import { MAC_CAPTURE_ID, readMacRecording, requireMacRecording, macChunkReceipt, closeMacRecording } from '../lib/mac-recording.js'
12
+ import { bodyName, nfc, sameRecordId, sameSpeakerName } from '../lib/speaker-name-nfc.js'
6
13
  import { readSpeakerPolicy, writeSpeakerPolicy, isSpeakerMode } from '../lib/meeting-speaker-policy.js'
7
14
  // POST /api/meeting/save — finalize an existing transcribe-stream session into
8
15
  // the standalone public meeting store. The live transcript and chunk metadata
@@ -12,7 +19,7 @@ import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } f
12
19
  import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
13
20
  import { basename, resolve } from 'node:path'
14
21
  import { createHash } from 'node:crypto'
15
- import { Router } from 'express'
22
+ import { Router, type RequestHandler } from 'express'
16
23
  import { emitDisplay } from '../lib/display-bus.js'
17
24
  import { createStopRequestStore, isStopRequestRefusal, STOP_REQUEST_CLAIM_WINDOW_MS, type StopRequest, type StopRequestStore } from '../lib/meeting-stop-requests.js'
18
25
  import { appendAutonomousSaveLog, getStopIntentStore, type StopIntent, type StopIntentStore } from '../lib/meeting-stop-intents.js'
@@ -47,6 +54,9 @@ import {
47
54
  import { readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
48
55
  import { enrolNamedVoice } from '../lib/meeting-relabel-enrolment.js'
49
56
  import { commitMacVoiceAssignment, planMacVoiceAssignment, type MacAssignGate, type MacAssignPlan, type MacAssignSkip } from '../lib/mac-voice-assignment.js'
57
+ import { readVoiceDisplay, voiceDisplayScopes } from '../lib/mac-voice-display.js'
58
+ import { registerMacVoiceNamingBatchRoutes } from '../lib/mac-voice-naming-batch.js'
59
+ import { getVoiceSuggestionRunner, voiceSidecarEntries, voiceSuggestionPauseLease, voiceSuggestionRecordingLease, type VoiceSuggestionBlock, type VoiceSuggestionRunner } from '../lib/mac-voice-suggestions.js'
50
60
  import { isMacCaptureSidecar, isVoiceLabel } from '../lib/mac-voice-labels.js'
51
61
 
52
62
  function requireMacRecordingForSave(sessionId: string, expectedChunkCount: unknown): void {
@@ -194,16 +204,20 @@ import {
194
204
  } from '../lib/g2-ops-handoff.js'
195
205
  import {
196
206
  canonicalFinalizationIsComplete,
207
+ finalizationStop,
197
208
  MeetingFinalizationJobStore,
198
209
  markCanonicalFinalizationState,
199
210
  readFinalizationChunkEntries,
211
+ stoppedFinalizationRetryDue,
200
212
  type MeetingFinalizationJob,
201
213
  } from '../lib/meeting-finalization-jobs.js'
202
214
  import { enrichStandaloneMeeting } from '../lib/meeting-summary-persistence.js'
203
215
  import { bootResumeGate, type BootResumeGate } from '../lib/boot-resume.js'
216
+ import { managedServerVersion } from '../lib/managed-runtime.js'
204
217
  import { recordFinalPassSkip, runNemotronFinalPass, type FinalPassOptions, type FinalPassOutcome } from '../lib/nemotron-final.js'
205
218
  import { runTranscriptCleanup, type CleanupOutcome } from '../lib/transcript-cleanup.js'
206
219
  import { notifySpeakerShadowFinalized } from '../lib/speaker-shadow.js'
220
+ import { macSpeakerCallCachedRefreshAvailability, notifyMacSpeakerCallFinalized, notifyMacSpeakerCallLedgerChanged } from '../lib/mac-speaker-call-producer.js'
207
221
  import { sweepTranscriptCleanupData } from '../lib/transcript-cleanup-screen.js'
208
222
  import type { TranscriptCleanupRouteDeps } from './transcript-cleanup.js'
209
223
 
@@ -228,13 +242,31 @@ function cosOpsPipelineConfigured(): boolean {
228
242
  * A failure is logged and never fails the capture's own finalization, because a retry would
229
243
  * re-run the standalone summary; mac_library_backfill.py files it later, idempotently.
230
244
  */
231
- async function fileFinishedMacRecording(sessionId: string, meetingPath: string, label: string): Promise<void> {
245
+ export async function fileFinishedMacRecording(sessionId: string, meetingPath: string, label: string, deps: {
246
+ configured?: () => boolean
247
+ file?: typeof fileMacRecordingInLibrary
248
+ notify?: typeof notifyMacSpeakerCallFinalized
249
+ shadowNotify?: typeof notifySpeakerShadowFinalized
250
+ operationsRoot?: () => string | null
251
+ } = {}): Promise<void> {
232
252
  // Callers pass Mac captures only (finalization and orphan recovery both check MAC_CAPTURE_ID).
233
- if (!cosOpsPipelineConfigured()) return
253
+ if (!MAC_CAPTURE_ID.test(sessionId)) return
254
+ const notify = deps.notify ?? notifyMacSpeakerCallFinalized
234
255
  try {
235
- const filing = await fileMacRecordingInLibrary(meetingPath)
256
+ if (!(deps.configured ?? cosOpsPipelineConfigured)()) return
257
+ const filing = await (deps.file ?? fileMacRecordingInLibrary)(meetingPath)
236
258
  if (filing.outcome === 'filed') {
237
259
  console.log(`[${label}] Mac recording ${sessionId} filed into the library (${filing.status}): ${filing.path}`)
260
+ // The exact-file importer returns its chosen destination. Never guess a
261
+ // second copy from a title or apply one copy's packet to another BASE.
262
+ const root = (deps.operationsRoot ?? resolveCosOperationsDir)()
263
+ const filedPath = resolve(filing.path), sidecarPath = filedPath.replace(/\.md$/, '.g2-chunks.json')
264
+ if (root && filedPath.startsWith(`${root}/`) && filedPath.endsWith('.md')) {
265
+ const source = JSON.parse(readMacCallHumanFile(sidecarPath, 32 * 1024 * 1024)!.text)
266
+ if (source.sessionId === sessionId && source.sourceType === 'mac_system_audio') {
267
+ await notify({ sessionId, sidecarPath })
268
+ }
269
+ }
238
270
  } else {
239
271
  console.log(`[${label}] Mac recording ${sessionId} stays standalone: ${filing.reason}`)
240
272
  }
@@ -243,6 +275,15 @@ async function fileFinishedMacRecording(sessionId: string, meetingPath: string,
243
275
  `[${label}] Mac recording ${sessionId} was not filed into the library: `
244
276
  + `${error instanceof Error ? error.message : String(error)}`,
245
277
  )
278
+ } finally {
279
+ // The canonical standalone recording remains a registered target when
280
+ // filing is unavailable or fails. Notifications never make saving fail.
281
+ try { await notify({ sessionId, sidecarPath: meetingPath.replace(/\.md$/, '.g2-chunks.json') }) }
282
+ catch { console.warn(`[${label}] Closed-call speaker reconciliation deferred for ${sessionId}`) }
283
+ // Register the publishing consumer before the log-only consumer can start
284
+ // shared inference. An already-running log-only execution cannot acquire
285
+ // pre-native source provenance retroactively.
286
+ (deps.shadowNotify ?? notifySpeakerShadowFinalized)({ sessionId, sidecarPath: meetingPath.replace(/\.md$/, '.g2-chunks.json') })
246
287
  }
247
288
  }
248
289
 
@@ -262,7 +303,7 @@ function markdownUnreadableReason(operations: { mergedScribeConflict?: string[]
262
303
  }
263
304
 
264
305
  function requestedRecordMatches(requested: unknown, expected: string): boolean {
265
- return requested == null || requested === '' || requested === expected
306
+ return requested == null || requested === '' || sameRecordId(requested, expected)
266
307
  }
267
308
 
268
309
  interface MeetingSessionSource {
@@ -317,6 +358,8 @@ export interface MeetingRouteDependencies {
317
358
  stopRequests?: StopRequestStore
318
359
  /** 6.77.0: the post-save transcript cleanup (lib/transcript-cleanup.ts). */
319
360
  transcriptCleanup?: TranscriptCleanupRunner
361
+ /** 6.96.3: on-click voice suggestions (lib/mac-voice-suggestions.ts). Tests inject a runner with a fake extractor. */
362
+ voiceSuggestions?: VoiceSuggestionRunner
320
363
  /** 6.81.0: the phone's Stop, durable (lib/meeting-stop-intents.ts). */
321
364
  stopIntents?: StopIntentStore
322
365
  /** 6.81.0: the server's own save of a stopped meeting. Tests inject the clock, timer and gate. */
@@ -331,6 +374,8 @@ export interface MeetingRouteDependencies {
331
374
 
332
375
  const activeFinalizationJobs = new Set<string>()
333
376
  const finalizationRetryCounts = new Map<string, number>()
377
+ /** Retries after a job's first failure in one process, 60 s and 120 s apart. */
378
+ const FINALIZATION_RETRY_LIMIT = 2
334
379
 
335
380
  interface FinalizationRuntime {
336
381
  finalizationJobs: MeetingFinalizationJobStore
@@ -383,13 +428,17 @@ export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Fi
383
428
  allowDuringDrain: true,
384
429
  phase: 'queued',
385
430
  })
431
+ // 6.97.0: an attempt is starting, so a job that had stopped is work in progress again.
432
+ try { runtime.finalizationJobs.clearStopped(job.sessionId) } catch { /* the lease above still holds the drain */ }
386
433
  const jobStartedAt = Date.now()
387
434
  const task = Promise.resolve().then(async () => {
388
435
  lease.setPhase('active')
389
436
  let current = runtime.finalizationJobs.get(job.sessionId) ?? job
390
437
  if (canonicalFinalizationIsComplete(current)) {
438
+ if (MAC_CAPTURE_ID.test(current.sessionId)) await notifyMacSpeakerCallFinalized({ sessionId: current.sessionId, sidecarPath: current.sidecarPath, freezeOnly: true })
391
439
  runtime.finalizationJobs.remove(current.sessionId)
392
440
  bootResumeGate().forget('meeting_finalization', current.sessionId)
441
+ if (MAC_CAPTURE_ID.test(current.sessionId)) runtime.scheduleBackground(fileFinishedMacRecording(current.sessionId, current.meetingPath, 'meeting/resume'))
393
442
  return
394
443
  }
395
444
  if (current.phase === 'capture_pending') {
@@ -492,9 +541,9 @@ export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Fi
492
541
  await enrichStandaloneMeeting(current.meetingPath, jobStartedAt)
493
542
  }
494
543
  markCanonicalFinalizationState(current.sidecarPath, 'complete', false)
495
- // 6.95.0: speaker merge shadow mode (off unless COS_SPEAKER_SHADOW=1). Log only and
496
- // fire-and-forget: it queues, defers under live work, and never throws back here.
497
- if (MAC_CAPTURE_ID.test(current.sessionId)) notifySpeakerShadowFinalized({ sessionId: current.sessionId, sidecarPath: current.sidecarPath })
544
+ // Capture the pre-correction source while the finalization guard still
545
+ // refuses human edits. A deferred producer keeps its own durable work item.
546
+ if (MAC_CAPTURE_ID.test(current.sessionId)) await notifyMacSpeakerCallFinalized({ sessionId: current.sessionId, sidecarPath: current.sidecarPath, freezeOnly: true })
498
547
  // The capture is finished and its files are durable, so the merge engine may score it.
499
548
  // Fire-and-forget by contract: the runner queues, defers under a drain or a live
500
549
  // capture, and never throws back at the finalization path.
@@ -512,6 +561,9 @@ export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Fi
512
561
  }
513
562
  }).catch(error => {
514
563
  const current = runtime.finalizationJobs.get(job.sessionId) ?? job
564
+ const retryCount = finalizationRetryCounts.get(key) ?? 0
565
+ // 6.97.0: nothing will try this again in this process, so it stops counting as work.
566
+ const outOfRetries = retryCount >= FINALIZATION_RETRY_LIMIT
515
567
  try {
516
568
  runtime.finalizationJobs.save({
517
569
  sessionId: current.sessionId,
@@ -522,14 +574,14 @@ export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Fi
522
574
  phase: current.phase,
523
575
  claimPending: current.claimPending,
524
576
  lastError: error instanceof Error ? error.message : String(error),
577
+ ...(outOfRetries ? { stopped: { at: new Date().toISOString(), reason: 'retries_exhausted' as const, version: managedServerVersion() } } : {}),
525
578
  })
526
579
  } catch { /* keep the original durable job if error annotation fails */ }
527
580
  console.error(
528
581
  `[meeting/save] Durable finalization retained for retry (${job.sessionId}): `
529
582
  + `${error instanceof Error ? error.message : String(error)}`,
530
583
  )
531
- const retryCount = finalizationRetryCounts.get(key) ?? 0
532
- if (retryCount < 2) {
584
+ if (!outOfRetries) {
533
585
  finalizationRetryCounts.set(key, retryCount + 1)
534
586
  const retryRetained = () => {
535
587
  if (!maintenanceAdmissionsOpen()) {
@@ -558,6 +610,8 @@ export function resumeMeetingFinalizationJobs(options: {
558
610
  gate?: BootResumeGate
559
611
  finalizationJobs?: MeetingFinalizationJobStore
560
612
  schedule?: (job: MeetingFinalizationJob) => void
613
+ now?: number
614
+ version?: string | null
561
615
  } = {}): void {
562
616
  const finalizationJobs = options.finalizationJobs ?? new MeetingFinalizationJobStore()
563
617
  finalizationJobs.reconcileCanonicalSidecars()
@@ -567,11 +621,25 @@ export function resumeMeetingFinalizationJobs(options: {
567
621
  runBatch: runMeetingBatchPipeline,
568
622
  scheduleBackground: task => { void task },
569
623
  }))
624
+ const now = options.now ?? Date.now()
625
+ const version = options.version === undefined ? managedServerVersion() : options.version
570
626
  for (const job of finalizationJobs.list()) {
571
627
  if (options.sessionId !== undefined && job.sessionId !== options.sessionId) continue
628
+ // 6.97.0: a job that gave up does not take the lock back on every start. It is tried again on
629
+ // another server version, which may have the fix, or 6 hours after it stopped. A person's Retry
630
+ // (`sessionId`) always runs.
631
+ const stop = finalizationStop(job)
632
+ if (stop && options.sessionId === undefined && !stoppedFinalizationRetryDue(stop, now, version)) continue
572
633
  // 6.78.0: counted (durably) BEFORE it runs, so a job that kills the process is parked
573
634
  // after RESTART_LIMIT crashed boots. A parked job is skipped and stays in its store.
574
- if (!gate.admit('meeting_finalization', job.sessionId, job.phase)) continue
635
+ if (!gate.admit('meeting_finalization', job.sessionId, job.phase)) {
636
+ // 6.97.0: parked means nothing is trying it, so it must not hold Restart or Update either.
637
+ try { if (stop?.reason !== 'crash_parked') finalizationJobs.markStopped(job, 'crash_parked', version) } catch { /* stays as it was */ }
638
+ continue
639
+ }
640
+ // 6.97.0: a job that already failed gets one attempt per start, with no timers: its lock lasts as
641
+ // long as that attempt runs. This also covers a job that failed before 6.97.0 wrote stops down.
642
+ if (stop || job.lastError) finalizationRetryCounts.set(`${finalizationJobs.root}:${job.sessionId}`, FINALIZATION_RETRY_LIMIT)
575
643
  schedule(job)
576
644
  }
577
645
  }
@@ -734,6 +802,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
734
802
  const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
735
803
  const qualifiedProfilePass = deps.qualifiedProfilePass ?? runQualifiedMacProfilePass
736
804
  const transcriptCleanup = deps.transcriptCleanup
805
+ const voiceSuggestions = deps.voiceSuggestions ?? getVoiceSuggestionRunner()
737
806
  const router = Router()
738
807
  const captureActive = deps.captureActive ?? (() => getTranscriptionSessionLiveness().live > 0)
739
808
  const savingSessions = new Set<string>()
@@ -1578,18 +1647,285 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1578
1647
  // Keyed on sessionId so it can reuse the store's traversal-hardened readers
1579
1648
  function reviewRevision(meetingPath: string, sidecarPath: string): string {
1580
1649
  const hash = createHash('sha256')
1650
+ let sidecarJSON: string | null = null
1581
1651
  for (const path of [meetingPath, sidecarPath]) {
1582
1652
  let text: string | null = null
1583
1653
  try { text = readFileSync(path, 'utf-8') } catch { /* missing content is part of the revision */ }
1584
1654
  hash.update(JSON.stringify(text))
1655
+ if (path === sidecarPath) sidecarJSON = text
1656
+ }
1657
+ // Existing clients already round-trip this opaque 64-character revision.
1658
+ // A packet/ledger change that withdraws a view must invalidate its preview,
1659
+ // even when the immutable base files have not changed at all.
1660
+ if (sidecarJSON) {
1661
+ try {
1662
+ const view = readMacSpeakerCallView(sidecarPath, sidecarJSON, meetingPath)
1663
+ if (isEffectiveMacSpeakerCallView(view)) hash.update(JSON.stringify({ packetSHA256: view.metadata.packetSHA256, generation: view.metadata.generation, revision: view.metadata.revision, mode: view.metadata.mode, humanAnnotation: view.metadata.humanAnnotation, intervalHumanAnnotation: view.metadata.intervalHumanAnnotation, intervalHumanRecoveryRequired: view.metadata.intervalHumanRecoveryRequired }))
1664
+ } catch { /* Preserve existing unreadable-base handling in each caller. */ }
1665
+ }
1666
+ // Include sidecar-only decisions and recovery states even after an automatic
1667
+ // packet is withdrawn or becomes unreadable. Old preview revisions fail CAS.
1668
+ if (MAC_CAPTURE_ID.test(String((() => { try { return JSON.parse(sidecarJSON ?? '{}').sessionId } catch { return '' } })()))) {
1669
+ const id = JSON.parse(sidecarJSON!).sessionId
1670
+ const files = { sidecarPath, meetingPath }, paths = macIntervalHumanPaths(files, id), legacy = macCallHumanPaths(files, id)
1671
+ for (const [path, limit] of [[paths.packet, 16 * 1024 * 1024], [sidecarPath.replace(/\.g2-chunks\.json$/, '.speaker-call-state.json'), 16 * 1024 * 1024], [paths.legacyAnnotation, 16 * 1024 * 1024], [paths.annotation, 16 * 1024 * 1024], [paths.journal, 40 * 1024 * 1024], [originalViewPaths(files, id).journal, 1024 * 1024], [legacy.journal, 64 * 1024 * 1024], [paths.ledger, 8 * 1024 * 1024]] as const) {
1672
+ try { hash.update(JSON.stringify(readMacCallHumanFile(path, limit, true)?.hash ?? null)) }
1673
+ catch { hash.update(JSON.stringify({ unreadable: path })) }
1674
+ }
1585
1675
  }
1586
1676
  return hash.digest('hex')
1587
1677
  }
1588
1678
 
1679
+ function callViewMutationRefusal(sessionId: string): { status: number; body: Record<string, unknown> } | null {
1680
+ const operations = cosOperationsMeetingsConfigured() ? findCosOperationsMeetingBySessionId(sessionId) : null
1681
+ const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
1682
+ const saved = operations || direct ? null : store.findBySessionId(sessionId)
1683
+ if (!operations && !direct && !saved) return null
1684
+ const sidecarPath = operations?.sidecarPath ?? direct?.sidecarPath ?? saved!.sidecarPath
1685
+ try {
1686
+ if (MAC_CAPTURE_ID.test(sessionId)) {
1687
+ const files = { sidecarPath, meetingPath: operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath }
1688
+ const originalTransaction = inspectOriginalViewTransaction(files, sessionId)
1689
+ if (originalTransaction.status === 'prepared' || originalTransaction.status === 'recovery-invalid') return { status: 409, body: { reason: 'human_annotation_recovery_required', error: 'Finish the interrupted return to the original view before another correction.', mutable: false } }
1690
+ let pending = true
1691
+ try { const journal = readMacCallHumanFile(macCallHumanPaths({ sidecarPath, meetingPath: operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath }, sessionId).journal, 64 * 1024 * 1024, true); pending = journal !== null && JSON.parse(journal.text).phase !== 'complete' } catch { /* A malformed owned journal must not permit a competing mutation. */ }
1692
+ const intervalJournal = inspectMacIntervalHumanJournal({ sidecarPath, meetingPath: operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath }, sessionId)
1693
+ pending = pending || intervalJournal.status === 'prepared' || intervalJournal.status === 'recovery-invalid'
1694
+ if (pending) return { status: 409, body: { reason: 'human_annotation_recovery_required', error: 'Finish the interrupted scoped annotation before another correction.', mutable: false } }
1695
+ }
1696
+ const view = readMacSpeakerCallView(sidecarPath, readFileSync(sidecarPath, 'utf8'), operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath)
1697
+ if (view.metadata.humanAnnotationInvalid) return { status: 409, body: { reason: 'human_annotation_invalid', error: 'The saved human annotation cannot be verified; restore or recover it before another correction.', mutable: false } }
1698
+ if (isEffectiveMacSpeakerCallView(view)) return { status: 409, body: { reason: 'derived_call_view_requires_scoped_action', error: 'This consolidated view is read-only. Naming and voice enrollment require an exact reviewed source selection; nothing was changed.', mutable: false } }
1699
+ } catch { /* The existing route reports an unreadable base independently. */ }
1700
+ return null
1701
+ }
1702
+
1589
1703
  function selectedReadMatches(requested: unknown, sessionId: string, actual: string): boolean {
1590
1704
  return requestedRecordMatches(requested, actual) || blendedRecordFor(requested, sessionId) !== undefined
1591
1705
  }
1592
1706
 
1707
+ function callGroupScopes(files: MacCallHumanFiles, baseJSON: string, view: MacSpeakerCallView): MacCallHumanScope[] {
1708
+ // A sample/word interval is not a whole raw-chunk naming selector. Keep
1709
+ // this policy read-only until the consumer can review exact ranges.
1710
+ if (intervalPolicyPresent(view)) return []
1711
+ const captureId = JSON.parse(baseJSON).sessionId, paths = macCallHumanPaths(files, captureId)
1712
+ const packet = readMacCallHumanFile(paths.packet, 16 * 1024 * 1024, true)
1713
+ let parsedPacket: MacSpeakerCallPacket | null = null
1714
+ try { parsedPacket = packet ? JSON.parse(packet.text) as MacSpeakerCallPacket : null } catch { /* Human-authored scopes are independent of a corrupt automatic packet. */ }
1715
+ return macCallHumanScopes({ baseJSON, view, packet: parsedPacket,
1716
+ markdown: readMacCallHumanFile(files.meetingPath, 16 * 1024 * 1024)!.text,
1717
+ ledgerJSONL: readMacCallHumanFile(paths.ledger, 8 * 1024 * 1024, true)?.text ?? '',
1718
+ annotationJSON: readMacCallHumanFile(paths.annotation, 16 * 1024 * 1024, true)?.text ?? null })
1719
+ }
1720
+ const publicCallScope = (scope: MacCallHumanScope) => ({ identityId: scope.identityId, displayLabel: scope.displayLabel, reviewBasis: scope.reviewBasis, scopeSHA256: scope.scopeSHA256, members: scope.members, fieldCount: scope.fields.length })
1721
+ function intervalPolicyPresent(view: MacSpeakerCallView): boolean {
1722
+ return view.metadata.status === 'applied' && view.metadata.mode === 'source-bound-intervals' || !!view.metadata.intervalHumanAnnotation || !!view.metadata.intervalHumanAnnotationInvalid || !!view.metadata.intervalHumanRecoveryRequired
1723
+ }
1724
+ function callGroupSource(sessionId: string) {
1725
+ const operations = cosOperationsMeetingsConfigured() ? findCosOperationsMeetingBySessionId(sessionId) : null
1726
+ if (operations?.mergedScribeConflict?.length) throw Error('meeting source conflict')
1727
+ const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
1728
+ if (direct) throw Error('direct library is read-only')
1729
+ const saved = operations ? null : store.findBySessionId(sessionId)
1730
+ if (!operations && !saved) throw Error('meeting not found')
1731
+ return { files: { sidecarPath: operations?.sidecarPath ?? saved!.sidecarPath, meetingPath: operations?.meetingPath ?? saved!.filepath },
1732
+ recordId: operations ? `ops:${operations.domain}:${operations.month}:${operations.filename}` : `standalone:${sessionId}` }
1733
+ }
1734
+
1735
+ // Exact reviewed source membership is a separate action from legacy from-label
1736
+ // relabels. No caller-provided patch paths, global expansion or enrollment.
1737
+ router.get('/meeting/:sessionId/speaker-groups', (req, res) => {
1738
+ res.set('Cache-Control', 'private, no-store')
1739
+ const sessionId = String(req.params.sessionId ?? '')
1740
+ if (!MAC_CAPTURE_ID.test(sessionId)) { res.status(400).json({ reason: 'not_mac_capture' }); return }
1741
+ try {
1742
+ const source = callGroupSource(sessionId)
1743
+ if (!requestedRecordMatches(req.query.recordId, source.recordId)) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
1744
+ const base = readMacCallHumanFile(source.files.sidecarPath, 32 * 1024 * 1024)!.text, view = readMacSpeakerCallView(source.files.sidecarPath, base, source.files.meetingPath)
1745
+ res.json({ sessionId, recordId: source.recordId, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath), groupScopes: callGroupScopes(source.files, base, view).map(publicCallScope), humanAnnotation: view.metadata.humanAnnotation ?? null, enrollmentAllowed: false })
1746
+ } catch (error) { res.status(409).json({ reason: 'group_review_unavailable', error: String(error) }) }
1747
+ })
1748
+ router.post('/meeting/:sessionId/speaker-groups/recover', (req, res) => {
1749
+ const sessionId = String(req.params.sessionId ?? '')
1750
+ if (!MAC_CAPTURE_ID.test(sessionId)) { res.status(400).json({ reason: 'not_mac_capture' }); return }
1751
+ const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? finalizingRefusal(sessionId)
1752
+ if (refusal) { res.status(refusal.status).json(refusal.body); return }
1753
+ try {
1754
+ const source = callGroupSource(sessionId)
1755
+ if (req.body?.recordId !== source.recordId) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
1756
+ const view = readMacSpeakerCallView(source.files.sidecarPath, readMacCallHumanFile(source.files.sidecarPath, 32 * 1024 * 1024)!.text, source.files.meetingPath)
1757
+ if (intervalPolicyPresent(view)) { res.status(409).json({ reason: 'interval_scoped_action_required' }); return }
1758
+ recoverMacCallHumanAnnotation(source.files, sessionId)
1759
+ notifyMacSpeakerCallLedgerChanged(sessionId)
1760
+ res.json({ ok: true, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath) })
1761
+ } catch (error) { res.status(409).json({ reason: 'group_recovery_refused', error: String(error) }) }
1762
+ })
1763
+ router.post('/meeting/:sessionId/speaker-groups/:identityId/relabel', async (req, res) => {
1764
+ res.set('Cache-Control', 'private, no-store')
1765
+ const sessionId = String(req.params.sessionId ?? ''), identityId = String(req.params.identityId ?? ''), body = req.body
1766
+ if (body && typeof body === 'object' && typeof body.to === 'string') body.to = nfc(body.to)
1767
+ if (!MAC_CAPTURE_ID.test(sessionId) || !body || Object.keys(body).some(k => !['recordId', 'expectedRevision', 'scopeSHA256', 'rawChunkIndices', 'to', 'dryRun', 'confirm'].includes(k)) || !isMacCallHumanName(body.to) || !Array.isArray(body.rawChunkIndices) || !body.rawChunkIndices.length || body.rawChunkIndices.length > 6000 || body.rawChunkIndices.some((i: unknown) => !Number.isSafeInteger(i) || Number(i) < 0) || new Set(body.rawChunkIndices).size !== body.rawChunkIndices.length || typeof body.expectedRevision !== 'string' || !/^[a-f0-9]{64}$/.test(body.expectedRevision)) { res.status(400).json({ reason: 'invalid_group_selection' }); return }
1768
+ const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(body.recordId) ?? sourceRevisionRefusal(sessionId, body) ?? finalizingRefusal(sessionId)
1769
+ if (refusal) { res.status(refusal.status).json(refusal.body); return }
1770
+ try {
1771
+ const source = callGroupSource(sessionId)
1772
+ if (body.recordId !== source.recordId) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
1773
+ const base = readMacCallHumanFile(source.files.sidecarPath, 32 * 1024 * 1024)!.text, view = readMacSpeakerCallView(source.files.sidecarPath, base, source.files.meetingPath)
1774
+ if (intervalPolicyPresent(view)) { res.status(409).json({ reason: 'interval_scoped_action_required' }); return }
1775
+ const scope = callGroupScopes(source.files, base, view).find(s => s.identityId === identityId && s.scopeSHA256 === body.scopeSHA256)
1776
+ if (!scope) { res.status(409).json({ reason: 'group_scope_changed', error: 'Reopen the group before naming it' }); return }
1777
+ const selected = new Set<number>(body.rawChunkIndices), members = scope.members.filter(m => selected.has(m.rawChunkIndex))
1778
+ if (members.length !== selected.size) { res.status(409).json({ reason: 'group_scope_changed' }); return }
1779
+ const selection = previewMacCallHumanAnnotation(base, readMacCallHumanFile(source.files.meetingPath, 16 * 1024 * 1024)!.text, view, scope, body.rawChunkIndices, body.to)
1780
+ const preview = { identityId, scopeSHA256: scope.scopeSHA256, ...selection, to: body.to, enrollmentAllowed: false, sourceRevision: body.expectedRevision }
1781
+ if (body.dryRun === true || body.confirm !== true) { res.status(body.dryRun === true ? 200 : 400).json({ ...(body.dryRun === true ? {} : { reason: 'confirmation_required' }), preview }); return }
1782
+ // A correction withdraws a packet bound to its prior ledger. Require the
1783
+ // verified retained native export before the FIRST automatic-group edit
1784
+ // so the remaining groups can refresh without rerunning audio inference.
1785
+ // Revising an existing human decision remains independent of the model.
1786
+ if (scope.reviewBasis !== 'human-authored') {
1787
+ const refresh = await macSpeakerCallCachedRefreshAvailability(sessionId, source.files.sidecarPath)
1788
+ // producer_disabled is a setting, not a wait: nothing is coming to finish it, so it must not ask for a retry.
1789
+ // It stays refused because naming now would withdraw the packet and strand every other group in this view.
1790
+ if (!refresh.available) { res.status(409).json({ reason: 'group_refresh_unavailable', ...(refresh.reason === 'producer_disabled'
1791
+ ? { error: 'Naming these groups is not available while speaker reconciliation is off. Your selection was not changed.', retryable: false }
1792
+ : { error: 'Speaker reconciliation is not ready. Your selection was not changed; reopen this group after it finishes.', retryable: true }) }); return }
1793
+ // The cached receipt check awaits child IO. A finalization/resume job
1794
+ // may start in that interval before it changes any source bytes.
1795
+ const afterAdmission = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(body.recordId) ?? sourceRevisionRefusal(sessionId, body) ?? finalizingRefusal(sessionId)
1796
+ if (afterAdmission) { res.status(afterAdmission.status).json(afterAdmission.body); return }
1797
+ }
1798
+ const correction = commitMacCallHumanAnnotation({ files: source.files, captureId: sessionId, scope, selectedRawChunkIndices: body.rawChunkIndices, to: body.to })
1799
+ notifyMacSpeakerCallLedgerChanged(sessionId)
1800
+ res.json({ ok: true, confirmed: true, correction, enrollmentAllowed: false, anonymousRefreshRequired: true, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath) })
1801
+ } catch (error) { res.status(409).json({ reason: 'group_correction_refused', error: String(error) }) }
1802
+ })
1803
+
1804
+ // Interval naming cannot reuse raw-chunk selectors. These actions write only
1805
+ // a separate source-bound human sidecar; they never invoke the acoustic producer,
1806
+ // enroll profiles, or rewrite BASE/Markdown/the shared corrections ledger.
1807
+ function intervalGroupScopes(files: MacCallHumanFiles, baseJSON: string, view: MacSpeakerCallView): MacIntervalHumanScope[] {
1808
+ if (view.metadata.humanAnnotationInvalid || view.metadata.intervalHumanRecoveryRequired) throw Error('human annotation requires recovery')
1809
+ const id = JSON.parse(baseJSON).sessionId, paths = macIntervalHumanPaths(files, id)
1810
+ const packetFile = readMacCallHumanFile(paths.packet, 16 * 1024 * 1024, true)
1811
+ let packet: MacSpeakerCallPacket | null = null
1812
+ try { packet = packetFile ? JSON.parse(packetFile.text) : null } catch { /* withdrawn automatic projection */ }
1813
+ return buildMacIntervalHumanScopes({ baseJSON, view, packet,
1814
+ markdown: readMacCallHumanFile(files.meetingPath, 16 * 1024 * 1024)!.text,
1815
+ ledgerJSONL: readMacCallHumanFile(paths.ledger, 8 * 1024 * 1024, true)?.text ?? '',
1816
+ legacyAnnotationJSON: readMacCallHumanFile(paths.legacyAnnotation, 16 * 1024 * 1024, true)?.text ?? null,
1817
+ annotationJSON: readMacCallHumanFile(paths.annotation, 16 * 1024 * 1024, true)?.text ?? null })
1818
+ }
1819
+ const publicIntervalScope = (scope: MacIntervalHumanScope) => ({ kind: scope.kind, reviewBasis: 'exact-hq-words', identityId: scope.identityId, displayLabel: scope.displayLabel, scopeSHA256: scope.scopeSHA256, members: scope.members, fieldCount: scope.fieldCount, ...(scope.selectionRuns ? { selectionRuns: scope.selectionRuns } : {}) })
1820
+ const publicIntervalAnnotation = (view: MacSpeakerCallView) => view.metadata.intervalHumanAnnotation ? {
1821
+ revision: view.metadata.intervalHumanAnnotation.revision, annotationSHA256: view.metadata.intervalHumanAnnotation.annotationSHA256,
1822
+ activeActions: view.metadata.intervalHumanAnnotation.activeActions,
1823
+ } : null
1824
+ function intervalReviewFields(files: MacCallHumanFiles, base: string, view: MacSpeakerCallView) {
1825
+ // Omit entirely for legacy/G2 readers; even an empty new array is a new-policy
1826
+ // signal to Control and must not make every old meeting read-only.
1827
+ const intervalPolicy = intervalPolicyPresent(view)
1828
+ const captureId = JSON.parse(base).sessionId
1829
+ if (!MAC_CAPTURE_ID.test(captureId) || !intervalPolicy && view.metadata.status === 'applied') return {}
1830
+ let originalAvailable = false
1831
+ if (!view.metadata.humanAnnotationInvalid && !view.metadata.intervalHumanRecoveryRequired && !view.metadata.intervalHumanAnnotation?.activeActions.length) {
1832
+ try { originalAvailable = previewOriginalViewTransaction(files, captureId).artifacts.length > 0 } catch { /* Refusal is an explicit unavailable capability. */ }
1833
+ }
1834
+ // An unreadable/withdrawn packet or a state-only remnant still blocks legacy
1835
+ // mutation. Offer its verified archival exit without treating that remnant
1836
+ // as a valid acoustic scope. Unaffected legacy views keep their old tools.
1837
+ if (!intervalPolicy && !originalAvailable) return {}
1838
+ let scopes: MacIntervalHumanScope[] = []
1839
+ if (intervalPolicy) try { scopes = intervalGroupScopes(files, base, view) } catch { /* read-only recovery UI */ }
1840
+ return { intervalGroupScopes: scopes.map(publicIntervalScope), intervalHumanAnnotation: publicIntervalAnnotation(view), intervalHumanRecoveryRequired: view.metadata.intervalHumanRecoveryRequired === true, intervalOriginalViewAvailable: originalAvailable }
1841
+ }
1842
+ function exactIntervalConfirmedWords(base: Record<string, any>, view: MacSpeakerCallView) {
1843
+ if (!view.metadata.intervalHumanAnnotation) return undefined
1844
+ return view.metadata.intervalHumanAnnotation.confirmedWords.map(proof => {
1845
+ const original = base.batchSegments?.[proof.batchIndex]?.speakerWords?.[proof.wordIndex], effective = view.doc.batchSegments?.[proof.batchIndex]?.speakerWords?.[proof.wordIndex]
1846
+ if (!original || createHash('sha256').update(JSON.stringify(original)).digest('hex') !== proof.wordSHA256 || effective?.speaker !== proof.label) throw Error('Exact human word source changed')
1847
+ return { batchIndex: proof.batchIndex, wordIndex: proof.wordIndex, label: proof.label }
1848
+ })
1849
+ }
1850
+ const validSHA = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
1851
+ const intervalBodyKeys = (body: any, keys: string[]) => body && typeof body === 'object' && !Array.isArray(body) && Object.keys(body).length === keys.length && Object.keys(body).every(k => keys.includes(k))
1852
+ router.get('/meeting/:sessionId/speaker-interval-groups', (req, res) => {
1853
+ res.set('Cache-Control', 'private, no-store')
1854
+ const sessionId = String(req.params.sessionId ?? '')
1855
+ if (!MAC_CAPTURE_ID.test(sessionId)) { res.status(400).json({ reason: 'not_mac_capture' }); return }
1856
+ const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.query.recordId)
1857
+ if (refusal) { res.status(refusal.status).json(refusal.body); return }
1858
+ try {
1859
+ const source = callGroupSource(sessionId)
1860
+ if (req.query.recordId !== source.recordId) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
1861
+ const base = readMacCallHumanFile(source.files.sidecarPath, 32 * 1024 * 1024)!.text, view = readMacSpeakerCallView(source.files.sidecarPath, base, source.files.meetingPath)
1862
+ res.json({ sessionId, recordId: source.recordId, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath), ...intervalReviewFields(source.files, base, view), enrollmentAllowed: false })
1863
+ } catch (error) { res.status(409).json({ reason: 'interval_group_review_unavailable', error: String(error) }) }
1864
+ })
1865
+ router.post('/meeting/:sessionId/speaker-interval-groups/recover', (req, res) => {
1866
+ const sessionId = String(req.params.sessionId ?? ''), body = req.body
1867
+ if (!MAC_CAPTURE_ID.test(sessionId) || !intervalBodyKeys(body, ['recordId', 'confirm']) || body.confirm !== true) { res.status(400).json({ reason: 'confirmation_required' }); return }
1868
+ const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(body.recordId) ?? finalizingRefusal(sessionId)
1869
+ if (refusal) { res.status(refusal.status).json(refusal.body); return }
1870
+ try {
1871
+ const source = callGroupSource(sessionId)
1872
+ if (body.recordId !== source.recordId) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
1873
+ const original = inspectOriginalViewTransaction(source.files, sessionId)
1874
+ if (original.status === 'prepared' || original.status === 'recovery-invalid') recoverOriginalViewTransaction(source.files, sessionId)
1875
+ else recoverMacIntervalHumanAnnotation(source.files, sessionId)
1876
+ res.json({ ok: true, confirmed: true, enrollmentAllowed: false, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath) })
1877
+ } catch (error) { res.status(409).json({ reason: 'interval_group_recovery_refused', error: String(error) }) }
1878
+ })
1879
+ router.post('/meeting/:sessionId/speaker-interval-groups/original', (req, res) => {
1880
+ const sessionId = String(req.params.sessionId ?? ''), body = req.body
1881
+ if (!MAC_CAPTURE_ID.test(sessionId) || !intervalBodyKeys(body, ['recordId', 'expectedRevision', 'confirm']) || body.confirm !== true || !validSHA(body.expectedRevision)) { res.status(400).json({ reason: 'invalid_original_view_confirmation' }); return }
1882
+ const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(body.recordId) ?? sourceRevisionRefusal(sessionId, body) ?? finalizingRefusal(sessionId)
1883
+ if (refusal) { res.status(refusal.status).json(refusal.body); return }
1884
+ try {
1885
+ const source = callGroupSource(sessionId)
1886
+ if (body.recordId !== source.recordId) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
1887
+ const preview = previewOriginalViewTransaction(source.files, sessionId)
1888
+ if (!preview.artifacts.length) throw Error('No active interval view to archive')
1889
+ commitOriginalViewTransaction({ files: source.files, captureId: sessionId, expectedScopeSHA256: preview.scopeSHA256 })
1890
+ res.json({ ok: true, confirmed: true, enrollmentAllowed: false, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath) })
1891
+ } catch (error) { res.status(409).json({ reason: 'interval_original_view_refused', error: String(error) }) }
1892
+ })
1893
+ router.post('/meeting/:sessionId/speaker-interval-groups/undo', (req, res) => {
1894
+ const sessionId = String(req.params.sessionId ?? ''), body = req.body
1895
+ if (!MAC_CAPTURE_ID.test(sessionId) || !intervalBodyKeys(body, ['recordId', 'expectedRevision', 'actionId', 'annotationRevision', 'annotationSHA256', 'confirm']) || body.confirm !== true || !validSHA(body.expectedRevision) || !validSHA(body.annotationSHA256) || !Number.isSafeInteger(body.annotationRevision) || body.annotationRevision < 1 || typeof body.actionId !== 'string' || !/^mac-speaker-call-interval-human-v1:[a-f0-9-]{36}$/.test(body.actionId)) { res.status(400).json({ reason: 'invalid_interval_undo' }); return }
1896
+ const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(body.recordId) ?? sourceRevisionRefusal(sessionId, body) ?? finalizingRefusal(sessionId)
1897
+ if (refusal) { res.status(refusal.status).json(refusal.body); return }
1898
+ try {
1899
+ const source = callGroupSource(sessionId)
1900
+ if (body.recordId !== source.recordId) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
1901
+ const view = readMacSpeakerCallView(source.files.sidecarPath, readMacCallHumanFile(source.files.sidecarPath, 32 * 1024 * 1024)!.text, source.files.meetingPath)
1902
+ if (view.metadata.humanAnnotationInvalid || view.metadata.intervalHumanRecoveryRequired) throw Error('human annotation recovery required')
1903
+ const correction = undoMacIntervalHumanAnnotation({ files: source.files, captureId: sessionId, actionId: body.actionId, revision: body.annotationRevision, annotationSHA256: body.annotationSHA256 })
1904
+ res.json({ ok: true, confirmed: true, correction, enrollmentAllowed: false, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath) })
1905
+ } catch (error) { res.status(409).json({ reason: 'interval_group_undo_refused', error: String(error) }) }
1906
+ })
1907
+ router.post('/meeting/:sessionId/speaker-interval-groups/:identityId/relabel', (req, res) => {
1908
+ res.set('Cache-Control', 'private, no-store')
1909
+ const sessionId = String(req.params.sessionId ?? ''), identityId = String(req.params.identityId ?? ''), body = req.body
1910
+ if (body && typeof body === 'object' && typeof body.to === 'string') body.to = nfc(body.to)
1911
+ const previewOnly = body?.dryRun === true && body?.confirm === undefined, confirmed = body?.confirm === true && body?.dryRun === undefined
1912
+ const keys = ['recordId', 'expectedRevision', 'scopeSHA256', 'memberIds', 'to', previewOnly ? 'dryRun' : 'confirm']
1913
+ if (!MAC_CAPTURE_ID.test(sessionId) || !/^[A-Za-z0-9:_-]{1,128}$/.test(identityId) || !intervalBodyKeys(body, keys) || !previewOnly && !confirmed || !validSHA(body.expectedRevision) || !validSHA(body.scopeSHA256) || !isMacCallHumanName(body.to) || !Array.isArray(body.memberIds) || !body.memberIds.length || body.memberIds.length > 20000 || !body.memberIds.every(validSHA) || new Set(body.memberIds).size !== body.memberIds.length) { res.status(400).json({ reason: 'invalid_interval_selection' }); return }
1914
+ const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(body.recordId) ?? sourceRevisionRefusal(sessionId, body) ?? finalizingRefusal(sessionId)
1915
+ if (refusal) { res.status(refusal.status).json(refusal.body); return }
1916
+ try {
1917
+ const source = callGroupSource(sessionId)
1918
+ if (body.recordId !== source.recordId) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
1919
+ const base = readMacCallHumanFile(source.files.sidecarPath, 32 * 1024 * 1024)!.text, view = readMacSpeakerCallView(source.files.sidecarPath, base, source.files.meetingPath)
1920
+ const scope = intervalGroupScopes(source.files, base, view).find(s => s.identityId === identityId && s.scopeSHA256 === body.scopeSHA256)
1921
+ if (!scope) { res.status(409).json({ reason: 'interval_scope_changed' }); return }
1922
+ const selection = previewMacIntervalHumanAnnotation(scope, body.memberIds, body.to)
1923
+ if (previewOnly) { res.json({ preview: { identityId, scopeSHA256: scope.scopeSHA256, ...selection, to: body.to, enrollmentAllowed: false, sourceRevision: body.expectedRevision } }); return }
1924
+ const correction = commitMacIntervalHumanAnnotation({ files: source.files, captureId: sessionId, scope, memberIds: body.memberIds, to: body.to })
1925
+ res.json({ ok: true, confirmed: true, correction, enrollmentAllowed: false, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath) })
1926
+ } catch (error) { res.status(409).json({ reason: 'interval_group_correction_refused', error: String(error) }) }
1927
+ })
1928
+
1593
1929
  function sourceRevisionRefusal(sessionId: string, body: any): { status: number; body: Record<string, unknown> } | null {
1594
1930
  const ops = cosOperationsMeetingsConfigured() ? findCosOperationsMeetingBySessionId(sessionId) : null
1595
1931
  if (ops?.mergedScribeConflict?.length) return { status: 409, body: { reason: 'record_source_mismatch', error: markdownUnreadableReason(ops) } }
@@ -1634,7 +1970,44 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1634
1970
  } catch (error) { res.status(409).json({ error: String(error) }); }
1635
1971
  })
1636
1972
 
1637
- router.get('/meeting/:sessionId/speakers', (req, res) => {
1973
+ /** The speaker review of a saved meeting, as GET /speakers answers it. Voice suggestions scope to these same rows. */
1974
+ function speakerReviewFor(input: { sessionId: string; sidecarPath: string; meetingPath: string; baseJSON: string; sidecar: Record<string, unknown>; chunks: unknown; phrasesPerVoice: number }) {
1975
+ const { sessionId, sidecarPath, meetingPath, baseJSON, sidecar, chunks, phrasesPerVoice } = input
1976
+ // Raw capture indices, so a phrase can address its own audio. Position in
1977
+ // `chunks` is NOT the WAV number — see attachRawChunkIndices.
1978
+ const callView = readMacSpeakerCallView(sidecarPath, baseJSON, meetingPath)
1979
+ const withIndices = attachRawChunkIndices(callView.doc.chunks ?? chunks as ReviewChunk[], sidecar.chunkEntries)
1980
+ const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
1981
+ const review = reviewMeetingSpeakers(withIndices, {
1982
+ callViewAnonymousLabels: callView.metadata.groups.map(g => g.label),
1983
+ effectiveWordView: callView.metadata.effectiveWordView,
1984
+ exactConfirmedWords: exactIntervalConfirmedWords(sidecar, callView),
1985
+ owner: getOwnerSpeakerLabel(),
1986
+ // Labels a human already vouched for in this meeting. Without this the
1987
+ // floor re-demotes a confirmed name on every reload, and the reviewer
1988
+ // confirms the same voice forever.
1989
+ confirmed: confirmedLabels(sessionId),
1990
+ confirmedChunks: confirmedChunks(sessionId),
1991
+ machineQualifiedPositions: machineEvidence.positions,
1992
+ machineEvidenceStatus: machineEvidence.status,
1993
+ phrasesPerVoice,
1994
+ // The sidecar's own durationMs is the meeting's true end. Deriving it from
1995
+ // max(elapsed) uses the START of the last chunk, which made the final
1996
+ // timeline span end where it began — a 1.5pt sliver labelled "1s" for what
1997
+ // may be a long closing monologue.
1998
+ durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
1999
+ // Word-level speaker timings from the HQ batch pass, present on 82 of 92
2000
+ // measured sidecars. These give REAL voiced time per speaker; without them
2001
+ // speaking time falls back to capped chunk deltas, which still carry the
2002
+ // sub-ceiling pauses. `speakingTimeSource` on the response says which ran.
2003
+ batchSegments: Array.isArray(callView.doc.batchSegments)
2004
+ ? (callView.doc.batchSegments as SpeakerWordSegment[])
2005
+ : undefined,
2006
+ })
2007
+ return { callView, review }
2008
+ }
2009
+
2010
+ router.get('/meeting/:sessionId/speakers', async (req, res) => {
1638
2011
  const sessionId = String(req.params.sessionId ?? '')
1639
2012
  if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1640
2013
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
@@ -1675,8 +2048,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1675
2048
 
1676
2049
  let chunks: unknown
1677
2050
  let sidecar: Record<string, unknown>
2051
+ let baseJSON: string
1678
2052
  try {
1679
- sidecar = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
2053
+ baseJSON = readFileSync(sidecarPath, 'utf-8')
2054
+ sidecar = JSON.parse(baseJSON) as Record<string, unknown>
1680
2055
  chunks = Array.isArray(sidecar) ? sidecar : sidecar.chunks
1681
2056
  } catch {
1682
2057
  // Defensive: both lookups above already read this sidecar to match the
@@ -1692,35 +2067,20 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1692
2067
  return
1693
2068
  }
1694
2069
 
1695
- // Raw capture indices, so a phrase can address its own audio. Position in
1696
- // `chunks` is NOT the WAV number — see attachRawChunkIndices.
1697
- const withIndices = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
1698
- const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
1699
- const review = reviewMeetingSpeakers(withIndices, {
1700
- owner: getOwnerSpeakerLabel(),
1701
- // Labels a human already vouched for in this meeting. Without this the
1702
- // floor re-demotes a confirmed name on every reload, and the reviewer
1703
- // confirms the same voice forever.
1704
- confirmed: confirmedLabels(sessionId),
1705
- confirmedChunks: confirmedChunks(sessionId),
1706
- machineQualifiedPositions: machineEvidence.positions,
1707
- machineEvidenceStatus: machineEvidence.status,
2070
+ const meetingPath = operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath
2071
+ const { callView, review } = speakerReviewFor({
2072
+ sessionId,
2073
+ sidecarPath,
2074
+ meetingPath,
2075
+ baseJSON: baseJSON!,
2076
+ sidecar,
2077
+ chunks,
1708
2078
  phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
1709
- // The sidecar's own durationMs is the meeting's true end. Deriving it from
1710
- // max(elapsed) uses the START of the last chunk, which made the final
1711
- // timeline span end where it began — a 1.5pt sliver labelled "1s" for what
1712
- // may be a long closing monologue.
1713
- durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
1714
- // Word-level speaker timings from the HQ batch pass, present on 82 of 92
1715
- // measured sidecars. These give REAL voiced time per speaker; without them
1716
- // speaking time falls back to capped chunk deltas, which still carry the
1717
- // sub-ceiling pauses. `speakingTimeSource` on the response says which ran.
1718
- batchSegments: Array.isArray(sidecar.batchSegments)
1719
- ? (sidecar.batchSegments as SpeakerWordSegment[])
1720
- : undefined,
1721
2079
  })
1722
- res.set('Cache-Control', 'private, no-store')
1723
- res.json({
2080
+ // The rows and the revision that vouches for them are read in one synchronous stretch, BEFORE the await below. A
2081
+ // POST /relabel landing during the await then makes this revision old too, and `expectedRevision` refuses the stale
2082
+ // view. Hashing after the await would pair old rows with a new revision, and that check would pass.
2083
+ const body = {
1724
2084
  sessionId,
1725
2085
  title,
1726
2086
  domain,
@@ -1728,13 +2088,95 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1728
2088
  ...identity,
1729
2089
  speakerPolicy: readSpeakerPolicy(sessionId),
1730
2090
  reviewIdentityVersion: 1,
1731
- sourceRevision: reviewRevision(operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath, sidecarPath),
2091
+ sourceRevision: reviewRevision(meetingPath, sidecarPath),
1732
2092
  ...(operations?.mergedScribeConflict?.length ? { mutable: false, identityConflict: operations.mergedScribeConflict } : {}),
1733
2093
  ...(saved ? { durationMin: saved.durationMin } : {}),
1734
2094
  ...review,
1735
- })
2095
+ ...(isEffectiveMacSpeakerCallView(callView) || callView.metadata.humanAnnotationInvalid ? { mutable: false, mutationBlockedReason: callView.metadata.humanAnnotationInvalid ? 'human_annotation_invalid' : 'derived_call_view_requires_scoped_action' } : {}),
2096
+ speakerCallView: callView.metadata,
2097
+ ...intervalReviewFields({ sidecarPath, meetingPath }, baseJSON, callView),
2098
+ ...(() => { try { return { groupScopes: callGroupScopes({ sidecarPath, meetingPath }, baseJSON, callView).map(publicCallScope) } } catch { return { groupScopes: [] } } })(),
2099
+ }
2100
+ // 6.96.3: display grouping from this Mac's speaker shadow log, read at request time. No shadow record, no field.
2101
+ // Mac captures only: anything else never yields here. A view that already groups the words stands on its own.
2102
+ const voiceDisplay = MAC_CAPTURE_ID.test(sessionId) && !isEffectiveMacSpeakerCallView(callView) ? await readVoiceDisplay(sessionId, review.voices.map(v => v.label)) : null
2103
+ res.set('Cache-Control', 'private, no-store')
2104
+ res.json(voiceDisplay ? { ...body, voiceDisplay } : body)
1736
2105
  })
1737
2106
 
2107
+ // ── Voice suggestions (6.96.3, lib/mac-voice-suggestions.ts) ───────────────────
2108
+ // POST is the ONLY thing that starts a job. Both answer one JSON state; only a malformed id is a 4xx.
2109
+ // `refused` comes from POST and is not remembered, so GET reads idle, running, done or failed.
2110
+ // Read-only: nothing here or in the job writes a profile, label, sidecar or vector file.
2111
+ function voiceSuggestionSource(sessionId: string): { refused: string } | { sidecarPath: string; meetingPath: string } {
2112
+ if (assertVoiceEvidenceSource([sessionId]) || !MAC_CAPTURE_ID.test(sessionId)) return { refused: 'not_saved_mac_capture' }
2113
+ const operations = cosOperationsMeetingsConfigured() ? findCosOperationsMeetingBySessionId(sessionId) : null
2114
+ const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
2115
+ const saved = operations || direct ? null : store.findBySessionId(sessionId)
2116
+ if (!operations && !direct && !saved) return { refused: 'not_saved_mac_capture' }
2117
+ return { sidecarPath: operations?.sidecarPath ?? direct?.sidecarPath ?? saved!.sidecarPath, meetingPath: operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath }
2118
+ }
2119
+
2120
+ // 6.96.3: name the high-confidence suggestions in one step, and undo that step (lib/mac-voice-naming-batch.ts).
2121
+ registerMacVoiceNamingBatchRoutes(router, { store, captureActive, reviewRevision, callViewMutationRefusal, finalizingRefusal })
2122
+
2123
+ const readVoiceSuggestions: RequestHandler = (req, res) => {
2124
+ const sessionId = String(req.params.sessionId ?? '')
2125
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) { res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' }); return }
2126
+ res.set('Cache-Control', 'private, no-store')
2127
+ // A job in flight answers from memory: Control polls this, and the revision hashes the whole meeting.
2128
+ const running = voiceSuggestions.running(sessionId)
2129
+ if (running) { res.json(running); return }
2130
+ const source = voiceSuggestionSource(sessionId)
2131
+ res.json(voiceSuggestions.read(sessionId, 'refused' in source ? '' : reviewRevision(source.meetingPath, source.sidecarPath)))
2132
+ }
2133
+
2134
+ const startVoiceSuggestions: RequestHandler = (req, res) => {
2135
+ const sessionId = String(req.params.sessionId ?? '')
2136
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) { res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' }); return }
2137
+ res.set('Cache-Control', 'private, no-store')
2138
+ const source = voiceSuggestionSource(sessionId)
2139
+ if ('refused' in source) { res.json(voiceSuggestions.refusal(source.refused, '')); return }
2140
+ const { sidecarPath, meetingPath } = source
2141
+ let baseJSON: string
2142
+ let sidecar: Record<string, unknown>
2143
+ try {
2144
+ baseJSON = readFileSync(sidecarPath, 'utf-8')
2145
+ sidecar = JSON.parse(baseJSON) as Record<string, unknown>
2146
+ if (!Array.isArray(sidecar.chunks)) throw new Error('no chunk array')
2147
+ } catch {
2148
+ res.json(voiceSuggestions.refusal('sidecar_unreadable', ''))
2149
+ return
2150
+ }
2151
+ const sourceRevision = reviewRevision(meetingPath, sidecarPath)
2152
+ // Its labels are not the labels on the audio's turns, so no unit could be chosen honestly.
2153
+ const view = readMacSpeakerCallView(sidecarPath, baseJSON, meetingPath)
2154
+ if (isEffectiveMacSpeakerCallView(view) || view.metadata.humanAnnotationInvalid) { res.json(voiceSuggestions.refusal('derived_call_view', sourceRevision)); return }
2155
+ const entries = voiceSidecarEntries(sidecar)
2156
+ if (!entries) { res.json(voiceSuggestions.refusal('no_index_mapping', sourceRevision)); return }
2157
+ const block = (): VoiceSuggestionBlock | null => {
2158
+ if (captureActive() || voiceSuggestionRecordingLease()) return 'recording_active'
2159
+ if (!maintenanceAdmissionsOpen()) return 'maintenance'
2160
+ if (finalizingRefusal(sessionId)) return 'meeting_finalizing'
2161
+ return null
2162
+ }
2163
+ res.json(voiceSuggestions.start({
2164
+ sessionId,
2165
+ sourceRevision,
2166
+ entries,
2167
+ block,
2168
+ pause: () => voiceSuggestionPauseLease(),
2169
+ loadScopes: async () => {
2170
+ const { review } = speakerReviewFor({ sessionId, sidecarPath, meetingPath, baseJSON, sidecar, chunks: sidecar.chunks, phrasesPerVoice: 1 })
2171
+ const labels = review.voices.map(v => v.label)
2172
+ return voiceDisplayScopes(await readVoiceDisplay(sessionId, labels), labels)
2173
+ },
2174
+ }))
2175
+ }
2176
+
2177
+ router.get('/meeting/:sessionId/voice-suggestions', readVoiceSuggestions)
2178
+ router.post('/meeting/:sessionId/voice-suggestions', startVoiceSuggestions)
2179
+
1738
2180
 
1739
2181
  // ── Per-meeting speaker relabel (6.21.16) ─────────────────────────────
1740
2182
  //
@@ -1792,8 +2234,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1792
2234
  const mdPath = operations?.meetingPath ?? direct?.meetingPath ?? sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
1793
2235
 
1794
2236
  let sidecar: Record<string, unknown>
2237
+ let baseJSON: string
1795
2238
  try {
1796
- sidecar = (JSON.parse(readFileSync(sidecarPath, 'utf-8')) ?? {}) as Record<string, unknown>
2239
+ baseJSON = readFileSync(sidecarPath, 'utf-8')
2240
+ sidecar = (JSON.parse(baseJSON) ?? {}) as Record<string, unknown>
1797
2241
  } catch {
1798
2242
  res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
1799
2243
  return
@@ -1807,10 +2251,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1807
2251
  res.status(422).json({ error: 'Chunk sidecar holds no chunk array', reason: 'sidecar_empty' })
1808
2252
  return
1809
2253
  }
1810
- const chunks = rawChunks as ReviewChunk[]
2254
+ const callView = readMacSpeakerCallView(sidecarPath, baseJSON!, mdPath)
2255
+ const chunks = (callView.doc.chunks ?? rawChunks) as ReviewChunk[]
1811
2256
 
1812
2257
  const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
1813
2258
  const review = reviewMeetingSpeakers(attachRawChunkIndices(chunks, sidecar.chunkEntries), {
2259
+ callViewAnonymousLabels: callView.metadata.groups.map(g => g.label),
2260
+ effectiveWordView: callView.metadata.effectiveWordView,
2261
+ exactConfirmedWords: exactIntervalConfirmedWords(sidecar, callView),
1814
2262
  owner: getOwnerSpeakerLabel(),
1815
2263
  confirmed: confirmedLabels(sessionId),
1816
2264
  confirmedChunks: confirmedChunks(sessionId),
@@ -1820,8 +2268,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1820
2268
  // discarded 48 transcript excerpts on a 16-voice meeting.
1821
2269
  phrasesPerVoice: 1,
1822
2270
  durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
1823
- batchSegments: Array.isArray(sidecar.batchSegments)
1824
- ? (sidecar.batchSegments as SpeakerWordSegment[])
2271
+ batchSegments: Array.isArray(callView.doc.batchSegments)
2272
+ ? (callView.doc.batchSegments as SpeakerWordSegment[])
1825
2273
  : undefined,
1826
2274
  })
1827
2275
 
@@ -1841,12 +2289,13 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1841
2289
  confirmedByHuman: v.confirmedByHuman,
1842
2290
  machineQualified: v.machineQualified,
1843
2291
  machineQualifiedSegments: v.machineQualifiedSegments,
2292
+ ...(v.kind ? { kind: v.kind, displayName: v.displayName, identityId: v.identityId } : {}),
1844
2293
  }))
1845
2294
 
1846
2295
  // A missing .md is not fatal — the review and the sidecar still describe the
1847
2296
  // meeting, and a reviewer would rather have who-spoke than a 404.
1848
2297
  let markdown = ''
1849
- try { markdown = readFileSync(mdPath, 'utf-8') } catch { markdown = '' }
2298
+ try { markdown = callView.markdown ?? readFileSync(mdPath, 'utf-8') } catch { markdown = '' }
1850
2299
  const scribe = parseScribe(markdown)
1851
2300
 
1852
2301
  const date = meetingDate(sidecar.startTime)
@@ -1905,6 +2354,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1905
2354
  date,
1906
2355
  durationMin,
1907
2356
  scribeAvailable: markdown.length > 0,
2357
+ speakerCallView: callView.metadata,
2358
+ ...(review.effectiveSpeakerSummary ? { effectiveSpeakerSummary: review.effectiveSpeakerSummary } : {}),
1908
2359
  attendees,
1909
2360
  speakingTimeSource: review.speakingTimeSource,
1910
2361
  voicedMs: review.voicedMs,
@@ -1920,6 +2371,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1920
2371
  capturedChars: clip.capturedChars,
1921
2372
  domain: clip.domain,
1922
2373
  ...identity,
2374
+ ...(isEffectiveMacSpeakerCallView(callView) || callView.metadata.humanAnnotationInvalid ? { mutable: false, mutationBlockedReason: callView.metadata.humanAnnotationInvalid ? 'human_annotation_invalid' : 'derived_call_view_requires_scoped_action' } : {}),
1923
2375
  reviewIdentityVersion: 1,
1924
2376
  sourceRevision: reviewRevision(operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath, sidecarPath),
1925
2377
  ...(operations?.mergedScribeConflict?.length ? { mutable: false, identityConflict: operations.mergedScribeConflict } : {}),
@@ -1971,7 +2423,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1971
2423
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1972
2424
  return
1973
2425
  }
1974
- const speaker = typeof req.body?.speaker === 'string' ? req.body.speaker.trim() : ''
2426
+ const speaker = bodyName(req.body?.speaker).trim()
1975
2427
  if (!speaker) {
1976
2428
  res.status(400).json({ error: 'speaker is required', reason: 'invalid_label' })
1977
2429
  return
@@ -1979,7 +2431,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1979
2431
  // Enrolment writes to the voice store, so the same two gates as the other
1980
2432
  // corrections: the session must be a capture, and the record aimed at must
1981
2433
  // not be a derived one.
1982
- const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body)
2434
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? callViewMutationRefusal(sessionId) ?? sourceRevisionRefusal(sessionId, req.body)
1983
2435
  if (evidenceRefusal) {
1984
2436
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
1985
2437
  return
@@ -2004,7 +2456,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2004
2456
  }
2005
2457
 
2006
2458
  // Only rows that ACTUALLY landed, and only those that named this speaker.
2007
- const rows = appliedCorrections(sessionId).filter(r => r.to === speaker && r.chunks.length > 0)
2459
+ const rows = appliedCorrections(sessionId).filter(r => sameSpeakerName(r.to, speaker) && r.chunks.length > 0)
2008
2460
  if (rows.length === 0) {
2009
2461
  res.status(404).json({ error: `No applied correction named "${speaker}" in this meeting`, reason: 'no_correction' })
2010
2462
  return
@@ -2045,14 +2497,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2045
2497
  // A merged G2 session is NOT refused here: it is a real capture with real
2046
2498
  // audio, and relabelling it is how its profile gets better. Only an imported
2047
2499
  // or derived IDENTITY is refused.
2048
- const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body) ?? finalizingRefusal(sessionId)
2500
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? callViewMutationRefusal(sessionId) ?? sourceRevisionRefusal(sessionId, req.body) ?? finalizingRefusal(sessionId)
2049
2501
  if (evidenceRefusal) {
2050
2502
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
2051
2503
  return
2052
2504
  }
2053
2505
 
2054
- const from = typeof req.body?.from === 'string' ? req.body.from : ''
2055
- const to = typeof req.body?.to === 'string' ? req.body.to : ''
2506
+ const from = bodyName(req.body?.from)
2507
+ const to = bodyName(req.body?.to)
2056
2508
  const chunks = Array.isArray(req.body?.chunks)
2057
2509
  ? (req.body.chunks as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0)
2058
2510
  : []
@@ -2163,7 +2615,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2163
2615
  // Other requests ran while the plan yielded. Nothing may have moved: the meeting's bytes, the caller's revision, or a new capture.
2164
2616
  let sidecarAfter: string | null = null
2165
2617
  try { sidecarAfter = readFileSync(sidecarPath, 'utf-8') } catch { sidecarAfter = null }
2166
- const revisionAfter = sourceRevisionRefusal(sessionId, req.body)
2618
+ const revisionAfter = callViewMutationRefusal(sessionId) ?? sourceRevisionRefusal(sessionId, req.body)
2167
2619
  if (sidecarAfter !== sidecarRaw || revisionAfter) {
2168
2620
  res.status(409).json({ error: 'The meeting changed while the voice was being read. Reopen it and try again; nothing was changed', reason: 'meeting_changed', retryable: true })
2169
2621
  return
@@ -2296,6 +2748,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2296
2748
  return
2297
2749
  }
2298
2750
 
2751
+ const viewBeforeWrite = callViewMutationRefusal(sessionId)
2752
+ if (viewBeforeWrite) { res.status(viewBeforeWrite.status).json(viewBeforeWrite.body); return }
2299
2753
  const id = `${sessionId}:${from}>${to}:${Date.now().toString(36)}`
2300
2754
  const at = new Date().toISOString()
2301
2755
  // Intent first. If this cannot be written, nothing is mutated — an
@@ -2400,12 +2854,12 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2400
2854
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
2401
2855
  return
2402
2856
  }
2403
- const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body)
2857
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? callViewMutationRefusal(sessionId) ?? sourceRevisionRefusal(sessionId, req.body)
2404
2858
  if (evidenceRefusal) {
2405
2859
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
2406
2860
  return
2407
2861
  }
2408
- const label = typeof req.body?.label === 'string' ? req.body.label : ''
2862
+ const label = bodyName(req.body?.label)
2409
2863
  const bad = invalidLabelReason(label)
2410
2864
  if (bad) {
2411
2865
  res.status(400).json({ error: `label: ${bad}`, reason: 'invalid_label' })
@@ -2453,7 +2907,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2453
2907
  const doc = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
2454
2908
  const rows = Array.isArray(doc.chunks) ? doc.chunks : []
2455
2909
  carried = rows.filter(r => r && typeof r === 'object'
2456
- && (r as Record<string, unknown>).speaker === label).length
2910
+ && sameSpeakerName((r as Record<string, unknown>).speaker, label)).length
2457
2911
  } catch {
2458
2912
  res.status(500).json({ error: 'Could not read the chunk sidecar', reason: 'sidecar_unreadable' })
2459
2913
  return
@@ -2491,12 +2945,12 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2491
2945
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
2492
2946
  return
2493
2947
  }
2494
- const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body) ?? finalizingRefusal(sessionId)
2948
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? callViewMutationRefusal(sessionId) ?? sourceRevisionRefusal(sessionId, req.body) ?? finalizingRefusal(sessionId)
2495
2949
  if (evidenceRefusal) {
2496
2950
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
2497
2951
  return
2498
2952
  }
2499
- const from = typeof req.body?.from === 'string' ? req.body.from : ''
2953
+ const from = bodyName(req.body?.from)
2500
2954
  const bad = invalidLabelReason(from)
2501
2955
  if (bad) {
2502
2956
  res.status(400).json({ error: `from: ${bad}`, reason: 'invalid_label' })
@@ -2572,7 +3026,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2572
3026
  if (Array.isArray(parsed.chunks)) {
2573
3027
  for (const c of parsed.chunks) {
2574
3028
  const sp = (c as { speaker?: unknown } | null)?.speaker
2575
- if (typeof sp === 'string' && !existingSpeakers.includes(sp)) existingSpeakers.push(sp)
3029
+ if (typeof sp === 'string' && !existingSpeakers.some(x => sameSpeakerName(x, sp))) existingSpeakers.push(sp)
2576
3030
  }
2577
3031
  }
2578
3032
  } catch { /* the relabel below reports a corrupt sidecar */ }
@@ -2585,7 +3039,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2585
3039
 
2586
3040
  // What this would remove from the profile, and what it CANNOT reach.
2587
3041
  // readVoiceProfiles returns a ProfileStore, not an array.
2588
- const profile = readVoiceProfiles().profiles.find(p => p.name === from)
3042
+ const profile = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, from))
2589
3043
  const profileSources: Array<string | undefined> = profile
2590
3044
  ? Array.from({ length: profile.embeddings.length }, (_, i) => profile.sources?.[i])
2591
3045
  : []