@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.
- package/.env.example +30 -1
- package/CHANGELOG.md +57 -29
- package/README.md +58 -0
- package/docs/active-query-jobs.md +1 -1
- package/docs/test-plan-6.71.7.md +1 -1
- package/managed-runtime-contract.json +2 -0
- package/package.json +2 -1
- package/server/lib/ask-session-id.ts +2 -1
- package/server/lib/embedding-eviction.ts +60 -2
- package/server/lib/fuzzy-correct.ts +1 -1
- package/server/lib/hallucination-filter.ts +1 -1
- package/server/lib/imported-library-rows.ts +4 -2
- package/server/lib/live-cos-ask.ts +3 -2
- package/server/lib/mac-live-voices.ts +57 -0
- package/server/lib/mac-recording-asr.ts +16 -8
- package/server/lib/mac-speaker-projection.ts +211 -0
- package/server/lib/mac-stream-diarizer.ts +185 -0
- package/server/lib/mac-voice-assignment.ts +361 -0
- package/server/lib/mac-voice-labels.ts +70 -8
- package/server/lib/mac-window-replay.ts +271 -0
- package/server/lib/mac-window-voices.ts +390 -0
- package/server/lib/meeting-actions.ts +1 -1
- package/server/lib/meeting-ask-owner.ts +4 -4
- package/server/lib/meeting-ask-transcript-sources.ts +41 -3
- package/server/lib/meeting-audio-archive.ts +4 -0
- package/server/lib/meeting-batch-transcribe.ts +7 -1
- package/server/lib/meeting-context-keys.ts +6 -3
- package/server/lib/meeting-corrections.ts +4 -4
- package/server/lib/meeting-fact-check.ts +3 -3
- package/server/lib/meeting-parse.ts +1 -1
- package/server/lib/meeting-relabel-enrolment.ts +8 -8
- package/server/lib/meeting-relabel.ts +3 -3
- package/server/lib/meeting-scribe-content.ts +1 -1
- package/server/lib/meeting-speaker-review.ts +8 -8
- package/server/lib/meeting-store.ts +19 -9
- package/server/lib/nemotron-final.ts +114 -9
- package/server/lib/nemotron-live.ts +58 -6
- package/server/lib/phone-recording.ts +114 -0
- package/server/lib/prompt-tail-guard.ts +1 -1
- package/server/lib/speaker-embeddings.ts +154 -28
- package/server/lib/speaker-name.ts +1 -1
- package/server/lib/stranded-session-actions.ts +7 -2
- package/server/lib/training-audio-provenance.ts +3 -2
- package/server/lib/transcript-cleanup-context.ts +3 -3
- package/server/lib/transcript-cleanup-model.ts +1 -1
- package/server/lib/transcript-cleanup-spans.ts +10 -10
- package/server/lib/transcript-cleanup-text.ts +4 -4
- package/server/lib/transcript-cleanup.ts +1 -1
- package/server/lib/unsaved-audio-quarantine.ts +10 -1
- package/server/lib/voice-directory.ts +1 -1
- package/server/lib/voice-evidence-guard.ts +2 -1
- package/server/lib/voice-profile-store.ts +11 -5
- package/server/lib/whisper-local.ts +1 -1
- package/server/routes/health.ts +8 -0
- package/server/routes/meeting.ts +189 -19
- package/server/routes/meetings.ts +9 -5
- package/server/routes/transcribe-stream.ts +140 -15
- package/server/routes/voice.ts +12 -4
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
- package/server/scripts/mac-window-voices-6930-mutation-gate.mjs +193 -0
package/server/routes/meeting.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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 (
|
|
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:
|
|
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 =
|
|
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
|
-
// "
|
|
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
|
-
/**
|
|
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 =>
|
|
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:
|
|
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:
|
|
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
|