@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,788 @@
1
+ // Speaker merge shadow mode (6.95.0). OFF BY DEFAULT. LOG ONLY.
2
+ //
3
+ // WHY. Mac recordings split one person into many anonymous `Voice N` labels (45 on one
4
+ // 60-minute call). A research canary found a hybrid that fixes this safely on the
5
+ // reference call: production labels stay the base and FluidAudio's offline Community-1
6
+ // diarizer only PROPOSES merges, behind the gates in speaker-shadow-decide.ts. Shadow mode
7
+ // computes and logs those proposals for every finished Mac meeting so they can be scored
8
+ // against Miles's later corrections. There is no write path, and none may be added here.
9
+ //
10
+ // SWITCH, per Mac, in the transcript-cleanup pattern (6.77.0), off for everyone by default:
11
+ // 1. env COS_SPEAKER_SHADOW=1|true|yes|on forces it on, =0|false|no|off forces it off
12
+ // (source 'env'); a request to change it then answers 409 env_forced;
13
+ // 2. the saved setting <data>/speaker-shadow/settings.json (atomic), written through the
14
+ // token-protected POST /api/speaker-shadow/settings {"enabled":true|false}, read at use
15
+ // time, so no restart is needed (source 'setting');
16
+ // 3. off (source 'default').
17
+ // An unreadable settings file reads as no setting, which is off. Off means nothing is queued,
18
+ // nothing is spawned and no file is created. Turning it off mid-run stops the run.
19
+ //
20
+ // HOW IT STOPS.
21
+ // - the switch (remove it and restart);
22
+ // - a daily cap on runs (COS_SPEAKER_SHADOW_DAILY_RUNS, default 12);
23
+ // - a breaker: 3 consecutive failures pause it for 1 hour;
24
+ // - a hard wall clock (180 s, COS_SPEAKER_SHADOW_TIMEOUT_MS may only lower it) and an
25
+ // RSS cap on the whole process group (COS_SPEAKER_SHADOW_RSS_MB, default 3072);
26
+ // - live work: a recording, a save, a batch finalization or a one-shot transcription
27
+ // defers it, and one that starts mid-run kills the run and puts the meeting back.
28
+ //
29
+ // No LLM and no network at run time: the CLI runs under sandbox-exec with network denied,
30
+ // and only after the pinned models verify (speaker-shadow-models.ts).
31
+ //
32
+ // WHAT IT WRITES, all under <data>/speaker-shadow/: proposals.jsonl (one record per
33
+ // meeting, atomic rewrite, rotated past 8 MB), state.json (queue, cap, breaker), and the
34
+ // CLI's private HOME. Plus a temp directory the worker removes. Never a meeting file, a
35
+ // label, a name, a profile, an embedding cache or the correction ledger.
36
+
37
+ import { execFile, spawn, type ChildProcess } from 'node:child_process'
38
+ import { createHash } from 'node:crypto'
39
+ import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
40
+ import { open, readFile, readdir, rename, unlink } from 'node:fs/promises'
41
+ import { createRequire } from 'node:module'
42
+ import { setPriority, tmpdir } from 'node:os'
43
+ import { dirname, join } from 'node:path'
44
+ import { pipeline } from 'node:stream/promises'
45
+ import { Transform } from 'node:stream'
46
+ import { fileURLToPath, pathToFileURL } from 'node:url'
47
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
48
+ import { MAC_CAPTURE_ID } from './mac-recording.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'
52
+ import { installedCliIntact, installedCliTrust, parakeetCliPath, parakeetInstalled, SANDBOX_EXEC } from './parakeet.js'
53
+ import { SHADOW_GATES } from './speaker-shadow-decide.js'
54
+ import { shadowModelsPresent, speakerShadowHome, speakerShadowModelsDir, speakerShadowRoot } from './speaker-shadow-models.js'
55
+ import { readShadowMeetingTitle, shadowEntries, shadowLabelsHash, shadowMeetingDay } from './speaker-shadow-timeline.js'
56
+ import { renderShadowRunFacts, renderShadowSkipReport, shadowReportRelativePath } from './speaker-shadow-report.js'
57
+ import { SHADOW_TMP_PREFIX, type ShadowJob, type ShadowJobOutcome, type ShadowRunFacts } from './speaker-shadow-worker.js'
58
+ import type { ShadowProposals } from './speaker-shadow-decide.js'
59
+
60
+ export const SPEAKER_SHADOW_ENV = 'COS_SPEAKER_SHADOW'
61
+ export const SPEAKER_SHADOW_RECORDS = 'proposals.jsonl'
62
+ export const SPEAKER_SHADOW_STATE = 'state.json'
63
+ /** The wall clock the brief fixes (the canary took about 20 s for 60 minutes). */
64
+ export const SPEAKER_SHADOW_MAX_TIMEOUT_MS = 180_000
65
+ export const SPEAKER_SHADOW_BREAKER_FAILURES = 3
66
+ export const SPEAKER_SHADOW_BREAKER_PAUSE_MS = 60 * 60_000
67
+ export const SPEAKER_SHADOW_RECORDS_ROTATE_BYTES = 8 * 1024 * 1024
68
+ const MAX_QUEUE = 25
69
+ const QUEUE_MAX_AGE_MS = 24 * 60 * 60_000
70
+ const DEFER_RETRY_MS = 60_000
71
+ const SAMPLE_MS = 1_000
72
+ const MAX_STDOUT = 8 * 1024 * 1024
73
+
74
+ export const SPEAKER_SHADOW_SETTINGS = 'settings.json'
75
+ const ENV_ON = new Set(['1', 'true', 'yes', 'on'])
76
+ const ENV_OFF = new Set(['0', 'false', 'no', 'off'])
77
+ export type SpeakerShadowSwitchSource = 'env' | 'setting' | 'default'
78
+
79
+ /** What the environment pins, or null when it pins nothing (unset, empty or unrecognised). */
80
+ export function speakerShadowEnvSwitch(env: NodeJS.ProcessEnv = process.env): boolean | null {
81
+ const raw = (env[SPEAKER_SHADOW_ENV] ?? '').trim().toLowerCase()
82
+ return ENV_ON.has(raw) ? true : ENV_OFF.has(raw) ? false : null
83
+ }
84
+
85
+ const settingsCache = new Map<string, { key: string; value: boolean | null }>()
86
+ /** The saved per-Mac setting: true, false, or null when there is none or it cannot be read. */
87
+ export function readSpeakerShadowSetting(root: string = speakerShadowRoot()): boolean | null {
88
+ const file = join(root, SPEAKER_SHADOW_SETTINGS)
89
+ try {
90
+ const st = statSync(file)
91
+ const key = `${st.mtimeMs}:${st.size}`
92
+ const cached = settingsCache.get(file)
93
+ if (cached?.key === key) return cached.value
94
+ const raw = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
95
+ const value = raw && typeof raw === 'object' && !Array.isArray(raw) && typeof raw.enabled === 'boolean' ? raw.enabled : null
96
+ settingsCache.set(file, { key, value })
97
+ return value
98
+ } catch { return null }
99
+ }
100
+
101
+ /** Atomic and durable. The only writer of the setting. */
102
+ export function writeSpeakerShadowSetting(enabled: boolean, root: string = speakerShadowRoot(), now: Date = new Date()): void {
103
+ mkdirSync(root, { recursive: true, mode: 0o700 })
104
+ durableAtomicWriteFileSync(join(root, SPEAKER_SHADOW_SETTINGS), `${JSON.stringify({ schema: 1, enabled, updatedAt: now.toISOString() })}\n`, { mode: 0o600 })
105
+ settingsCache.delete(join(root, SPEAKER_SHADOW_SETTINGS))
106
+ }
107
+
108
+ export function resolveSpeakerShadowSwitch(env: NodeJS.ProcessEnv = process.env, root: string = speakerShadowRoot()): { enabled: boolean; source: SpeakerShadowSwitchSource } {
109
+ const pinned = speakerShadowEnvSwitch(env)
110
+ if (pinned !== null) return { enabled: pinned, source: 'env' }
111
+ const stored = readSpeakerShadowSetting(root)
112
+ if (stored !== null) return { enabled: stored, source: 'setting' }
113
+ return { enabled: false, source: 'default' }
114
+ }
115
+
116
+ export function speakerShadowEnabled(env: NodeJS.ProcessEnv = process.env, root: string = speakerShadowRoot()): boolean {
117
+ return resolveSpeakerShadowSwitch(env, root).enabled
118
+ }
119
+
120
+ function intEnv(env: NodeJS.ProcessEnv, key: string, fallback: number, min: number, max: number): number {
121
+ const raw = Number(env[key])
122
+ return Number.isFinite(raw) && raw > 0 ? Math.min(max, Math.max(min, Math.floor(raw))) : fallback
123
+ }
124
+
125
+ export interface SpeakerShadowLimits { dailyRuns: number; timeoutMs: number; rssBytes: number }
126
+ export function speakerShadowLimits(env: NodeJS.ProcessEnv = process.env): SpeakerShadowLimits {
127
+ return {
128
+ dailyRuns: intEnv(env, 'COS_SPEAKER_SHADOW_DAILY_RUNS', 12, 1, 50),
129
+ timeoutMs: intEnv(env, 'COS_SPEAKER_SHADOW_TIMEOUT_MS', SPEAKER_SHADOW_MAX_TIMEOUT_MS, 10_000, SPEAKER_SHADOW_MAX_TIMEOUT_MS),
130
+ rssBytes: intEnv(env, 'COS_SPEAKER_SHADOW_RSS_MB', 3072, 256, 8192) * 1024 * 1024,
131
+ }
132
+ }
133
+
134
+ // ── Records ──────────────────────────────────────────────────────────────────
135
+
136
+ export type SpeakerShadowStatus = 'proposed' | 'skipped' | 'failed'
137
+ export type SpeakerShadowRunnerReason =
138
+ | 'stale' | 'daily_cap' | 'cli_missing' | 'models_missing' | 'audio_missing'
139
+ | 'timeout' | 'rss_cap' | 'worker_crashed' | 'worker_spawn_failed'
140
+
141
+ export interface SpeakerShadowRecord {
142
+ schema: 1
143
+ kind: 'speaker_shadow'
144
+ /** Always true: nothing in this record was applied anywhere. */
145
+ logOnly: true
146
+ recordedAt: string
147
+ serverVersion: string
148
+ sessionId: string
149
+ enqueuedAt: string
150
+ status: SpeakerShadowStatus
151
+ reason: string | null
152
+ detail: string | null
153
+ gates: { majority: number; minCoveredFraction: number; floorSeconds: number; outlier: string; anchorRatioMax: number }
154
+ facts?: ShadowRunFacts
155
+ paired?: Extract<ShadowJobOutcome, { status: 'proposed' }>['paired']
156
+ proposals?: ShadowProposals
157
+ resources?: { wallMs: number; peakRssBytes: number | null; cliTrust: string | null }
158
+ /** The readable report, relative to <data>/speaker-shadow/ (reports/<day>/<title>__<session>.md). */
159
+ report?: string | null
160
+ }
161
+
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 }
163
+
164
+ /** Append one record: the whole file is rewritten to a temp file, fsynced and renamed, so a reader never sees half a line. */
165
+ export async function appendSpeakerShadowRecord(root: string, record: SpeakerShadowRecord): Promise<void> {
166
+ mkdirSync(root, { recursive: true, mode: 0o700 })
167
+ const path = join(root, SPEAKER_SHADOW_RECORDS)
168
+ let existing = ''
169
+ try { existing = await readFile(path, 'utf8') } catch { /* first record */ }
170
+ if (existing.length >= SPEAKER_SHADOW_RECORDS_ROTATE_BYTES) {
171
+ await rename(path, join(root, `proposals-${new Date(Date.parse(record.recordedAt)).toISOString().replace(/[:.]/g, '-')}.jsonl`))
172
+ existing = ''
173
+ }
174
+ if (existing && !existing.endsWith('\n')) existing += '\n'
175
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
176
+ const handle = await open(tmp, 'wx', 0o600)
177
+ try {
178
+ await handle.writeFile(`${existing}${JSON.stringify(record)}\n`)
179
+ await handle.sync()
180
+ } finally { await handle.close() }
181
+ try { await rename(tmp, path) } catch (error) { await unlink(tmp).catch(() => {}); throw error }
182
+ }
183
+
184
+ /** Newest first. Malformed lines are skipped, never fatal. */
185
+ export async function readSpeakerShadowRecords(root: string, options: { limit?: number; sessionId?: string } = {}): Promise<SpeakerShadowRecord[]> {
186
+ const limit = Math.max(1, Math.min(100, options.limit ?? 10))
187
+ let names: string[] = []
188
+ try { names = (await readdir(root)).filter(n => n === SPEAKER_SHADOW_RECORDS || /^proposals-[0-9TZ-]+\.jsonl$/.test(n)) } catch { return [] }
189
+ names.sort((a, b) => a === SPEAKER_SHADOW_RECORDS ? -1 : b === SPEAKER_SHADOW_RECORDS ? 1 : b.localeCompare(a))
190
+ const out: SpeakerShadowRecord[] = []
191
+ for (const name of names) {
192
+ let text = ''
193
+ try { text = await readFile(join(root, name), 'utf8') } catch { continue }
194
+ const lines = text.split('\n').filter(Boolean).reverse()
195
+ for (const line of lines) {
196
+ let record: SpeakerShadowRecord
197
+ try { record = JSON.parse(line) } catch { continue }
198
+ if (record?.kind !== 'speaker_shadow') continue
199
+ if (options.sessionId && record.sessionId !== options.sessionId) continue
200
+ out.push(record)
201
+ if (out.length >= limit) return out
202
+ }
203
+ }
204
+ return out
205
+ }
206
+
207
+ // ── State ────────────────────────────────────────────────────────────────────
208
+
209
+ interface QueueItem { sessionId: string; sidecarPath: string; labelsHash: string | null; enqueuedAt: number;producerRequest?:MacSpeakerCallProducerRequest }
210
+ interface ShadowState {
211
+ schema: 1
212
+ day: string
213
+ runsToday: number
214
+ consecutiveFailures: number
215
+ breakerUntil: number | null
216
+ lastRun: { at: number; sessionId: string; status: SpeakerShadowStatus; reason: string | null } | null
217
+ lastError: { at: number; reason: string; detail: string } | null
218
+ done: string[]
219
+ queue: QueueItem[]
220
+ }
221
+
222
+ const emptyState = (day: string): ShadowState => ({ schema: 1, day, runsToday: 0, consecutiveFailures: 0, breakerUntil: null, lastRun: null, lastError: null, done: [], queue: [] })
223
+
224
+ function localDay(ms: number): string {
225
+ const d = new Date(ms)
226
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
227
+ }
228
+
229
+ // ── Runner ───────────────────────────────────────────────────────────────────
230
+
231
+ export interface SpeakerShadowCli { path: string; sha256: string; trust: string | null }
232
+
233
+ export interface SpeakerShadowDeps {
234
+ env: () => NodeJS.ProcessEnv
235
+ root: () => string
236
+ now: () => number
237
+ /** True while a recording, save, finalization or one-shot transcription is live. Null = not wired: never run. */
238
+ captureActive: (() => boolean) | null
239
+ /** False during a maintenance drain. */
240
+ admissionsOpen: () => boolean
241
+ resolveCli: () => Promise<SpeakerShadowCli | null>
242
+ audioDirFor: (sessionId: string) => string
243
+ modelsDir: () => string
244
+ homeDir: () => string
245
+ sandboxExec: string
246
+ tmpRoot: string
247
+ /** Starts the worker child. Must start it as its own process group leader. */
248
+ spawnWorker: () => ChildProcess
249
+ /** Resident bytes of the process group led by pid, or null when it cannot be read. */
250
+ sampleRss: (pgid: number) => Promise<number | null>
251
+ producerResources:typeof sampleMacSpeakerCallResources
252
+ killGroup: (pgid: number) => void
253
+ log: (line: string) => void
254
+ setTimer: (fn: () => void, ms: number) => { unref?: () => void }
255
+ /** The cheap scheduler-side model look (sizes only); the worker hashes every file. */
256
+ modelsPresent: (dir: string) => boolean
257
+ limits: () => SpeakerShadowLimits
258
+ /** Cheap, synchronous: is the CLI the runner would use installed, and how is it trusted. */
259
+ cliReadiness: () => { installed: boolean; trust: string | null }
260
+ }
261
+
262
+ export interface SpeakerShadowReadiness {
263
+ /** Every pinned model file is present with its size (the worker hashes them before each run). */
264
+ modelsPresent: boolean
265
+ /** The Parakeet install's fluidaudiocli is installed, and how it was trusted. */
266
+ cliInstalled: boolean
267
+ cliTrust: string | null
268
+ sandboxExec: boolean
269
+ }
270
+
271
+ export interface SpeakerShadowSnapshot {
272
+ enabled: boolean
273
+ /** Where the switch came from: env pins it, the saved per-Mac setting, or the default (off). */
274
+ source: SpeakerShadowSwitchSource
275
+ state: 'off' | 'idle' | 'queued' | 'running' | 'deferred' | 'breaker_open' | 'no_live_probe'
276
+ queued: number
277
+ runsToday: number
278
+ dailyCap: number
279
+ breaker: { open: boolean; failures: number; until: string | null }
280
+ lastRun: { at: string; status: SpeakerShadowStatus; reason: string | null } | null
281
+ lastError: { at: string; reason: string } | null
282
+ lastErrorDetail?: string | null
283
+ readiness?: SpeakerShadowReadiness
284
+ deferredReason: string | null
285
+ }
286
+
287
+ const WORKER_PATH = fileURLToPath(new URL('./speaker-shadow-worker.ts', import.meta.url))
288
+
289
+ function defaultSpawnWorker(): ChildProcess {
290
+ const tsx = createRequire(import.meta.url).resolve('tsx/esm')
291
+ return spawn(process.execPath, ['--import', pathToFileURL(tsx).href, WORKER_PATH], {
292
+ detached: true,
293
+ stdio: ['pipe', 'pipe', 'pipe'],
294
+ // The worker needs the data dir to resolve nothing at all (paths arrive in the job), but
295
+ // importing server modules creates DATA_DIR, so point it at the same one.
296
+ env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, COS_DATA_DIR: process.env.COS_DATA_DIR, LANG: process.env.LANG },
297
+ })
298
+ }
299
+
300
+ function defaultSampleRss(pgid: number): Promise<number | null> {
301
+ return new Promise(resolve => {
302
+ execFile('/bin/ps', ['-axo', 'pgid=,rss='], { timeout: 5_000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
303
+ if (error) { resolve(null); return }
304
+ let kb = 0
305
+ for (const line of stdout.split('\n')) {
306
+ const [g, rss] = line.trim().split(/\s+/).map(Number)
307
+ if (g === pgid && Number.isFinite(rss)) kb += rss
308
+ }
309
+ resolve(kb * 1024)
310
+ })
311
+ })
312
+ }
313
+
314
+ async function sha256File(path: string): Promise<string> {
315
+ const hash = createHash('sha256')
316
+ await pipeline(createReadStream(path), new Transform({ transform(chunk, _e, cb) { hash.update(chunk); cb(null) } }))
317
+ return hash.digest('hex')
318
+ }
319
+
320
+ /** The server's own fluidaudiocli: the one the Parakeet install placed and verified. */
321
+ async function defaultResolveCli(): Promise<SpeakerShadowCli | null> {
322
+ try {
323
+ if (!parakeetInstalled().installed) return null
324
+ const path = parakeetCliPath()
325
+ if (!(await installedCliIntact(path))) return null
326
+ return { path, sha256: await sha256File(path), trust: installedCliTrust() }
327
+ } catch { return null }
328
+ }
329
+
330
+ export function defaultSpeakerShadowDeps(): SpeakerShadowDeps {
331
+ return {
332
+ env: () => process.env,
333
+ root: speakerShadowRoot,
334
+ now: Date.now,
335
+ captureActive: null,
336
+ admissionsOpen: () => true,
337
+ resolveCli: defaultResolveCli,
338
+ audioDirFor: sessionId => join(speakerShadowRoot(), '..', 'meeting-audio', sessionId.replace(/:/g, '_')),
339
+ modelsDir: () => speakerShadowModelsDir(process.env),
340
+ homeDir: speakerShadowHome,
341
+ sandboxExec: SANDBOX_EXEC,
342
+ tmpRoot: tmpdir(),
343
+ spawnWorker: defaultSpawnWorker,
344
+ sampleRss: defaultSampleRss,
345
+ producerResources:sampleMacSpeakerCallResources,
346
+ killGroup: pgid => { try { process.kill(-pgid, 'SIGKILL') } catch { /* gone */ } },
347
+ log: line => console.log(`[speaker-shadow] ${line}`),
348
+ setTimer: (fn, ms) => setTimeout(fn, ms),
349
+ modelsPresent: shadowModelsPresent,
350
+ limits: () => speakerShadowLimits(process.env),
351
+ cliReadiness: () => { try { return { installed: parakeetInstalled().installed, trust: installedCliTrust() } } catch { return { installed: false, trust: null } } },
352
+ }
353
+ }
354
+
355
+ type Supervised = ShadowJobOutcome | { status: 'deferred'; reason: string } | { status: 'failed'; reason: SpeakerShadowRunnerReason; detail: string }
356
+
357
+ export class SpeakerShadowRunner {
358
+ private readonly deps: SpeakerShadowDeps
359
+ private running: { sessionId: string; pgid: number | null;producerRequest?:MacSpeakerCallProducerRequest } | null = null
360
+ private pumping = false
361
+ private timer: { unref?: () => void } | null = null
362
+ private deferredReason: string | null = null
363
+ private exitHook = false
364
+
365
+ constructor(deps: Partial<SpeakerShadowDeps> = {}) {
366
+ this.deps = { ...defaultSpeakerShadowDeps(), ...deps }
367
+ }
368
+
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
+ }
384
+
385
+ switchState(): { enabled: boolean; source: SpeakerShadowSwitchSource } { return resolveSpeakerShadowSwitch(this.deps.env(), this.deps.root()) }
386
+
387
+ /** Production wiring: what counts as live meeting work. Until it is set, nothing runs. */
388
+ setCaptureProbe(probe: () => boolean, admissionsOpen?: () => boolean): void {
389
+ this.deps.captureActive = probe
390
+ if (admissionsOpen) this.deps.admissionsOpen = admissionsOpen
391
+ }
392
+
393
+ private statePath(): string { return join(this.deps.root(), SPEAKER_SHADOW_STATE) }
394
+
395
+ private readState(): ShadowState {
396
+ const day = localDay(this.deps.now())
397
+ let state: ShadowState
398
+ try {
399
+ const parsed = JSON.parse(readFileSyncSafe(this.statePath()) ?? '') as ShadowState
400
+ state = parsed?.schema === 1 && Array.isArray(parsed.queue) ? { ...emptyState(day), ...parsed, done: Array.isArray(parsed.done) ? parsed.done : [] } : emptyState(day)
401
+ } catch { state = emptyState(day) }
402
+ if (state.day !== day) state = { ...state, day, runsToday: 0 }
403
+ return state
404
+ }
405
+
406
+ private writeState(state: ShadowState): void {
407
+ mkdirSync(this.deps.root(), { recursive: true, mode: 0o700 })
408
+ state.done = state.done.slice(-500)
409
+ durableAtomicWriteFileSync(this.statePath(), `${JSON.stringify(state, null, 1)}\n`, { mode: 0o600 })
410
+ }
411
+
412
+ /**
413
+ * A Mac recording finished finalizing. Never throws and never blocks the caller: the
414
+ * sidecar read and everything after it run in the background.
415
+ */
416
+ notifyFinalized(input: { sessionId: string; sidecarPath: string }): void {
417
+ try {
418
+ if (!this.enabled() || !MAC_CAPTURE_ID.test(input.sessionId)) return
419
+ void this.enqueue(input).catch(error => this.deps.log(`enqueue failed for ${input.sessionId}: ${describe(error)}`))
420
+ } catch (error) {
421
+ this.deps.log(`notify failed: ${describe(error)}`)
422
+ }
423
+ }
424
+
425
+ private async enqueue(input: { sessionId: string; sidecarPath: string },producerRequest?:MacSpeakerCallProducerRequest): Promise<void> {
426
+ let labelsHash: string | null = null
427
+ try { labelsHash = shadowLabelsHash(shadowEntries(JSON.parse(await readFile(input.sidecarPath, 'utf8')))) } catch { /* the worker reports the sidecar */ }
428
+ // Read, change and write with no await in between: the pump also rewrites this file.
429
+ const state = this.readState()
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}:{}) })
434
+ const dropped = state.queue.splice(0, Math.max(0, state.queue.length - MAX_QUEUE))
435
+ for (const item of dropped) state.done.push(item.sessionId)
436
+ this.writeState(state)
437
+ for (const item of dropped) await this.record(item, 'skipped', 'stale', 'the shadow queue was full')
438
+ this.deps.log(`queued ${input.sessionId}`)
439
+ void this.pump()
440
+ }
441
+
442
+ /** Boot: pick up meetings a previous process queued but never ran. */
443
+ resume(): void {
444
+ try {
445
+ if (!this.nativeEnabled() || !existsSync(this.statePath())) return
446
+ if (this.readState().queue.length) void this.pump()
447
+ } catch (error) { this.deps.log(`resume failed: ${describe(error)}`) }
448
+ }
449
+
450
+ private schedule(ms: number, reason: string): void {
451
+ if (this.deferredReason !== reason) this.deps.log(`deferred: ${reason}`)
452
+ this.deferredReason = reason
453
+ if (this.timer) return
454
+ const t = this.deps.setTimer(() => { this.timer = null; void this.pump() }, ms)
455
+ t.unref?.()
456
+ this.timer = t
457
+ }
458
+
459
+ private async record(item: QueueItem, status: SpeakerShadowStatus, reason: string | null, detail: string | null, extra: Partial<SpeakerShadowRecord> = {}): Promise<void> {
460
+ const record: SpeakerShadowRecord = {
461
+ schema: 1, kind: 'speaker_shadow', logOnly: true,
462
+ recordedAt: new Date(this.deps.now()).toISOString(),
463
+ serverVersion: process.env.COS_SERVER_VERSION?.trim() || 'development',
464
+ sessionId: item.sessionId, enqueuedAt: new Date(item.enqueuedAt).toISOString(),
465
+ status, reason, detail, gates: GATES_RECORD, ...extra,
466
+ }
467
+ await appendSpeakerShadowRecord(this.deps.root(), record)
468
+ }
469
+
470
+ /**
471
+ * The readable page for one finished meeting, written atomically under reports/<day>/. Every
472
+ * finished meeting gets one: proposals, or a short page saying why there are none. A report
473
+ * that cannot be written is logged and never blocks the record.
474
+ */
475
+ private async writeReport(item: QueueItem, status: SpeakerShadowStatus, reason: string | null, detail: string | null, report?: { title: string; markdown: string }): Promise<string | null> {
476
+ try {
477
+ const title = report?.title ?? await readShadowMeetingTitle(item.sidecarPath)
478
+ const markdown = report?.markdown ?? renderShadowSkipReport({ title, sessionId: item.sessionId, status: status === 'proposed' ? 'skipped' : status, reason, detail })
479
+ const rel = shadowReportRelativePath(title, item.sessionId, shadowMeetingDay(item.sidecarPath) ?? localDay(this.deps.now()))
480
+ const path = join(this.deps.root(), rel)
481
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
482
+ durableAtomicWriteFileSync(path, markdown, { mode: 0o600 })
483
+ return rel
484
+ } catch (error) {
485
+ this.deps.log(`report not written for ${item.sessionId}: ${describe(error)}`)
486
+ return null
487
+ }
488
+ }
489
+
490
+ /** Runs queued meetings one at a time until the queue is empty or something says wait. */
491
+ async pump(): Promise<void> {
492
+ if (this.pumping || this.running) return
493
+ this.pumping = true
494
+ try {
495
+ for (;;) {
496
+ if (!this.nativeEnabled()) return
497
+ if(macSpeakerCallProducerBusy()){this.schedule(DEFER_RETRY_MS,'independent_consumer');return}
498
+ const state = this.readState()
499
+ const item = state.queue[0]
500
+ if (!item) { this.deferredReason = null; return }
501
+ const now = this.deps.now()
502
+ const finish = async (status: SpeakerShadowStatus, reason: string | null, detail: string | null, extra: Partial<SpeakerShadowRecord> = {}, report?: { title: string; markdown: string }) => {
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
+ }
507
+ const next = this.readState()
508
+ next.queue = next.queue.filter(q => q.sessionId !== item.sessionId)
509
+ next.done.push(item.sessionId)
510
+ next.lastRun = { at: this.deps.now(), sessionId: item.sessionId, status, reason }
511
+ if (status === 'failed') {
512
+ next.consecutiveFailures += 1
513
+ next.lastError = { at: this.deps.now(), reason: reason ?? 'failed', detail: (detail ?? '').slice(0, 300) }
514
+ if (next.consecutiveFailures >= SPEAKER_SHADOW_BREAKER_FAILURES) {
515
+ next.breakerUntil = this.deps.now() + SPEAKER_SHADOW_BREAKER_PAUSE_MS
516
+ this.deps.log(`breaker open for 1 h after ${next.consecutiveFailures} failures (last: ${reason})`)
517
+ }
518
+ } else if (status === 'proposed') {
519
+ next.consecutiveFailures = 0
520
+ next.breakerUntil = null
521
+ }
522
+ this.writeState(next)
523
+ this.deps.log(`${status} ${item.sessionId}${reason ? ` (${reason})` : ''}${extra.proposals ? `: ${extra.proposals.productionVoices} voices, ${extra.proposals.acceptedMerges} of ${extra.proposals.proposedMerges} proposed merges pass` : ''}`)
524
+ }
525
+
526
+ if (now - item.enqueuedAt > QUEUE_MAX_AGE_MS) { await finish('skipped', 'stale', 'queued for more than 24 hours'); continue }
527
+ if (state.breakerUntil && now < state.breakerUntil) { this.schedule(state.breakerUntil - now, 'breaker_open'); return }
528
+ if (!this.deps.captureActive) { this.schedule(DEFER_RETRY_MS, 'no_live_probe'); return }
529
+ if (this.deps.captureActive() || !this.deps.admissionsOpen()) { this.schedule(DEFER_RETRY_MS, 'live_work'); return }
530
+ const limits = this.deps.limits()
531
+ if (state.runsToday >= limits.dailyRuns) { await finish('skipped', 'daily_cap', `${limits.dailyRuns} runs today`); continue }
532
+ const cli = await this.deps.resolveCli()
533
+ if (!cli) { await finish('skipped', 'cli_missing', 'no verified fluidaudiocli from the Parakeet install'); continue }
534
+ const modelsDir = this.deps.modelsDir()
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 }
536
+ const audioDir = this.deps.audioDirFor(item.sessionId)
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}
545
+
546
+ // Fresh read: the awaits above let an enqueue rewrite the file since `state` was read.
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}
553
+ counted.runsToday += 1
554
+ this.writeState(counted)
555
+ this.deferredReason = null
556
+ const job: ShadowJob = {
557
+ sessionId: item.sessionId, sidecarPath: item.sidecarPath, audioDir, expectedLabelsHash: item.labelsHash,
558
+ cliPath: cli.path, cliSha256: cli.sha256, sandboxExec: this.deps.sandboxExec, modelsDir,
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}:{}),
561
+ }
562
+ this.deps.log(`start ${item.sessionId}`)
563
+ const started = this.deps.now()
564
+ const { outcome, peakRss } = await this.supervise(job, limits)
565
+ const resources = { wallMs: this.deps.now() - started, peakRssBytes: peakRss, cliTrust: cli.trust }
566
+ if (outcome.status === 'deferred') {
567
+ // Not a failure and not a run: the meeting stays at the head of the queue.
568
+ const back = this.readState()
569
+ back.runsToday = Math.max(0, back.runsToday - 1)
570
+ this.writeState(back)
571
+ this.deps.log(`stopped ${item.sessionId}: ${outcome.reason}; it runs again when the live work ends`)
572
+ this.schedule(DEFER_RETRY_MS, outcome.reason)
573
+ return
574
+ }
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)
587
+ const f = outcome.facts
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 })}`
589
+ await finish('proposed', null, null, { facts: outcome.facts, proposals: outcome.proposals, paired: outcome.paired, resources }, { title: outcome.report.title, markdown })
590
+ }
591
+ else await finish(outcome.status, outcome.reason, outcome.detail, { resources })
592
+ }
593
+ } catch (error) {
594
+ this.deps.log(`pump failed: ${describe(error)}`)
595
+ } finally {
596
+ this.pumping = false
597
+ }
598
+ }
599
+
600
+ private supervise(job: ShadowJob, limits: SpeakerShadowLimits): Promise<{ outcome: Supervised; peakRss: number | null }> {
601
+ return new Promise(resolvePromise => {
602
+ let producerLease:ReturnType<typeof acquireMaintenanceWork>|null=null
603
+ let child: ChildProcess
604
+ try {if(job.callProducer)producerLease=acquireMaintenanceWork('mac_speaker_call');child = this.deps.spawnWorker() } catch (error) {
605
+ producerLease?.release()
606
+ resolvePromise({ outcome: { status: 'failed', reason: 'worker_spawn_failed', detail: describe(error) }, peakRss: null })
607
+ return
608
+ }
609
+ const pgid = child.pid ?? null
610
+ this.running = { sessionId: job.sessionId, pgid,...(job.callProducer?{producerRequest:job.callProducer}:{}) }
611
+ this.installExitHook()
612
+ if (pgid) { try { setPriority(pgid, 10) } catch { /* runs at normal priority */ } }
613
+ const started = this.deps.now()
614
+ let stdout = '', stderr = '', verdict: Supervised | null = null, peak: number | null = null, sampling = false, settled = false
615
+ let swapStart:number|null=job.callProducerSwapStart??null
616
+ const stop = (outcome: Supervised) => { if (!verdict) verdict = outcome; if (pgid) this.deps.killGroup(pgid) }
617
+ const tick = setInterval(() => {
618
+ if (verdict) return
619
+ if (this.deps.now() - started > limits.timeoutMs) { stop({ status: 'failed', reason: 'timeout', detail: `no result within ${limits.timeoutMs} ms` }); return }
620
+ if (!this.nativeEnabled()||job.callProducer&&!macSpeakerCallProducerEnabled(this.deps.env())) { stop({ status: 'deferred', reason: 'switched_off' }); return }
621
+ if (this.deps.captureActive?.() || !this.deps.admissionsOpen()) { stop({ status: 'deferred', reason: 'capture_live' }); return }
622
+ if (!pgid || sampling) return
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
+ })
630
+ void this.deps.sampleRss(pgid).then(bytes => {
631
+ sampling = false
632
+ if (verdict||settled)return
633
+ if(bytes===null){if(job.callProducer)stop({status:'deferred',reason:'producer_rss_unavailable'});return}
634
+ peak = Math.max(peak ?? 0, bytes)
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` })
636
+ })
637
+ }, SAMPLE_MS)
638
+ tick.unref?.()
639
+ const done = (code: number | null, signal: NodeJS.Signals | null) => {
640
+ if (settled) return
641
+ settled = true
642
+ clearInterval(tick)
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'}
646
+ if (verdict) { resolvePromise({ outcome: verdict, peakRss: peak }); return }
647
+ const line = stdout.trim().split('\n').pop() ?? ''
648
+ try {
649
+ const outcome = JSON.parse(line) as ShadowJobOutcome
650
+ if (outcome && (outcome.status === 'proposed' || outcome.status === 'skipped' || outcome.status === 'failed')) { resolvePromise({ outcome, peakRss: peak }); return }
651
+ } catch { /* below */ }
652
+ resolvePromise({ outcome: { status: 'failed', reason: 'worker_crashed', detail: `worker exit ${code ?? signal}: ${stderr.trim().split('\n').slice(-2).join(' | ').slice(0, 200)}` }, peakRss: peak })
653
+ }
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' }) })
655
+ child.stderr?.on('data', (d: Buffer) => { if (stderr.length < 16_384) stderr += d.toString() })
656
+ child.on('error', error => { stop({ status: 'failed', reason: 'worker_spawn_failed', detail: error.message }); if(!child.pid)done(null, null) })
657
+ child.on('close', done)
658
+ child.stdin?.on('error', () => { /* a crashed worker closes stdin; close reports it */ })
659
+ child.stdin?.end(JSON.stringify(job))
660
+ })
661
+ }
662
+
663
+ private installExitHook(): void {
664
+ if (this.exitHook) return
665
+ this.exitHook = true
666
+ process.once('exit', () => { if (this.running?.pgid) this.deps.killGroup(this.running.pgid) })
667
+ }
668
+
669
+ snapshot(options: { detail?: boolean } = {}): SpeakerShadowSnapshot {
670
+ const { enabled, source } = this.switchState()
671
+ const limits = this.deps.limits()
672
+ let state: ShadowState | null = null
673
+ try { if (existsSync(this.statePath())) state = this.readState() } catch { state = null }
674
+ const now = this.deps.now()
675
+ const breakerOpen = !!state?.breakerUntil && now < state.breakerUntil
676
+ const phase: SpeakerShadowSnapshot['state'] = !enabled ? 'off'
677
+ : this.running ? 'running'
678
+ : breakerOpen ? 'breaker_open'
679
+ : !this.deps.captureActive && state?.queue.length ? 'no_live_probe'
680
+ : this.deferredReason && state?.queue.length ? 'deferred'
681
+ : state?.queue.length ? 'queued' : 'idle'
682
+ const readiness = (): SpeakerShadowReadiness => {
683
+ const cli = this.deps.cliReadiness()
684
+ let modelsPresent = false
685
+ try { modelsPresent = this.deps.modelsPresent(this.deps.modelsDir()) } catch { modelsPresent = false }
686
+ return { modelsPresent, cliInstalled: cli.installed, cliTrust: cli.trust, sandboxExec: existsSync(this.deps.sandboxExec) }
687
+ }
688
+ return {
689
+ enabled, source, state: phase,
690
+ queued: state?.queue.length ?? 0,
691
+ runsToday: state && state.day === localDay(now) ? state.runsToday : 0,
692
+ dailyCap: limits.dailyRuns,
693
+ breaker: { open: breakerOpen, failures: state?.consecutiveFailures ?? 0, until: breakerOpen ? new Date(state!.breakerUntil!).toISOString() : null },
694
+ lastRun: state?.lastRun ? { at: new Date(state.lastRun.at).toISOString(), status: state.lastRun.status, reason: state.lastRun.reason } : null,
695
+ lastError: state?.lastError ? { at: new Date(state.lastError.at).toISOString(), reason: state.lastError.reason } : null,
696
+ ...(options.detail ? { lastErrorDetail: state?.lastError?.detail ?? null, readiness: readiness() } : {}),
697
+ deferredReason: this.deferredReason,
698
+ }
699
+ }
700
+ }
701
+
702
+ /** One synchronous read, only for the small state file. */
703
+ function readFileSyncSafe(path: string): string | null {
704
+ try { return readFileSync(path, 'utf8') } catch { return null }
705
+ }
706
+
707
+ function describe(error: unknown): string {
708
+ return error instanceof Error ? error.message : String(error)
709
+ }
710
+
711
+ /** Server start: temp directories a previous worker left (a SIGKILL skips its cleanup). */
712
+ export function sweepStaleSpeakerShadowTmp(root = tmpdir(), minAgeMs = 10 * 60_000, now = Date.now()): number {
713
+ let removed = 0
714
+ try {
715
+ for (const name of readdirSync(root)) {
716
+ if (!name.startsWith(SHADOW_TMP_PREFIX)) continue
717
+ const path = join(root, name)
718
+ try { if (now - statSync(path).mtimeMs >= minAgeMs) { rmSync(path, { recursive: true, force: true }); removed++ } } catch { /* raced */ }
719
+ }
720
+ } catch { /* no tmp root */ }
721
+ return removed
722
+ }
723
+
724
+ // ── Process-wide runner ──────────────────────────────────────────────────────
725
+
726
+ let shared: SpeakerShadowRunner | null = null
727
+ export function getSpeakerShadowRunner(): SpeakerShadowRunner {
728
+ shared ??= new SpeakerShadowRunner()
729
+ return shared
730
+ }
731
+ export function resetSpeakerShadowRunnerForTests(runner: SpeakerShadowRunner | null = null): void {
732
+ shared = runner
733
+ }
734
+
735
+ /**
736
+ * Leases that mean live meeting or dictation work. The merge engine's CAPTURE_LEASE_KINDS
737
+ * (meeting-actions.ts) plus dictation's draft writes, warms and finalize: dictation runs
738
+ * Parakeet on the same Neural Engine this CLI uses. Pinned against CAPTURE_LEASE_KINDS by test.
739
+ */
740
+ export const SPEAKER_SHADOW_LIVE_LEASES = [
741
+ 'recording_chunk', 'meeting_save', 'meeting_batch_finalization', 'orphan_recovery', 'one_shot_transcription',
742
+ 'prompt_draft_write', 'prompt_draft_warm', 'prompt_draft_finalize',
743
+ ] as const
744
+
745
+ export function speakerShadowLeaseActive(activeByKind: () => Record<string, number> = () => maintenanceLifecycle.snapshot().activeByKind as Record<string, number>): boolean {
746
+ const byKind = activeByKind()
747
+ return SPEAKER_SHADOW_LIVE_LEASES.some(kind => (byKind[kind] ?? 0) > 0)
748
+ }
749
+
750
+ /** Server boot: what counts as live work, then the temp sweep and any queue a previous process left. */
751
+ export function configureSpeakerShadow(probe: { captureActive: () => boolean; admissionsOpen: () => boolean }): void {
752
+ try {
753
+ const runner = getSpeakerShadowRunner()
754
+ runner.setCaptureProbe(probe.captureActive, probe.admissionsOpen)
755
+ configureMacSpeakerCallProducer({captureActive:()=>probe.captureActive()||runner.nativeRunning(),admissionsOpen:probe.admissionsOpen,requestNative:r=>runner.notifyConsumerFinalized(r)})
756
+ if (!runner.nativeEnabled()) return
757
+ const swept = sweepStaleSpeakerShadowTmp()
758
+ if (swept) console.log(`[speaker-shadow] removed ${swept} stale temp dir(s)`)
759
+ console.log('[speaker-shadow] enabled (log only)')
760
+ runner.resume()
761
+ } catch (error) {
762
+ console.warn('[speaker-shadow] configure failed:', describe(error))
763
+ }
764
+ }
765
+
766
+ /** The finalization path's call. Never throws. */
767
+ export function notifySpeakerShadowFinalized(input: { sessionId: string; sidecarPath: string }): void {
768
+ try { getSpeakerShadowRunner().notifyFinalized(input) } catch (error) { console.warn('[speaker-shadow] unavailable:', describe(error)) }
769
+ }
770
+
771
+ /**
772
+ * `/api/health`: `speaker_shadow` only while shadow mode is on. Absent, never null, when off, so
773
+ * the public health shape is unchanged by default (pinned by health-6890-parity.test.ts).
774
+ */
775
+ export function speakerShadowHealthFields(env: NodeJS.ProcessEnv = process.env, root: string = speakerShadowRoot()): { speaker_shadow?: Omit<SpeakerShadowSnapshot, 'lastErrorDetail' | 'readiness'> } {
776
+ if (!speakerShadowEnabled(env, root)) return {}
777
+ return { speaker_shadow: speakerShadowHealth() }
778
+ }
779
+
780
+ /** For /api/health (codes and counts only, no session ids, paths or error text). */
781
+ export function speakerShadowHealth(): Omit<SpeakerShadowSnapshot, 'lastErrorDetail' | 'readiness'> {
782
+ try {
783
+ const { lastErrorDetail: _detail, readiness: _readiness, ...rest } = getSpeakerShadowRunner().snapshot()
784
+ return rest
785
+ } catch {
786
+ return { enabled: false, source: 'default', state: 'off', queued: 0, runsToday: 0, dailyCap: 0, breaker: { open: false, failures: 0, until: null }, lastRun: null, lastError: null, deferredReason: null }
787
+ }
788
+ }