@gotcos/glasses-server 6.95.0 → 6.98.1

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 (123) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +4 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +12 -0
  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/decision-provider.ts +4 -2
  12. package/server/lib/dictation-clean-models.ts +2 -0
  13. package/server/lib/dictation-clean.ts +2 -2
  14. package/server/lib/dictation-polish-pipeline.ts +22 -2
  15. package/server/lib/dictation-prewarm.ts +16 -0
  16. package/server/lib/direct-bridge.ts +14 -2
  17. package/server/lib/direct-sse.ts +6 -1
  18. package/server/lib/display-bus.ts +1 -1
  19. package/server/lib/fork-thread.ts +32 -1
  20. package/server/lib/held-naming-batches.ts +44 -7
  21. package/server/lib/held-voice-groups.ts +5 -4
  22. package/server/lib/job-audit-caller.ts +20 -0
  23. package/server/lib/keyed-sessions.ts +21 -1
  24. package/server/lib/lens-gist-engines.ts +17 -5
  25. package/server/lib/live-cos-ask.ts +29 -6
  26. package/server/lib/live-cue-verdicts.ts +43 -8
  27. package/server/lib/live-cues-engine.ts +4 -1
  28. package/server/lib/live-cues-filter.ts +50 -0
  29. package/server/lib/live-cues-general-prompts.ts +137 -0
  30. package/server/lib/live-cues-general-topic.ts +245 -0
  31. package/server/lib/live-cues-general.ts +715 -0
  32. package/server/lib/live-cues-v2-data.ts +763 -0
  33. package/server/lib/live-cues-v2-grounded.ts +262 -0
  34. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  35. package/server/lib/live-cues-v2-names.ts +49 -0
  36. package/server/lib/live-cues-v2-notes.ts +8 -4
  37. package/server/lib/live-cues-v2-open-items.ts +132 -0
  38. package/server/lib/live-cues-v2-pacing.ts +45 -0
  39. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  40. package/server/lib/live-cues-v2-settings.ts +89 -10
  41. package/server/lib/live-cues-v2.ts +572 -47
  42. package/server/lib/mac-recording-asr.ts +6 -3
  43. package/server/lib/mac-speaker-call-human.ts +371 -0
  44. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  45. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  46. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  47. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  48. package/server/lib/mac-speaker-call-producer.ts +539 -0
  49. package/server/lib/mac-speaker-call-store.ts +264 -0
  50. package/server/lib/mac-speaker-call-view.ts +373 -0
  51. package/server/lib/mac-speaker-live-compute.ts +72 -0
  52. package/server/lib/mac-speaker-live-memory.ts +414 -0
  53. package/server/lib/mac-speaker-live-projection.ts +126 -0
  54. package/server/lib/mac-speaker-live-save.ts +90 -0
  55. package/server/lib/mac-speaker-live-store.ts +71 -0
  56. package/server/lib/mac-speaker-live-worker.ts +58 -0
  57. package/server/lib/mac-speaker-live.ts +175 -0
  58. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  59. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  60. package/server/lib/mac-voice-assignment.ts +46 -27
  61. package/server/lib/mac-voice-display.ts +195 -0
  62. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  63. package/server/lib/mac-voice-suggestions.ts +548 -0
  64. package/server/lib/maintenance-lifecycle.ts +2 -0
  65. package/server/lib/media-store.ts +2 -1
  66. package/server/lib/meeting-ask-pasted.ts +135 -0
  67. package/server/lib/meeting-ask-review.ts +8 -1
  68. package/server/lib/meeting-ask-runner.ts +4 -1
  69. package/server/lib/meeting-ask-runtime.ts +189 -17
  70. package/server/lib/meeting-ask-store.ts +2 -0
  71. package/server/lib/meeting-ask-web.ts +284 -13
  72. package/server/lib/meeting-batch-progress.ts +51 -5
  73. package/server/lib/meeting-batch-transcribe.ts +35 -2
  74. package/server/lib/meeting-corrections.ts +2 -1
  75. package/server/lib/meeting-fact-check.ts +3 -0
  76. package/server/lib/meeting-finalization-jobs.ts +69 -2
  77. package/server/lib/meeting-public-topic.ts +6 -1
  78. package/server/lib/meeting-relabel-enrolment.ts +2 -1
  79. package/server/lib/meeting-relabel.ts +17 -10
  80. package/server/lib/meeting-speaker-review.ts +187 -21
  81. package/server/lib/meeting-store.ts +6 -1
  82. package/server/lib/parakeet.ts +51 -5
  83. package/server/lib/pending-batch-retention.ts +58 -0
  84. package/server/lib/private-reference.ts +38 -0
  85. package/server/lib/provider-usage.ts +561 -0
  86. package/server/lib/public-https-url.ts +32 -0
  87. package/server/lib/query-job-runtime.ts +3 -0
  88. package/server/lib/rich-media-safety.ts +23 -2
  89. package/server/lib/session-find-ask.ts +288 -0
  90. package/server/lib/session-find-collect.ts +715 -0
  91. package/server/lib/session-find-redact.ts +254 -0
  92. package/server/lib/session-find.ts +345 -0
  93. package/server/lib/speaker-calibration-log.ts +3 -2
  94. package/server/lib/speaker-embeddings.ts +46 -35
  95. package/server/lib/speaker-name-nfc.ts +37 -0
  96. package/server/lib/speaker-rename-fanout.ts +7 -6
  97. package/server/lib/speaker-shadow-decide.ts +59 -22
  98. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  99. package/server/lib/speaker-shadow-paired.ts +268 -0
  100. package/server/lib/speaker-shadow-report.ts +2 -2
  101. package/server/lib/speaker-shadow-worker.ts +50 -15
  102. package/server/lib/speaker-shadow.ts +80 -19
  103. package/server/lib/speaker-trainer.ts +2 -1
  104. package/server/lib/token-audit.ts +36 -6
  105. package/server/lib/transcript-cleanup.ts +3 -2
  106. package/server/lib/voice-directory.ts +10 -5
  107. package/server/lib/voice-profile-store.ts +24 -14
  108. package/server/lib/web-lookup-ledger.ts +151 -0
  109. package/server/lib/work-review-runtime.ts +2 -0
  110. package/server/lib/work-search.ts +3 -1
  111. package/server/lib/xlsx-text.ts +277 -0
  112. package/server/routes/decision-provider.ts +4 -1
  113. package/server/routes/jev.ts +5 -1
  114. package/server/routes/live-cues.ts +36 -14
  115. package/server/routes/meeting.ts +515 -61
  116. package/server/routes/session-find.ts +73 -0
  117. package/server/routes/transcribe-stream.ts +114 -33
  118. package/server/routes/voice.ts +28 -18
  119. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  120. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  121. package/shared/live-cues-v2.ts +38 -5
  122. package/shared/media-attachment.ts +2 -0
  123. package/shared/meeting-ask.ts +11 -0
@@ -46,7 +46,9 @@ import { Transform } from 'node:stream'
46
46
  import { fileURLToPath, pathToFileURL } from 'node:url'
47
47
  import { durableAtomicWriteFileSync } from './atomic-fs.js'
48
48
  import { MAC_CAPTURE_ID } from './mac-recording.js'
49
- import { maintenanceLifecycle } from './maintenance-lifecycle.js'
49
+ import { maintenanceLifecycle,acquireMaintenanceWork } from './maintenance-lifecycle.js'
50
+ import {configureMacSpeakerCallProducer,macSpeakerCallProducerEnabled,macSpeakerCallProducerBusy,macSpeakerCallProducerRequestValid,notifyMacSpeakerCallNativeExport,sampleMacSpeakerCallResources} from './mac-speaker-call-producer.js'
51
+ import type {MacSpeakerCallProducerRequest} from './mac-speaker-call-producer-worker.js'
50
52
  import { installedCliIntact, installedCliTrust, parakeetCliPath, parakeetInstalled, SANDBOX_EXEC } from './parakeet.js'
51
53
  import { SHADOW_GATES } from './speaker-shadow-decide.js'
52
54
  import { shadowModelsPresent, speakerShadowHome, speakerShadowModelsDir, speakerShadowRoot } from './speaker-shadow-models.js'
@@ -148,15 +150,16 @@ export interface SpeakerShadowRecord {
148
150
  status: SpeakerShadowStatus
149
151
  reason: string | null
150
152
  detail: string | null
151
- gates: { majority: number; minCoveredFraction: number; floorSeconds: number; outlier: string; anchorRatioPostHocColumnOnly: number }
153
+ gates: { majority: number; minCoveredFraction: number; floorSeconds: number; outlier: string; anchorRatioMax: number }
152
154
  facts?: ShadowRunFacts
155
+ paired?: Extract<ShadowJobOutcome, { status: 'proposed' }>['paired']
153
156
  proposals?: ShadowProposals
154
157
  resources?: { wallMs: number; peakRssBytes: number | null; cliTrust: string | null }
155
158
  /** The readable report, relative to <data>/speaker-shadow/ (reports/<day>/<title>__<session>.md). */
156
159
  report?: string | null
157
160
  }
158
161
 
159
- const GATES_RECORD = { majority: SHADOW_GATES.majority, minCoveredFraction: SHADOW_GATES.minCoveredFraction, floorSeconds: SHADOW_GATES.floorSeconds, outlier: 'leave-fragment-out cluster p95', anchorRatioPostHocColumnOnly: SHADOW_GATES.anchorRatioPostHoc }
162
+ const GATES_RECORD = { majority: SHADOW_GATES.majority, minCoveredFraction: SHADOW_GATES.minCoveredFraction, floorSeconds: SHADOW_GATES.floorSeconds, outlier: 'leave-fragment-out cluster p95', anchorRatioMax: SHADOW_GATES.anchorRatioMax }
160
163
 
161
164
  /** Append one record: the whole file is rewritten to a temp file, fsynced and renamed, so a reader never sees half a line. */
162
165
  export async function appendSpeakerShadowRecord(root: string, record: SpeakerShadowRecord): Promise<void> {
@@ -203,7 +206,7 @@ export async function readSpeakerShadowRecords(root: string, options: { limit?:
203
206
 
204
207
  // ── State ────────────────────────────────────────────────────────────────────
205
208
 
206
- interface QueueItem { sessionId: string; sidecarPath: string; labelsHash: string | null; enqueuedAt: number }
209
+ interface QueueItem { sessionId: string; sidecarPath: string; labelsHash: string | null; enqueuedAt: number;producerRequest?:MacSpeakerCallProducerRequest }
207
210
  interface ShadowState {
208
211
  schema: 1
209
212
  day: string
@@ -245,6 +248,7 @@ export interface SpeakerShadowDeps {
245
248
  spawnWorker: () => ChildProcess
246
249
  /** Resident bytes of the process group led by pid, or null when it cannot be read. */
247
250
  sampleRss: (pgid: number) => Promise<number | null>
251
+ producerResources:typeof sampleMacSpeakerCallResources
248
252
  killGroup: (pgid: number) => void
249
253
  log: (line: string) => void
250
254
  setTimer: (fn: () => void, ms: number) => { unref?: () => void }
@@ -338,6 +342,7 @@ export function defaultSpeakerShadowDeps(): SpeakerShadowDeps {
338
342
  tmpRoot: tmpdir(),
339
343
  spawnWorker: defaultSpawnWorker,
340
344
  sampleRss: defaultSampleRss,
345
+ producerResources:sampleMacSpeakerCallResources,
341
346
  killGroup: pgid => { try { process.kill(-pgid, 'SIGKILL') } catch { /* gone */ } },
342
347
  log: line => console.log(`[speaker-shadow] ${line}`),
343
348
  setTimer: (fn, ms) => setTimeout(fn, ms),
@@ -351,7 +356,7 @@ type Supervised = ShadowJobOutcome | { status: 'deferred'; reason: string } | {
351
356
 
352
357
  export class SpeakerShadowRunner {
353
358
  private readonly deps: SpeakerShadowDeps
354
- private running: { sessionId: string; pgid: number | null } | null = null
359
+ private running: { sessionId: string; pgid: number | null;producerRequest?:MacSpeakerCallProducerRequest } | null = null
355
360
  private pumping = false
356
361
  private timer: { unref?: () => void } | null = null
357
362
  private deferredReason: string | null = null
@@ -362,6 +367,20 @@ export class SpeakerShadowRunner {
362
367
  }
363
368
 
364
369
  enabled(): boolean { return speakerShadowEnabled(this.deps.env(), this.deps.root()) }
370
+ nativeEnabled():boolean{return this.enabled()||macSpeakerCallProducerEnabled(this.deps.env())}
371
+ nativeRunning():boolean{return this.running!==null}
372
+ async notifyConsumerFinalized(request:MacSpeakerCallProducerRequest):Promise<void> {
373
+ if(!macSpeakerCallProducerEnabled(this.deps.env())||!macSpeakerCallProducerRequestValid(request))throw Error('independent_consumer_request_invalid_or_disabled')
374
+ if(this.running?.sessionId===request.captureId){
375
+ const admitted=this.running.producerRequest
376
+ if(!admitted)throw Object.assign(Error('native_active_without_prenative_provenance'),{code:'MAC_CALL_NATIVE_PENDING'})
377
+ if(admitted.policySHA256!==request.policySHA256||admitted.root!==request.root)throw Object.assign(Error('native_running_other_policy'),{code:'MAC_CALL_NATIVE_UNAVAILABLE'})
378
+ // seedReady already durably registered this exact copy. The one
379
+ // admitted execution will fan out only fresh per-copy CAS publications.
380
+ return
381
+ }
382
+ await this.enqueue({sessionId:request.captureId,sidecarPath:request.sidecarPath},request)
383
+ }
365
384
 
366
385
  switchState(): { enabled: boolean; source: SpeakerShadowSwitchSource } { return resolveSpeakerShadowSwitch(this.deps.env(), this.deps.root()) }
367
386
 
@@ -403,13 +422,15 @@ export class SpeakerShadowRunner {
403
422
  }
404
423
  }
405
424
 
406
- private async enqueue(input: { sessionId: string; sidecarPath: string }): Promise<void> {
425
+ private async enqueue(input: { sessionId: string; sidecarPath: string },producerRequest?:MacSpeakerCallProducerRequest): Promise<void> {
407
426
  let labelsHash: string | null = null
408
427
  try { labelsHash = shadowLabelsHash(shadowEntries(JSON.parse(await readFile(input.sidecarPath, 'utf8')))) } catch { /* the worker reports the sidecar */ }
409
428
  // Read, change and write with no await in between: the pump also rewrites this file.
410
429
  const state = this.readState()
411
- if (state.done.includes(input.sessionId) || state.queue.some(q => q.sessionId === input.sessionId)) return
412
- state.queue.push({ sessionId: input.sessionId, sidecarPath: input.sidecarPath, labelsHash, enqueuedAt: this.deps.now() })
430
+ if (state.done.includes(input.sessionId)) {if(producerRequest)throw Object.assign(Error('native_already_completed_without_retained_export'),{code:'MAC_CALL_NATIVE_UNAVAILABLE'});return}
431
+ const existing=state.queue.find(q=>q.sessionId===input.sessionId)
432
+ if(existing){if(producerRequest){existing.producerRequest=producerRequest;this.writeState(state)}return}
433
+ state.queue.push({ sessionId: input.sessionId, sidecarPath: input.sidecarPath, labelsHash, enqueuedAt: this.deps.now(),...(producerRequest?{producerRequest}:{}) })
413
434
  const dropped = state.queue.splice(0, Math.max(0, state.queue.length - MAX_QUEUE))
414
435
  for (const item of dropped) state.done.push(item.sessionId)
415
436
  this.writeState(state)
@@ -421,7 +442,7 @@ export class SpeakerShadowRunner {
421
442
  /** Boot: pick up meetings a previous process queued but never ran. */
422
443
  resume(): void {
423
444
  try {
424
- if (!this.enabled() || !existsSync(this.statePath())) return
445
+ if (!this.nativeEnabled() || !existsSync(this.statePath())) return
425
446
  if (this.readState().queue.length) void this.pump()
426
447
  } catch (error) { this.deps.log(`resume failed: ${describe(error)}`) }
427
448
  }
@@ -472,14 +493,17 @@ export class SpeakerShadowRunner {
472
493
  this.pumping = true
473
494
  try {
474
495
  for (;;) {
475
- if (!this.enabled()) return
496
+ if (!this.nativeEnabled()) return
497
+ if(macSpeakerCallProducerBusy()){this.schedule(DEFER_RETRY_MS,'independent_consumer');return}
476
498
  const state = this.readState()
477
499
  const item = state.queue[0]
478
500
  if (!item) { this.deferredReason = null; return }
479
501
  const now = this.deps.now()
480
502
  const finish = async (status: SpeakerShadowStatus, reason: string | null, detail: string | null, extra: Partial<SpeakerShadowRecord> = {}, report?: { title: string; markdown: string }) => {
481
- const reportPath = await this.writeReport(item, status, reason, detail, report)
482
- await this.record(item, status, reason, detail, { ...extra, report: reportPath })
503
+ if(this.enabled()){
504
+ const reportPath = await this.writeReport(item, status, reason, detail, report)
505
+ await this.record(item, status, reason, detail, { ...extra, report: reportPath })
506
+ }
483
507
  const next = this.readState()
484
508
  next.queue = next.queue.filter(q => q.sessionId !== item.sessionId)
485
509
  next.done.push(item.sessionId)
@@ -511,9 +535,21 @@ export class SpeakerShadowRunner {
511
535
  if (!this.deps.modelsPresent(modelsDir)) { await finish('skipped', 'models_missing', 'the pinned speaker-diarization models are not on this Mac (the server never downloads them)'); continue }
512
536
  const audioDir = this.deps.audioDirFor(item.sessionId)
513
537
  if (!existsSync(audioDir)) { await finish('skipped', 'audio_missing', 'no retained chunk audio for this meeting'); continue }
538
+ let producerRequest=macSpeakerCallProducerEnabled(this.deps.env())?item.producerRequest:undefined
539
+ if(producerRequest&&(!macSpeakerCallProducerRequestValid(producerRequest)||producerRequest.captureId!==item.sessionId||producerRequest.sidecarPath!==item.sidecarPath)){await finish('skipped','bad_sidecar','untrusted independent consumer request');continue}
540
+ if(!this.enabled()&&!producerRequest)return
541
+ const resourceStart=producerRequest?await this.deps.producerResources().catch(()=>null):null
542
+ if(producerRequest&&(!resourceStart||resourceStart.availableBytes<16*1024**3)){this.schedule(DEFER_RETRY_MS,'producer_resource_admission');return}
543
+ if(this.deps.captureActive()||!this.deps.admissionsOpen()||!this.nativeEnabled())return
544
+ if(macSpeakerCallProducerBusy()){this.schedule(DEFER_RETRY_MS,'independent_consumer');return}
514
545
 
515
546
  // Fresh read: the awaits above let an enqueue rewrite the file since `state` was read.
516
547
  const counted = this.readState()
548
+ // A shared request may have joined while CLI/model checks awaited. Do
549
+ // not start a log-only execution that strands its admitted consumer.
550
+ const latest=counted.queue.find(q=>q.sessionId===item.sessionId)?.producerRequest
551
+ if(!producerRequest&&latest){this.schedule(0,'consumer_joined');return}
552
+ if(producerRequest&&JSON.stringify(latest)!==JSON.stringify(producerRequest)){this.schedule(0,'consumer_request_changed');return}
517
553
  counted.runsToday += 1
518
554
  this.writeState(counted)
519
555
  this.deferredReason = null
@@ -521,6 +557,7 @@ export class SpeakerShadowRunner {
521
557
  sessionId: item.sessionId, sidecarPath: item.sidecarPath, audioDir, expectedLabelsHash: item.labelsHash,
522
558
  cliPath: cli.path, cliSha256: cli.sha256, sandboxExec: this.deps.sandboxExec, modelsDir,
523
559
  homeDir: this.deps.homeDir(), tmpRoot: this.deps.tmpRoot, cliTimeoutMs: Math.max(5_000, limits.timeoutMs - 5_000),
560
+ ...(producerRequest?{callProducer:producerRequest,shadowLogRequested:this.enabled(),callProducerSwapStart:resourceStart!.swapBytes}:{}),
524
561
  }
525
562
  this.deps.log(`start ${item.sessionId}`)
526
563
  const started = this.deps.now()
@@ -536,9 +573,20 @@ export class SpeakerShadowRunner {
536
573
  return
537
574
  }
538
575
  if (outcome.status === 'proposed') {
576
+ if (producerRequest && !outcome.callProducerExport) {
577
+ // Native ran once: preserve terminal done/history, never re-infer to
578
+ // manufacture missing pre-native provenance. Expose the consumer
579
+ // failure even when legacy shadow logging is disabled.
580
+ const detail = outcome.callProducerFailure
581
+ ? `${outcome.callProducerFailure.stage}: ${outcome.callProducerFailure.reason}`
582
+ : 'admitted native completed without a retained export'
583
+ await finish('failed', 'consumer_export_failed', detail.slice(0, 300), { facts: outcome.facts, proposals: outcome.proposals, paired: outcome.paired, resources }, outcome.report)
584
+ continue
585
+ }
586
+ if(outcome.callProducerExport&&macSpeakerCallProducerEnabled(this.deps.env()))notifyMacSpeakerCallNativeExport(outcome.callProducerExport)
539
587
  const f = outcome.facts
540
588
  const markdown = `${outcome.report.markdown}\n${renderShadowRunFacts({ rebuildMs: f.timing.rebuildMs, nativeMs: f.timing.nativeMs, wallMs: resources.wallMs, peakRssBytes: resources.peakRssBytes, osTempDirsLeft: f.osTempDirsLeft, sidecarPath: item.sidecarPath, pcmSHA256: f.timelinePCMSHA256, cliTrust: cli.trust })}`
541
- await finish('proposed', null, null, { facts: outcome.facts, proposals: outcome.proposals, resources }, { title: outcome.report.title, markdown })
589
+ await finish('proposed', null, null, { facts: outcome.facts, proposals: outcome.proposals, paired: outcome.paired, resources }, { title: outcome.report.title, markdown })
542
590
  }
543
591
  else await finish(outcome.status, outcome.reason, outcome.detail, { resources })
544
592
  }
@@ -551,28 +599,38 @@ export class SpeakerShadowRunner {
551
599
 
552
600
  private supervise(job: ShadowJob, limits: SpeakerShadowLimits): Promise<{ outcome: Supervised; peakRss: number | null }> {
553
601
  return new Promise(resolvePromise => {
602
+ let producerLease:ReturnType<typeof acquireMaintenanceWork>|null=null
554
603
  let child: ChildProcess
555
- try { child = this.deps.spawnWorker() } catch (error) {
604
+ try {if(job.callProducer)producerLease=acquireMaintenanceWork('mac_speaker_call');child = this.deps.spawnWorker() } catch (error) {
605
+ producerLease?.release()
556
606
  resolvePromise({ outcome: { status: 'failed', reason: 'worker_spawn_failed', detail: describe(error) }, peakRss: null })
557
607
  return
558
608
  }
559
609
  const pgid = child.pid ?? null
560
- this.running = { sessionId: job.sessionId, pgid }
610
+ this.running = { sessionId: job.sessionId, pgid,...(job.callProducer?{producerRequest:job.callProducer}:{}) }
561
611
  this.installExitHook()
562
612
  if (pgid) { try { setPriority(pgid, 10) } catch { /* runs at normal priority */ } }
563
613
  const started = this.deps.now()
564
614
  let stdout = '', stderr = '', verdict: Supervised | null = null, peak: number | null = null, sampling = false, settled = false
615
+ let swapStart:number|null=job.callProducerSwapStart??null
565
616
  const stop = (outcome: Supervised) => { if (!verdict) verdict = outcome; if (pgid) this.deps.killGroup(pgid) }
566
617
  const tick = setInterval(() => {
567
618
  if (verdict) return
568
619
  if (this.deps.now() - started > limits.timeoutMs) { stop({ status: 'failed', reason: 'timeout', detail: `no result within ${limits.timeoutMs} ms` }); return }
569
- if (!this.enabled()) { stop({ status: 'deferred', reason: 'switched_off' }); return }
620
+ if (!this.nativeEnabled()||job.callProducer&&!macSpeakerCallProducerEnabled(this.deps.env())) { stop({ status: 'deferred', reason: 'switched_off' }); return }
570
621
  if (this.deps.captureActive?.() || !this.deps.admissionsOpen()) { stop({ status: 'deferred', reason: 'capture_live' }); return }
571
622
  if (!pgid || sampling) return
572
623
  sampling = true
624
+ if(job.callProducer)void this.deps.producerResources().catch(()=>null).then(r=>{
625
+ if(settled||verdict)return
626
+ if(!r||r.availableBytes<8*1024**3){stop({status:'deferred',reason:'producer_resource_reserve'});return}
627
+ swapStart??=r.swapBytes
628
+ if(r.swapBytes-swapStart>256*1024**2)stop({status:'deferred',reason:'producer_swap_growth'})
629
+ })
573
630
  void this.deps.sampleRss(pgid).then(bytes => {
574
631
  sampling = false
575
- if (bytes === null || verdict) return
632
+ if (verdict||settled)return
633
+ if(bytes===null){if(job.callProducer)stop({status:'deferred',reason:'producer_rss_unavailable'});return}
576
634
  peak = Math.max(peak ?? 0, bytes)
577
635
  if (bytes > limits.rssBytes) stop({ status: 'failed', reason: 'rss_cap', detail: `the run used ${Math.round(bytes / 1048576)} MB, over the ${Math.round(limits.rssBytes / 1048576)} MB cap` })
578
636
  })
@@ -583,6 +641,8 @@ export class SpeakerShadowRunner {
583
641
  settled = true
584
642
  clearInterval(tick)
585
643
  this.running = null
644
+ producerLease?.release()
645
+ if(!verdict&&this.deps.now()-started>limits.timeoutMs)verdict={status:'failed',reason:'timeout',detail:'worker close exceeded absolute wall budget'}
586
646
  if (verdict) { resolvePromise({ outcome: verdict, peakRss: peak }); return }
587
647
  const line = stdout.trim().split('\n').pop() ?? ''
588
648
  try {
@@ -593,7 +653,7 @@ export class SpeakerShadowRunner {
593
653
  }
594
654
  child.stdout?.on('data', (d: Buffer) => { if (stdout.length < MAX_STDOUT) stdout += d.toString(); else stop({ status: 'failed', reason: 'worker_crashed', detail: 'worker output over 8 MB' }) })
595
655
  child.stderr?.on('data', (d: Buffer) => { if (stderr.length < 16_384) stderr += d.toString() })
596
- child.on('error', error => { stop({ status: 'failed', reason: 'worker_spawn_failed', detail: error.message }); done(null, null) })
656
+ child.on('error', error => { stop({ status: 'failed', reason: 'worker_spawn_failed', detail: error.message }); if(!child.pid)done(null, null) })
597
657
  child.on('close', done)
598
658
  child.stdin?.on('error', () => { /* a crashed worker closes stdin; close reports it */ })
599
659
  child.stdin?.end(JSON.stringify(job))
@@ -692,7 +752,8 @@ export function configureSpeakerShadow(probe: { captureActive: () => boolean; ad
692
752
  try {
693
753
  const runner = getSpeakerShadowRunner()
694
754
  runner.setCaptureProbe(probe.captureActive, probe.admissionsOpen)
695
- if (!runner.enabled()) return
755
+ configureMacSpeakerCallProducer({captureActive:()=>probe.captureActive()||runner.nativeRunning(),admissionsOpen:probe.admissionsOpen,requestNative:r=>runner.notifyConsumerFinalized(r)})
756
+ if (!runner.nativeEnabled()) return
696
757
  const swept = sweepStaleSpeakerShadowTmp()
697
758
  if (swept) console.log(`[speaker-shadow] removed ${swept} stale temp dir(s)`)
698
759
  console.log('[speaker-shadow] enabled (log only)')
@@ -13,6 +13,7 @@ import {
13
13
  rawCosineSimilarity, saveProfileStore, rebuildAllProfiles, clearSpeakerEmbeddings,
14
14
  } from './speaker-embeddings.js'
15
15
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
16
+ import { sameSpeakerName } from './speaker-name-nfc.js'
16
17
 
17
18
  const CACHE_DIR = '/tmp/cos-speaker-training'
18
19
  const PROGRESS_PATH = resolve(CACHE_DIR, 'training-progress.json')
@@ -520,7 +521,7 @@ export async function getTrainingStatus(): Promise<TrainingStatus> {
520
521
  }
521
522
 
522
523
  for (const name of enrolledNames) {
523
- if (!speakers.some(s => s.name === name)) {
524
+ if (!speakers.some(s => sameSpeakerName(s.name, name))) {
524
525
  speakers.push({ name, segments: 0, meetings: 0, enrolled: true })
525
526
  }
526
527
  }
@@ -8,12 +8,17 @@ import { homedir } from 'node:os'
8
8
 
9
9
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
10
10
  import type { TokenAuditKeyFields } from './key-relay-contract.js'
11
+ import { cleanProviderId, normalizeRealUsage, type RealUsage } from './provider-usage.js'
11
12
 
12
13
  // Shared JSONL file — same path as Python's llm_client.py uses
13
14
  // Uses COS_SCRIPTS_DIR when available (server mode), falls back to user home
14
- const AUDIT_FILE = COS_SCRIPTS_DIR
15
- ? resolve(COS_SCRIPTS_DIR, '.cos_token_audit.jsonl')
16
- : resolve(homedir(), '.cos-glasses', 'token-audit.jsonl')
15
+ // 6.97.0: COS_TOKEN_AUDIT_FILE is set only by the test config, so no test writes a made-up row to a
16
+ // real ledger now that a row can claim to be measured.
17
+ const AUDIT_FILE = process.env.COS_TOKEN_AUDIT_FILE
18
+ ? resolve(process.env.COS_TOKEN_AUDIT_FILE)
19
+ : COS_SCRIPTS_DIR
20
+ ? resolve(COS_SCRIPTS_DIR, '.cos_token_audit.jsonl')
21
+ : resolve(homedir(), '.cos-glasses', 'token-audit.jsonl')
17
22
 
18
23
  // Rough char-to-token ratio (1 token ~ 4 chars for English)
19
24
  const CHARS_PER_TOKEN = 4
@@ -33,7 +38,12 @@ export interface TokenAuditEntry extends TokenAuditKeyFields {
33
38
  runId?: string
34
39
  sessionId?: string
35
40
  clientJobId?: string
36
- usageKind?: 'provider_final' | 'estimated'
41
+ /** The provider's own session id: Claude's session, Codex's thread, Cursor's chat. `sessionId` is COS's. */
42
+ cliSessionId?: string
43
+ /** 6.97.0: what the provider reported. The row says `provider_final` only when these are real numbers. */
44
+ realUsage?: RealUsage
45
+ /** 6.97.0, dictation polish only: what the model is sent, system prompt included. `inputChars` stays the words dictated. */
46
+ promptChars?: number
37
47
  /** 6.85.0, dictation polish only: whether the user's polish instructions were in the prompt, and their hash. */
38
48
  polishInstructions?: { on: boolean; hash: string }
39
49
  /** 6.91.0, dictation polish only: whether the learned style was in the prompt, and its hash. */
@@ -56,6 +66,8 @@ export interface TokenAuditEntry extends TokenAuditKeyFields {
56
66
  * (subscription) row is byte-identical to 6.79.0. Callers set them only on keyed turns.
57
67
  */
58
68
  export function buildTokenAuditRecord(entry: TokenAuditEntry, now: Date = new Date()): Record<string, unknown> {
69
+ const real = normalizeRealUsage(entry.realUsage)
70
+ const cliSessionId = cleanProviderId(entry.cliSessionId)
59
71
  return {
60
72
  ts: now.toISOString(),
61
73
  source: entry.source,
@@ -66,7 +78,7 @@ export function buildTokenAuditRecord(entry: TokenAuditEntry, now: Date = new Da
66
78
  est_output_tokens: estimateTokens(entry.outputChars),
67
79
  duration_ms: entry.durationMs,
68
80
  caller: entry.caller,
69
- usage_kind: entry.usageKind ?? 'estimated',
81
+ usage_kind: real ? 'provider_final' : 'estimated',
70
82
  ...(entry.turnId ? { turn_id: entry.turnId } : {}),
71
83
  ...(entry.runId ? { run_id: entry.runId } : {}),
72
84
  ...(entry.sessionId ? { session_id: entry.sessionId } : {}),
@@ -91,12 +103,30 @@ export function buildTokenAuditRecord(entry: TokenAuditEntry, now: Date = new Da
91
103
  ...(entry.polishGuard ? { polish_guard: entry.polishGuard } : {}),
92
104
  // 6.92.0: set only by the prompt-edit caller.
93
105
  ...(entry.promptEditOutcome ? { prompt_edit_outcome: entry.promptEditOutcome } : {}),
106
+ // 6.97.0: last, and only when set, so a row without them is byte-identical to 6.96.4. Numbers and
107
+ // ids only. COS Control reads these names. All four counts are always written. input_tokens never
108
+ // includes cache reads; it excludes cache writes only on Claude, the one provider that reports
109
+ // them, so on Codex and direct cache_write_tokens is 0 and any write stays in input_tokens.
110
+ // resolved_model is the CLI's word on Claude, and what COS asked for elsewhere. provider-usage.ts
111
+ // says exactly what each means. cache_write_1h_tokens is part of cache_write_tokens; it and cost_usd
112
+ // appear only when stated.
113
+ ...(typeof entry.promptChars === 'number' ? { prompt_chars: entry.promptChars } : {}),
114
+ ...(cliSessionId ? { cli_session_id: cliSessionId } : {}),
115
+ ...(real ? {
116
+ input_tokens: real.inputTokens,
117
+ output_tokens: real.outputTokens,
118
+ cache_read_tokens: real.cacheReadTokens ?? 0,
119
+ cache_write_tokens: real.cacheWriteTokens ?? 0,
120
+ ...(real.cacheWrite1hTokens !== undefined ? { cache_write_1h_tokens: real.cacheWrite1hTokens } : {}),
121
+ ...(real.costUsd !== undefined ? { cost_usd: real.costUsd } : {}),
122
+ ...(real.resolvedModel ? { resolved_model: real.resolvedModel } : {}),
123
+ } : {}),
94
124
  }
95
125
  }
96
126
 
97
127
  export function logTokenAudit(entry: TokenAuditEntry): void {
98
- const record = buildTokenAuditRecord(entry)
99
128
  try {
129
+ const record = buildTokenAuditRecord(entry)
100
130
  appendFileSync(AUDIT_FILE, JSON.stringify(record) + '\n', { encoding: 'utf8', mode: 0o600 })
101
131
  chmodSync(AUDIT_FILE, 0o600)
102
132
  } catch {
@@ -169,8 +169,9 @@ const notesSearchDefault: CleanupDeps['searchNotes'] = async (queries, sessionId
169
169
 
170
170
  const filesDefault: CleanupDeps['files'] = async sessionId => {
171
171
  try {
172
- const { MeetingAskStore } = await import('./meeting-ask-store.js')
173
- return new MeetingAskStore().files(sessionId).filter(f => f.status === 'ready' && typeof f.text === 'string' && f.text.trim()).map(f => ({ name: f.name, text: f.text }))
172
+ const { MeetingAskStore, isPastedFileId } = await import('./meeting-ask-store.js')
173
+ // Pasted text and read pages (paste_, web_) are article words, not the room's vocabulary: they must not rewrite the saved transcript.
174
+ return new MeetingAskStore().files(sessionId).filter(f => !isPastedFileId(f.id) && f.status === 'ready' && typeof f.text === 'string' && f.text.trim()).map(f => ({ name: f.name, text: f.text }))
174
175
  } catch { return [] }
175
176
  }
176
177
 
@@ -14,6 +14,7 @@ import { lstat, readFile, readdir, realpath } from 'node:fs/promises'
14
14
  import { basename, dirname, join } from 'node:path'
15
15
  import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
16
16
  import { readMacSpeakerMachineEvidence } from './mac-speaker-machine-evidence.js'
17
+ import { readMacSpeakerCallView } from './mac-speaker-call-view.js'
17
18
  import { dataPath } from './data-dir.js'
18
19
  import { confirmedLabels, confirmedChunks } from './meeting-corrections.js'
19
20
  import { resolveCosOperationsDir, resolveMeetingLibrary } from './cos-operations-meetings.js'
@@ -26,6 +27,7 @@ import {
26
27
  } from './meeting-speaker-review.js'
27
28
  import { getOwnerSpeakerLabel } from './profile.js'
28
29
  import { readVoiceProfiles, type VoiceProfile } from './speaker-embeddings.js'
30
+ import { sameSpeakerName } from './speaker-name-nfc.js'
29
31
 
30
32
  const MONTH = /^\d{4}-(0[1-9]|1[0-2])$/
31
33
  const SIDECAR = /\.g2-chunks\.json$/
@@ -277,7 +279,8 @@ export async function buildVoiceDirectorySnapshot(): Promise<VoiceDirectorySnaps
277
279
  continue
278
280
  }
279
281
  totalBytes += link.size
280
- const raw = JSON.parse(await readFile(real, 'utf8')) as Record<string, unknown> | ReviewChunk[]
282
+ const baseJSON = await readFile(real, 'utf8')
283
+ const raw = JSON.parse(baseJSON) as Record<string, unknown> | ReviewChunk[]
281
284
  const chunks = Array.isArray(raw) ? raw : raw.chunks
282
285
  const sessionId = Array.isArray(raw) ? '' : String(raw.sessionId ?? '')
283
286
  if (!Array.isArray(chunks) || !SESSION.test(sessionId) || seenSessions.has(sessionId)) {
@@ -296,7 +299,9 @@ export async function buildVoiceDirectorySnapshot(): Promise<VoiceDirectorySnaps
296
299
  )
297
300
 
298
301
  const machineEvidence = readMacSpeakerMachineEvidence(record)
299
- const review = reviewMeetingSpeakers(chunks as ReviewChunk[], {
302
+ const callView = readMacSpeakerCallView(real, baseJSON, candidate.markdownPath)
303
+ const review = reviewMeetingSpeakers((callView.doc.chunks ?? chunks) as ReviewChunk[], {
304
+ callViewAnonymousLabels: callView.metadata.groups.map(g => g.label),
300
305
  owner,
301
306
  phrasesPerVoice: 1,
302
307
  confirmed: confirmedLabels(sessionId),
@@ -304,8 +309,8 @@ export async function buildVoiceDirectorySnapshot(): Promise<VoiceDirectorySnaps
304
309
  machineQualifiedPositions: machineEvidence.positions,
305
310
  machineEvidenceStatus: machineEvidence.status,
306
311
  durationMs: typeof record.durationMs === 'number' ? record.durationMs : undefined,
307
- batchSegments: Array.isArray(record.batchSegments)
308
- ? record.batchSegments as SpeakerWordSegment[]
312
+ batchSegments: Array.isArray(callView.doc.batchSegments)
313
+ ? callView.doc.batchSegments as SpeakerWordSegment[]
309
314
  : undefined,
310
315
  })
311
316
  meetingsScanned++
@@ -363,7 +368,7 @@ export async function buildVoiceDirectorySnapshot(): Promise<VoiceDirectorySnaps
363
368
  .slice(0, MAX_APPEARANCES_PER_VOICE)
364
369
  return {
365
370
  name: profile.name,
366
- isOwner: profile.name === owner,
371
+ isOwner: sameSpeakerName(profile.name, owner),
367
372
  embeddings: profile.embeddings.length,
368
373
  sources: sourceCounts(profile),
369
374
  sourcesAligned: (profile.sources?.length ?? 0) === profile.embeddings.length,
@@ -16,6 +16,7 @@ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:f
16
16
  import { basename, dirname, join, resolve } from 'node:path'
17
17
  import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
18
18
  import { EVICTION_ORDER, isMacAssign, macAssignCapFor, provenanceTier } from './embedding-eviction.js'
19
+ import { sameSpeakerName } from './speaker-name-nfc.js'
19
20
 
20
21
  export interface VoiceProfile {
21
22
  name: string
@@ -512,6 +513,11 @@ export interface MergeOutcome {
512
513
  * removable later, and overwriting it to record a bookkeeping event would trade
513
514
  * a useful fact for a useless one.
514
515
  */
516
+ /** The profile stored under `name`: the exact spelling first, else the one that is the same name in NFC. */
517
+ export function findStoredProfile(store: ProfileStore, name: string): VoiceProfile | undefined {
518
+ return store.profiles.find(p => p.name === name) ?? store.profiles.find(p => sameSpeakerName(p.name, name))
519
+ }
520
+
515
521
  export function mergeProfilesInStore(
516
522
  store: ProfileStore,
517
523
  into: string,
@@ -519,7 +525,7 @@ export function mergeProfilesInStore(
519
525
  options: { cap?: number } = {},
520
526
  ): MergeOutcome {
521
527
  const cap = options.cap ?? 40
522
- const target = store.profiles.find(p => p.name === into)
528
+ const target = findStoredProfile(store, into)
523
529
  const outcome: MergeOutcome = {
524
530
  similarity: {},
525
531
  samplesBefore: target?.embeddings.length ?? 0,
@@ -537,18 +543,23 @@ export function mergeProfilesInStore(
537
543
  const sources = [...(target.sources ?? [])]
538
544
  while (sources.length < embeddings.length) sources.push(UNKNOWN_SOURCE)
539
545
 
546
+ // Sources are the stored profiles themselves, never the spelling asked for: a name stored decomposed and its composed twin are two
547
+ // profiles (the twin is merged into the one that was asked for), and the target is never its own source.
548
+ const absorbed = new Set<VoiceProfile>()
540
549
  for (const name of from) {
541
- if (name === into) continue
542
- const source = store.profiles.find(p => p.name === name)
543
- if (!source) { outcome.missing.push(name); continue }
544
- outcome.similarity[name] = profileSimilarity(target, source)
545
- const sourceSources = [...(source.sources ?? [])]
546
- while (sourceSources.length < source.embeddings.length) sourceSources.push(UNKNOWN_SOURCE)
547
- for (let i = 0; i < source.embeddings.length; i++) {
548
- embeddings.push(source.embeddings[i])
549
- sources.push(sourceSources[i] ?? UNKNOWN_SOURCE)
550
+ const found = store.profiles.filter(p => p !== target && !absorbed.has(p) && sameSpeakerName(p.name, name))
551
+ if (found.length === 0) { if (!store.profiles.some(p => sameSpeakerName(p.name, name) && p !== target)) outcome.missing.push(name); continue }
552
+ for (const source of found) {
553
+ outcome.similarity[name] = profileSimilarity(target, source)
554
+ const sourceSources = [...(source.sources ?? [])]
555
+ while (sourceSources.length < source.embeddings.length) sourceSources.push(UNKNOWN_SOURCE)
556
+ for (let i = 0; i < source.embeddings.length; i++) {
557
+ embeddings.push(source.embeddings[i])
558
+ sources.push(sourceSources[i] ?? UNKNOWN_SOURCE)
559
+ }
560
+ outcome.mergedFrom.push(source.name)
561
+ absorbed.add(source)
550
562
  }
551
- outcome.mergedFrom.push(name)
552
563
  }
553
564
 
554
565
  if (outcome.mergedFrom.length === 0) return outcome
@@ -569,8 +580,7 @@ export function mergeProfilesInStore(
569
580
  target.sources = keep.map(i => sources[i])
570
581
  outcome.samplesAfter = target.embeddings.length
571
582
 
572
- const removed = new Set(outcome.mergedFrom)
573
- store.profiles = store.profiles.filter(p => !removed.has(p.name))
583
+ store.profiles = store.profiles.filter(p => !absorbed.has(p))
574
584
  return outcome
575
585
  }
576
586
 
@@ -583,7 +593,7 @@ export function deleteProfileFromStore(
583
593
  let removedProfiles = 0
584
594
  let removedEmbeddings = 0
585
595
  store.profiles = store.profiles.filter(profile => {
586
- if (profile.name !== name) return true
596
+ if (!sameSpeakerName(profile.name, name)) return true
587
597
  removedProfiles++
588
598
  removedEmbeddings += profile.embeddings.length
589
599
  return false