@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
@@ -1,9 +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'
6
- import { bodyName, sameRecordId, sameSpeakerName } from '../lib/speaker-name-nfc.js'
12
+ import { bodyName, nfc, sameRecordId, sameSpeakerName } from '../lib/speaker-name-nfc.js'
7
13
  import { readSpeakerPolicy, writeSpeakerPolicy, isSpeakerMode } from '../lib/meeting-speaker-policy.js'
8
14
  // POST /api/meeting/save — finalize an existing transcribe-stream session into
9
15
  // the standalone public meeting store. The live transcript and chunk metadata
@@ -13,7 +19,7 @@ import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } f
13
19
  import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
14
20
  import { basename, resolve } from 'node:path'
15
21
  import { createHash } from 'node:crypto'
16
- import { Router } from 'express'
22
+ import { Router, type RequestHandler } from 'express'
17
23
  import { emitDisplay } from '../lib/display-bus.js'
18
24
  import { createStopRequestStore, isStopRequestRefusal, STOP_REQUEST_CLAIM_WINDOW_MS, type StopRequest, type StopRequestStore } from '../lib/meeting-stop-requests.js'
19
25
  import { appendAutonomousSaveLog, getStopIntentStore, type StopIntent, type StopIntentStore } from '../lib/meeting-stop-intents.js'
@@ -48,6 +54,9 @@ import {
48
54
  import { readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
49
55
  import { enrolNamedVoice } from '../lib/meeting-relabel-enrolment.js'
50
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'
51
60
  import { isMacCaptureSidecar, isVoiceLabel } from '../lib/mac-voice-labels.js'
52
61
 
53
62
  function requireMacRecordingForSave(sessionId: string, expectedChunkCount: unknown): void {
@@ -195,15 +204,20 @@ import {
195
204
  } from '../lib/g2-ops-handoff.js'
196
205
  import {
197
206
  canonicalFinalizationIsComplete,
207
+ finalizationStop,
198
208
  MeetingFinalizationJobStore,
199
209
  markCanonicalFinalizationState,
200
210
  readFinalizationChunkEntries,
211
+ stoppedFinalizationRetryDue,
201
212
  type MeetingFinalizationJob,
202
213
  } from '../lib/meeting-finalization-jobs.js'
203
214
  import { enrichStandaloneMeeting } from '../lib/meeting-summary-persistence.js'
204
215
  import { bootResumeGate, type BootResumeGate } from '../lib/boot-resume.js'
216
+ import { managedServerVersion } from '../lib/managed-runtime.js'
205
217
  import { recordFinalPassSkip, runNemotronFinalPass, type FinalPassOptions, type FinalPassOutcome } from '../lib/nemotron-final.js'
206
218
  import { runTranscriptCleanup, type CleanupOutcome } from '../lib/transcript-cleanup.js'
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
 
@@ -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,6 +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)
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 })
495
547
  // The capture is finished and its files are durable, so the merge engine may score it.
496
548
  // Fire-and-forget by contract: the runner queues, defers under a drain or a live
497
549
  // capture, and never throws back at the finalization path.
@@ -509,6 +561,9 @@ export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Fi
509
561
  }
510
562
  }).catch(error => {
511
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
512
567
  try {
513
568
  runtime.finalizationJobs.save({
514
569
  sessionId: current.sessionId,
@@ -519,14 +574,14 @@ export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Fi
519
574
  phase: current.phase,
520
575
  claimPending: current.claimPending,
521
576
  lastError: error instanceof Error ? error.message : String(error),
577
+ ...(outOfRetries ? { stopped: { at: new Date().toISOString(), reason: 'retries_exhausted' as const, version: managedServerVersion() } } : {}),
522
578
  })
523
579
  } catch { /* keep the original durable job if error annotation fails */ }
524
580
  console.error(
525
581
  `[meeting/save] Durable finalization retained for retry (${job.sessionId}): `
526
582
  + `${error instanceof Error ? error.message : String(error)}`,
527
583
  )
528
- const retryCount = finalizationRetryCounts.get(key) ?? 0
529
- if (retryCount < 2) {
584
+ if (!outOfRetries) {
530
585
  finalizationRetryCounts.set(key, retryCount + 1)
531
586
  const retryRetained = () => {
532
587
  if (!maintenanceAdmissionsOpen()) {
@@ -555,6 +610,8 @@ export function resumeMeetingFinalizationJobs(options: {
555
610
  gate?: BootResumeGate
556
611
  finalizationJobs?: MeetingFinalizationJobStore
557
612
  schedule?: (job: MeetingFinalizationJob) => void
613
+ now?: number
614
+ version?: string | null
558
615
  } = {}): void {
559
616
  const finalizationJobs = options.finalizationJobs ?? new MeetingFinalizationJobStore()
560
617
  finalizationJobs.reconcileCanonicalSidecars()
@@ -564,11 +621,25 @@ export function resumeMeetingFinalizationJobs(options: {
564
621
  runBatch: runMeetingBatchPipeline,
565
622
  scheduleBackground: task => { void task },
566
623
  }))
624
+ const now = options.now ?? Date.now()
625
+ const version = options.version === undefined ? managedServerVersion() : options.version
567
626
  for (const job of finalizationJobs.list()) {
568
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
569
633
  // 6.78.0: counted (durably) BEFORE it runs, so a job that kills the process is parked
570
634
  // after RESTART_LIMIT crashed boots. A parked job is skipped and stays in its store.
571
- 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)
572
643
  schedule(job)
573
644
  }
574
645
  }
@@ -731,6 +802,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
731
802
  const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
732
803
  const qualifiedProfilePass = deps.qualifiedProfilePass ?? runQualifiedMacProfilePass
733
804
  const transcriptCleanup = deps.transcriptCleanup
805
+ const voiceSuggestions = deps.voiceSuggestions ?? getVoiceSuggestionRunner()
734
806
  const router = Router()
735
807
  const captureActive = deps.captureActive ?? (() => getTranscriptionSessionLiveness().live > 0)
736
808
  const savingSessions = new Set<string>()
@@ -1575,18 +1647,285 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1575
1647
  // Keyed on sessionId so it can reuse the store's traversal-hardened readers
1576
1648
  function reviewRevision(meetingPath: string, sidecarPath: string): string {
1577
1649
  const hash = createHash('sha256')
1650
+ let sidecarJSON: string | null = null
1578
1651
  for (const path of [meetingPath, sidecarPath]) {
1579
1652
  let text: string | null = null
1580
1653
  try { text = readFileSync(path, 'utf-8') } catch { /* missing content is part of the revision */ }
1581
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
+ }
1582
1675
  }
1583
1676
  return hash.digest('hex')
1584
1677
  }
1585
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
+
1586
1703
  function selectedReadMatches(requested: unknown, sessionId: string, actual: string): boolean {
1587
1704
  return requestedRecordMatches(requested, actual) || blendedRecordFor(requested, sessionId) !== undefined
1588
1705
  }
1589
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
+
1590
1929
  function sourceRevisionRefusal(sessionId: string, body: any): { status: number; body: Record<string, unknown> } | null {
1591
1930
  const ops = cosOperationsMeetingsConfigured() ? findCosOperationsMeetingBySessionId(sessionId) : null
1592
1931
  if (ops?.mergedScribeConflict?.length) return { status: 409, body: { reason: 'record_source_mismatch', error: markdownUnreadableReason(ops) } }
@@ -1631,7 +1970,44 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1631
1970
  } catch (error) { res.status(409).json({ error: String(error) }); }
1632
1971
  })
1633
1972
 
1634
- 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) => {
1635
2011
  const sessionId = String(req.params.sessionId ?? '')
1636
2012
  if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1637
2013
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
@@ -1672,8 +2048,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1672
2048
 
1673
2049
  let chunks: unknown
1674
2050
  let sidecar: Record<string, unknown>
2051
+ let baseJSON: string
1675
2052
  try {
1676
- 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>
1677
2055
  chunks = Array.isArray(sidecar) ? sidecar : sidecar.chunks
1678
2056
  } catch {
1679
2057
  // Defensive: both lookups above already read this sidecar to match the
@@ -1689,35 +2067,20 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1689
2067
  return
1690
2068
  }
1691
2069
 
1692
- // Raw capture indices, so a phrase can address its own audio. Position in
1693
- // `chunks` is NOT the WAV number — see attachRawChunkIndices.
1694
- const withIndices = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
1695
- const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
1696
- const review = reviewMeetingSpeakers(withIndices, {
1697
- owner: getOwnerSpeakerLabel(),
1698
- // Labels a human already vouched for in this meeting. Without this the
1699
- // floor re-demotes a confirmed name on every reload, and the reviewer
1700
- // confirms the same voice forever.
1701
- confirmed: confirmedLabels(sessionId),
1702
- confirmedChunks: confirmedChunks(sessionId),
1703
- machineQualifiedPositions: machineEvidence.positions,
1704
- 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,
1705
2078
  phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
1706
- // The sidecar's own durationMs is the meeting's true end. Deriving it from
1707
- // max(elapsed) uses the START of the last chunk, which made the final
1708
- // timeline span end where it began — a 1.5pt sliver labelled "1s" for what
1709
- // may be a long closing monologue.
1710
- durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
1711
- // Word-level speaker timings from the HQ batch pass, present on 82 of 92
1712
- // measured sidecars. These give REAL voiced time per speaker; without them
1713
- // speaking time falls back to capped chunk deltas, which still carry the
1714
- // sub-ceiling pauses. `speakingTimeSource` on the response says which ran.
1715
- batchSegments: Array.isArray(sidecar.batchSegments)
1716
- ? (sidecar.batchSegments as SpeakerWordSegment[])
1717
- : undefined,
1718
2079
  })
1719
- res.set('Cache-Control', 'private, no-store')
1720
- 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 = {
1721
2084
  sessionId,
1722
2085
  title,
1723
2086
  domain,
@@ -1725,13 +2088,95 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1725
2088
  ...identity,
1726
2089
  speakerPolicy: readSpeakerPolicy(sessionId),
1727
2090
  reviewIdentityVersion: 1,
1728
- sourceRevision: reviewRevision(operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath, sidecarPath),
2091
+ sourceRevision: reviewRevision(meetingPath, sidecarPath),
1729
2092
  ...(operations?.mergedScribeConflict?.length ? { mutable: false, identityConflict: operations.mergedScribeConflict } : {}),
1730
2093
  ...(saved ? { durationMin: saved.durationMin } : {}),
1731
2094
  ...review,
1732
- })
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)
1733
2105
  })
1734
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
+
1735
2180
 
1736
2181
  // ── Per-meeting speaker relabel (6.21.16) ─────────────────────────────
1737
2182
  //
@@ -1789,8 +2234,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1789
2234
  const mdPath = operations?.meetingPath ?? direct?.meetingPath ?? sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
1790
2235
 
1791
2236
  let sidecar: Record<string, unknown>
2237
+ let baseJSON: string
1792
2238
  try {
1793
- 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>
1794
2241
  } catch {
1795
2242
  res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
1796
2243
  return
@@ -1804,10 +2251,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1804
2251
  res.status(422).json({ error: 'Chunk sidecar holds no chunk array', reason: 'sidecar_empty' })
1805
2252
  return
1806
2253
  }
1807
- const chunks = rawChunks as ReviewChunk[]
2254
+ const callView = readMacSpeakerCallView(sidecarPath, baseJSON!, mdPath)
2255
+ const chunks = (callView.doc.chunks ?? rawChunks) as ReviewChunk[]
1808
2256
 
1809
2257
  const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
1810
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),
1811
2262
  owner: getOwnerSpeakerLabel(),
1812
2263
  confirmed: confirmedLabels(sessionId),
1813
2264
  confirmedChunks: confirmedChunks(sessionId),
@@ -1817,8 +2268,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1817
2268
  // discarded 48 transcript excerpts on a 16-voice meeting.
1818
2269
  phrasesPerVoice: 1,
1819
2270
  durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
1820
- batchSegments: Array.isArray(sidecar.batchSegments)
1821
- ? (sidecar.batchSegments as SpeakerWordSegment[])
2271
+ batchSegments: Array.isArray(callView.doc.batchSegments)
2272
+ ? (callView.doc.batchSegments as SpeakerWordSegment[])
1822
2273
  : undefined,
1823
2274
  })
1824
2275
 
@@ -1838,12 +2289,13 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1838
2289
  confirmedByHuman: v.confirmedByHuman,
1839
2290
  machineQualified: v.machineQualified,
1840
2291
  machineQualifiedSegments: v.machineQualifiedSegments,
2292
+ ...(v.kind ? { kind: v.kind, displayName: v.displayName, identityId: v.identityId } : {}),
1841
2293
  }))
1842
2294
 
1843
2295
  // A missing .md is not fatal — the review and the sidecar still describe the
1844
2296
  // meeting, and a reviewer would rather have who-spoke than a 404.
1845
2297
  let markdown = ''
1846
- try { markdown = readFileSync(mdPath, 'utf-8') } catch { markdown = '' }
2298
+ try { markdown = callView.markdown ?? readFileSync(mdPath, 'utf-8') } catch { markdown = '' }
1847
2299
  const scribe = parseScribe(markdown)
1848
2300
 
1849
2301
  const date = meetingDate(sidecar.startTime)
@@ -1902,6 +2354,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1902
2354
  date,
1903
2355
  durationMin,
1904
2356
  scribeAvailable: markdown.length > 0,
2357
+ speakerCallView: callView.metadata,
2358
+ ...(review.effectiveSpeakerSummary ? { effectiveSpeakerSummary: review.effectiveSpeakerSummary } : {}),
1905
2359
  attendees,
1906
2360
  speakingTimeSource: review.speakingTimeSource,
1907
2361
  voicedMs: review.voicedMs,
@@ -1917,6 +2371,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1917
2371
  capturedChars: clip.capturedChars,
1918
2372
  domain: clip.domain,
1919
2373
  ...identity,
2374
+ ...(isEffectiveMacSpeakerCallView(callView) || callView.metadata.humanAnnotationInvalid ? { mutable: false, mutationBlockedReason: callView.metadata.humanAnnotationInvalid ? 'human_annotation_invalid' : 'derived_call_view_requires_scoped_action' } : {}),
1920
2375
  reviewIdentityVersion: 1,
1921
2376
  sourceRevision: reviewRevision(operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath, sidecarPath),
1922
2377
  ...(operations?.mergedScribeConflict?.length ? { mutable: false, identityConflict: operations.mergedScribeConflict } : {}),
@@ -1976,7 +2431,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1976
2431
  // Enrolment writes to the voice store, so the same two gates as the other
1977
2432
  // corrections: the session must be a capture, and the record aimed at must
1978
2433
  // not be a derived one.
1979
- 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)
1980
2435
  if (evidenceRefusal) {
1981
2436
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
1982
2437
  return
@@ -2042,7 +2497,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2042
2497
  // A merged G2 session is NOT refused here: it is a real capture with real
2043
2498
  // audio, and relabelling it is how its profile gets better. Only an imported
2044
2499
  // or derived IDENTITY is refused.
2045
- 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)
2046
2501
  if (evidenceRefusal) {
2047
2502
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
2048
2503
  return
@@ -2160,7 +2615,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2160
2615
  // Other requests ran while the plan yielded. Nothing may have moved: the meeting's bytes, the caller's revision, or a new capture.
2161
2616
  let sidecarAfter: string | null = null
2162
2617
  try { sidecarAfter = readFileSync(sidecarPath, 'utf-8') } catch { sidecarAfter = null }
2163
- const revisionAfter = sourceRevisionRefusal(sessionId, req.body)
2618
+ const revisionAfter = callViewMutationRefusal(sessionId) ?? sourceRevisionRefusal(sessionId, req.body)
2164
2619
  if (sidecarAfter !== sidecarRaw || revisionAfter) {
2165
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 })
2166
2621
  return
@@ -2293,6 +2748,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2293
2748
  return
2294
2749
  }
2295
2750
 
2751
+ const viewBeforeWrite = callViewMutationRefusal(sessionId)
2752
+ if (viewBeforeWrite) { res.status(viewBeforeWrite.status).json(viewBeforeWrite.body); return }
2296
2753
  const id = `${sessionId}:${from}>${to}:${Date.now().toString(36)}`
2297
2754
  const at = new Date().toISOString()
2298
2755
  // Intent first. If this cannot be written, nothing is mutated — an
@@ -2397,7 +2854,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2397
2854
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
2398
2855
  return
2399
2856
  }
2400
- 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)
2401
2858
  if (evidenceRefusal) {
2402
2859
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
2403
2860
  return
@@ -2488,7 +2945,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2488
2945
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
2489
2946
  return
2490
2947
  }
2491
- 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)
2492
2949
  if (evidenceRefusal) {
2493
2950
  res.status(evidenceRefusal.status).json(evidenceRefusal.body)
2494
2951
  return