@gotcos/glasses-server 6.94.4 → 6.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +52 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +19 -1
  6. package/server/lib/attached-provider-adapter.ts +35 -1
  7. package/server/lib/claude-bridge.ts +47 -0
  8. package/server/lib/codex-bridge.ts +11 -1
  9. package/server/lib/codex-rollout-watermark.ts +42 -0
  10. package/server/lib/cursor-bridge.ts +2 -1
  11. package/server/lib/dictation-clean-models.ts +2 -0
  12. package/server/lib/dictation-clean.ts +2 -2
  13. package/server/lib/dictation-polish-pipeline.ts +22 -2
  14. package/server/lib/dictation-prewarm.ts +16 -0
  15. package/server/lib/direct-bridge.ts +14 -2
  16. package/server/lib/direct-sse.ts +6 -1
  17. package/server/lib/display-bus.ts +1 -1
  18. package/server/lib/fork-thread.ts +32 -1
  19. package/server/lib/held-naming-batches.ts +36 -0
  20. package/server/lib/job-audit-caller.ts +20 -0
  21. package/server/lib/lens-gist-engines.ts +17 -5
  22. package/server/lib/live-cos-ask.ts +29 -6
  23. package/server/lib/live-cue-verdicts.ts +43 -8
  24. package/server/lib/live-cues-engine.ts +4 -1
  25. package/server/lib/live-cues-filter.ts +50 -0
  26. package/server/lib/live-cues-general-prompts.ts +137 -0
  27. package/server/lib/live-cues-general-topic.ts +245 -0
  28. package/server/lib/live-cues-general.ts +715 -0
  29. package/server/lib/live-cues-v2-data.ts +453 -0
  30. package/server/lib/live-cues-v2-grounded.ts +262 -0
  31. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  32. package/server/lib/live-cues-v2-names.ts +49 -0
  33. package/server/lib/live-cues-v2-notes.ts +8 -4
  34. package/server/lib/live-cues-v2-open-items.ts +132 -0
  35. package/server/lib/live-cues-v2-pacing.ts +45 -0
  36. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  37. package/server/lib/live-cues-v2-settings.ts +89 -10
  38. package/server/lib/live-cues-v2.ts +570 -47
  39. package/server/lib/mac-recording-asr.ts +6 -3
  40. package/server/lib/mac-speaker-call-human.ts +371 -0
  41. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  42. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  43. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  44. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  45. package/server/lib/mac-speaker-call-producer.ts +539 -0
  46. package/server/lib/mac-speaker-call-store.ts +264 -0
  47. package/server/lib/mac-speaker-call-view.ts +373 -0
  48. package/server/lib/mac-speaker-live-compute.ts +72 -0
  49. package/server/lib/mac-speaker-live-memory.ts +414 -0
  50. package/server/lib/mac-speaker-live-projection.ts +126 -0
  51. package/server/lib/mac-speaker-live-save.ts +90 -0
  52. package/server/lib/mac-speaker-live-store.ts +71 -0
  53. package/server/lib/mac-speaker-live-worker.ts +58 -0
  54. package/server/lib/mac-speaker-live.ts +175 -0
  55. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  56. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  57. package/server/lib/mac-voice-assignment.ts +44 -26
  58. package/server/lib/mac-voice-display.ts +195 -0
  59. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  60. package/server/lib/mac-voice-suggestions.ts +548 -0
  61. package/server/lib/maintenance-lifecycle.ts +2 -0
  62. package/server/lib/media-store.ts +2 -1
  63. package/server/lib/meeting-ask-pasted.ts +135 -0
  64. package/server/lib/meeting-ask-review.ts +8 -1
  65. package/server/lib/meeting-ask-runner.ts +4 -1
  66. package/server/lib/meeting-ask-runtime.ts +189 -17
  67. package/server/lib/meeting-ask-store.ts +2 -0
  68. package/server/lib/meeting-ask-web.ts +284 -13
  69. package/server/lib/meeting-batch-progress.ts +51 -5
  70. package/server/lib/meeting-batch-transcribe.ts +35 -2
  71. package/server/lib/meeting-fact-check.ts +3 -0
  72. package/server/lib/meeting-finalization-jobs.ts +69 -2
  73. package/server/lib/meeting-public-topic.ts +6 -1
  74. package/server/lib/meeting-speaker-review.ts +182 -17
  75. package/server/lib/meeting-store.ts +6 -1
  76. package/server/lib/parakeet.ts +51 -5
  77. package/server/lib/pending-batch-retention.ts +58 -0
  78. package/server/lib/private-reference.ts +38 -0
  79. package/server/lib/provider-usage.ts +561 -0
  80. package/server/lib/public-https-url.ts +32 -0
  81. package/server/lib/query-job-runtime.ts +3 -0
  82. package/server/lib/rich-media-safety.ts +23 -2
  83. package/server/lib/speaker-shadow-decide.ts +503 -0
  84. package/server/lib/speaker-shadow-models.ts +150 -0
  85. package/server/lib/speaker-shadow-numeric.ts +143 -0
  86. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  87. package/server/lib/speaker-shadow-paired.ts +268 -0
  88. package/server/lib/speaker-shadow-report.ts +113 -0
  89. package/server/lib/speaker-shadow-timeline.ts +210 -0
  90. package/server/lib/speaker-shadow-worker.ts +312 -0
  91. package/server/lib/speaker-shadow.ts +788 -0
  92. package/server/lib/token-audit.ts +36 -6
  93. package/server/lib/transcript-cleanup.ts +3 -2
  94. package/server/lib/voice-directory.ts +8 -4
  95. package/server/lib/web-lookup-ledger.ts +151 -0
  96. package/server/lib/work-review-runtime.ts +2 -0
  97. package/server/lib/work-search.ts +3 -1
  98. package/server/lib/xlsx-text.ts +277 -0
  99. package/server/routes/health.ts +5 -0
  100. package/server/routes/live-cues.ts +36 -14
  101. package/server/routes/meeting.ts +506 -49
  102. package/server/routes/speaker-shadow.ts +79 -0
  103. package/server/routes/transcribe-stream.ts +114 -33
  104. package/server/routes/voice.ts +3 -0
  105. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  106. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  107. package/shared/live-cues-v2.ts +38 -5
  108. package/shared/media-attachment.ts +2 -0
  109. package/shared/meeting-ask.ts +11 -0
@@ -0,0 +1,79 @@
1
+ // Speaker merge shadow mode (6.95.0). Token-protected by the /api middleware. READ ONLY.
2
+ //
3
+ // GET /api/speaker-shadow/proposals the logged proposal records, newest first
4
+ // ?limit=1..100 (default 10) ?sessionId=mac_<uuid>
5
+ // GET /api/speaker-shadow/status the switch, queue, daily cap, breaker, last error and
6
+ // setup readiness (models, CLI, sandbox)
7
+ // GET /api/speaker-shadow/settings { enabled, source } source: env | setting | default
8
+ // POST /api/speaker-shadow/settings {"enabled": true|false}: this Mac's saved switch (no
9
+ // restart); 409 env_forced when COS_SPEAKER_SHADOW pins it
10
+ //
11
+ // The settings POST is the only write, and it writes only <data>/speaker-shadow/settings.json.
12
+ // Nothing on these routes changes a meeting, a label, a name or a profile. A 404 means a
13
+ // server older than 6.95.0.
14
+
15
+ import { Router } from 'express'
16
+ import { getSpeakerShadowRunner, readSpeakerShadowRecords, resolveSpeakerShadowSwitch, speakerShadowEnvSwitch, writeSpeakerShadowSetting } from '../lib/speaker-shadow.js'
17
+ import { speakerShadowRoot } from '../lib/speaker-shadow-models.js'
18
+
19
+ export interface SpeakerShadowRouterDeps {
20
+ root?: () => string
21
+ env?: NodeJS.ProcessEnv
22
+ snapshot?: () => ReturnType<ReturnType<typeof getSpeakerShadowRunner>['snapshot']>
23
+ /** Called after the setting turns shadow mode on (the runner picks up anything still queued). */
24
+ onEnabled?: () => void
25
+ }
26
+
27
+ const SESSION_ID = /^mac_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
28
+
29
+ export function createSpeakerShadowRouter(deps: SpeakerShadowRouterDeps = {}): Router {
30
+ const router = Router()
31
+ const root = deps.root ?? speakerShadowRoot
32
+ const snapshot = deps.snapshot ?? (() => getSpeakerShadowRunner().snapshot({ detail: true }))
33
+ const env = deps.env ?? process.env
34
+ const onEnabled = deps.onEnabled ?? (() => getSpeakerShadowRunner().resume())
35
+
36
+ router.get('/speaker-shadow/proposals', async (req, res) => {
37
+ const rawLimit = Number(req.query.limit ?? 10)
38
+ if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 100) { res.status(400).json({ error: 'limit must be an integer from 1 to 100' }); return }
39
+ const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : undefined
40
+ if (sessionId !== undefined && !SESSION_ID.test(sessionId)) { res.status(400).json({ error: 'sessionId must be a mac_<uuid> session id' }); return }
41
+ try {
42
+ const records = await readSpeakerShadowRecords(root(), { limit: rawLimit, sessionId })
43
+ res.json({ logOnly: true, shadow: snapshot(), count: records.length, records })
44
+ } catch (error) {
45
+ res.status(500).json({ error: `could not read the shadow log: ${error instanceof Error ? error.message : String(error)}` })
46
+ }
47
+ })
48
+
49
+ router.get('/speaker-shadow/status', (_req, res) => {
50
+ res.json({ logOnly: true, ...snapshot() })
51
+ })
52
+
53
+ router.get('/speaker-shadow/settings', (_req, res) => {
54
+ res.set('Cache-Control', 'private, no-store')
55
+ res.json(resolveSpeakerShadowSwitch(env, root()))
56
+ })
57
+
58
+ router.post('/speaker-shadow/settings', (req, res) => {
59
+ res.set('Cache-Control', 'private, no-store')
60
+ const body = req.body as { enabled?: unknown } | undefined
61
+ if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.enabled !== 'boolean' || Object.keys(body).some(k => k !== 'enabled')) {
62
+ res.status(400).json({ error: 'invalid_request', expected: '{"enabled": true|false}' })
63
+ return
64
+ }
65
+ const pinned = speakerShadowEnvSwitch(env)
66
+ if (pinned !== null && pinned !== body.enabled) { res.status(409).json({ error: 'env_forced', ...resolveSpeakerShadowSwitch(env, root()) }); return }
67
+ try {
68
+ writeSpeakerShadowSetting(body.enabled, root())
69
+ } catch {
70
+ res.status(500).json({ error: 'settings_write_failed' })
71
+ return
72
+ }
73
+ const view = resolveSpeakerShadowSwitch(env, root())
74
+ if (view.enabled) { try { onEnabled() } catch { /* the next finished meeting starts it */ } }
75
+ res.json(view)
76
+ })
77
+
78
+ return router
79
+ }
@@ -1,3 +1,8 @@
1
+ import { takeMacSpeakerLivePublication } from '../lib/mac-speaker-live-compute.js'
2
+ import type { MacSpeakerLivePreparedPublication } from '../lib/mac-speaker-live-worker.js'
3
+ import { createMacSpeakerLiveScheduler, macSpeakerLiveEnabled, assertMacSpeakerLivePublicationBindings, type MacSpeakerLiveOutcome, type MacSpeakerLiveFileBinding } from '../lib/mac-speaker-live.js'
4
+ import { hashMacSpeakerLive, type MacSpeakerLiveState } from '../lib/mac-speaker-live-memory.js'
5
+ import { macSpeakerLiveOriginalOnly, macSpeakerLiveVoiceHighWater } from '../lib/mac-speaker-live-store.js'
1
6
  import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, assertPhoneProvenance, admitPhoneRecording, phoneQueryInteger, requirePhoneRecording, readPhoneRecording, validatePhoneWav, persistPhoneChunkReceipt } from '../lib/phone-recording.js'
2
7
  import { matchMacLiveVoice, macAnonymousProjection, type MacLiveVoiceState } from '../lib/mac-live-voices.js'
3
8
  import { chunkShowsVoiceLabel, macLiveVoicesMode } from '../lib/mac-voice-labels.js'
@@ -15,7 +20,7 @@ import { readSpeakerPolicy, policyLabel, type SpeakerMode } from '../lib/meeting
15
20
  // Streams speaker-labeled transcript chunks for live meeting capture.
16
21
 
17
22
  import { Router } from 'express'
18
- import { createHash } from 'node:crypto'
23
+ import { createHash, randomUUID } from 'node:crypto'
19
24
  import { chmodSync, readFileSync, writeFileSync, existsSync, lstatSync, mkdirSync, readdirSync, unlinkSync, rmSync, renameSync, statSync } from 'node:fs'
20
25
  import { writeFile } from 'node:fs/promises'
21
26
  import { resolve } from 'node:path'
@@ -86,6 +91,7 @@ import {
86
91
  type EvenSpeakerRoleHistogram,
87
92
  } from '../lib/even-hub-speaker-role.js'
88
93
  import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
94
+ import { sweepStalePendingBatch } from '../lib/pending-batch-retention.js'
89
95
  import {
90
96
  DISCARDED_RETENTION_MS,
91
97
  countChunkWavs,
@@ -339,6 +345,18 @@ export interface ProviderCandidateRecord {
339
345
  }
340
346
 
341
347
  interface TranscriptSession {
348
+ /** Capture-frozen opt-in; original ASR chunks remain immutable inputs. */
349
+ speakerIdentityPolicy?: 'mac-native-v1'
350
+ speakerGeneration?: string
351
+ speakerRevision?: number
352
+ speakerProjectionSHA256?: string
353
+ macSpeakerLive?: MacSpeakerLiveState
354
+ macSpeakerLiveQuarantineSHA256?: string
355
+ /** Ephemeral: recovered evidence cannot publish before asynchronous revalidation. */
356
+ macSpeakerLiveValidated?: boolean
357
+ macSpeakerLiveSourceBindingSHA256?: string
358
+ macSpeakerLiveFileBindings?: MacSpeakerLiveFileBinding[]
359
+ macSpeakerLiveStatus?: {reason:string;latencyMs:number;pendingWindows:number;completeEligibleSnapshot:boolean}
342
360
  chunks: TranscriptChunk[]
343
361
  /** Bounded anonymous acoustic anchors; active capture only, never training. */
344
362
  macLiveVoices?: MacLiveVoiceState
@@ -370,6 +388,44 @@ interface TranscriptSession {
370
388
  }
371
389
 
372
390
  const sessions = new Map<string, TranscriptSession>()
391
+ function liveIndexed(session:TranscriptSession){return session.chunks.flatMap((c,i)=>c?.text?[{i,c}]:[])}
392
+ function liveSnapshot(captureId:string){
393
+ const s=sessions.get(captureId)
394
+ if(!maintenanceAdmissionsOpen()||!macSpeakerLiveEnabled()||!s||s.speakerIdentityPolicy!=='mac-native-v1'||!s.speakerGeneration||isSessionDeleted(captureId)||macChunkLocks.has(captureId))return null
395
+ return{captureId,generation:s.speakerGeneration,audioDir:resolve(SESSION_AUDIO_DIR,captureId),ledgerPath:dataPath('meeting-corrections',`${captureId}.jsonl`),
396
+ chunksIndexed:structuredClone(liveIndexed(s)) as unknown as {i:number;c:Record<string,unknown>}[],state:s.macSpeakerLive?structuredClone(s.macSpeakerLive):undefined,
397
+ voiceHighWater:macSpeakerLiveVoiceHighWater(s.chunks,Math.max(s.macWindowVoices?.voices??0,Number.isSafeInteger(s.macSpeakerLive?.voiceHighWater)?s.macSpeakerLive!.voiceHighWater:0)),revision:s.speakerRevision??0,previousDigest:s.speakerProjectionSHA256}
398
+ }
399
+ function commitMacSpeakerLive(captureId:string,outcome:MacSpeakerLiveOutcome):boolean{
400
+ const s=sessions.get(captureId)
401
+ if(!s||s.speakerIdentityPolicy!=='mac-native-v1'||!s.speakerGeneration||isSessionDeleted(captureId)||macChunkLocks.has(captureId)||hashMacSpeakerLive(liveIndexed(s))!==outcome.sourceBindingSHA256)return false
402
+ if(outcome.state&&(outcome.state.captureId!==captureId||outcome.state.generation!==s.speakerGeneration))return false
403
+ try{if(outcome.completeEligibleSnapshot)assertMacSpeakerLivePublicationBindings(outcome.fileBindings)}catch{
404
+ s.macSpeakerLiveValidated=false;s.macSpeakerLiveStatus={reason:'publication_binding_changed',latencyMs:outcome.latencyMs,pendingWindows:0,completeEligibleSnapshot:false};persistSessionBestEffort(captureId);return false
405
+ }
406
+ const certified=takeMacSpeakerLivePublication(outcome.capability,{captureId,generation:s.speakerGeneration,sourceBindingSHA256:outcome.sourceBindingSHA256,fileBindingsSHA256:hashMacSpeakerLive(outcome.fileBindings),revision:s.speakerRevision??0,
407
+ voiceHighWater:macSpeakerLiveVoiceHighWater(s.chunks,Math.max(s.macWindowVoices?.voices??0,Number.isSafeInteger(s.macSpeakerLive?.voiceHighWater)?s.macSpeakerLive!.voiceHighWater:0)),mode:'mac-native-v1'})
408
+ if(outcome.completeEligibleSnapshot&&!certified)return false
409
+ const previous={source:s.macSpeakerLiveSourceBindingSHA256,files:s.macSpeakerLiveFileBindings,state:s.macSpeakerLive,validated:s.macSpeakerLiveValidated,status:s.macSpeakerLiveStatus,window:s.macWindowVoices?structuredClone(s.macWindowVoices):undefined,revision:s.speakerRevision,digest:s.speakerProjectionSHA256}
410
+ if(certified)s.macSpeakerLive=certified.macSpeakerLive??outcome.state
411
+ s.macSpeakerLiveSourceBindingSHA256=outcome.sourceBindingSHA256;s.macSpeakerLiveFileBindings=outcome.fileBindings
412
+ s.macSpeakerLiveValidated=maintenanceAdmissionsOpen()&&macSpeakerLiveEnabled()&&!!certified&&!!outcome.state&&outcome.completeEligibleSnapshot
413
+ s.macSpeakerLiveStatus={reason:outcome.reason,latencyMs:outcome.latencyMs,pendingWindows:outcome.pendingWindows,completeEligibleSnapshot:outcome.completeEligibleSnapshot}
414
+ if(s.macWindowVoices&&s.macSpeakerLive)s.macWindowVoices.voices=Math.max(s.macWindowVoices.voices,s.macSpeakerLive.voiceHighWater)
415
+ try{persistSessionRequired(captureId,certified??undefined)}catch{Object.assign(s,{macSpeakerLiveSourceBindingSHA256:previous.source,macSpeakerLiveFileBindings:previous.files,macSpeakerLive:previous.state,macSpeakerLiveValidated:previous.validated,macSpeakerLiveStatus:previous.status,macWindowVoices:previous.window,speakerRevision:previous.revision,speakerProjectionSHA256:previous.digest});return false}
416
+ // Dedicated invalidation cursor; consumers fetch the durable full replacement.
417
+ emitDisplay({type:'mac_speaker_live_revision',data:{sessionId:captureId,speakerIdentityPolicy:'mac-native-v1',generation:s.speakerGeneration,speakerRevision:s.speakerRevision,speakerProjectionSHA256:s.speakerProjectionSHA256}})
418
+ return true
419
+ }
420
+ const macSpeakerLiveScheduler=createMacSpeakerLiveScheduler({snapshot:liveSnapshot,commit:commitMacSpeakerLive,lease:()=>acquireMaintenanceWork('mac_speaker_live'),runtime:{admit:captureId=>maintenanceAdmissionsOpen()&&macSpeakerLiveEnabled()&&sessions.has(captureId)&&!isSessionDeleted(captureId)&&!macChunkLocks.has(captureId)}})
421
+ export const __macSpeakerLiveForTests={snapshot:liveSnapshot,commit:commitMacSpeakerLive,persist:(id:string)=>persistSessionRequired(id),drain:()=>macSpeakerLiveScheduler.drain()}
422
+ /** Save consumers receive only the durable, fully validated exact-source snapshot. */
423
+ export function getMacSpeakerLiveSaveSnapshot(captureId:string):{state:MacSpeakerLiveState;sourceBindingSHA256:string}|null{
424
+ const s=sessions.get(captureId);if(!macSpeakerLiveEnabled()||!s?.macSpeakerLiveValidated||!s.macSpeakerLive||s.macSpeakerLiveStatus?.completeEligibleSnapshot!==true||s.macSpeakerLiveSourceBindingSHA256!==hashMacSpeakerLive(liveIndexed(s)))return null
425
+ try{assertMacSpeakerLivePublicationBindings(s.macSpeakerLiveFileBindings??[])}catch{return null}
426
+ return{state:structuredClone(s.macSpeakerLive),sourceBindingSHA256:hashMacSpeakerLive(liveIndexed(s))}
427
+ }
428
+
373
429
  const CLOSED_SESSION_TTL_MS = LOCAL_FIRST_MEETING_IDLE_RETENTION_MS
374
430
  const CLOSED_SESSIONS_FILE = dataPath('closed-transcript-sessions.json')
375
431
 
@@ -704,7 +760,7 @@ function recoverClosedSessions(): void {
704
760
 
705
761
  /** Persist a session before acknowledging any chunk. Throws on failure so a
706
762
  * client never interprets a non-durable index as accepted. */
707
- function persistSessionRequired(sessionId: string): void {
763
+ function persistSessionRequired(sessionId: string, certified?:MacSpeakerLivePreparedPublication): void {
708
764
  const session = sessions.get(sessionId)
709
765
  if (!session) throw makeHttpError(404, 'session not found', 'session_not_found')
710
766
  const filePath = resolve(CHUNK_PERSIST_DIR, `${sessionId}.json`)
@@ -715,8 +771,23 @@ function persistSessionRequired(sessionId: string): void {
715
771
  const c = session.chunks[i]
716
772
  if (c && c.text) chunksIndexed.push({ i, c })
717
773
  }
774
+ if(session.macSpeakerLiveValidated){
775
+ let reason:string|undefined
776
+ if(!macSpeakerLiveEnabled())reason='feature_disabled'
777
+ else if(session.macSpeakerLiveSourceBindingSHA256!==hashMacSpeakerLive(chunksIndexed))reason='new_source_pending'
778
+ else try{assertMacSpeakerLivePublicationBindings(session.macSpeakerLiveFileBindings??[])}catch{reason='resource_binding_changed'}
779
+ if(reason){session.macSpeakerLiveValidated=false;session.macSpeakerLiveStatus={reason,latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}}
780
+ }
781
+ const live = session.speakerIdentityPolicy === 'mac-native-v1' && session.speakerGeneration
782
+ ? (session.macSpeakerLiveValidated?certified:undefined)??macSpeakerLiveOriginalOnly({captureId:sessionId,generation:session.speakerGeneration,chunksIndexed:chunksIndexed as unknown as {i:number;c:Record<string,unknown>}[],state:session.macSpeakerLive,revision:session.speakerRevision??0,previousDigest:session.speakerProjectionSHA256}) : null
783
+ const projectionWithdrawn=!!session.macSpeakerLive?.projections.length&&!live?.macSpeakerLive?.projections.length
784
+ const liveStatus=live?.macSpeakerLive?.withheldReason?{reason:live.macSpeakerLive.withheldReason,latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}
785
+ :projectionWithdrawn&&session.macSpeakerLiveValidated?{reason:'projection_binding_changed',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}
786
+ :!live?.macSpeakerLive?{reason:!macSpeakerLiveEnabled()?'feature_disabled':session.macSpeakerLiveStatus?.reason??'awaiting_qualified_evidence',latencyMs:session.macSpeakerLiveStatus?.latencyMs??0,pendingWindows:session.macSpeakerLiveStatus?.pendingWindows??0,completeEligibleSnapshot:false}
787
+ :session.macSpeakerLiveStatus??{reason:'awaiting_qualified_evidence',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false}
718
788
  const data = JSON.stringify({
719
789
  sessionId,
790
+ ...(live ? {speakerIdentityPolicy:live.speakerIdentityPolicy,sourceType:live.sourceType,speakerGeneration:live.speakerGeneration,speakerRevision:live.speakerRevision,speakerProjectionSHA256:live.speakerProjectionSHA256,speakerProjectedChunksIndexed:live.speakerProjectedChunksIndexed,...(live.macSpeakerLive?{macSpeakerLive:live.macSpeakerLive}:{}),...('macSpeakerLiveRetainedState'in live?{macSpeakerLiveRetainedState:live.macSpeakerLiveRetainedState}:{}),...(('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined)?{macSpeakerLiveQuarantineSHA256:('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined)}:{}),...(session.macSpeakerLiveQuarantineSHA256?{macSpeakerLiveQuarantineSHA256:session.macSpeakerLiveQuarantineSHA256}:{}),macSpeakerLiveStatus:liveStatus} : {}),
720
791
  ...(PHONE_CAPTURE_ID.test(sessionId) ? { captureSource: requirePhoneRecording(sessionId), sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous' } : {}),
721
792
  startTime: session.startTime,
722
793
  lastActivityAt: session.lastActivityAt,
@@ -736,7 +807,11 @@ function persistSessionRequired(sessionId: string): void {
736
807
  ...(session.macWindowVoices !== undefined ? { macWindowVoices: session.macWindowVoices } : {}),
737
808
  })
738
809
  try {
810
+ if(certified&&session.macSpeakerLiveValidated){try{assertMacSpeakerLivePublicationBindings(session.macSpeakerLiveFileBindings??[])}catch{session.macSpeakerLiveValidated=false;session.macSpeakerLiveStatus={reason:'resource_binding_changed',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false};return persistSessionRequired(sessionId)}}
739
811
  durableAtomicWriteFileSync(filePath, data, { mode: 0o600 })
812
+ if(live){session.macSpeakerLiveStatus=liveStatus;if(projectionWithdrawn)session.macSpeakerLiveValidated=false;session.macSpeakerLive=live.macSpeakerLive??('macSpeakerLiveRetainedState'in live?live.macSpeakerLiveRetainedState as MacSpeakerLiveState:undefined);session.speakerRevision=live.speakerRevision;session.speakerProjectionSHA256=live.speakerProjectionSHA256
813
+ if(('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined))session.macSpeakerLiveQuarantineSHA256=('macSpeakerLiveQuarantineSHA256'in live?live.macSpeakerLiveQuarantineSHA256:undefined)
814
+ if(live.invalid)session.macSpeakerLiveValidated=false}
740
815
  } catch (error) {
741
816
  console.error(`[transcribe-stream] Durable session write failed for ${sessionId}: ${errMsg(error)}`)
742
817
  throw makeHttpError(503, 'meeting session persistence unavailable', 'session_persistence_failed')
@@ -918,6 +993,12 @@ function recoverSessions(): void {
918
993
  for (const key of Object.keys(emptyCompletions)) insertSortedUnique(asrCompletedIndices, Number(key))
919
994
  const session: TranscriptSession = {
920
995
  chunks,
996
+ ...(MAC_CAPTURE_ID.test(data.sessionId)&&data.speakerIdentityPolicy==='mac-native-v1'?{
997
+ speakerIdentityPolicy:'mac-native-v1' as const,speakerGeneration:typeof data.speakerGeneration==='string'&&data.speakerGeneration.length>0&&data.speakerGeneration.length<=128?data.speakerGeneration:randomUUID(),
998
+ speakerRevision:typeof data.speakerRevision==='number'?data.speakerRevision:Number.NaN,
999
+ speakerProjectionSHA256:data.speakerProjectionSHA256,macSpeakerLive:data.macSpeakerLiveRetainedState??data.macSpeakerLive,macSpeakerLiveValidated:false,
1000
+ macSpeakerLiveStatus:{reason:'recovery_revalidation_pending',latencyMs:0,pendingWindows:0,completeEligibleSnapshot:false},
1001
+ }:{}),
921
1002
  startTime: data.startTime,
922
1003
  title: data.title || '',
923
1004
  lastActivityAt,
@@ -957,6 +1038,20 @@ function recoverSessions(): void {
957
1038
  }
958
1039
  sessions.set(data.sessionId, session)
959
1040
  recoveredIds.add(data.sessionId)
1041
+ if(session.speakerIdentityPolicy==='mac-native-v1'){
1042
+ // Clear previously exposed claims durably before asynchronous model,
1043
+ // profile, ledger and every retained raw binding are revalidated.
1044
+ try{persistSessionRequired(data.sessionId)}catch(error){
1045
+ sessions.delete(data.sessionId);recoveredIds.delete(data.sessionId);rememberDeletedSession(data.sessionId)
1046
+ // Preserve evidence outside the directory read as active claims.
1047
+ // Do not adopt a capture whose mandatory withdrawal was not durable.
1048
+ ensurePrivateDirectory(STALE_ACTIVE_SESSION_DIR)
1049
+ renameSync(resolve(CHUNK_PERSIST_DIR,file),resolve(STALE_ACTIVE_SESSION_DIR,`${data.sessionId}.speaker-revalidation-${Date.now()}.json`))
1050
+ console.error(`[session-recovery] Speaker revalidation clear failed; capture quarantined: ${data.sessionId}`)
1051
+ continue
1052
+ }
1053
+ if(macSpeakerLiveEnabled())setImmediate(()=>macSpeakerLiveScheduler.schedule(data.sessionId))
1054
+ }
960
1055
  if (cleaned > 0) {
961
1056
  console.log(`[session-recovery] Cleaned inline hallucinations from ${cleaned} chunks`)
962
1057
  persistSessionBestEffort(data.sessionId) // re-persist cleaned data to disk
@@ -1269,30 +1364,7 @@ setInterval(() => {
1269
1364
  try {
1270
1365
  const batchBusy = ((maintenanceLifecycle.snapshot().activeByKind as Record<string, number>)
1271
1366
  .meeting_batch_finalization ?? 0) > 0
1272
- for (const dir of readdirSync(PENDING_BATCH_DIR)) {
1273
- const dirPath = resolve(PENDING_BATCH_DIR, dir)
1274
- try {
1275
- const files = readdirSync(dirPath)
1276
- if (files.length === 0) { rmSync(dirPath, { recursive: true, force: true }); continue }
1277
- // Prefer the marker file mtime over first chunk mtime
1278
- const markerPath = resolve(dirPath, '_batch_pending.marker')
1279
- let ageSource: number
1280
- if (existsSync(markerPath)) {
1281
- ageSource = statSync(markerPath).mtimeMs
1282
- } else {
1283
- // Fallback for pre-v5.4.3 dirs: use directory ctime (changes on rename)
1284
- ageSource = statSync(dirPath).ctimeMs
1285
- }
1286
- if (Date.now() - ageSource > 12 * 60 * 60 * 1000) {
1287
- if (batchBusy) {
1288
- console.warn(`[cleanup] Retaining stale pending-batch while batch lease held: ${dir}`)
1289
- continue
1290
- }
1291
- rmSync(dirPath, { recursive: true, force: true })
1292
- console.log(`[cleanup] Purged stale pending-batch: ${dir}`)
1293
- }
1294
- } catch {}
1295
- }
1367
+ sweepStalePendingBatch(PENDING_BATCH_DIR, { now: Date.now(), batchBusy })
1296
1368
  } catch {}
1297
1369
 
1298
1370
  // Meeting audio for review: 7-day window, then an 8 GB budget as a backstop.
@@ -1385,13 +1457,19 @@ setInterval(() => {
1385
1457
  }, 60_000)
1386
1458
 
1387
1459
  /** Get or create a transcript session */
1388
- export function getSession(sessionId: string): TranscriptSession {
1460
+ export function getSession(sessionId: string, admission: {newMacRegistry?:boolean} = {}): TranscriptSession {
1389
1461
  const phone = sessionId.startsWith('phone_') ? requirePhoneRecording(sessionId) : null
1390
1462
  let session = sessions.get(sessionId)
1391
1463
  if (!session) {
1464
+ // The durable registry proves this capture was already admitted. A missing
1465
+ // or unreadable active snapshot is not permission to reset its generation,
1466
+ // acoustic evidence or Voice namespace. Only the initial registry creator
1467
+ // may create a new Mac session; replayed starts must recover existing state.
1468
+ if(MAC_CAPTURE_ID.test(sessionId)&&readMacRecording(sessionId)&&!admission.newMacRegistry)throw macError(409,'mac_speaker_state_missing','Capture state is unavailable; preserve this recording and start a new capture')
1392
1469
  const now = Date.now()
1393
1470
  session = {
1394
1471
  chunks: [],
1472
+ ...(MAC_CAPTURE_ID.test(sessionId)&&macSpeakerLiveEnabled()?{speakerIdentityPolicy:'mac-native-v1' as const,speakerGeneration:randomUUID(),speakerRevision:0,macSpeakerLiveValidated:false}:{}),
1395
1473
  startTime: phone?.startTime ?? now,
1396
1474
  lastActivityAt: now,
1397
1475
  title: '',
@@ -2478,6 +2556,7 @@ async function processStreamChunk(opts: {
2478
2556
  const macWindowWithheld = macWindowOn ? macWindowWithheldReason(session.macWindowVoices, session.chunks) : null
2479
2557
  if (macWindowWithheld) noteMacWindowWithheld(sessionId, macWindowWithheld)
2480
2558
  const macWindowState = macWindowOn ? macWindowStateFor(session.macWindowVoices, session.chunks) : null
2559
+ if(macWindowState&&session.speakerIdentityPolicy==='mac-native-v1')macWindowState.voices=Math.max(macWindowState.voices,session.macSpeakerLive?.voiceHighWater??0)
2481
2560
  const macWindow = macWindowState
2482
2561
  ? startMacWindowDiarize({
2483
2562
  sessionId,
@@ -2510,7 +2589,8 @@ async function processStreamChunk(opts: {
2510
2589
  const whisperContext = recentWhisperContext(sessionId, session)
2511
2590
  console.log(`[perf] whisperContext: ${whisperContext.length}b | session.chunks: ${session.chunks.length}`)
2512
2591
 
2513
- const speakerPromise = Promise.resolve(identifyChunkSpeaker(audioBuffer, sessionId, chunkIndex, clientSpeaker, capturePolicy))
2592
+ const nativeIdentity = session.speakerIdentityPolicy === 'mac-native-v1'
2593
+ const speakerPromise = Promise.resolve(nativeIdentity ? {speaker:'Ext',similarity:0} : identifyChunkSpeaker(audioBuffer, sessionId, chunkIndex, clientSpeaker, capturePolicy))
2514
2594
 
2515
2595
  let rawText = ''
2516
2596
  let words: WhisperWord[] | undefined
@@ -2603,7 +2683,7 @@ async function processStreamChunk(opts: {
2603
2683
  owner: ownerLabelOrNull(),
2604
2684
  ...(macWindow ? {
2605
2685
  anonymousChannel: (channel: number) => macWindow.result()?.labelOf(channel) ?? null,
2606
- } : MAC_CAPTURE_ID.test(sessionId) && macLiveVoicesMode() === 'anchors' ? {
2686
+ } : !nativeIdentity && MAC_CAPTURE_ID.test(sessionId) && macLiveVoicesMode() === 'anchors' ? {
2607
2687
  anonymousVoice: (wav: Buffer) => {
2608
2688
  // Fail closed after an upgrade/recovery lacking acoustic anchors:
2609
2689
  // never reuse Voice 1 for an unrelated person from a later chunk.
@@ -2614,7 +2694,7 @@ async function processStreamChunk(opts: {
2614
2694
  } : {}),
2615
2695
  identify: wav => {
2616
2696
  const policy = capturePolicy
2617
- if (policy === 'anonymous') return { speaker: 'Ext', similarity: 0 }
2697
+ if (nativeIdentity || policy === 'anonymous') return { speaker: 'Ext', similarity: 0 }
2618
2698
  // 6.90.0: a Mac track is never checked against the wearer first (the wearer
2619
2699
  // is not in Mac playback) and is tagged so its calibration rows are separable.
2620
2700
  const mac = MAC_CAPTURE_ID.test(sessionId)
@@ -2715,6 +2795,7 @@ async function processStreamChunk(opts: {
2715
2795
  console.log(`[perf] persistSession: ${(performance.now() - tPersist).toFixed(1)}ms (${session.chunks.filter(c => c).length} chunks)`)
2716
2796
 
2717
2797
  scheduleSessionProgressiveHq(sessionId)
2798
+ if(nativeIdentity)macSpeakerLiveScheduler.schedule(sessionId)
2718
2799
 
2719
2800
  emitDisplay({
2720
2801
  type: 'transcript_chunk',
@@ -2725,7 +2806,7 @@ async function processStreamChunk(opts: {
2725
2806
  // bare `void` on a rejecting promise is an unhandled rejection, which Node
2726
2807
  // throws on by default. All gates live inside the feed.
2727
2808
  // The ASR await above lets a late chunk land after Stop or save; a closed session never feeds cues.
2728
- if (!isSessionDeleted(sessionId)) void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason)).catch(() => {})
2809
+ if (!isSessionDeleted(sessionId)) void feedLiveCueTranscript(sessionId, trimmedText, Boolean(fallbackReason), [speaker, ...(chunk.segments ?? []).map(segment => segment.speaker)]).catch(() => {})
2729
2810
 
2730
2811
  // 6.85.0: timing and the character count only. This line runs for every chunk, so the meeting
2731
2812
  // text itself never goes to the server log (transcribe-stream-log-privacy.test.ts).
@@ -2884,7 +2965,7 @@ transcribeStreamRouter.post('/mac-recording/start', (req, res) => {
2884
2965
  if (sessions.has(req.body.captureId) && !readMacRecording(req.body.captureId)) throw macError(409, 'mac_capture_conflict', 'Capture ID already exists')
2885
2966
  lease = acquireRecordingChunkLease({ allowDuringDrain: sessions.has(req.body.captureId) })
2886
2967
  const { recording, replayed } = registerMacRecording(req.body)
2887
- const session = getSession(recording.sessionId)
2968
+ const session = getSession(recording.sessionId,{newMacRegistry:!replayed})
2888
2969
  if (!session.chunks.some(Boolean)) session.startTime = recording.startTime
2889
2970
  persistSessionRequired(recording.sessionId)
2890
2971
  // Arming happens only after the recording is admitted, and its answer never fails the start (armMacLiveCues returns a
@@ -2920,7 +3001,7 @@ transcribeStreamRouter.post('/mac-recording/:captureId/chunks', async (req, res)
2920
3001
  const prior = session.chunks[chunkIndex] ?? session.emptyCompletions?.[String(chunkIndex)]
2921
3002
  if (prior?.audioSha256 && prior.audioSha256 !== sha256Hex(audio)) throw macError(409, 'mac_chunk_conflict', 'Chunk index already holds different audio')
2922
3003
  res.json(await processStreamChunk({ sessionId: id, chunkIndex, clientSpeaker: 'Ext', audioBuffer: audio, clientElapsed: elapsed - duration, audioDurationMs: duration, startTimeOverride: recording.startTime }))
2923
- } catch (error) { sendStreamError(res, error) } finally { if (locked) macChunkLocks.delete(id); lease?.release() }
3004
+ } catch (error) { sendStreamError(res, error) } finally { if (locked) {macChunkLocks.delete(id);if(sessions.get(id)?.speakerIdentityPolicy==='mac-native-v1')macSpeakerLiveScheduler.schedule(id)}; lease?.release() }
2924
3005
  })
2925
3006
 
2926
3007
  const phoneChunkLocks = new Set<string>()
@@ -2,6 +2,7 @@
2
2
 
3
3
  import express, { Router } from 'express'
4
4
  import { errMsg } from '../lib/utils.js'
5
+ import { forgetMacVoiceNamingPerson } from '../lib/mac-voice-naming-batch.js'
5
6
  import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
6
7
  import { resolve } from 'node:path'
7
8
  import { checkSpeakerName } from '../lib/speaker-name.js'
@@ -959,6 +960,8 @@ voiceRouter.post('/voice/delete-person', (req, res) => {
959
960
 
960
961
  // 1. Voice profile + sherpa manager registration.
961
962
  const profileResult = removeSpeakerProfile(target)
963
+ // A deleted person's voiceprints must not survive in a naming batch's Undo journal.
964
+ forgetMacVoiceNamingPerson(target)
962
965
 
963
966
  // 2. Saved G2 training audio for this person.
964
967
  let trainingAudioFiles = 0