@gotcos/glasses-server 6.94.2 → 6.95.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.
@@ -0,0 +1,277 @@
1
+ // Speaker shadow mode (6.95.0): one meeting's proposal run, in its own child process.
2
+ //
3
+ // The server never runs this on its event loop. speaker-shadow.ts starts it as a niced
4
+ // child (`node --import tsx/esm speaker-shadow-worker.ts`), sends one job on stdin, and
5
+ // reads exactly one JSON outcome line from stdout. The parent owns the wall clock and the
6
+ // RSS cap and kills the whole process group; this side also stops the CLI on its own
7
+ // deadline and exits if its parent goes away.
8
+ //
9
+ // READ ONLY, LOG ONLY. Reads the finalized sidecar and the retained chunk WAVs; writes
10
+ // only a private temp directory (the timeline WAV and the CLI's two JSON outputs), which
11
+ // is removed before the outcome is printed. No meeting file, label, name, profile,
12
+ // embedding cache or correction ledger is opened for writing anywhere in this file.
13
+ //
14
+ // The CLI is FluidAudio 0.17.4 `process --mode offline --overlapping-segments
15
+ // --export-embeddings`, the exact invocation of round3/propose.py, run through
16
+ // sandbox-exec with network denied (the Parakeet profile: writes only to the temp dir,
17
+ // the private HOME and the per-user temp dir NSTemporaryDirectory uses).
18
+
19
+ import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
20
+ import { createHash } from 'node:crypto'
21
+ import { createReadStream, existsSync, mkdtempSync, readdirSync, realpathSync, rmSync } from 'node:fs'
22
+ import { readFile, writeFile } from 'node:fs/promises'
23
+ import { join } from 'node:path'
24
+ import { pipeline } from 'node:stream/promises'
25
+ import { Transform } from 'node:stream'
26
+ import { fileURLToPath } from 'node:url'
27
+ import { darwinUserTempDir, parakeetChildEnv, parakeetSandboxProfile } from './parakeet.js'
28
+ import {
29
+ SHADOW_GATES, SHADOW_SAMPLE_RATE, buildShadowProposals, decide, nativeTurns,
30
+ type ShadowEmbeddingRow, type ShadowProposals,
31
+ } from './speaker-shadow-decide.js'
32
+ import { SHADOW_MODEL_REPO, SHADOW_MODEL_REVISION, prepareShadowHome, verifyShadowModels, type ShadowModelCheck } from './speaker-shadow-models.js'
33
+ import {
34
+ ShadowInputError, buildShadowTimeline, pcm16WavFile, readShadowMeetingTitle, shadowEntries, shadowHumanProtectedVoices,
35
+ shadowLabelsHash, shadowProductionSegments,
36
+ } from './speaker-shadow-timeline.js'
37
+ import { renderShadowReport } from './speaker-shadow-report.js'
38
+ import { pyRound } from './speaker-shadow-numeric.js'
39
+
40
+ export const SHADOW_TMP_PREFIX = 'cos-speaker-shadow-'
41
+ /** The CLI's flags, verbatim from round3/propose.py (via scripts/guard.py). */
42
+ export const SHADOW_CLI_FLAGS = ['--mode', 'offline', '--overlapping-segments'] as const
43
+
44
+ export interface ShadowJob {
45
+ sessionId: string
46
+ sidecarPath: string
47
+ /** The retained chunk WAV directory (`<data>/meeting-audio/<session>`). */
48
+ audioDir: string
49
+ /** The labels hash recorded when the meeting finalized; a different one now means a person edited it. */
50
+ expectedLabelsHash: string | null
51
+ cliPath: string
52
+ /** SHA-256 the parent verified for cliPath; re-checked here before the run. */
53
+ cliSha256: string
54
+ sandboxExec: string
55
+ modelsDir: string
56
+ homeDir: string
57
+ tmpRoot: string
58
+ cliTimeoutMs: number
59
+ }
60
+
61
+ export type ShadowSkipReason =
62
+ | 'not_finalized' | 'labels_changed' | 'no_voices' | 'audio_missing' | 'audio_drift' | 'audio_format'
63
+ | 'bad_sidecar' | 'too_long' | 'models_missing' | 'models_mismatch' | 'cli_changed' | 'cli_lacks_flags'
64
+ export type ShadowFailReason = 'cli_timeout' | 'cli_failed' | 'cli_unsupported' | 'bad_cli_output' | 'sandbox_unavailable' | 'internal'
65
+
66
+ export interface ShadowRunFacts {
67
+ minutes: number
68
+ originals: number
69
+ verifiedAgainstSidecar: number
70
+ unhashedOriginals: number
71
+ unhashedWithText: number[]
72
+ timelinePCMSHA256: string
73
+ timelineWAVSHA256: string
74
+ labelsHash: string
75
+ clippedTurns: number
76
+ humanProtectedVoices: string[]
77
+ /**
78
+ * Empty `NSIRD_fluidaudiocli_*` OS temp dirs that appeared during the run and are still
79
+ * there. A cold CoreML cache leaves up to four (MPSGraph staging); warm runs leave none.
80
+ * Counted, never removed: the server cannot prove a dir in the shared temp folder is its own.
81
+ */
82
+ osTempDirsLeft: number
83
+ timing: { rebuildMs: number; nativeMs: number; totalMs: number }
84
+ tool: { cliSha256: string; flags: string[]; modelRepo: string; modelRevision: string; modelBytes: number }
85
+ }
86
+
87
+ export type ShadowJobOutcome =
88
+ | { status: 'proposed'; facts: ShadowRunFacts; proposals: ShadowProposals; report: { title: string; markdown: string } }
89
+ | { status: 'skipped'; reason: ShadowSkipReason; detail: string }
90
+ | { status: 'failed'; reason: ShadowFailReason; detail: string }
91
+
92
+ type SpawnLike = (command: string, args: string[], options: SpawnOptions) => ChildProcess
93
+
94
+ export interface ShadowWorkerDeps {
95
+ spawn?: SpawnLike
96
+ now?: () => number
97
+ /** Aborts the CLI (the parent went away). */
98
+ signal?: AbortSignal
99
+ /** Tests only (the fake-CLI integration worker): real model bytes cannot be faked. */
100
+ verifyModels?: (dir: string) => Promise<ShadowModelCheck>
101
+ }
102
+
103
+ async function sha256File(path: string): Promise<string> {
104
+ const hash = createHash('sha256')
105
+ await pipeline(createReadStream(path), new Transform({ transform(chunk, _e, cb) { hash.update(chunk); cb(null) } }))
106
+ return hash.digest('hex')
107
+ }
108
+
109
+ /** The CLI flags shadow mode passes that are not in the binary's own strings. Read only. */
110
+ export async function cliMissingFlags(cliPath: string, flags: readonly string[] = ['--export-embeddings', '--overlapping-segments']): Promise<string[]> {
111
+ const bytes = await readFile(cliPath)
112
+ return flags.filter(flag => bytes.indexOf(flag) < 0)
113
+ }
114
+
115
+ /** Foundation's item-replacement dirs for this CLI, in the per-user temp folder. Read only. */
116
+ function nsirdDirs(): Set<string> {
117
+ const root = darwinUserTempDir()
118
+ if (!root) return new Set()
119
+ try { return new Set(readdirSync(join(root, 'TemporaryItems')).filter(name => name.startsWith('NSIRD_fluidaudiocli_'))) } catch { return new Set() }
120
+ }
121
+
122
+ const skip = (reason: ShadowSkipReason, detail: string): ShadowJobOutcome => ({ status: 'skipped', reason, detail })
123
+ const fail = (reason: ShadowFailReason, detail: string): ShadowJobOutcome => ({ status: 'failed', reason, detail: detail.slice(0, 300) })
124
+
125
+ function runCli(job: ShadowJob, wavPath: string, nativePath: string, embPath: string, work: string, deps: ShadowWorkerDeps): Promise<{ code: number | null; signal: NodeJS.Signals | null; stderr: string; timedOut: boolean; error?: string }> {
126
+ const args = ['-p', parakeetSandboxProfile(work, job.homeDir), job.cliPath, 'process', wavPath, '--mode', 'offline', '--output', nativePath, '--overlapping-segments', '--export-embeddings', embPath]
127
+ return new Promise(resolvePromise => {
128
+ let child: ChildProcess
129
+ try {
130
+ child = (deps.spawn ?? (spawn as unknown as SpawnLike))(job.sandboxExec, args, { stdio: ['ignore', 'ignore', 'pipe'], env: parakeetChildEnv(job.homeDir, work) })
131
+ } catch (error) {
132
+ resolvePromise({ code: null, signal: null, stderr: '', timedOut: false, error: error instanceof Error ? error.message : String(error) })
133
+ return
134
+ }
135
+ let stderr = '', timedOut = false, settled = false
136
+ const done = (value: Parameters<typeof resolvePromise>[0]) => { if (settled) return; settled = true; clearTimeout(timer); deps.signal?.removeEventListener('abort', onAbort); resolvePromise(value) }
137
+ const kill = () => { try { child.kill('SIGKILL') } catch { /* gone */ } }
138
+ // SIGKILL: CoreML work does not stop for TERM.
139
+ const timer = setTimeout(() => { timedOut = true; kill() }, job.cliTimeoutMs)
140
+ const onAbort = () => kill()
141
+ deps.signal?.addEventListener('abort', onAbort, { once: true })
142
+ child.stderr?.on('data', (d: Buffer) => { if (stderr.length < 65_536) stderr += d.toString() })
143
+ child.on('error', error => done({ code: null, signal: null, stderr, timedOut, error: error.message }))
144
+ child.on('close', (code, signal) => done({ code, signal, stderr, timedOut }))
145
+ })
146
+ }
147
+
148
+ /** One meeting. Never throws: every path ends in an outcome, and the temp dir is always removed. */
149
+ export async function runShadowJob(job: ShadowJob, deps: ShadowWorkerDeps = {}): Promise<ShadowJobOutcome> {
150
+ const now = deps.now ?? Date.now
151
+ const started = now()
152
+ let work: string | null = null
153
+ try {
154
+ // 1. The finalized meeting, unchanged since it finalized.
155
+ let sidecar: Record<string, unknown>
156
+ try { sidecar = JSON.parse(await readFile(job.sidecarPath, 'utf8')) } catch { return skip('bad_sidecar', 'the sidecar is missing or unreadable') }
157
+ if (sidecar.sessionId !== job.sessionId) return skip('bad_sidecar', 'the sidecar belongs to another session')
158
+ if (sidecar.finalizationState !== 'complete') return skip('not_finalized', `finalizationState=${String(sidecar.finalizationState)}`)
159
+ const entries = shadowEntries(sidecar)
160
+ const labelsHash = shadowLabelsHash(entries)
161
+ if (job.expectedLabelsHash && labelsHash !== job.expectedLabelsHash) return skip('labels_changed', 'labels changed after the meeting finalized (a correction landed first)')
162
+ const missing = entries.filter(e => !existsSync(join(job.audioDir, `chunk_${String(e.chunkIndex).padStart(4, '0')}.wav`))).length
163
+ if (missing) return skip('audio_missing', `${missing} original chunk WAVs missing`)
164
+
165
+ // 2. Models and CLI, verified now rather than trusted from the scheduler's look.
166
+ const models = await (deps.verifyModels ?? verifyShadowModels)(job.modelsDir)
167
+ if (!models.ok) return skip(models.reason, models.detail)
168
+ if (!existsSync(job.cliPath) || await sha256File(job.cliPath) !== job.cliSha256) return skip('cli_changed', 'the FluidAudio CLI changed since it was verified')
169
+ // A build without these would fail every run and open the breaker; say so once instead.
170
+ const missingFlags = await cliMissingFlags(job.cliPath)
171
+ if (missingFlags.length) return skip('cli_lacks_flags', `the installed fluidaudiocli has no ${missingFlags.join(' or ')} (COS Control's signed helper has both)`)
172
+ if (!existsSync(job.sandboxExec)) return fail('sandbox_unavailable', 'sandbox-exec is missing, so the CLI cannot be proven offline and does not run')
173
+
174
+ // 3. The timeline, then the production segments on it.
175
+ const timeline = await buildShadowTimeline(entries, idx => join(job.audioDir, `chunk_${String(idx).padStart(4, '0')}.wav`))
176
+ const { segs, sources } = shadowProductionSegments(entries, timeline.rows, timeline.total)
177
+ if (!segs.length) return skip('no_voices', 'no production segments to compare')
178
+ const wav = pcm16WavFile(timeline.pcm)
179
+ const rebuildMs = now() - started
180
+
181
+ work = mkdtempSync(join(job.tmpRoot, SHADOW_TMP_PREFIX))
182
+ const wavPath = join(work, 'timeline.wav'), nativePath = join(work, 'native.json'), embPath = join(work, 'embeddings.json')
183
+ await writeFile(wavPath, wav, { mode: 0o600, flag: 'wx' })
184
+ const timelineWAVSHA256 = createHash('sha256').update(wav).digest('hex')
185
+ prepareShadowHome(job.homeDir, models.dir)
186
+
187
+ // 4. One offline diarization.
188
+ const nativeStarted = now()
189
+ const nsirdBefore = nsirdDirs()
190
+ const run = await runCli(job, wavPath, nativePath, embPath, work, deps)
191
+ const nativeMs = now() - nativeStarted
192
+ const osTempDirsLeft = [...nsirdDirs()].filter(name => !nsirdBefore.has(name)).length
193
+ if (run.timedOut) return fail('cli_timeout', `no result within ${job.cliTimeoutMs} ms`)
194
+ if (run.error) return fail('cli_failed', `spawn: ${run.error}`)
195
+ const tail = run.stderr.trim().split('\n').slice(-2).join(' | ')
196
+ if (/Unknown option|unexpected argument|Unrecognized/i.test(run.stderr)) return fail('cli_unsupported', `the CLI rejected an option: ${tail}`)
197
+ if (run.code !== 0) return fail('cli_failed', `exit ${run.code ?? run.signal}: ${tail}`)
198
+
199
+ let native: { segments?: unknown }, emb: unknown
200
+ try {
201
+ native = JSON.parse(await readFile(nativePath, 'utf8'))
202
+ emb = JSON.parse(await readFile(embPath, 'utf8'))
203
+ } catch (error) { return fail('bad_cli_output', `unreadable output: ${error instanceof Error ? error.message : String(error)}`) }
204
+ if (!Array.isArray(native.segments) || !Array.isArray(emb)) return fail('bad_cli_output', 'the CLI output has no segments or embeddings')
205
+
206
+ // 5. The gates, exactly as the research tool applies them.
207
+ let proposals: ShadowProposals, clipped: number
208
+ try {
209
+ const turns = nativeTurns(native.segments as Array<{ speakerId: unknown; startTimeSeconds: unknown; endTimeSeconds: unknown }>, timeline.total / SHADOW_SAMPLE_RATE)
210
+ clipped = turns.clipped
211
+ for (const row of emb as ShadowEmbeddingRow[]) {
212
+ if (!row || typeof row.startTime !== 'number' || typeof row.endTime !== 'number' || !Array.isArray(row.embedding256) || row.embedding256.length !== 256) throw new Error('bad embedding row')
213
+ }
214
+ const D = decide(segs, timeline.total, turns.turns, emb as ShadowEmbeddingRow[], SHADOW_GATES.floorSeconds, SHADOW_GATES.majority)
215
+ proposals = buildShadowProposals(D, segs, sources, turns.turns)
216
+ } catch (error) { return fail('bad_cli_output', error instanceof Error ? error.message : String(error)) }
217
+
218
+ const humanProtectedVoices = shadowHumanProtectedVoices(entries)
219
+ const facts: ShadowRunFacts = {
220
+ minutes: pyRound(timeline.total / SHADOW_SAMPLE_RATE / 60, 1),
221
+ originals: entries.length,
222
+ verifiedAgainstSidecar: timeline.verified,
223
+ unhashedOriginals: timeline.unhashed,
224
+ unhashedWithText: timeline.unhashedWithText,
225
+ timelinePCMSHA256: timeline.pcmSHA256,
226
+ timelineWAVSHA256,
227
+ labelsHash,
228
+ clippedTurns: clipped,
229
+ humanProtectedVoices,
230
+ osTempDirsLeft,
231
+ timing: { rebuildMs, nativeMs, totalMs: now() - started },
232
+ tool: { cliSha256: job.cliSha256, flags: [...SHADOW_CLI_FLAGS, '--export-embeddings'], modelRepo: SHADOW_MODEL_REPO, modelRevision: SHADOW_MODEL_REVISION, modelBytes: models.bytes },
233
+ }
234
+ const title = await readShadowMeetingTitle(job.sidecarPath)
235
+ const chunkText = new Map(entries.map(e => [e.chunkIndex, typeof e.chunk.text === 'string' ? e.chunk.text : '']))
236
+ const markdown = renderShadowReport({ title, sessionId: job.sessionId, proposals, facts, audioDir: job.audioDir, chunkText })
237
+ return { status: 'proposed', proposals, facts, report: { title, markdown } }
238
+ } catch (error) {
239
+ if (error instanceof ShadowInputError) return skip(error.reason, error.message)
240
+ return fail('internal', error instanceof Error ? error.message : String(error))
241
+ } finally {
242
+ if (work) { try { rmSync(work, { recursive: true, force: true }) } catch { /* best effort; swept at boot */ } }
243
+ }
244
+ }
245
+
246
+ // ── Child process entry ──────────────────────────────────────────────────────
247
+
248
+ async function readStdin(limit = 64 * 1024): Promise<string> {
249
+ let text = ''
250
+ for await (const chunk of process.stdin) {
251
+ text += chunk.toString()
252
+ if (text.length > limit) throw new Error('job too large')
253
+ }
254
+ return text
255
+ }
256
+
257
+ /** The child's whole life: one job in on stdin, one outcome line out on stdout. */
258
+ export async function workerMain(deps: Omit<ShadowWorkerDeps, 'signal'> = {}): Promise<void> {
259
+ const controller = new AbortController()
260
+ const parent = process.ppid
261
+ // A parent that died (or was SIGKILLed with its group spared) must not leave this running.
262
+ const watch = setInterval(() => { if (process.ppid !== parent) { controller.abort(); process.exit(3) } }, 1_000)
263
+ watch.unref()
264
+ let outcome: ShadowJobOutcome
265
+ try {
266
+ const job = JSON.parse(await readStdin()) as ShadowJob
267
+ outcome = await runShadowJob(job, { ...deps, signal: controller.signal })
268
+ } catch (error) {
269
+ outcome = fail('internal', error instanceof Error ? error.message : String(error))
270
+ }
271
+ process.stdout.write(`${JSON.stringify(outcome)}\n`, () => process.exit(0))
272
+ }
273
+
274
+ function isEntry(): boolean {
275
+ try { return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) } catch { return false }
276
+ }
277
+ if (isEntry()) void workerMain()