@gotcos/glasses-server 6.92.0 → 6.93.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 (60) hide show
  1. package/.env.example +30 -1
  2. package/CHANGELOG.md +57 -29
  3. package/README.md +58 -0
  4. package/docs/active-query-jobs.md +1 -1
  5. package/docs/test-plan-6.71.7.md +1 -1
  6. package/managed-runtime-contract.json +2 -0
  7. package/package.json +2 -1
  8. package/server/lib/ask-session-id.ts +2 -1
  9. package/server/lib/embedding-eviction.ts +60 -2
  10. package/server/lib/fuzzy-correct.ts +1 -1
  11. package/server/lib/hallucination-filter.ts +1 -1
  12. package/server/lib/imported-library-rows.ts +4 -2
  13. package/server/lib/live-cos-ask.ts +3 -2
  14. package/server/lib/mac-live-voices.ts +57 -0
  15. package/server/lib/mac-recording-asr.ts +16 -8
  16. package/server/lib/mac-speaker-projection.ts +211 -0
  17. package/server/lib/mac-stream-diarizer.ts +185 -0
  18. package/server/lib/mac-voice-assignment.ts +361 -0
  19. package/server/lib/mac-voice-labels.ts +70 -8
  20. package/server/lib/mac-window-replay.ts +271 -0
  21. package/server/lib/mac-window-voices.ts +390 -0
  22. package/server/lib/meeting-actions.ts +1 -1
  23. package/server/lib/meeting-ask-owner.ts +4 -4
  24. package/server/lib/meeting-ask-transcript-sources.ts +41 -3
  25. package/server/lib/meeting-audio-archive.ts +4 -0
  26. package/server/lib/meeting-batch-transcribe.ts +7 -1
  27. package/server/lib/meeting-context-keys.ts +6 -3
  28. package/server/lib/meeting-corrections.ts +4 -4
  29. package/server/lib/meeting-fact-check.ts +3 -3
  30. package/server/lib/meeting-parse.ts +1 -1
  31. package/server/lib/meeting-relabel-enrolment.ts +8 -8
  32. package/server/lib/meeting-relabel.ts +3 -3
  33. package/server/lib/meeting-scribe-content.ts +1 -1
  34. package/server/lib/meeting-speaker-review.ts +8 -8
  35. package/server/lib/meeting-store.ts +19 -9
  36. package/server/lib/nemotron-final.ts +114 -9
  37. package/server/lib/nemotron-live.ts +58 -6
  38. package/server/lib/phone-recording.ts +114 -0
  39. package/server/lib/prompt-tail-guard.ts +1 -1
  40. package/server/lib/speaker-embeddings.ts +154 -28
  41. package/server/lib/speaker-name.ts +1 -1
  42. package/server/lib/stranded-session-actions.ts +7 -2
  43. package/server/lib/training-audio-provenance.ts +3 -2
  44. package/server/lib/transcript-cleanup-context.ts +3 -3
  45. package/server/lib/transcript-cleanup-model.ts +1 -1
  46. package/server/lib/transcript-cleanup-spans.ts +10 -10
  47. package/server/lib/transcript-cleanup-text.ts +4 -4
  48. package/server/lib/transcript-cleanup.ts +1 -1
  49. package/server/lib/unsaved-audio-quarantine.ts +10 -1
  50. package/server/lib/voice-directory.ts +1 -1
  51. package/server/lib/voice-evidence-guard.ts +2 -1
  52. package/server/lib/voice-profile-store.ts +11 -5
  53. package/server/lib/whisper-local.ts +1 -1
  54. package/server/routes/health.ts +8 -0
  55. package/server/routes/meeting.ts +189 -19
  56. package/server/routes/meetings.ts +9 -5
  57. package/server/routes/transcribe-stream.ts +140 -15
  58. package/server/routes/voice.ts +12 -4
  59. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
  60. package/server/scripts/mac-window-voices-6930-mutation-gate.mjs +193 -0
@@ -1,3 +1,5 @@
1
+ import { dataPath } from '../lib/data-dir.js'
2
+ import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, assertPhoneProvenance, readPhoneRecording, requirePhoneRecording, closePhoneRecording } from '../lib/phone-recording.js'
1
3
  import { MAC_CAPTURE_ID, readMacRecording, requireMacRecording, macChunkReceipt, closeMacRecording } from '../lib/mac-recording.js'
2
4
  import { readSpeakerPolicy, writeSpeakerPolicy, isSpeakerMode } from '../lib/meeting-speaker-policy.js'
3
5
  // POST /api/meeting/save — finalize an existing transcribe-stream session into
@@ -42,6 +44,8 @@ import {
42
44
  } from '../lib/meeting-audio-archive.js'
43
45
  import { readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
44
46
  import { enrolNamedVoice } from '../lib/meeting-relabel-enrolment.js'
47
+ import { commitMacVoiceAssignment, planMacVoiceAssignment, type MacAssignGate, type MacAssignPlan, type MacAssignSkip } from '../lib/mac-voice-assignment.js'
48
+ import { isMacCaptureSidecar, isVoiceLabel } from '../lib/mac-voice-labels.js'
45
49
 
46
50
  function requireMacRecordingForSave(sessionId: string, expectedChunkCount: unknown): void {
47
51
  try {
@@ -264,6 +268,8 @@ function defaultSessionLedger(sessionId: string): SessionLedger {
264
268
 
265
269
  export interface MeetingRouteDependencies {
266
270
  store?: MeetingStore
271
+ /** 6.93.0: is any meeting capture (glasses, Mac or iPhone) live right now? Assigning a Mac voice to a profile is refused while one is. */
272
+ captureActive?: () => boolean
267
273
  sessions?: MeetingSessionSource
268
274
  runBatch?: (
269
275
  audioDir: string,
@@ -439,7 +445,7 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
439
445
  // summary describes, the cleaned text. Off unless Miles turns it on.
440
446
  await runCleanupStage(runtime.transcriptCleanup, { sessionId: current.sessionId, meetingPath: current.meetingPath, sidecarPath: current.sidecarPath }, runtime.sessions)
441
447
 
442
- if (cosOpsPipelineConfigured() && !MAC_CAPTURE_ID.test(current.sessionId)) {
448
+ if (cosOpsPipelineConfigured() && !MAC_CAPTURE_ID.test(current.sessionId) && !PHONE_CAPTURE_ID.test(current.sessionId)) {
443
449
  await handoffMeetingToOperations(current.meetingPath)
444
450
  } else {
445
451
  // Standalone: no sync_meetings.py to produce summary/topics/decisions.
@@ -452,7 +458,7 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
452
458
  // The capture is finished and its files are durable, so the merge engine may score it.
453
459
  // Fire-and-forget by contract: the runner queues, defers under a drain or a live
454
460
  // capture, and never throws back at the finalization path.
455
- if (!MAC_CAPTURE_ID.test(current.sessionId)) triggerMeetingMergeRun('g2_finalized')
461
+ if (!MAC_CAPTURE_ID.test(current.sessionId) && !PHONE_CAPTURE_ID.test(current.sessionId)) triggerMeetingMergeRun('g2_finalized')
456
462
  runtime.finalizationJobs.remove(current.sessionId)
457
463
  // 6.78.0: finished, so its crash-loop count (lib/boot-resume.ts) is done.
458
464
  bootResumeGate().forget('meeting_finalization', current.sessionId)
@@ -559,7 +565,7 @@ function publicSaveResponse(saved: SavedMeeting, replayed = false): Record<strin
559
565
  : 100
560
566
  return {
561
567
  receiptVersion: 1,
562
- ...(saved.sourceType === 'mac_system_audio' ? { sourceType: saved.sourceType, sessionId: saved.captureId, captureId: saved.captureId } : {}),
568
+ ...(saved.sourceType ? { sourceType: saved.sourceType, sessionId: saved.captureId, captureId: saved.captureId, ...(saved.sourceType === PHONE_SOURCE_TYPE ? { speakerPolicy: 'anonymous' } : {}) } : {}),
563
569
  serverInstanceId: getServerInstanceId(),
564
570
  saved: true,
565
571
  // Keep the build199 string field without leaking an absolute host path.
@@ -646,6 +652,31 @@ function suppliedServerPin(
646
652
  return header || secondary || null
647
653
  }
648
654
 
655
+ const MAC_ASSIGN_SKIP_TEXT: Record<string, string> = {
656
+ not_mac_capture: 'this is not a Mac recording',
657
+ not_a_voice_label: 'only an unnamed Voice N can be assigned',
658
+ unknown_profile: 'that name is not an enrolled voice profile',
659
+ disabled: 'voice samples are switched off on this server (COS_CHUNK_EMBEDDINGS)',
660
+ store_unavailable: 'the speaker model is not loaded on this Mac, or the voice profile store could not be written',
661
+ no_index_mapping: 'this recording has no chunk index map to find its audio',
662
+ audio_not_retained: 'the recording audio is no longer retained (it is kept for 7 days)',
663
+ no_usable_audio: 'no usable audio from this voice, or the audio does not sound like one person',
664
+ owner_profile: 'that is the wearer profile, and a Mac recording has no wearer microphone',
665
+ no_stored_voice: 'that profile has no stored voice to compare against',
666
+ profile_full: 'that profile is full and holds no Mac-assigned sample to replace',
667
+ deadline: 'reading the audio took longer than the time limit; ask again to continue where it stopped',
668
+ }
669
+ const macAssignSkipText = (skipped: MacAssignSkip): string => (skipped && MAC_ASSIGN_SKIP_TEXT[skipped]) || String(skipped)
670
+
671
+ const MAC_ASSIGN_GATE_TEXT: Record<MacAssignGate, string> = {
672
+ below_floor: 'this voice sounds unlike that profile (under the 0.55 floor a voice merge also refuses at)',
673
+ mixed_voice: 'this Voice N holds more than one voice, so the dominant one may not be the person you picked',
674
+ too_little_audio: 'there is too little clean audio from this voice to trust one short sample',
675
+ }
676
+
677
+ /** Meetings with an assignment plan running now. One at a time per meeting, so two requests cannot interleave their reads and writes. */
678
+ const macAssignInFlight = new Set<string>()
679
+
649
680
  export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router {
650
681
  const store = deps.store ?? getMeetingStore()
651
682
  const sessions = deps.sessions ?? defaultSessionSource
@@ -656,6 +687,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
656
687
  const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
657
688
  const transcriptCleanup = deps.transcriptCleanup
658
689
  const router = Router()
690
+ const captureActive = deps.captureActive ?? (() => getTranscriptionSessionLiveness().live > 0)
659
691
  const savingSessions = new Set<string>()
660
692
  const stopRequests = deps.stopRequests ?? createStopRequestStore()
661
693
  setNamingSessionLiveness(id => sessions.getStartTime(id) !== null || savingSessions.has(id) || activeFinalizationJobs.has(id))
@@ -740,6 +772,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
740
772
  let recoveryPendingLogged = false
741
773
  router.get('/meeting/sessions/:sessionId/status', (req, res) => {
742
774
  const sessionId = String(req.params.sessionId ?? '')
775
+ let phoneProvenanceState: 'admitted' | 'not_admitted' | undefined
776
+ if (sessionId.startsWith('phone_')) {
777
+ try {
778
+ if (readPhoneRecording(sessionId)) { requirePhoneRecording(sessionId); phoneProvenanceState = 'admitted' }
779
+ else if (existsSync(dataPath('session-audio', sessionId)) || existsSync(dataPath('meeting-audio', sessionId)) || existsSync(dataPath('pending-batch', sessionId)) || existsSync(dataPath('active-sessions', sessionId + '.json')) || findQuarantineDir(sessionId)) requirePhoneRecording(sessionId)
780
+ else { assertPhoneProvenance(sessionId, req.query); phoneProvenanceState = 'not_admitted' }
781
+ } catch (error) { const e = error as Error & { status?: number; reason?: string }; res.status(e.status ?? 409).json({ error: e.message, reason: e.reason }); return }
782
+ }
743
783
  if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
744
784
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
745
785
  return
@@ -755,6 +795,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
755
795
  return
756
796
  }
757
797
  const saved = store.findBySessionId(sessionId)
798
+ if (phoneProvenanceState === 'admitted' && readPhoneRecording(sessionId)?.closedAt && !saved) { res.status(409).json({ error: 'Saved iPhone metadata is incomplete; audio is retained', reason: 'phone_saved_metadata_unreadable' }); return }
758
799
  // 6.71.14: before durable recovery has run (a boot behind a closed or stuck
759
800
  // maintenance gate), a persisted meeting is not in memory YET, and answering
760
801
  // `missing` would tell the phone the Mac lost it. Every phone status caller
@@ -786,6 +827,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
786
827
  res.set('Cache-Control', 'private, no-store')
787
828
  res.json({
788
829
  sessionId,
830
+ ...(phoneProvenanceState ? { sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous', provenanceState: phoneProvenanceState } : {}),
789
831
  state,
790
832
  serverInstanceId,
791
833
  receivedRanges: live.receivedRanges,
@@ -902,6 +944,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
902
944
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
903
945
  return null
904
946
  }
947
+ if (sessionId.startsWith('phone_')) {
948
+ res.status(400).json({ error: 'Stop this recording on iPhone', reason: 'phone_capture_stop_on_iphone' })
949
+ return null
950
+ }
905
951
  if (MAC_CAPTURE_ID.test(sessionId)) {
906
952
  // A Mac recording stops from Control itself; it has no glasses to ask.
907
953
  res.status(400).json({ error: 'A Mac recording stops from Record on Mac', reason: 'mac_capture' })
@@ -995,6 +1041,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
995
1041
  // off screen still tells the Mac. It never closes the session and never refuses a chunk:
996
1042
  // the server saves on its own only once it holds every chunk 0..finalChunkIndex.
997
1043
  router.post('/meeting/sessions/:sessionId/stop', (req, res) => {
1044
+ try { assertPhoneProvenance(String(req.params.sessionId ?? ''), req.body) } catch (error) { const e = error as Error & { status?: number; reason?: string }; res.status(e.status ?? 400).json({ error: e.message, reason: e.reason }); return }
998
1045
  const sessionId = String(req.params.sessionId ?? '')
999
1046
  if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1000
1047
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
@@ -1109,6 +1156,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1109
1156
  try {
1110
1157
  // A response can be lost after both durable files were committed. Find
1111
1158
  // the sidecar by session ID so client retry/restart is idempotent.
1159
+ if (sessionId.startsWith('phone_')) {
1160
+ try { requirePhoneRecording(sessionId) } catch (error) { const failure = error as Error & { status?: number; reason?: string }; throw new MeetingStoreError(failure.message, failure.status ?? 409, failure.reason ?? 'phone_capture_provenance_missing') }
1161
+ }
1112
1162
  const alreadySaved = store.findBySessionId(sessionId)
1113
1163
  if (alreadySaved) {
1114
1164
  finalizationJobs.reconcileCanonicalSidecars()
@@ -1126,6 +1176,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1126
1176
  respond(200, publicSaveResponse(alreadySaved, true), true)
1127
1177
  return alreadySaved
1128
1178
  }
1179
+ if (PHONE_CAPTURE_ID.test(sessionId) && requirePhoneRecording(sessionId).closedAt) throw new MeetingStoreError('Saved iPhone source metadata is incomplete; audio is retained', 409, 'phone_saved_metadata_unreadable')
1129
1180
  if (MAC_CAPTURE_ID.test(sessionId)) {
1130
1181
  requireMacRecordingForSave(sessionId, body?.expectedChunkCount)
1131
1182
  }
@@ -1176,17 +1227,18 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1176
1227
  ?? chunks.map((chunk, chunkIndex) => ({ chunkIndex, chunk }))
1177
1228
  const startTime = sessions.getStartTime(sessionId) ?? Date.now()
1178
1229
  const macRecording = readMacRecording(sessionId)
1230
+ const phoneRecording = readPhoneRecording(sessionId)
1179
1231
  const durationFromTimeline = macRecording
1180
1232
  ? macChunkReceipt(sessionId, Number(body?.expectedChunkCount) - 1)?.endElapsed ?? 0
1181
1233
  : chunks.reduce(
1182
- (maximum, chunk) => Math.max(maximum, chunk?.elapsed ?? 0),
1234
+ (maximum, chunk) => Math.max(maximum, (chunk?.elapsed ?? 0) + (PHONE_CAPTURE_ID.test(sessionId) ? chunk?.audioDurationMs ?? 0 : 0)),
1183
1235
  0,
1184
1236
  )
1185
1237
  const durationMs = durationFromTimeline > 0
1186
1238
  ? durationFromTimeline
1187
1239
  : Math.max(0, Date.now() - startTime)
1188
1240
  const integrity = withPhoneFinalIndex(sessions.getIntegrity(sessionId), phoneFinal)
1189
- const needsOperations = cosOpsPipelineConfigured() && !macRecording
1241
+ const needsOperations = cosOpsPipelineConfigured() && !macRecording && !phoneRecording
1190
1242
  // Standalone saves need a finalization pass too, for summary enrichment.
1191
1243
  // Before 6.37 a standalone save with no audio nulled the job below and
1192
1244
  // never reached the ops_pending slot, so enrichment could never run.
@@ -1301,7 +1353,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1301
1353
  markCanonicalFinalizationState(saved.sidecarPath, finalizationJob.phase, claimPending)
1302
1354
  } else if (finalizationJob) {
1303
1355
  markCanonicalFinalizationState(saved.sidecarPath, 'complete', false)
1304
- if (!readMacRecording(sessionId)) triggerMeetingMergeRun('g2_finalized')
1356
+ if (!readMacRecording(sessionId) && !PHONE_CAPTURE_ID.test(sessionId)) triggerMeetingMergeRun('g2_finalized')
1305
1357
  finalizationJobs.remove(sessionId)
1306
1358
  bootResumeGate().forget('meeting_finalization', sessionId)
1307
1359
  finalizationJob = null
@@ -1361,13 +1413,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1361
1413
  res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
1362
1414
  return
1363
1415
  }
1416
+ try { assertPhoneProvenance(sessionId, body) } catch (error) { const e = error as Error & { status?: number; reason?: string }; respond(e.status ?? 400, { error: e.message, reason: e.reason }); return }
1364
1417
  const serverInstanceId = getServerInstanceId()
1365
1418
  if (!serverInstanceId) {
1366
1419
  res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
1367
1420
  return
1368
1421
  }
1369
1422
  const pin = suppliedServerPin(req, body?.serverInstanceId)
1370
- if (MAC_CAPTURE_ID.test(sessionId) && !pin) {
1423
+ if ((MAC_CAPTURE_ID.test(sessionId) || PHONE_CAPTURE_ID.test(sessionId)) && !pin) {
1371
1424
  res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
1372
1425
  return
1373
1426
  }
@@ -1376,7 +1429,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1376
1429
  return
1377
1430
  }
1378
1431
  // Only the phone pins its saves (the local-first contract); the stranded sweep does not.
1379
- await performSave(sessionId, body, respond, pin ? 'phone' : 'client')
1432
+ await performSave(sessionId, body, respond, pin && req.get('X-COS-Recording-Origin') !== 'stranded-sweep' ? 'phone' : 'client')
1380
1433
  } catch (error) {
1381
1434
  console.error('[meeting/save] Finalization failed:', error)
1382
1435
  respond(500, { error: 'Meeting save failed', reason: 'meeting_save_error' })
@@ -1829,7 +1882,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1829
1882
  * Replay a recorded correction's enrolment — the retroactive path.
1830
1883
  *
1831
1884
  * WHY. Enrolment fires inside `POST /relabel`, so a voice named BEFORE that shipped
1832
- * has a correct transcript and no profile. Kirstyn Blum is the live case: named
1885
+ * has a correct transcript and no profile. Wren Hale is the live case: named
1833
1886
  * across two meetings (60 and 109 chunks), 182 mentions in the sidecars, absent from
1834
1887
  * a 77-profile store. Re-running the rename cannot help — she is already a real
1835
1888
  * name there, so the placeholder guard correctly declines.
@@ -1847,7 +1900,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1847
1900
  * refusal when unmappable, voice coherence, the diversity cap and the
1848
1901
  * `correction:<sessionId>` tag all apply identically. A named `from` is still
1849
1902
  * enrolled when `to` is a real person — that is how a wrong existing match
1850
- * becomes a new profile (Nick Gurney → Milo LeBaron). Placeholder targets
1903
+ * becomes a new profile (Cole Garner → Leo Ashby). Placeholder targets
1851
1904
  * stay idle.
1852
1905
  *
1853
1906
  * FAILS CLOSED. Without `confirm: true` it reports what it would enrol and writes
@@ -1924,7 +1977,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1924
1977
  })
1925
1978
  })
1926
1979
 
1927
- router.post('/meeting/:sessionId/relabel', (req, res) => {
1980
+ router.post('/meeting/:sessionId/relabel', async (req, res) => {
1928
1981
  res.set('Cache-Control', 'private, no-store')
1929
1982
  const sessionId = String(req.params.sessionId ?? '')
1930
1983
  if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
@@ -2010,6 +2063,63 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2010
2063
  return
2011
2064
  }
2012
2065
 
2066
+ // 6.93.0: a Mac recording voice assigned to an EXISTING voice profile. Opt-in per
2067
+ // request (`assignVoiceProfile: true`); without it a Mac rename stays text only.
2068
+ // Planned BEFORE anything is written: the preview shows exactly what would be added,
2069
+ // and a confirm that would add nothing is refused with the rename unapplied.
2070
+ // 6.93.0: refusals first (owner, a live capture), then an async plan that yields to the
2071
+ // event loop, then a re-check that nothing moved while it ran.
2072
+ let macAssign: MacAssignPlan | null = null
2073
+ if (req.body?.assignVoiceProfile === true) {
2074
+ let parsedForAssign: Record<string, unknown> = {}
2075
+ try {
2076
+ const doc = JSON.parse(sidecarRaw) as unknown
2077
+ if (doc && typeof doc === 'object' && !Array.isArray(doc)) parsedForAssign = doc as Record<string, unknown>
2078
+ } catch { /* relabelSidecarJson already parsed it */ }
2079
+ if (!isMacCaptureSidecar(parsedForAssign, sessionId)) {
2080
+ res.status(400).json({ error: 'assignVoiceProfile applies to Mac recordings only; a glasses rename already trains the profile', reason: 'not_mac_capture' })
2081
+ return
2082
+ }
2083
+ if (!isVoiceLabel(from)) {
2084
+ res.status(400).json({ error: 'Only an unnamed Voice N can be assigned to a voice profile', reason: 'not_a_voice_label' })
2085
+ return
2086
+ }
2087
+ // The wearer's own profile is refused inside the plan (`owner_profile`): a Mac recording has no wearer microphone, and a wrong
2088
+ // sample there would poison owner detection. The route only has to pass the label and map the answer.
2089
+ const ownerLabel = getOwnerSpeakerLabel()
2090
+ // Embedding and the profile write share this server with a live recording. Never while one runs.
2091
+ if (captureActive()) {
2092
+ res.status(409).json({ error: 'A meeting is being recorded right now. Assign the voice after it ends, so the recording is never held up', reason: 'capture_active', retryable: true })
2093
+ return
2094
+ }
2095
+ if (macAssignInFlight.has(sessionId)) {
2096
+ res.status(409).json({ error: 'Another voice assignment for this meeting is still running', reason: 'assign_in_progress', retryable: true })
2097
+ return
2098
+ }
2099
+ macAssignInFlight.add(sessionId)
2100
+ try {
2101
+ macAssign = await planMacVoiceAssignment({ sessionId, from, to, changed: plan.value.changed, sidecar: parsedForAssign, ownerLabel })
2102
+ } finally {
2103
+ macAssignInFlight.delete(sessionId)
2104
+ }
2105
+ // Other requests ran while the plan yielded. Nothing may have moved: the meeting's bytes, the caller's revision, or a new capture.
2106
+ let sidecarAfter: string | null = null
2107
+ try { sidecarAfter = readFileSync(sidecarPath, 'utf-8') } catch { sidecarAfter = null }
2108
+ const revisionAfter = sourceRevisionRefusal(sessionId, req.body)
2109
+ if (sidecarAfter !== sidecarRaw || revisionAfter) {
2110
+ 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 })
2111
+ return
2112
+ }
2113
+ if (macAssign.report.skipped === 'owner_profile') {
2114
+ res.status(409).json({ error: 'That is the wearer profile. A Mac recording cannot add voice samples to it, so nothing was changed', reason: 'owner_profile' })
2115
+ return
2116
+ }
2117
+ if (macAssign.report.skipped === 'unknown_profile') {
2118
+ res.status(404).json({ error: `"${to}" is not an enrolled voice profile, so nothing was changed`, reason: 'unknown_profile', enrolment: macAssign.report })
2119
+ return
2120
+ }
2121
+ }
2122
+
2013
2123
  // The markdown may only be rewritten when EVERY chunk carrying `from` is
2014
2124
  // covered. Its turn segmentation does not match the sidecar's, so a partial
2015
2125
  // relabel has no way to know which turns the selected chunks became.
@@ -2051,7 +2161,16 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2051
2161
  if (req.body?.dryRun === true || req.body?.confirm !== true) {
2052
2162
  res.status(req.body?.dryRun === true ? 200 : 400).json({
2053
2163
  ...(req.body?.dryRun === true ? {} : { error: 'confirmation required', reason: 'confirmation_required' }),
2164
+ ...(macAssign ? { enrolment: macAssign.report } : {}),
2054
2165
  message: `Relabelling ${surfaces.sidecar} chunk(s) from "${from}" to "${to}" in this meeting`
2166
+ + (macAssign
2167
+ ? macAssign.report.skipped === null
2168
+ ? `, and adding up to ${macAssign.report.enrolled} voice sample(s) to ${to}'s profile`
2169
+ + (macAssign.report.gates.length > 0
2170
+ ? `. The server will refuse that unless you confirm adding it anyway: ${macAssign.report.gates.map(g => MAC_ASSIGN_GATE_TEXT[g]).join('; ')}`
2171
+ : '')
2172
+ : `. Assigning is not possible: ${macAssignSkipText(macAssign.report.skipped)}. You can still rename the speaker in the transcript without adding any voice samples`
2173
+ : '')
2055
2174
  + (preview.proseStale
2056
2175
  ? '. The summary and decisions still name the old speaker and are NOT rewritten — '
2057
2176
  + `prose refers to people by first name (${preview.proseHits.join(', ')}), `
@@ -2062,15 +2181,59 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2062
2181
  return
2063
2182
  }
2064
2183
 
2184
+ // Assigning to a profile with nothing to add is refused whole: the person asked for
2185
+ // samples, so a rename that silently adds none is not what they confirmed.
2186
+ if (macAssign && macAssign.report.skipped !== null) {
2187
+ res.status(422).json({
2188
+ error: `Nothing was changed. Assigning is not possible: ${macAssignSkipText(macAssign.report.skipped)}`,
2189
+ reason: 'nothing_to_enrol',
2190
+ enrolment: macAssign.report,
2191
+ })
2192
+ return
2193
+ }
2194
+
2195
+ // 6.93.0: a wrong voice is never written into a real person's profile on a warning alone. Below the floor,
2196
+ // a mixed group or too little audio is a 409 unless the caller sends `force: true` (COS Control's separate
2197
+ // "Add anyway"). The plan was just recomputed against the profile as it is now, so a change since the preview counts.
2198
+ if (macAssign && macAssign.report.gates.length > 0 && req.body?.force !== true) {
2199
+ const gate = macAssign.report.gates[0]
2200
+ res.status(409).json({
2201
+ error: `Nothing was changed. ${MAC_ASSIGN_GATE_TEXT[gate].replace(/^./, c => c.toUpperCase())}. Add it anyway only if you are sure`,
2202
+ reason: gate,
2203
+ gates: macAssign.report.gates,
2204
+ enrolment: macAssign.report,
2205
+ })
2206
+ return
2207
+ }
2208
+
2209
+ // A capture that started while the plan ran must not meet the profile write.
2210
+ if (macAssign && captureActive()) {
2211
+ res.status(409).json({ error: 'A meeting started recording while the voice was being read. Assign the voice after it ends; nothing was changed', reason: 'capture_active', retryable: true })
2212
+ return
2213
+ }
2214
+
2215
+ // The plan awaited, so a finalization pass may have started for this meeting since the check at the top of the route.
2216
+ // A job record exists from save to completion, which makes that unlikely today; this keeps it from depending on it.
2217
+ if (macAssign) {
2218
+ const finalizingAfter = finalizingRefusal(sessionId)
2219
+ if (finalizingAfter) {
2220
+ res.status(finalizingAfter.status).json(finalizingAfter.body)
2221
+ return
2222
+ }
2223
+ }
2224
+
2065
2225
  // A prior correction that never closed means this meeting's files may already
2066
2226
  // be half-written. Refuse rather than layering a second rewrite on top.
2227
+ // `force` on an assignment means "add this voice anyway" (above). It must not also skip this guard.
2067
2228
  const stalled = pendingCorrections(sessionId)
2068
- if (stalled.length > 0 && req.body?.force !== true) {
2229
+ if (stalled.length > 0 && (req.body?.force !== true || macAssign)) {
2069
2230
  res.status(409).json({
2070
2231
  error: 'a previous correction on this meeting never completed',
2071
2232
  reason: 'correction_pending',
2072
2233
  pending: stalled.map(r => ({ id: r.id, at: r.at, from: r.from, to: r.to })),
2073
- message: 'Its files may be partly rewritten. Re-check the meeting, then pass { force: true } to proceed.',
2234
+ message: macAssign
2235
+ ? 'Its files may be partly rewritten. Re-check the meeting, or do a text-only rename with { force: true } first.'
2236
+ : 'Its files may be partly rewritten. Re-check the meeting, then pass { force: true } to proceed.',
2074
2237
  })
2075
2238
  return
2076
2239
  }
@@ -2125,7 +2288,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2125
2288
  if (doc && typeof doc === 'object' && !Array.isArray(doc)) parsedSidecar = doc as Record<string, unknown>
2126
2289
  } catch { /* enrolment refuses on an unusable sidecar; the rename already landed */ }
2127
2290
 
2128
- const enrolment = enrolNamedVoice({ sessionId, from, to, changed: plan.value.changed, sidecar: parsedSidecar })
2291
+ const enrolment = macAssign
2292
+ ? commitMacVoiceAssignment(sessionId, macAssign)
2293
+ : enrolNamedVoice({ sessionId, from, to, changed: plan.value.changed, sidecar: parsedSidecar })
2129
2294
  res.json({
2130
2295
  ok: true,
2131
2296
  correctionId: id,
@@ -2153,7 +2318,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2153
2318
  // profile, so the wrong voice is now part of what the system thinks they sound
2154
2319
  // like and will keep matching. Removing only the label fixes the transcript and
2155
2320
  // leaves the profile poisoned — the exact mechanism that grew the phantom
2156
- // "Erick Hernandez" from 3 mislabelled seeds to 18 samples.
2321
+ // "Evan Hartwell" from 3 mislabelled seeds to 18 samples.
2157
2322
  /**
2158
2323
  * Confirm the identifier was RIGHT about a label the display floor demoted.
2159
2324
  *
@@ -2685,6 +2850,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2685
2850
  return
2686
2851
  }
2687
2852
  const body = (req.body ?? {}) as Record<string, unknown>
2853
+ if (sessionId.startsWith('phone_')) { try { requirePhoneRecording(sessionId) } catch (error) { const e = error as Error & { status?: number; reason?: string }; res.status(e.status ?? 409).json({ error: e.message, reason: e.reason }); return } }
2688
2854
  if (body.title !== undefined && typeof body.title !== 'string') {
2689
2855
  res.status(400).json({ error: 'Invalid title', reason: 'invalid_title' })
2690
2856
  return
@@ -2719,7 +2885,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2719
2885
  }
2720
2886
  const capture = synthesizeEntriesFromChunkWavs(quarantineDir)
2721
2887
  const macCapture = readMacRecording(sessionId)
2888
+ const phoneCapture = readPhoneRecording(sessionId)
2722
2889
  const entries = capture.entries.map(entry => {
2890
+ if (phoneCapture) return { ...entry, chunk: { ...entry.chunk, speaker: 'Ext', speakerPolicy: 'anonymous' as const, sourceType: PHONE_SOURCE_TYPE } }
2723
2891
  if (!macCapture) return entry
2724
2892
  const receipt = macChunkReceipt(sessionId, entry.chunkIndex)
2725
2893
  return { ...entry, chunk: { ...entry.chunk, speaker: 'Ext', speakerPolicy: 'high-confidence' as const,
@@ -2797,6 +2965,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2797
2965
  // retention window, so a capture wrongly judged silent is not lost.
2798
2966
  markRecoveredNoSpeech(quarantineDir, entries.length)
2799
2967
  if (macCapture) closeMacRecording(sessionId)
2968
+ if (phoneCapture) closePhoneRecording(sessionId)
2800
2969
  console.log(
2801
2970
  `[meeting/orphans] ${sessionId} held no speech `
2802
2971
  + `(${entries.length} chunks, ${Math.round((Date.now() - startedAt) / 1000)}s) `
@@ -2806,7 +2975,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2806
2975
  }
2807
2976
  const recoveredChunks = results.map(item => ({
2808
2977
  text: item.text,
2809
- speaker: macCapture ? 'Ext' : 'Unknown',
2978
+ speaker: macCapture || phoneCapture ? 'Ext' : 'Unknown',
2979
+ ...(phoneCapture ? { speakerPolicy: 'anonymous' as const, sourceType: PHONE_SOURCE_TYPE } : {}),
2810
2980
  ...(macCapture ? { speakerPolicy: 'high-confidence' as const } : {}),
2811
2981
  elapsed: item.segment.startElapsed,
2812
2982
  similarity: 0,
@@ -2822,7 +2992,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2822
2992
  resolveCosOperationsDir(),
2823
2993
  ),
2824
2994
  transcript,
2825
- startTime: macCapture?.startTime ?? capture.startTime,
2995
+ startTime: macCapture?.startTime ?? phoneCapture?.startTime ?? capture.startTime,
2826
2996
  durationMs: macCapture ? Math.max(...entries.map(e => e.chunk.elapsed + (e.chunk.audioDurationMs ?? 0))) : capture.durationMs,
2827
2997
  chunks: recoveredChunks,
2828
2998
  chunkEntries: recoveredChunks.map((chunk, position) => ({
@@ -2851,7 +3021,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2851
3021
  })
2852
3022
  } catch { /* display is best-effort */ }
2853
3023
  await runCleanupStage(transcriptCleanup, { sessionId, meetingPath: saved.filepath, sidecarPath: saved.sidecarPath }, sessions)
2854
- if (cosOpsPipelineConfigured() && !macCapture) {
3024
+ if (cosOpsPipelineConfigured() && !macCapture && !phoneCapture) {
2855
3025
  await handoffMeetingToOperations(saved.filepath)
2856
3026
  } else {
2857
3027
  // Orphan recovery is its own path and never reaches
@@ -2862,7 +3032,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2862
3032
  // Control surfaces and warns on before committing a drain.
2863
3033
  await enrichStandaloneMeeting(saved.filepath, Date.now())
2864
3034
  }
2865
- if (!macCapture) triggerMeetingMergeRun('orphan_recovered')
3035
+ if (!macCapture && !phoneCapture) triggerMeetingMergeRun('orphan_recovered')
2866
3036
  }).catch(error => {
2867
3037
  // The quarantined audio is untouched on failure — retry stays possible
2868
3038
  // until the retention clock clears it.
@@ -1,3 +1,4 @@
1
+ import { PHONE_CAPTURE_ID } from '../lib/phone-recording.js'
1
2
  // Meeting archive: COS operations tree when configured, else standalone recordings.
2
3
 
3
4
  import { Router } from 'express'
@@ -73,13 +74,16 @@ export function withMeetingContext<T extends MeetingMeta>(meeting: T, source: Me
73
74
  return { ...meeting, ...readOnlyContext() }
74
75
  }
75
76
 
76
- /** A Mac capture's session id, as routes/meeting.ts keys the pipeline skip. */
77
+ /** Exact Mac identity; iPhone captures never satisfy this predicate. */
77
78
  export function isMacCaptureId(sessionId: string | undefined): boolean {
78
79
  return typeof sessionId === 'string' && MAC_CAPTURE_ID.test(sessionId)
79
80
  }
81
+ function isStandaloneCaptureId(sessionId: string | undefined): boolean {
82
+ return typeof sessionId === 'string' && (MAC_CAPTURE_ID.test(sessionId) || PHONE_CAPTURE_ID.test(sessionId))
83
+ }
80
84
 
81
85
  /**
82
- * Operations rows plus the store's Mac recordings (6.73.14), newest first by date and time, capped at `limit`. Only Mac
86
+ * Operations rows plus the store's Mac recordings (6.73.14), newest first by date and time, capped at `limit`. Only Mac and admitted phone
83
87
  * rows are added: a G2 store row is the pipeline's to file, and its operations copy is the row. A Mac row is left out
84
88
  * when an operations row holds its session, or when `held` does (the sessions merged scribes declare), the same rule
85
89
  * the day count applies (imported-library-rows macStoreDayCounts). Operations rows are never deduplicated against each
@@ -90,7 +94,7 @@ export function withMacStoreRows(operations: MeetingMeta[], storeRows: MeetingMe
90
94
  const mac = storeRows
91
95
  // Keyed on the sidecar's session, as the save route keys the pipeline skip (MAC_CAPTURE_ID). The row's sourceType
92
96
  // comes from the markdown's Capture source line instead, which an edited or older record can lack.
93
- .filter(row => isMacCaptureId(row.sessionId) && !sessions.has(row.sessionId) && !held.has(row.sessionId!))
97
+ .filter(row => isStandaloneCaptureId(row.sessionId) && (!PHONE_CAPTURE_ID.test(row.sessionId!) || row.sourceType === 'iphone_microphone') && !sessions.has(row.sessionId) && !held.has(row.sessionId!))
94
98
  .map(withStandaloneIdentity)
95
99
  if (mac.length === 0) return operations
96
100
  return [...operations, ...mac]
@@ -342,7 +346,7 @@ export function createMeetingsRouter(
342
346
  // 6.73.14 (Miles 2026-10-15): routes/meeting.ts never hands a Mac recording to the pipeline (MAC_CAPTURE_ID),
343
347
  // so on a pipeline Mac it lives only in this store and never appeared in Meetings; Control's hand-off read
344
348
  // "Saved meeting not found yet" forever. The store's Mac rows join the operations rows here.
345
- const meetings = withMacStoreRows(operationsMeetings, store.list({ ...listOptions, sessionMatch: isMacCaptureId }), limit, declaredSessions)
349
+ const meetings = withMacStoreRows(operationsMeetings, store.list({ ...listOptions, sessionMatch: isStandaloneCaptureId }), limit, declaredSessions)
346
350
  // ONLY WHEN THE LIST SAW THE WHOLE MONTH. The row list is capped, and a capped
347
351
  // list knows about fewer merged scribes than the month holds, so handing its
348
352
  // sessions to an UNCAPPED day count would subtract too little and put the dot
@@ -356,7 +360,7 @@ export function createMeetingsRouter(
356
360
  const operationsMonths = listCosOperationsMeetingMonths(domain)
357
361
  const known = new Set(operationsMonths)
358
362
  const macOnly = store.listMonths().filter(month => !known.has(month)
359
- && store.list({ month, domain, limit: 1, sessionMatch: isMacCaptureId }).length > 0)
363
+ && store.list({ month, domain, limit: 1, sessionMatch: isStandaloneCaptureId }).length > 0)
360
364
  return macOnly.length ? uniqueSortedMonths([operationsMonths, macOnly]) : operationsMonths
361
365
  })(),
362
366
  // The rows above already read every scribe in this month and told us which