@gotcos/glasses-server 6.94.1 → 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.
- package/CHANGELOG.md +24 -0
- package/README.md +75 -0
- package/package.json +2 -1
- package/server/index.ts +13 -1
- package/server/lib/mac-speaker-machine-evidence.ts +111 -0
- package/server/lib/mac-speaker-profile-extractor.ts +41 -0
- package/server/lib/mac-speaker-profile-finalizer.ts +257 -0
- package/server/lib/mac-speaker-protection.ts +22 -0
- package/server/lib/mac-speaker-reconcile.ts +300 -0
- package/server/lib/mac-speaker-repair-projection.ts +301 -0
- package/server/lib/mac-speaker-repair-render.ts +105 -0
- package/server/lib/mac-speaker-windows.ts +239 -0
- package/server/lib/meeting-scribe-content.ts +8 -1
- package/server/lib/meeting-speaker-review.ts +39 -1
- package/server/lib/nemotron-final.ts +64 -14
- package/server/lib/speaker-shadow-decide.ts +466 -0
- package/server/lib/speaker-shadow-models.ts +150 -0
- package/server/lib/speaker-shadow-numeric.ts +143 -0
- package/server/lib/speaker-shadow-report.ts +113 -0
- package/server/lib/speaker-shadow-timeline.ts +210 -0
- package/server/lib/speaker-shadow-worker.ts +277 -0
- package/server/lib/speaker-shadow.ts +727 -0
- package/server/lib/voice-directory.ts +5 -1
- package/server/routes/health.ts +5 -0
- package/server/routes/meeting.ts +36 -5
- package/server/routes/speaker-shadow.ts +79 -0
- package/server/scripts/mac-speaker-repair.ts +324 -0
|
@@ -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()
|