@gotcos/glasses-server 6.92.0 → 6.94.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 (63) hide show
  1. package/.env.example +30 -1
  2. package/CHANGELOG.md +72 -29
  3. package/README.md +70 -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/g2-enrichment-runner.ts +7 -0
  12. package/server/lib/g2-ops-handoff.ts +204 -2
  13. package/server/lib/hallucination-filter.ts +1 -1
  14. package/server/lib/imported-library-rows.ts +4 -2
  15. package/server/lib/live-cos-ask.ts +3 -2
  16. package/server/lib/mac-live-voices.ts +57 -0
  17. package/server/lib/mac-recording-asr.ts +16 -8
  18. package/server/lib/mac-speaker-projection.ts +211 -0
  19. package/server/lib/mac-stream-diarizer.ts +185 -0
  20. package/server/lib/mac-voice-assignment.ts +361 -0
  21. package/server/lib/mac-voice-labels.ts +70 -8
  22. package/server/lib/mac-window-replay.ts +271 -0
  23. package/server/lib/mac-window-voices.ts +390 -0
  24. package/server/lib/meeting-actions.ts +1 -1
  25. package/server/lib/meeting-ask-owner.ts +4 -4
  26. package/server/lib/meeting-ask-transcript-sources.ts +41 -3
  27. package/server/lib/meeting-audio-archive.ts +4 -0
  28. package/server/lib/meeting-batch-transcribe.ts +7 -1
  29. package/server/lib/meeting-context-keys.ts +6 -3
  30. package/server/lib/meeting-corrections.ts +4 -4
  31. package/server/lib/meeting-fact-check.ts +3 -3
  32. package/server/lib/meeting-parse.ts +1 -1
  33. package/server/lib/meeting-relabel-enrolment.ts +8 -8
  34. package/server/lib/meeting-relabel.ts +3 -3
  35. package/server/lib/meeting-scribe-content.ts +1 -1
  36. package/server/lib/meeting-speaker-review.ts +8 -8
  37. package/server/lib/meeting-store.ts +19 -9
  38. package/server/lib/nemotron-final.ts +114 -9
  39. package/server/lib/nemotron-live.ts +58 -6
  40. package/server/lib/phone-recording.ts +114 -0
  41. package/server/lib/prompt-tail-guard.ts +1 -1
  42. package/server/lib/speaker-embeddings.ts +154 -28
  43. package/server/lib/speaker-name.ts +1 -1
  44. package/server/lib/stranded-session-actions.ts +7 -2
  45. package/server/lib/training-audio-provenance.ts +3 -2
  46. package/server/lib/transcript-cleanup-context.ts +3 -3
  47. package/server/lib/transcript-cleanup-model.ts +1 -1
  48. package/server/lib/transcript-cleanup-spans.ts +10 -10
  49. package/server/lib/transcript-cleanup-text.ts +4 -4
  50. package/server/lib/transcript-cleanup.ts +1 -1
  51. package/server/lib/unsaved-audio-quarantine.ts +10 -1
  52. package/server/lib/voice-directory.ts +1 -1
  53. package/server/lib/voice-evidence-guard.ts +2 -1
  54. package/server/lib/voice-profile-store.ts +11 -5
  55. package/server/lib/whisper-local.ts +1 -1
  56. package/server/routes/__fixtures__/g2-filing-golden-6.93.0.json +62 -0
  57. package/server/routes/health.ts +8 -0
  58. package/server/routes/meeting.ts +231 -20
  59. package/server/routes/meetings.ts +9 -5
  60. package/server/routes/transcribe-stream.ts +140 -15
  61. package/server/routes/voice.ts +12 -4
  62. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
  63. 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 {
@@ -183,6 +187,7 @@ import {
183
187
  import {
184
188
  claimMeetingInOperations,
185
189
  earlyMeetingSyncEnabled,
190
+ fileMacRecordingInLibrary,
186
191
  handoffMeetingToOperations,
187
192
  } from '../lib/g2-ops-handoff.js'
188
193
  import {
@@ -211,6 +216,33 @@ function cosOpsPipelineConfigured(): boolean {
211
216
  return Boolean(process.env.COS_SCRIPTS_DIR?.trim())
212
217
  }
213
218
 
219
+ /**
220
+ * 6.94.0: file a finished Mac recording into the COS meeting library.
221
+ *
222
+ * Runs AFTER the standalone enrichment, so the local recording is exactly what 6.93.0 left
223
+ * whether or not filing works. A Mac recording is never handed to the G2 import: it takes
224
+ * sync_meetings.py --mac-import-file, and only when that script reports the capability.
225
+ * A failure is logged and never fails the capture's own finalization, because a retry would
226
+ * re-run the standalone summary; mac_library_backfill.py files it later, idempotently.
227
+ */
228
+ async function fileFinishedMacRecording(sessionId: string, meetingPath: string, label: string): Promise<void> {
229
+ // Callers pass Mac captures only (finalization and orphan recovery both check MAC_CAPTURE_ID).
230
+ if (!cosOpsPipelineConfigured()) return
231
+ try {
232
+ const filing = await fileMacRecordingInLibrary(meetingPath)
233
+ if (filing.outcome === 'filed') {
234
+ console.log(`[${label}] Mac recording ${sessionId} filed into the library (${filing.status}): ${filing.path}`)
235
+ } else {
236
+ console.log(`[${label}] Mac recording ${sessionId} stays standalone: ${filing.reason}`)
237
+ }
238
+ } catch (error) {
239
+ console.warn(
240
+ `[${label}] Mac recording ${sessionId} was not filed into the library: `
241
+ + `${error instanceof Error ? error.message : String(error)}`,
242
+ )
243
+ }
244
+ }
245
+
214
246
  /**
215
247
  * Why a correction left the meeting's markdown alone when it could not read it.
216
248
  *
@@ -264,6 +296,8 @@ function defaultSessionLedger(sessionId: string): SessionLedger {
264
296
 
265
297
  export interface MeetingRouteDependencies {
266
298
  store?: MeetingStore
299
+ /** 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. */
300
+ captureActive?: () => boolean
267
301
  sessions?: MeetingSessionSource
268
302
  runBatch?: (
269
303
  audioDir: string,
@@ -439,7 +473,7 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
439
473
  // summary describes, the cleaned text. Off unless Miles turns it on.
440
474
  await runCleanupStage(runtime.transcriptCleanup, { sessionId: current.sessionId, meetingPath: current.meetingPath, sidecarPath: current.sidecarPath }, runtime.sessions)
441
475
 
442
- if (cosOpsPipelineConfigured() && !MAC_CAPTURE_ID.test(current.sessionId)) {
476
+ if (cosOpsPipelineConfigured() && !MAC_CAPTURE_ID.test(current.sessionId) && !PHONE_CAPTURE_ID.test(current.sessionId)) {
443
477
  await handoffMeetingToOperations(current.meetingPath)
444
478
  } else {
445
479
  // Standalone: no sync_meetings.py to produce summary/topics/decisions.
@@ -452,11 +486,18 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
452
486
  // The capture is finished and its files are durable, so the merge engine may score it.
453
487
  // Fire-and-forget by contract: the runner queues, defers under a drain or a live
454
488
  // capture, and never throws back at the finalization path.
455
- if (!MAC_CAPTURE_ID.test(current.sessionId)) triggerMeetingMergeRun('g2_finalized')
489
+ if (!MAC_CAPTURE_ID.test(current.sessionId) && !PHONE_CAPTURE_ID.test(current.sessionId)) triggerMeetingMergeRun('g2_finalized')
456
490
  runtime.finalizationJobs.remove(current.sessionId)
457
491
  // 6.78.0: finished, so its crash-loop count (lib/boot-resume.ts) is done.
458
492
  bootResumeGate().forget('meeting_finalization', current.sessionId)
459
493
  finalizationRetryCounts.delete(key)
494
+ // Filed AFTER the capture's own finalization is complete and its job removed: the
495
+ // filing holds no maintenance lease (a restart drain never waits on it) and a restart
496
+ // mid-filing never re-runs the standalone summary. An interrupted filing leaves the
497
+ // staged copy unfinished, and sync_meetings' broad sync retries it.
498
+ if (MAC_CAPTURE_ID.test(current.sessionId)) {
499
+ runtime.scheduleBackground(fileFinishedMacRecording(current.sessionId, current.meetingPath, 'meeting/save'))
500
+ }
460
501
  }).catch(error => {
461
502
  const current = runtime.finalizationJobs.get(job.sessionId) ?? job
462
503
  try {
@@ -559,7 +600,7 @@ function publicSaveResponse(saved: SavedMeeting, replayed = false): Record<strin
559
600
  : 100
560
601
  return {
561
602
  receiptVersion: 1,
562
- ...(saved.sourceType === 'mac_system_audio' ? { sourceType: saved.sourceType, sessionId: saved.captureId, captureId: saved.captureId } : {}),
603
+ ...(saved.sourceType ? { sourceType: saved.sourceType, sessionId: saved.captureId, captureId: saved.captureId, ...(saved.sourceType === PHONE_SOURCE_TYPE ? { speakerPolicy: 'anonymous' } : {}) } : {}),
563
604
  serverInstanceId: getServerInstanceId(),
564
605
  saved: true,
565
606
  // Keep the build199 string field without leaking an absolute host path.
@@ -646,6 +687,31 @@ function suppliedServerPin(
646
687
  return header || secondary || null
647
688
  }
648
689
 
690
+ const MAC_ASSIGN_SKIP_TEXT: Record<string, string> = {
691
+ not_mac_capture: 'this is not a Mac recording',
692
+ not_a_voice_label: 'only an unnamed Voice N can be assigned',
693
+ unknown_profile: 'that name is not an enrolled voice profile',
694
+ disabled: 'voice samples are switched off on this server (COS_CHUNK_EMBEDDINGS)',
695
+ store_unavailable: 'the speaker model is not loaded on this Mac, or the voice profile store could not be written',
696
+ no_index_mapping: 'this recording has no chunk index map to find its audio',
697
+ audio_not_retained: 'the recording audio is no longer retained (it is kept for 7 days)',
698
+ no_usable_audio: 'no usable audio from this voice, or the audio does not sound like one person',
699
+ owner_profile: 'that is the wearer profile, and a Mac recording has no wearer microphone',
700
+ no_stored_voice: 'that profile has no stored voice to compare against',
701
+ profile_full: 'that profile is full and holds no Mac-assigned sample to replace',
702
+ deadline: 'reading the audio took longer than the time limit; ask again to continue where it stopped',
703
+ }
704
+ const macAssignSkipText = (skipped: MacAssignSkip): string => (skipped && MAC_ASSIGN_SKIP_TEXT[skipped]) || String(skipped)
705
+
706
+ const MAC_ASSIGN_GATE_TEXT: Record<MacAssignGate, string> = {
707
+ below_floor: 'this voice sounds unlike that profile (under the 0.55 floor a voice merge also refuses at)',
708
+ mixed_voice: 'this Voice N holds more than one voice, so the dominant one may not be the person you picked',
709
+ too_little_audio: 'there is too little clean audio from this voice to trust one short sample',
710
+ }
711
+
712
+ /** Meetings with an assignment plan running now. One at a time per meeting, so two requests cannot interleave their reads and writes. */
713
+ const macAssignInFlight = new Set<string>()
714
+
649
715
  export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router {
650
716
  const store = deps.store ?? getMeetingStore()
651
717
  const sessions = deps.sessions ?? defaultSessionSource
@@ -656,6 +722,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
656
722
  const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
657
723
  const transcriptCleanup = deps.transcriptCleanup
658
724
  const router = Router()
725
+ const captureActive = deps.captureActive ?? (() => getTranscriptionSessionLiveness().live > 0)
659
726
  const savingSessions = new Set<string>()
660
727
  const stopRequests = deps.stopRequests ?? createStopRequestStore()
661
728
  setNamingSessionLiveness(id => sessions.getStartTime(id) !== null || savingSessions.has(id) || activeFinalizationJobs.has(id))
@@ -740,6 +807,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
740
807
  let recoveryPendingLogged = false
741
808
  router.get('/meeting/sessions/:sessionId/status', (req, res) => {
742
809
  const sessionId = String(req.params.sessionId ?? '')
810
+ let phoneProvenanceState: 'admitted' | 'not_admitted' | undefined
811
+ if (sessionId.startsWith('phone_')) {
812
+ try {
813
+ if (readPhoneRecording(sessionId)) { requirePhoneRecording(sessionId); phoneProvenanceState = 'admitted' }
814
+ 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)
815
+ else { assertPhoneProvenance(sessionId, req.query); phoneProvenanceState = 'not_admitted' }
816
+ } catch (error) { const e = error as Error & { status?: number; reason?: string }; res.status(e.status ?? 409).json({ error: e.message, reason: e.reason }); return }
817
+ }
743
818
  if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
744
819
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
745
820
  return
@@ -755,6 +830,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
755
830
  return
756
831
  }
757
832
  const saved = store.findBySessionId(sessionId)
833
+ 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
834
  // 6.71.14: before durable recovery has run (a boot behind a closed or stuck
759
835
  // maintenance gate), a persisted meeting is not in memory YET, and answering
760
836
  // `missing` would tell the phone the Mac lost it. Every phone status caller
@@ -786,6 +862,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
786
862
  res.set('Cache-Control', 'private, no-store')
787
863
  res.json({
788
864
  sessionId,
865
+ ...(phoneProvenanceState ? { sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous', provenanceState: phoneProvenanceState } : {}),
789
866
  state,
790
867
  serverInstanceId,
791
868
  receivedRanges: live.receivedRanges,
@@ -902,6 +979,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
902
979
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
903
980
  return null
904
981
  }
982
+ if (sessionId.startsWith('phone_')) {
983
+ res.status(400).json({ error: 'Stop this recording on iPhone', reason: 'phone_capture_stop_on_iphone' })
984
+ return null
985
+ }
905
986
  if (MAC_CAPTURE_ID.test(sessionId)) {
906
987
  // A Mac recording stops from Control itself; it has no glasses to ask.
907
988
  res.status(400).json({ error: 'A Mac recording stops from Record on Mac', reason: 'mac_capture' })
@@ -995,6 +1076,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
995
1076
  // off screen still tells the Mac. It never closes the session and never refuses a chunk:
996
1077
  // the server saves on its own only once it holds every chunk 0..finalChunkIndex.
997
1078
  router.post('/meeting/sessions/:sessionId/stop', (req, res) => {
1079
+ 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
1080
  const sessionId = String(req.params.sessionId ?? '')
999
1081
  if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1000
1082
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
@@ -1109,6 +1191,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1109
1191
  try {
1110
1192
  // A response can be lost after both durable files were committed. Find
1111
1193
  // the sidecar by session ID so client retry/restart is idempotent.
1194
+ if (sessionId.startsWith('phone_')) {
1195
+ 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') }
1196
+ }
1112
1197
  const alreadySaved = store.findBySessionId(sessionId)
1113
1198
  if (alreadySaved) {
1114
1199
  finalizationJobs.reconcileCanonicalSidecars()
@@ -1126,6 +1211,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1126
1211
  respond(200, publicSaveResponse(alreadySaved, true), true)
1127
1212
  return alreadySaved
1128
1213
  }
1214
+ 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
1215
  if (MAC_CAPTURE_ID.test(sessionId)) {
1130
1216
  requireMacRecordingForSave(sessionId, body?.expectedChunkCount)
1131
1217
  }
@@ -1176,17 +1262,18 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1176
1262
  ?? chunks.map((chunk, chunkIndex) => ({ chunkIndex, chunk }))
1177
1263
  const startTime = sessions.getStartTime(sessionId) ?? Date.now()
1178
1264
  const macRecording = readMacRecording(sessionId)
1265
+ const phoneRecording = readPhoneRecording(sessionId)
1179
1266
  const durationFromTimeline = macRecording
1180
1267
  ? macChunkReceipt(sessionId, Number(body?.expectedChunkCount) - 1)?.endElapsed ?? 0
1181
1268
  : chunks.reduce(
1182
- (maximum, chunk) => Math.max(maximum, chunk?.elapsed ?? 0),
1269
+ (maximum, chunk) => Math.max(maximum, (chunk?.elapsed ?? 0) + (PHONE_CAPTURE_ID.test(sessionId) ? chunk?.audioDurationMs ?? 0 : 0)),
1183
1270
  0,
1184
1271
  )
1185
1272
  const durationMs = durationFromTimeline > 0
1186
1273
  ? durationFromTimeline
1187
1274
  : Math.max(0, Date.now() - startTime)
1188
1275
  const integrity = withPhoneFinalIndex(sessions.getIntegrity(sessionId), phoneFinal)
1189
- const needsOperations = cosOpsPipelineConfigured() && !macRecording
1276
+ const needsOperations = cosOpsPipelineConfigured() && !macRecording && !phoneRecording
1190
1277
  // Standalone saves need a finalization pass too, for summary enrichment.
1191
1278
  // Before 6.37 a standalone save with no audio nulled the job below and
1192
1279
  // never reached the ops_pending slot, so enrichment could never run.
@@ -1301,7 +1388,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1301
1388
  markCanonicalFinalizationState(saved.sidecarPath, finalizationJob.phase, claimPending)
1302
1389
  } else if (finalizationJob) {
1303
1390
  markCanonicalFinalizationState(saved.sidecarPath, 'complete', false)
1304
- if (!readMacRecording(sessionId)) triggerMeetingMergeRun('g2_finalized')
1391
+ if (!readMacRecording(sessionId) && !PHONE_CAPTURE_ID.test(sessionId)) triggerMeetingMergeRun('g2_finalized')
1305
1392
  finalizationJobs.remove(sessionId)
1306
1393
  bootResumeGate().forget('meeting_finalization', sessionId)
1307
1394
  finalizationJob = null
@@ -1361,13 +1448,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1361
1448
  res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
1362
1449
  return
1363
1450
  }
1451
+ 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
1452
  const serverInstanceId = getServerInstanceId()
1365
1453
  if (!serverInstanceId) {
1366
1454
  res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
1367
1455
  return
1368
1456
  }
1369
1457
  const pin = suppliedServerPin(req, body?.serverInstanceId)
1370
- if (MAC_CAPTURE_ID.test(sessionId) && !pin) {
1458
+ if ((MAC_CAPTURE_ID.test(sessionId) || PHONE_CAPTURE_ID.test(sessionId)) && !pin) {
1371
1459
  res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
1372
1460
  return
1373
1461
  }
@@ -1376,7 +1464,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1376
1464
  return
1377
1465
  }
1378
1466
  // Only the phone pins its saves (the local-first contract); the stranded sweep does not.
1379
- await performSave(sessionId, body, respond, pin ? 'phone' : 'client')
1467
+ await performSave(sessionId, body, respond, pin && req.get('X-COS-Recording-Origin') !== 'stranded-sweep' ? 'phone' : 'client')
1380
1468
  } catch (error) {
1381
1469
  console.error('[meeting/save] Finalization failed:', error)
1382
1470
  respond(500, { error: 'Meeting save failed', reason: 'meeting_save_error' })
@@ -1829,7 +1917,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1829
1917
  * Replay a recorded correction's enrolment — the retroactive path.
1830
1918
  *
1831
1919
  * 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
1920
+ * has a correct transcript and no profile. Wren Hale is the live case: named
1833
1921
  * across two meetings (60 and 109 chunks), 182 mentions in the sidecars, absent from
1834
1922
  * a 77-profile store. Re-running the rename cannot help — she is already a real
1835
1923
  * name there, so the placeholder guard correctly declines.
@@ -1847,7 +1935,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1847
1935
  * refusal when unmappable, voice coherence, the diversity cap and the
1848
1936
  * `correction:<sessionId>` tag all apply identically. A named `from` is still
1849
1937
  * 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
1938
+ * becomes a new profile (Cole Garner → Leo Ashby). Placeholder targets
1851
1939
  * stay idle.
1852
1940
  *
1853
1941
  * FAILS CLOSED. Without `confirm: true` it reports what it would enrol and writes
@@ -1924,7 +2012,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1924
2012
  })
1925
2013
  })
1926
2014
 
1927
- router.post('/meeting/:sessionId/relabel', (req, res) => {
2015
+ router.post('/meeting/:sessionId/relabel', async (req, res) => {
1928
2016
  res.set('Cache-Control', 'private, no-store')
1929
2017
  const sessionId = String(req.params.sessionId ?? '')
1930
2018
  if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
@@ -2010,6 +2098,63 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2010
2098
  return
2011
2099
  }
2012
2100
 
2101
+ // 6.93.0: a Mac recording voice assigned to an EXISTING voice profile. Opt-in per
2102
+ // request (`assignVoiceProfile: true`); without it a Mac rename stays text only.
2103
+ // Planned BEFORE anything is written: the preview shows exactly what would be added,
2104
+ // and a confirm that would add nothing is refused with the rename unapplied.
2105
+ // 6.93.0: refusals first (owner, a live capture), then an async plan that yields to the
2106
+ // event loop, then a re-check that nothing moved while it ran.
2107
+ let macAssign: MacAssignPlan | null = null
2108
+ if (req.body?.assignVoiceProfile === true) {
2109
+ let parsedForAssign: Record<string, unknown> = {}
2110
+ try {
2111
+ const doc = JSON.parse(sidecarRaw) as unknown
2112
+ if (doc && typeof doc === 'object' && !Array.isArray(doc)) parsedForAssign = doc as Record<string, unknown>
2113
+ } catch { /* relabelSidecarJson already parsed it */ }
2114
+ if (!isMacCaptureSidecar(parsedForAssign, sessionId)) {
2115
+ res.status(400).json({ error: 'assignVoiceProfile applies to Mac recordings only; a glasses rename already trains the profile', reason: 'not_mac_capture' })
2116
+ return
2117
+ }
2118
+ if (!isVoiceLabel(from)) {
2119
+ res.status(400).json({ error: 'Only an unnamed Voice N can be assigned to a voice profile', reason: 'not_a_voice_label' })
2120
+ return
2121
+ }
2122
+ // The wearer's own profile is refused inside the plan (`owner_profile`): a Mac recording has no wearer microphone, and a wrong
2123
+ // sample there would poison owner detection. The route only has to pass the label and map the answer.
2124
+ const ownerLabel = getOwnerSpeakerLabel()
2125
+ // Embedding and the profile write share this server with a live recording. Never while one runs.
2126
+ if (captureActive()) {
2127
+ 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 })
2128
+ return
2129
+ }
2130
+ if (macAssignInFlight.has(sessionId)) {
2131
+ res.status(409).json({ error: 'Another voice assignment for this meeting is still running', reason: 'assign_in_progress', retryable: true })
2132
+ return
2133
+ }
2134
+ macAssignInFlight.add(sessionId)
2135
+ try {
2136
+ macAssign = await planMacVoiceAssignment({ sessionId, from, to, changed: plan.value.changed, sidecar: parsedForAssign, ownerLabel })
2137
+ } finally {
2138
+ macAssignInFlight.delete(sessionId)
2139
+ }
2140
+ // Other requests ran while the plan yielded. Nothing may have moved: the meeting's bytes, the caller's revision, or a new capture.
2141
+ let sidecarAfter: string | null = null
2142
+ try { sidecarAfter = readFileSync(sidecarPath, 'utf-8') } catch { sidecarAfter = null }
2143
+ const revisionAfter = sourceRevisionRefusal(sessionId, req.body)
2144
+ if (sidecarAfter !== sidecarRaw || revisionAfter) {
2145
+ 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 })
2146
+ return
2147
+ }
2148
+ if (macAssign.report.skipped === 'owner_profile') {
2149
+ 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' })
2150
+ return
2151
+ }
2152
+ if (macAssign.report.skipped === 'unknown_profile') {
2153
+ res.status(404).json({ error: `"${to}" is not an enrolled voice profile, so nothing was changed`, reason: 'unknown_profile', enrolment: macAssign.report })
2154
+ return
2155
+ }
2156
+ }
2157
+
2013
2158
  // The markdown may only be rewritten when EVERY chunk carrying `from` is
2014
2159
  // covered. Its turn segmentation does not match the sidecar's, so a partial
2015
2160
  // relabel has no way to know which turns the selected chunks became.
@@ -2051,7 +2196,16 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2051
2196
  if (req.body?.dryRun === true || req.body?.confirm !== true) {
2052
2197
  res.status(req.body?.dryRun === true ? 200 : 400).json({
2053
2198
  ...(req.body?.dryRun === true ? {} : { error: 'confirmation required', reason: 'confirmation_required' }),
2199
+ ...(macAssign ? { enrolment: macAssign.report } : {}),
2054
2200
  message: `Relabelling ${surfaces.sidecar} chunk(s) from "${from}" to "${to}" in this meeting`
2201
+ + (macAssign
2202
+ ? macAssign.report.skipped === null
2203
+ ? `, and adding up to ${macAssign.report.enrolled} voice sample(s) to ${to}'s profile`
2204
+ + (macAssign.report.gates.length > 0
2205
+ ? `. The server will refuse that unless you confirm adding it anyway: ${macAssign.report.gates.map(g => MAC_ASSIGN_GATE_TEXT[g]).join('; ')}`
2206
+ : '')
2207
+ : `. Assigning is not possible: ${macAssignSkipText(macAssign.report.skipped)}. You can still rename the speaker in the transcript without adding any voice samples`
2208
+ : '')
2055
2209
  + (preview.proseStale
2056
2210
  ? '. The summary and decisions still name the old speaker and are NOT rewritten — '
2057
2211
  + `prose refers to people by first name (${preview.proseHits.join(', ')}), `
@@ -2062,15 +2216,59 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2062
2216
  return
2063
2217
  }
2064
2218
 
2219
+ // Assigning to a profile with nothing to add is refused whole: the person asked for
2220
+ // samples, so a rename that silently adds none is not what they confirmed.
2221
+ if (macAssign && macAssign.report.skipped !== null) {
2222
+ res.status(422).json({
2223
+ error: `Nothing was changed. Assigning is not possible: ${macAssignSkipText(macAssign.report.skipped)}`,
2224
+ reason: 'nothing_to_enrol',
2225
+ enrolment: macAssign.report,
2226
+ })
2227
+ return
2228
+ }
2229
+
2230
+ // 6.93.0: a wrong voice is never written into a real person's profile on a warning alone. Below the floor,
2231
+ // a mixed group or too little audio is a 409 unless the caller sends `force: true` (COS Control's separate
2232
+ // "Add anyway"). The plan was just recomputed against the profile as it is now, so a change since the preview counts.
2233
+ if (macAssign && macAssign.report.gates.length > 0 && req.body?.force !== true) {
2234
+ const gate = macAssign.report.gates[0]
2235
+ res.status(409).json({
2236
+ error: `Nothing was changed. ${MAC_ASSIGN_GATE_TEXT[gate].replace(/^./, c => c.toUpperCase())}. Add it anyway only if you are sure`,
2237
+ reason: gate,
2238
+ gates: macAssign.report.gates,
2239
+ enrolment: macAssign.report,
2240
+ })
2241
+ return
2242
+ }
2243
+
2244
+ // A capture that started while the plan ran must not meet the profile write.
2245
+ if (macAssign && captureActive()) {
2246
+ 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 })
2247
+ return
2248
+ }
2249
+
2250
+ // The plan awaited, so a finalization pass may have started for this meeting since the check at the top of the route.
2251
+ // A job record exists from save to completion, which makes that unlikely today; this keeps it from depending on it.
2252
+ if (macAssign) {
2253
+ const finalizingAfter = finalizingRefusal(sessionId)
2254
+ if (finalizingAfter) {
2255
+ res.status(finalizingAfter.status).json(finalizingAfter.body)
2256
+ return
2257
+ }
2258
+ }
2259
+
2065
2260
  // A prior correction that never closed means this meeting's files may already
2066
2261
  // be half-written. Refuse rather than layering a second rewrite on top.
2262
+ // `force` on an assignment means "add this voice anyway" (above). It must not also skip this guard.
2067
2263
  const stalled = pendingCorrections(sessionId)
2068
- if (stalled.length > 0 && req.body?.force !== true) {
2264
+ if (stalled.length > 0 && (req.body?.force !== true || macAssign)) {
2069
2265
  res.status(409).json({
2070
2266
  error: 'a previous correction on this meeting never completed',
2071
2267
  reason: 'correction_pending',
2072
2268
  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.',
2269
+ message: macAssign
2270
+ ? 'Its files may be partly rewritten. Re-check the meeting, or do a text-only rename with { force: true } first.'
2271
+ : 'Its files may be partly rewritten. Re-check the meeting, then pass { force: true } to proceed.',
2074
2272
  })
2075
2273
  return
2076
2274
  }
@@ -2125,7 +2323,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2125
2323
  if (doc && typeof doc === 'object' && !Array.isArray(doc)) parsedSidecar = doc as Record<string, unknown>
2126
2324
  } catch { /* enrolment refuses on an unusable sidecar; the rename already landed */ }
2127
2325
 
2128
- const enrolment = enrolNamedVoice({ sessionId, from, to, changed: plan.value.changed, sidecar: parsedSidecar })
2326
+ const enrolment = macAssign
2327
+ ? commitMacVoiceAssignment(sessionId, macAssign)
2328
+ : enrolNamedVoice({ sessionId, from, to, changed: plan.value.changed, sidecar: parsedSidecar })
2129
2329
  res.json({
2130
2330
  ok: true,
2131
2331
  correctionId: id,
@@ -2153,7 +2353,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2153
2353
  // profile, so the wrong voice is now part of what the system thinks they sound
2154
2354
  // like and will keep matching. Removing only the label fixes the transcript and
2155
2355
  // leaves the profile poisoned — the exact mechanism that grew the phantom
2156
- // "Erick Hernandez" from 3 mislabelled seeds to 18 samples.
2356
+ // "Evan Hartwell" from 3 mislabelled seeds to 18 samples.
2157
2357
  /**
2158
2358
  * Confirm the identifier was RIGHT about a label the display floor demoted.
2159
2359
  *
@@ -2685,6 +2885,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2685
2885
  return
2686
2886
  }
2687
2887
  const body = (req.body ?? {}) as Record<string, unknown>
2888
+ 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
2889
  if (body.title !== undefined && typeof body.title !== 'string') {
2689
2890
  res.status(400).json({ error: 'Invalid title', reason: 'invalid_title' })
2690
2891
  return
@@ -2719,7 +2920,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2719
2920
  }
2720
2921
  const capture = synthesizeEntriesFromChunkWavs(quarantineDir)
2721
2922
  const macCapture = readMacRecording(sessionId)
2923
+ const phoneCapture = readPhoneRecording(sessionId)
2722
2924
  const entries = capture.entries.map(entry => {
2925
+ if (phoneCapture) return { ...entry, chunk: { ...entry.chunk, speaker: 'Ext', speakerPolicy: 'anonymous' as const, sourceType: PHONE_SOURCE_TYPE } }
2723
2926
  if (!macCapture) return entry
2724
2927
  const receipt = macChunkReceipt(sessionId, entry.chunkIndex)
2725
2928
  return { ...entry, chunk: { ...entry.chunk, speaker: 'Ext', speakerPolicy: 'high-confidence' as const,
@@ -2758,6 +2961,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2758
2961
  + 'ran for this capture. The capture leaves the unsaved count once its scribe is durable.',
2759
2962
  })
2760
2963
 
2964
+ let macFilingPath: string | null = null
2761
2965
  const task = Promise.resolve().then(async () => {
2762
2966
  lease.setPhase('active')
2763
2967
  const startedAt = Date.now()
@@ -2797,6 +3001,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2797
3001
  // retention window, so a capture wrongly judged silent is not lost.
2798
3002
  markRecoveredNoSpeech(quarantineDir, entries.length)
2799
3003
  if (macCapture) closeMacRecording(sessionId)
3004
+ if (phoneCapture) closePhoneRecording(sessionId)
2800
3005
  console.log(
2801
3006
  `[meeting/orphans] ${sessionId} held no speech `
2802
3007
  + `(${entries.length} chunks, ${Math.round((Date.now() - startedAt) / 1000)}s) `
@@ -2806,7 +3011,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2806
3011
  }
2807
3012
  const recoveredChunks = results.map(item => ({
2808
3013
  text: item.text,
2809
- speaker: macCapture ? 'Ext' : 'Unknown',
3014
+ speaker: macCapture || phoneCapture ? 'Ext' : 'Unknown',
3015
+ ...(phoneCapture ? { speakerPolicy: 'anonymous' as const, sourceType: PHONE_SOURCE_TYPE } : {}),
2810
3016
  ...(macCapture ? { speakerPolicy: 'high-confidence' as const } : {}),
2811
3017
  elapsed: item.segment.startElapsed,
2812
3018
  similarity: 0,
@@ -2822,7 +3028,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2822
3028
  resolveCosOperationsDir(),
2823
3029
  ),
2824
3030
  transcript,
2825
- startTime: macCapture?.startTime ?? capture.startTime,
3031
+ startTime: macCapture?.startTime ?? phoneCapture?.startTime ?? capture.startTime,
2826
3032
  durationMs: macCapture ? Math.max(...entries.map(e => e.chunk.elapsed + (e.chunk.audioDurationMs ?? 0))) : capture.durationMs,
2827
3033
  chunks: recoveredChunks,
2828
3034
  chunkEntries: recoveredChunks.map((chunk, position) => ({
@@ -2851,7 +3057,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2851
3057
  })
2852
3058
  } catch { /* display is best-effort */ }
2853
3059
  await runCleanupStage(transcriptCleanup, { sessionId, meetingPath: saved.filepath, sidecarPath: saved.sidecarPath }, sessions)
2854
- if (cosOpsPipelineConfigured() && !macCapture) {
3060
+ if (cosOpsPipelineConfigured() && !macCapture && !phoneCapture) {
2855
3061
  await handoffMeetingToOperations(saved.filepath)
2856
3062
  } else {
2857
3063
  // Orphan recovery is its own path and never reaches
@@ -2861,8 +3067,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2861
3067
  // already holds the long-running orphan_recovery lease that COS
2862
3068
  // Control surfaces and warns on before committing a drain.
2863
3069
  await enrichStandaloneMeeting(saved.filepath, Date.now())
3070
+ if (macCapture) macFilingPath = saved.filepath
2864
3071
  }
2865
- if (!macCapture) triggerMeetingMergeRun('orphan_recovered')
3072
+ if (!macCapture && !phoneCapture) triggerMeetingMergeRun('orphan_recovered')
2866
3073
  }).catch(error => {
2867
3074
  // The quarantined audio is untouched on failure — retry stays possible
2868
3075
  // until the retention clock clears it.
@@ -2880,7 +3087,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2880
3087
  recoveringOrphans.delete(sessionId)
2881
3088
  lease.release()
2882
3089
  })
2883
- scheduleBackground(task)
3090
+ // As on the save path: a recovered Mac capture is filed only after the recovery has
3091
+ // finished and released its orphan_recovery lease, so a drain never waits on it.
3092
+ scheduleBackground(task.then(() => macFilingPath
3093
+ ? fileFinishedMacRecording(sessionId, macFilingPath, 'meeting/orphans')
3094
+ : undefined))
2884
3095
  })
2885
3096
 
2886
3097
  return router
@@ -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