@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,210 @@
1
+ // Speaker shadow mode (6.95.0): the whole-call timeline and the production segments.
2
+ //
3
+ // A port of round3/propose.py `build_timeline` and its segment loop. READ ONLY: it reads
4
+ // the finalized sidecar and the retained chunk WAVs and returns buffers; it never writes.
5
+ //
6
+ // Placement policy (the canary's): each original chunk starts at floor(elapsed_ms*16+.5),
7
+ // rows sort by (start, raw index), an original's tail that runs into the next one's start
8
+ // is trimmed, and gaps stay zero. Every original with an `audioSha256` must still match
9
+ // it, and each file is read twice with the second read required to hash the same as the
10
+ // first, which is what the tool's "rebuild twice, same PCM SHA" check proves.
11
+
12
+ import { createHash } from 'node:crypto'
13
+ import { readFile } from 'node:fs/promises'
14
+ import { basename } from 'node:path'
15
+ import { hasMacSpeakerProtectedRows } from './mac-speaker-protection.js'
16
+ import { SHADOW_SAMPLE_RATE, isShadowVoiceLabel, type ShadowSegment, type ShadowSegmentSource } from './speaker-shadow-decide.js'
17
+ import { PY_SPACE_CLASS, pyRoundInt } from './speaker-shadow-numeric.js'
18
+
19
+ /** A meeting longer than this is not reconstructed (the timeline buffer is 115 MB per hour). */
20
+ export const SHADOW_MAX_TIMELINE_SECONDS = 4 * 60 * 60
21
+
22
+ export class ShadowInputError extends Error {
23
+ constructor(readonly reason: 'bad_sidecar' | 'audio_missing' | 'audio_format' | 'audio_drift' | 'too_long', message: string) {
24
+ super(message)
25
+ this.name = 'ShadowInputError'
26
+ }
27
+ }
28
+
29
+ export interface ShadowChunkEntry { chunkIndex: number; chunk: Record<string, unknown> }
30
+ export interface ShadowTimelineRow { rawChunkIndex: number; startSample: number; sampleCount: number; usedSampleCount: number }
31
+ export interface ShadowTimeline {
32
+ rows: ShadowTimelineRow[]
33
+ pcm: Buffer
34
+ total: number
35
+ verified: number
36
+ unhashed: number
37
+ unhashedWithText: number[]
38
+ pcmSHA256: string
39
+ }
40
+
41
+ const sha256 = (data: Buffer) => createHash('sha256').update(data).digest('hex')
42
+ const PY_STRIP = new RegExp(`^${PY_SPACE_CLASS}+|${PY_SPACE_CLASS}+$`, 'gu')
43
+
44
+ /** Validated entries in sidecar order, or a bad_sidecar error. */
45
+ export function shadowEntries(sidecar: Record<string, unknown>): ShadowChunkEntry[] {
46
+ const raw = sidecar.chunkEntries
47
+ if (!Array.isArray(raw) || !raw.length) throw new ShadowInputError('bad_sidecar', 'the sidecar has no chunkEntries')
48
+ const seen = new Set<number>()
49
+ return raw.map(item => {
50
+ const e = item as { chunkIndex?: unknown; chunk?: unknown }
51
+ if (!e || typeof e !== 'object' || !Number.isInteger(e.chunkIndex) || (e.chunkIndex as number) < 0 || (e.chunkIndex as number) > 99_999) throw new ShadowInputError('bad_sidecar', 'a chunk entry has no valid chunkIndex')
52
+ if (!e.chunk || typeof e.chunk !== 'object' || Array.isArray(e.chunk)) throw new ShadowInputError('bad_sidecar', 'a chunk entry has no chunk')
53
+ const chunk = e.chunk as Record<string, unknown>
54
+ if (typeof chunk.elapsed !== 'number' || !Number.isFinite(chunk.elapsed)) throw new ShadowInputError('bad_sidecar', 'a chunk has no elapsed clock')
55
+ if (chunk.segments !== undefined && !Array.isArray(chunk.segments)) throw new ShadowInputError('bad_sidecar', 'a chunk has malformed segments')
56
+ if (seen.has(e.chunkIndex as number)) throw new ShadowInputError('bad_sidecar', 'duplicate chunkIndex')
57
+ seen.add(e.chunkIndex as number)
58
+ return { chunkIndex: e.chunkIndex as number, chunk }
59
+ })
60
+ }
61
+
62
+ /** propose.py labels_of: what a later correction would change. Hashed, so a run can refuse labels that moved after finalize. */
63
+ export function shadowLabelsHash(entries: readonly ShadowChunkEntry[]): string {
64
+ const projection = entries.map(e => [
65
+ e.chunkIndex,
66
+ e.chunk.speaker ?? null,
67
+ ((e.chunk.segments as unknown[] | undefined) ?? []).map(s => {
68
+ const seg = (s ?? {}) as Record<string, unknown>
69
+ return [seg.speaker ?? null, seg.startSec ?? null, seg.endSec ?? null]
70
+ }),
71
+ ])
72
+ return createHash('sha256').update(JSON.stringify(projection)).digest('hex')
73
+ }
74
+
75
+ /** Python `wave` read of a 16 kHz mono 16-bit PCM file: the declared frames and their bytes. */
76
+ export function readPcm16Wav(wav: Buffer): { sampleCount: number; pcm: Buffer } {
77
+ if (wav.length < 12 || wav.toString('ascii', 0, 4) !== 'RIFF' || wav.toString('ascii', 8, 12) !== 'WAVE') throw new ShadowInputError('audio_format', 'not a RIFF/WAVE file')
78
+ let offset = 12, fmtOk = false
79
+ while (offset + 8 <= wav.length) {
80
+ const id = wav.toString('ascii', offset, offset + 4)
81
+ const size = wav.readUInt32LE(offset + 4)
82
+ const body = offset + 8
83
+ if (id === 'fmt ') {
84
+ if (body + 16 > wav.length) throw new ShadowInputError('audio_format', 'truncated fmt chunk')
85
+ const format = wav.readUInt16LE(body), channels = wav.readUInt16LE(body + 2), rate = wav.readUInt32LE(body + 4), bits = wav.readUInt16LE(body + 14)
86
+ if (format !== 1 || channels !== 1 || rate !== SHADOW_SAMPLE_RATE || bits !== 16) throw new ShadowInputError('audio_format', `expected 16 kHz mono PCM16, got format ${format}, ${channels} ch, ${rate} Hz, ${bits} bit`)
87
+ fmtOk = true
88
+ } else if (id === 'data') {
89
+ if (!fmtOk) throw new ShadowInputError('audio_format', 'data before fmt')
90
+ const sampleCount = Math.floor(size / 2)
91
+ if (body + sampleCount * 2 > wav.length) throw new ShadowInputError('audio_format', 'truncated data chunk')
92
+ return { sampleCount, pcm: wav.subarray(body, body + sampleCount * 2) }
93
+ }
94
+ offset = body + size + (size % 2)
95
+ }
96
+ throw new ShadowInputError('audio_format', 'no data chunk')
97
+ }
98
+
99
+ /** The 44-byte header Python's wave writer emits, then the PCM. */
100
+ export function pcm16WavFile(pcm: Buffer): Buffer {
101
+ const header = Buffer.alloc(44)
102
+ header.write('RIFF', 0); header.writeUInt32LE(36 + pcm.length, 4); header.write('WAVE', 8); header.write('fmt ', 12)
103
+ header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22); header.writeUInt32LE(SHADOW_SAMPLE_RATE, 24)
104
+ header.writeUInt32LE(SHADOW_SAMPLE_RATE * 2, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34); header.write('data', 36); header.writeUInt32LE(pcm.length, 40)
105
+ return Buffer.concat([header, pcm])
106
+ }
107
+
108
+ async function readOriginal(path: string): Promise<Buffer> {
109
+ try { return await readFile(path) } catch (error) {
110
+ const code = (error as NodeJS.ErrnoException)?.code
111
+ throw new ShadowInputError(code === 'ENOENT' ? 'audio_missing' : 'audio_format', `original ${path.split('/').pop()}: ${code ?? 'unreadable'}`)
112
+ }
113
+ }
114
+
115
+ /** Every original verified, placed and copied into one PCM buffer. Async reads, one file at a time. */
116
+ export async function buildShadowTimeline(entries: readonly ShadowChunkEntry[], originalPath: (chunkIndex: number) => string): Promise<ShadowTimeline> {
117
+ const SR = SHADOW_SAMPLE_RATE
118
+ let verified = 0
119
+ const unhashed: number[] = [], unhashedWithText: number[] = []
120
+ const firstHash = new Map<number, string>()
121
+ const rows: Array<ShadowTimelineRow & { path: string }> = []
122
+ for (const e of entries) {
123
+ const path = originalPath(e.chunkIndex)
124
+ const bytes = await readOriginal(path)
125
+ const digest = sha256(bytes)
126
+ const want = e.chunk.audioSha256
127
+ if (typeof want === 'string' && want) {
128
+ if (digest !== want) throw new ShadowInputError('audio_drift', `original ${e.chunkIndex} no longer matches the sidecar audioSha256`)
129
+ verified++
130
+ } else {
131
+ unhashed.push(e.chunkIndex)
132
+ const text = typeof e.chunk.text === 'string' ? e.chunk.text : ''
133
+ if (text.replace(PY_STRIP, '')) unhashedWithText.push(e.chunkIndex)
134
+ }
135
+ const { sampleCount } = readPcm16Wav(bytes)
136
+ firstHash.set(e.chunkIndex, digest)
137
+ rows.push({ rawChunkIndex: e.chunkIndex, startSample: Math.floor((e.chunk.elapsed as number) * 16 + 0.5), sampleCount, usedSampleCount: 0, path })
138
+ }
139
+ rows.sort((a, b) => (a.startSample - b.startSample) || (a.rawChunkIndex - b.rawChunkIndex))
140
+ if (rows[0].startSample < 0) throw new ShadowInputError('bad_sidecar', 'a chunk starts before the recording')
141
+ const total = Math.max(...rows.map(x => x.startSample + x.sampleCount))
142
+ if (total > SHADOW_MAX_TIMELINE_SECONDS * SR) throw new ShadowInputError('too_long', `the meeting is longer than ${SHADOW_MAX_TIMELINE_SECONDS / 3600} h`)
143
+ const pcm = Buffer.alloc(total * 2)
144
+ for (let i = 0; i < rows.length; i++) {
145
+ const x = rows[i]
146
+ const bytes = await readOriginal(x.path)
147
+ if (sha256(bytes) !== firstHash.get(x.rawChunkIndex)) throw new ShadowInputError('audio_drift', `original ${x.rawChunkIndex} changed while the timeline was built`)
148
+ const raw = readPcm16Wav(bytes).pcm
149
+ const use = i + 1 < rows.length ? Math.min(x.sampleCount, Math.max(0, rows[i + 1].startSample - x.startSample)) : x.sampleCount
150
+ if (!(use > 0)) throw new ShadowInputError('bad_sidecar', `original ${x.rawChunkIndex} is fully shadowed by the next one`)
151
+ x.usedSampleCount = use
152
+ raw.copy(pcm, x.startSample * 2, 0, use * 2)
153
+ }
154
+ return {
155
+ rows: rows.map(({ path: _path, ...row }) => row),
156
+ pcm, total, verified, unhashed: unhashed.length, unhashedWithText, pcmSHA256: sha256(pcm),
157
+ }
158
+ }
159
+
160
+ /** propose.py's segment loop: production labels on the timeline, plus where each came from. */
161
+ export function shadowProductionSegments(entries: readonly ShadowChunkEntry[], rows: readonly ShadowTimelineRow[], total: number): { segs: ShadowSegment[]; sources: ShadowSegmentSource[] } {
162
+ const SR = SHADOW_SAMPLE_RATE
163
+ const placed = new Map(rows.map(x => [x.rawChunkIndex, x]))
164
+ // Python's isinstance(x, (int, float)) is true for a JSON boolean, which counts as 0 or 1.
165
+ const num = (x: unknown): number | null => typeof x === 'number' ? x : typeof x === 'boolean' ? Number(x) : null
166
+ const segs: ShadowSegment[] = [], sources: ShadowSegmentSource[] = []
167
+ for (const e of entries) {
168
+ const x = placed.get(e.chunkIndex)!
169
+ const off = x.startSample, end = Math.min(total, off + x.usedSampleCount)
170
+ for (const raw of (e.chunk.segments as unknown[] | undefined) ?? []) {
171
+ const s = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
172
+ const a = num(s.startSec), b = num(s.endSec), label = s.speaker
173
+ if (a === null || b === null || !label) continue
174
+ if (typeof label !== 'string') throw new ShadowInputError('bad_sidecar', 'a segment speaker is not a string')
175
+ const lo = off + pyRoundInt(a * SR), hi = Math.min(end, off + pyRoundInt(b * SR))
176
+ if (lo < hi) {
177
+ segs.push([lo, hi, label])
178
+ sources.push({ chunkIndex: e.chunkIndex, start: a, end: Math.min(b, x.usedSampleCount / SR), abs: lo / SR })
179
+ }
180
+ }
181
+ }
182
+ return { segs, sources }
183
+ }
184
+
185
+ /** Voice labels that appear on any chunk a human touched. A hint in the record; it never changes a gate. */
186
+ export function shadowHumanProtectedVoices(entries: readonly ShadowChunkEntry[]): string[] {
187
+ const out = new Set<string>()
188
+ for (const e of entries) {
189
+ if (!hasMacSpeakerProtectedRows(e.chunk)) continue
190
+ const labels = [e.chunk.speaker, ...((e.chunk.segments as unknown[] | undefined) ?? []).map(s => (s as Record<string, unknown> | null)?.speaker)]
191
+ for (const label of labels) if (typeof label === 'string' && isShadowVoiceLabel(label)) out.add(label)
192
+ }
193
+ return [...out].sort()
194
+ }
195
+
196
+ /** The meeting's title (the saved markdown's `# ` line), else the sidecar's file name. Read only. */
197
+ export async function readShadowMeetingTitle(sidecarPath: string): Promise<string> {
198
+ const fallback = basename(sidecarPath).replace(/\.g2-chunks\.json$/, '')
199
+ try {
200
+ const text = await readFile(sidecarPath.replace(/\.g2-chunks\.json$/, '.md'), 'utf8')
201
+ const line = text.split('\n', 40).find(l => l.startsWith('# '))
202
+ const title = line?.slice(2).trim()
203
+ return title ? title.slice(0, 160) : fallback
204
+ } catch { return fallback }
205
+ }
206
+
207
+ /** The meeting day for the report folder: the sidecar name's YYYY-MM-DD prefix, else null. */
208
+ export function shadowMeetingDay(sidecarPath: string): string | null {
209
+ return /^(\d{4}-\d{2}-\d{2})/.exec(basename(sidecarPath))?.[1] ?? null
210
+ }
@@ -0,0 +1,312 @@
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
+ // Shadow remains READ ONLY, LOG ONLY: its temporary native outputs are removed.
10
+ // A separately default-off reviewed closed-call consumer may retain the SAME verified
11
+ // execution exports in its own data directory. It never edits BASE, names, profiles or
12
+ // corrections here; later derived-view publication has an independent queue/admission.
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
+ import { findShadowPairSource } from './speaker-shadow-pair-source.js'
40
+ import { evaluatePairedSpeakerShadow, renderPairedSpeakerShadow, type PairedShadowReport } from './speaker-shadow-paired.js'
41
+ import { beginMacSpeakerCallNative,retainMacSpeakerCallNative,type MacSpeakerCallProducerRequest,type MacSpeakerCallNativeRef } from './mac-speaker-call-producer-worker.js'
42
+
43
+ export const SHADOW_TMP_PREFIX = 'cos-speaker-shadow-'
44
+ /** The CLI's flags, verbatim from round3/propose.py (via scripts/guard.py). */
45
+ export const SHADOW_CLI_FLAGS = ['--mode', 'offline', '--overlapping-segments'] as const
46
+
47
+ export interface ShadowJob {
48
+ sessionId: string
49
+ sidecarPath: string
50
+ /** The retained chunk WAV directory (`<data>/meeting-audio/<session>`). */
51
+ audioDir: string
52
+ /** The labels hash recorded when the meeting finalized; a different one now means a person edited it. */
53
+ expectedLabelsHash: string | null
54
+ cliPath: string
55
+ /** SHA-256 the parent verified for cliPath; re-checked here before the run. */
56
+ cliSha256: string
57
+ sandboxExec: string
58
+ modelsDir: string
59
+ homeDir: string
60
+ tmpRoot: string
61
+ cliTimeoutMs: number
62
+ /** Separate default-off consumer, admitted independently from log-only shadow. */
63
+ callProducer?: MacSpeakerCallProducerRequest
64
+ shadowLogRequested?: boolean
65
+ callProducerSwapStart?:number
66
+ }
67
+
68
+ export type ShadowSkipReason =
69
+ | 'not_finalized' | 'labels_changed' | 'no_voices' | 'audio_missing' | 'audio_drift' | 'audio_format'
70
+ | 'bad_sidecar' | 'too_long' | 'models_missing' | 'models_mismatch' | 'cli_changed' | 'cli_lacks_flags'
71
+ export type ShadowFailReason = 'cli_timeout' | 'cli_failed' | 'cli_unsupported' | 'bad_cli_output' | 'sandbox_unavailable' | 'internal'
72
+
73
+ export interface ShadowRunFacts {
74
+ minutes: number
75
+ originals: number
76
+ verifiedAgainstSidecar: number
77
+ unhashedOriginals: number
78
+ unhashedWithText: number[]
79
+ timelinePCMSHA256: string
80
+ timelineWAVSHA256: string
81
+ labelsHash: string
82
+ clippedTurns: number
83
+ humanProtectedVoices: string[]
84
+ /**
85
+ * Empty `NSIRD_fluidaudiocli_*` OS temp dirs that appeared during the run and are still
86
+ * there. A cold CoreML cache leaves up to four (MPSGraph staging); warm runs leave none.
87
+ * Counted, never removed: the server cannot prove a dir in the shared temp folder is its own.
88
+ */
89
+ osTempDirsLeft: number
90
+ timing: { rebuildMs: number; nativeMs: number; totalMs: number }
91
+ tool: { cliSha256: string; flags: string[]; modelRepo: string; modelRevision: string; modelBytes: number }
92
+ }
93
+
94
+ export type ShadowJobOutcome =
95
+ | { status: 'proposed'; facts: ShadowRunFacts; proposals: ShadowProposals; paired?: PairedShadowReport & { pairSessionId?: string; pairSourceSha256?: string; discoveryReason: string }; report: { title: string; markdown: string };callProducerExport?:MacSpeakerCallNativeRef; callProducerFailure?:{stage:'retain';reason:string} }
96
+ | { status: 'skipped'; reason: ShadowSkipReason; detail: string }
97
+ | { status: 'failed'; reason: ShadowFailReason; detail: string }
98
+
99
+ type SpawnLike = (command: string, args: string[], options: SpawnOptions) => ChildProcess
100
+
101
+ export interface ShadowWorkerDeps {
102
+ spawn?: SpawnLike
103
+ now?: () => number
104
+ /** Aborts the CLI (the parent went away). */
105
+ signal?: AbortSignal
106
+ /** Tests only (the fake-CLI integration worker): real model bytes cannot be faked. */
107
+ verifyModels?: (dir: string) => Promise<ShadowModelCheck>
108
+ }
109
+
110
+ async function sha256File(path: string): Promise<string> {
111
+ const hash = createHash('sha256')
112
+ await pipeline(createReadStream(path), new Transform({ transform(chunk, _e, cb) { hash.update(chunk); cb(null) } }))
113
+ return hash.digest('hex')
114
+ }
115
+
116
+ /** The CLI flags shadow mode passes that are not in the binary's own strings. Read only. */
117
+ export async function cliMissingFlags(cliPath: string, flags: readonly string[] = ['--export-embeddings', '--overlapping-segments']): Promise<string[]> {
118
+ const bytes = await readFile(cliPath)
119
+ return flags.filter(flag => bytes.indexOf(flag) < 0)
120
+ }
121
+
122
+ /** Foundation's item-replacement dirs for this CLI, in the per-user temp folder. Read only. */
123
+ function nsirdDirs(): Set<string> {
124
+ const root = darwinUserTempDir()
125
+ if (!root) return new Set()
126
+ try { return new Set(readdirSync(join(root, 'TemporaryItems')).filter(name => name.startsWith('NSIRD_fluidaudiocli_'))) } catch { return new Set() }
127
+ }
128
+
129
+ const skip = (reason: ShadowSkipReason, detail: string): ShadowJobOutcome => ({ status: 'skipped', reason, detail })
130
+ const fail = (reason: ShadowFailReason, detail: string): ShadowJobOutcome => ({ status: 'failed', reason, detail: detail.slice(0, 300) })
131
+
132
+ 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;closed:boolean; error?: string }> {
133
+ const args = ['-p', parakeetSandboxProfile(work, job.homeDir), job.cliPath, 'process', wavPath, '--mode', 'offline', '--output', nativePath, '--overlapping-segments', '--export-embeddings', embPath]
134
+ return new Promise(resolvePromise => {
135
+ if(deps.signal?.aborted){resolvePromise({code:null,signal:null,stderr:'',timedOut:false,closed:false,error:'cancelled before native admission'});return}
136
+ const deadline=Date.now()+job.cliTimeoutMs
137
+ let child: ChildProcess
138
+ try {
139
+ child = (deps.spawn ?? (spawn as unknown as SpawnLike))(job.sandboxExec, args, { stdio: ['ignore', 'ignore', 'pipe'], env: {...parakeetChildEnv(job.homeDir, work),...(job.callProducer?{OMP_NUM_THREADS:'1',VECLIB_MAXIMUM_THREADS:'1',OPENBLAS_NUM_THREADS:'1'}:{})} })
140
+ } catch (error) {
141
+ resolvePromise({ code: null, signal: null, stderr: '', timedOut: false,closed:false, error: error instanceof Error ? error.message : String(error) })
142
+ return
143
+ }
144
+ let stderr = '', timedOut = false, settled = false, childError:string|undefined
145
+ const done = (value: Parameters<typeof resolvePromise>[0]) => { if (settled) return; settled = true; clearTimeout(timer); deps.signal?.removeEventListener('abort', onAbort); resolvePromise(value) }
146
+ const kill = () => { try { child.kill('SIGKILL') } catch { /* gone */ } }
147
+ // SIGKILL: CoreML work does not stop for TERM.
148
+ const timer = setTimeout(() => { timedOut = true; kill() }, job.cliTimeoutMs)
149
+ const onAbort = () => kill()
150
+ deps.signal?.addEventListener('abort', onAbort, { once: true })
151
+ child.stderr?.on('data', (d: Buffer) => { if (stderr.length < 65_536) stderr += d.toString() })
152
+ child.on('error', error => {childError=error.message;if(!child.pid)done({ code: null, signal: null, stderr, timedOut,closed:false, error: error.message });else kill()})
153
+ child.on('close', (code, signal) => done({ code, signal, stderr, timedOut:timedOut||Date.now()>deadline,closed:true,error:childError }))
154
+ })
155
+ }
156
+
157
+ /** One meeting. Never throws: every path ends in an outcome, and the temp dir is always removed. */
158
+ export async function runShadowJob(job: ShadowJob, deps: ShadowWorkerDeps = {}): Promise<ShadowJobOutcome> {
159
+ const now = deps.now ?? Date.now
160
+ const started = now()
161
+ let work: string | null = null
162
+ try {
163
+ // 1. The finalized meeting, unchanged since it finalized.
164
+ let sidecar: Record<string, unknown>,sourceJSON:string
165
+ try { sourceJSON=await readFile(job.sidecarPath, 'utf8');sidecar = JSON.parse(sourceJSON) } catch { return skip('bad_sidecar', 'the sidecar is missing or unreadable') }
166
+ if (sidecar.sessionId !== job.sessionId) return skip('bad_sidecar', 'the sidecar belongs to another session')
167
+ if (sidecar.finalizationState !== 'complete') return skip('not_finalized', `finalizationState=${String(sidecar.finalizationState)}`)
168
+ const entries = shadowEntries(sidecar)
169
+ const labelsHash = shadowLabelsHash(entries)
170
+ if (job.expectedLabelsHash && labelsHash !== job.expectedLabelsHash) return skip('labels_changed', 'labels changed after the meeting finalized (a correction landed first)')
171
+ const missing = entries.filter(e => !existsSync(join(job.audioDir, `chunk_${String(e.chunkIndex).padStart(4, '0')}.wav`))).length
172
+ if (missing) return skip('audio_missing', `${missing} original chunk WAVs missing`)
173
+
174
+ // 2. Models and CLI, verified now rather than trusted from the scheduler's look.
175
+ const models = await (deps.verifyModels ?? verifyShadowModels)(job.modelsDir)
176
+ if (!models.ok) return skip(models.reason, models.detail)
177
+ if (!existsSync(job.cliPath) || await sha256File(job.cliPath) !== job.cliSha256) return skip('cli_changed', 'the FluidAudio CLI changed since it was verified')
178
+ // A build without these would fail every run and open the breaker; say so once instead.
179
+ const missingFlags = await cliMissingFlags(job.cliPath)
180
+ if (missingFlags.length) return skip('cli_lacks_flags', `the installed fluidaudiocli has no ${missingFlags.join(' or ')} (COS Control's signed helper has both)`)
181
+ if (!existsSync(job.sandboxExec)) return fail('sandbox_unavailable', 'sandbox-exec is missing, so the CLI cannot be proven offline and does not run')
182
+
183
+ // 3. The timeline, then the production segments on it.
184
+ const timeline = await buildShadowTimeline(entries, idx => join(job.audioDir, `chunk_${String(idx).padStart(4, '0')}.wav`))
185
+ const { segs, sources } = shadowProductionSegments(entries, timeline.rows, timeline.total)
186
+ if (!segs.length) return skip('no_voices', 'no production segments to compare')
187
+ const wav = pcm16WavFile(timeline.pcm)
188
+ const rebuildMs = now() - started
189
+
190
+ // The platform temp root may spell /private/var as /var. Pin the owned
191
+ // directory itself; retained-output readers keep their strict canonical path fence.
192
+ work = mkdtempSync(join(job.tmpRoot, SHADOW_TMP_PREFIX))
193
+ work = realpathSync(work)
194
+ const wavPath = join(work, 'timeline.wav'), nativePath = join(work, 'native.json'), embPath = join(work, 'embeddings.json')
195
+ await writeFile(wavPath, wav, { mode: 0o600, flag: 'wx' })
196
+ const timelineWAVSHA256 = createHash('sha256').update(wav).digest('hex')
197
+ prepareShadowHome(job.homeDir, models.dir)
198
+ let producerContext:ReturnType<typeof beginMacSpeakerCallNative>|null=null
199
+ if(job.callProducer){
200
+ try{producerContext=beginMacSpeakerCallNative(job.callProducer,{sourceJSON,audioDir:job.audioDir,cliPath:job.cliPath,modelsDir:models.dir,timeline:{rows:timeline.rows,total:timeline.total,pcmSHA256:timeline.pcmSHA256}})}
201
+ catch(error){if(job.shadowLogRequested===false)return skip('bad_sidecar',`independent consumer withheld: ${error instanceof Error?error.message:String(error)}`)}
202
+ }
203
+
204
+ // 4. One offline diarization.
205
+ const nativeStarted = now()
206
+ const nsirdBefore = nsirdDirs()
207
+ const run = await runCli(job, wavPath, nativePath, embPath, work, deps)
208
+ const nativeMs = now() - nativeStarted
209
+ const osTempDirsLeft = [...nsirdDirs()].filter(name => !nsirdBefore.has(name)).length
210
+ if (run.timedOut) return fail('cli_timeout', `no result within ${job.cliTimeoutMs} ms`)
211
+ if (run.error) return fail('cli_failed', `spawn: ${run.error}`)
212
+ const tail = run.stderr.trim().split('\n').slice(-2).join(' | ')
213
+ if (/Unknown option|unexpected argument|Unrecognized/i.test(run.stderr)) return fail('cli_unsupported', `the CLI rejected an option: ${tail}`)
214
+ if (run.code !== 0) return fail('cli_failed', `exit ${run.code ?? run.signal}: ${tail}`)
215
+
216
+ let native: { segments?: unknown }, emb: unknown
217
+ try {
218
+ native = JSON.parse(await readFile(nativePath, 'utf8'))
219
+ emb = JSON.parse(await readFile(embPath, 'utf8'))
220
+ } catch (error) { return fail('bad_cli_output', `unreadable output: ${error instanceof Error ? error.message : String(error)}`) }
221
+ if (!Array.isArray(native.segments) || !Array.isArray(emb)) return fail('bad_cli_output', 'the CLI output has no segments or embeddings')
222
+
223
+ // 5. The gates, exactly as the research tool applies them.
224
+ let proposals: ShadowProposals, clipped: number
225
+ let paired: Extract<ShadowJobOutcome, { status: 'proposed' }>['paired']
226
+ try {
227
+ const turns = nativeTurns(native.segments as Array<{ speakerId: unknown; startTimeSeconds: unknown; endTimeSeconds: unknown }>, timeline.total / SHADOW_SAMPLE_RATE)
228
+ clipped = turns.clipped
229
+ for (const row of emb as ShadowEmbeddingRow[]) {
230
+ if (!row || typeof row.startTime !== 'number' || typeof row.endTime !== 'number' || !Array.isArray(row.embedding256) || row.embedding256.length !== 256) throw new Error('bad embedding row')
231
+ }
232
+ const D = decide(segs, timeline.total, turns.turns, emb as ShadowEmbeddingRow[], SHADOW_GATES.floorSeconds, SHADOW_GATES.majority)
233
+ proposals = buildShadowProposals(D, segs, sources, turns.turns)
234
+ // Pair scoring is advisory; an unavailable G2 must not suppress the acoustic report.
235
+ try {
236
+ const source = await findShadowPairSource(job.sidecarPath, sidecar, timeline.total / SHADOW_SAMPLE_RATE * 1000)
237
+ paired = { ...evaluatePairedSpeakerShadow({ macStartTime: sidecar.startTime as number, totalSamples: timeline.total,
238
+ segments: segs, regularTurns: turns.turns, acceptedGroups: proposals.accepted,
239
+ voiceEvidence: [...proposals.accepted.flatMap(group => group.voices), ...proposals.rejected],
240
+ g2: source.g2, alignment: { status: source.status } }),
241
+ pairSessionId: source.sessionId, pairSourceSha256: source.sourceSha256, discoveryReason: source.reason }
242
+ } catch {
243
+ paired = { ...evaluatePairedSpeakerShadow({ macStartTime: sidecar.startTime as number, totalSamples: timeline.total,
244
+ segments: segs, regularTurns: turns.turns, acceptedGroups: proposals.accepted, voiceEvidence: [],
245
+ alignment: { status: 'unpaired' } }), discoveryReason: 'pair discovery or scoring unavailable' }
246
+ }
247
+ } catch (error) { return fail('bad_cli_output', error instanceof Error ? error.message : String(error)) }
248
+
249
+ const humanProtectedVoices = shadowHumanProtectedVoices(entries)
250
+ const facts: ShadowRunFacts = {
251
+ minutes: pyRound(timeline.total / SHADOW_SAMPLE_RATE / 60, 1),
252
+ originals: entries.length,
253
+ verifiedAgainstSidecar: timeline.verified,
254
+ unhashedOriginals: timeline.unhashed,
255
+ unhashedWithText: timeline.unhashedWithText,
256
+ timelinePCMSHA256: timeline.pcmSHA256,
257
+ timelineWAVSHA256,
258
+ labelsHash,
259
+ clippedTurns: clipped,
260
+ humanProtectedVoices,
261
+ osTempDirsLeft,
262
+ timing: { rebuildMs, nativeMs, totalMs: now() - started },
263
+ tool: { cliSha256: job.cliSha256, flags: [...SHADOW_CLI_FLAGS, '--export-embeddings'], modelRepo: SHADOW_MODEL_REPO, modelRevision: SHADOW_MODEL_REVISION, modelBytes: models.bytes },
264
+ }
265
+ const title = await readShadowMeetingTitle(job.sidecarPath)
266
+ const chunkText = new Map(entries.map(e => [e.chunkIndex, typeof e.chunk.text === 'string' ? e.chunk.text : '']))
267
+ let markdown = renderShadowReport({ title, sessionId: job.sessionId, proposals, facts, audioDir: job.audioDir, chunkText })
268
+ if (paired) markdown += `\n${renderPairedSpeakerShadow(paired)}\nDiscovery: ${paired.discoveryReason}\n`
269
+ let callProducerExport:MacSpeakerCallNativeRef|undefined
270
+ let callProducerFailure:Extract<ShadowJobOutcome,{status:'proposed'}>['callProducerFailure']
271
+ if(producerContext){try{callProducerExport=retainMacSpeakerCallNative(producerContext,{nativePath,embeddingPath:embPath,exitCode:run.code,closed:run.closed,networkDenied:true})}catch(error){callProducerFailure={stage:'retain',reason:(error instanceof Error?error.message:'native export retention failed').slice(0,200)}}}
272
+ return { status: 'proposed', proposals, paired, facts, report: { title, markdown },...(callProducerExport?{callProducerExport}:{}),...(callProducerFailure?{callProducerFailure}:{}) }
273
+ } catch (error) {
274
+ if (error instanceof ShadowInputError) return skip(error.reason, error.message)
275
+ return fail('internal', error instanceof Error ? error.message : String(error))
276
+ } finally {
277
+ if (work) { try { rmSync(work, { recursive: true, force: true }) } catch { /* best effort; swept at boot */ } }
278
+ }
279
+ }
280
+
281
+ // ── Child process entry ──────────────────────────────────────────────────────
282
+
283
+ async function readStdin(limit = 64 * 1024): Promise<string> {
284
+ let text = ''
285
+ for await (const chunk of process.stdin) {
286
+ text += chunk.toString()
287
+ if (text.length > limit) throw new Error('job too large')
288
+ }
289
+ return text
290
+ }
291
+
292
+ /** The child's whole life: one job in on stdin, one outcome line out on stdout. */
293
+ export async function workerMain(deps: Omit<ShadowWorkerDeps, 'signal'> = {}): Promise<void> {
294
+ const controller = new AbortController()
295
+ const parent = process.ppid
296
+ // A parent that died (or was SIGKILLed with its group spared) must not leave this running.
297
+ const watch = setInterval(() => { if (process.ppid !== parent) { controller.abort(); process.exit(3) } }, 1_000)
298
+ watch.unref()
299
+ let outcome: ShadowJobOutcome
300
+ try {
301
+ const job = JSON.parse(await readStdin()) as ShadowJob
302
+ outcome = await runShadowJob(job, { ...deps, signal: controller.signal })
303
+ } catch (error) {
304
+ outcome = fail('internal', error instanceof Error ? error.message : String(error))
305
+ }
306
+ process.stdout.write(`${JSON.stringify(outcome)}\n`, () => process.exit(0))
307
+ }
308
+
309
+ function isEntry(): boolean {
310
+ try { return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) } catch { return false }
311
+ }
312
+ if (isEntry()) void workerMain()