@gotcos/glasses-server 6.46.1 → 6.47.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 (38) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/package.json +6 -2
  3. package/server/index.ts +76 -0
  4. package/server/lib/cos-operations-meetings.ts +99 -8
  5. package/server/lib/fireflies-client.ts +862 -0
  6. package/server/lib/fireflies-key.ts +182 -0
  7. package/server/lib/imported-library-rows.ts +616 -0
  8. package/server/lib/imported-meeting-library.ts +608 -0
  9. package/server/lib/maintenance-lifecycle.ts +14 -0
  10. package/server/lib/meeting-actions-store.ts +478 -0
  11. package/server/lib/meeting-actions.ts +2583 -0
  12. package/server/lib/meeting-corrections.ts +32 -1
  13. package/server/lib/meeting-decisions.ts +223 -0
  14. package/server/lib/meeting-engine/align.ts +167 -0
  15. package/server/lib/meeting-engine/attribute.ts +265 -0
  16. package/server/lib/meeting-engine/evidence.ts +428 -0
  17. package/server/lib/meeting-engine/pairing.ts +327 -0
  18. package/server/lib/meeting-engine/render.ts +694 -0
  19. package/server/lib/meeting-engine/split.ts +242 -0
  20. package/server/lib/meeting-engine/worker.ts +238 -0
  21. package/server/lib/meeting-engine-mode.ts +197 -0
  22. package/server/lib/meeting-file-guards.ts +141 -0
  23. package/server/lib/meeting-import.ts +763 -0
  24. package/server/lib/meeting-library-search.ts +146 -11
  25. package/server/lib/meeting-parse.ts +184 -0
  26. package/server/lib/meeting-store.ts +108 -275
  27. package/server/lib/meeting-suggestion-sides.ts +242 -0
  28. package/server/lib/morning-brief-runtime.ts +20 -8
  29. package/server/lib/pipeline-runner.ts +227 -0
  30. package/server/lib/voice-evidence-guard.ts +87 -0
  31. package/server/routes/fireflies-key.ts +102 -0
  32. package/server/routes/meeting-actions.ts +82 -0
  33. package/server/routes/meeting-engine.ts +52 -0
  34. package/server/routes/meeting-import.ts +67 -0
  35. package/server/routes/meeting-suggestions.ts +66 -0
  36. package/server/routes/meeting.ts +117 -10
  37. package/server/routes/meetings.ts +177 -50
  38. package/server/routes/voice.ts +18 -0
@@ -0,0 +1,242 @@
1
+ /**
2
+ * What a suggestion is ABOUT, resolved on the server (6.47.0, QA1).
3
+ *
4
+ * WHY THIS EXISTS. A suggestion carries canonical ids and a phrase count: a G2 `sessionId`
5
+ * and a Fireflies vendor id. That is everything the engine needs and nothing a person does.
6
+ * "Shares 47 phrases" between `s_9f2a...` and `01JQ...` is not a question anyone can answer.
7
+ *
8
+ * WHY THE SERVER AND NOT THE CLIENT. COS Control tried to resolve the two sides itself and
9
+ * could only do it in imports mode: it re-derived `imported:fireflies:<h16>` from the vendor
10
+ * id and looked the row up in the imported library. On a PIPELINE Mac — the one surface
11
+ * where a person actually reviews these, because advise mode is the whole point of the
12
+ * review gate — there is no imported library. The Fireflies side lives in the operations
13
+ * tree under a path only the server knows, so the row never resolved and every suggestion
14
+ * rendered as two opaque ids with `resolved` hard-coded from whether an index was empty.
15
+ *
16
+ * THREE PLACES A SIDE CAN LIVE, tried in order, and `resolved: false` when it is in none of
17
+ * them — which is a real state (a deleted recording, a transcript the vendor removed) and is
18
+ * reported rather than papered over.
19
+ */
20
+
21
+ import { existsSync, readFileSync, statSync } from 'node:fs'
22
+ import { basename, join } from 'node:path'
23
+ import {
24
+ findCosOperationsMeetingBySessionId,
25
+ resolveCosOperationsDir,
26
+ } from './cos-operations-meetings.js'
27
+ import {
28
+ type ImportedMeetingLibrary,
29
+ getImportedMeetingLibrary,
30
+ importHash,
31
+ importRecordId,
32
+ } from './imported-meeting-library.js'
33
+ import type { CanonicalInputs, MergeSuggestionRecord } from './meeting-actions-store.js'
34
+ import { getMeetingStore, parseField, type MeetingStore } from './meeting-store.js'
35
+
36
+ /** Bounded read for an operations `.fireflies.json`. Same ceiling the engine uses. */
37
+ export const SIDE_SIDECAR_MAX_BYTES = 64 * 1024 * 1024
38
+
39
+ export type SuggestionSideKind = 'g2' | 'fireflies'
40
+
41
+ export interface SuggestionSide {
42
+ kind: SuggestionSideKind
43
+ /** The canonical id: a G2 `sessionId`, or the Fireflies vendor id. */
44
+ id: string
45
+ /** The record that still holds it, in the form `/api/meetings` uses. */
46
+ recordId?: string
47
+ title?: string
48
+ /** Epoch ms. Absent when nothing on disk could say when this happened. */
49
+ startMs?: number
50
+ durationMinutes?: number
51
+ /** Which library the side was found in. */
52
+ source?: 'imported' | 'cos_operations' | 'standalone_recordings'
53
+ /** False when nothing on this Mac holds this id any more. */
54
+ resolved: boolean
55
+ }
56
+
57
+ export interface SuggestionWithSides extends MergeSuggestionRecord {
58
+ sides: SuggestionSide[]
59
+ }
60
+
61
+ export interface SideResolverDeps {
62
+ library?: ImportedMeetingLibrary
63
+ store?: MeetingStore
64
+ /** Operations-relative `.fireflies.json` path per vendor id, from the engine's own scan. */
65
+ firefliesScribeRelPaths?: Record<string, { sidecarRelPath?: string; scribeRelPath?: string }>
66
+ }
67
+
68
+ function minutesFromDurationField(value: string | undefined): number | undefined {
69
+ const match = value?.match(/(\d+)/)
70
+ return match ? Number(match[1]) : undefined
71
+ }
72
+
73
+ function epochFromDateField(date: string | undefined, time: string | undefined): number | undefined {
74
+ if (!date) return undefined
75
+ const parsed = Date.parse(time ? `${date}T${time}` : date)
76
+ return Number.isFinite(parsed) ? parsed : undefined
77
+ }
78
+
79
+ /** The imported library's own row for a Fireflies id, when this Mac imported it. */
80
+ function firefliesFromImports(id: string, library: ImportedMeetingLibrary): SuggestionSide | null {
81
+ const recordId = importRecordId('fireflies', importHash(id))
82
+ const row = library.list().find(candidate => candidate.recordId === recordId)
83
+ if (!row) return null
84
+ return {
85
+ kind: 'fireflies',
86
+ id,
87
+ recordId,
88
+ title: row.title,
89
+ ...(epochFromDateField(row.date, row.time) !== undefined ? { startMs: epochFromDateField(row.date, row.time)! } : {}),
90
+ ...(row.durationMinutes !== undefined ? { durationMinutes: row.durationMinutes } : {}),
91
+ source: 'imported',
92
+ resolved: true,
93
+ }
94
+ }
95
+
96
+ /**
97
+ * The operations tree's own scribe for a Fireflies id, on a pipeline Mac.
98
+ *
99
+ * The `.fireflies.json` sidecar is the only file that maps a vendor id to a path, so the
100
+ * caller hands over the map the engine's collector already built. Without it this side is
101
+ * unresolvable on exactly the Macs where the review happens.
102
+ */
103
+ function firefliesFromOperations(
104
+ id: string,
105
+ paths: SideResolverDeps['firefliesScribeRelPaths'],
106
+ ): SuggestionSide | null {
107
+ const entry = paths?.[id]
108
+ const operationsDir = resolveCosOperationsDir()
109
+ if (!entry || !operationsDir) return null
110
+ const relPath = entry.scribeRelPath ?? entry.sidecarRelPath?.replace(/\.fireflies\.json$/, '.md')
111
+ if (!relPath) return null
112
+ const absolute = join(operationsDir, relPath)
113
+ const parts = relPath.split('/')
114
+ // `<domain>/meetings/<month>/<filename>` is the operations shape; anything else is a path
115
+ // this server did not write and has no record id for.
116
+ const recordId = parts.length >= 4 && parts[1] === 'meetings'
117
+ ? `ops:${parts[0]}:${parts[2]}:${parts[parts.length - 1]}`
118
+ : undefined
119
+ let title = basename(relPath, '.md')
120
+ let startMs: number | undefined
121
+ let durationMinutes: number | undefined
122
+ if (existsSync(absolute)) {
123
+ try {
124
+ const head = readFileSync(absolute, 'utf8').slice(0, 4096)
125
+ title = head.match(/^#\s+(.+)$/m)?.[1]?.trim() || title
126
+ const dateField = parseField(head, 'Date')
127
+ const [datePart, timePart] = (dateField ?? '').split(/\s+/)
128
+ startMs = epochFromDateField(datePart, timePart)
129
+ durationMinutes = minutesFromDurationField(parseField(head, 'Duration'))
130
+ } catch {
131
+ // A scribe that cannot be read still resolves to a path and a name.
132
+ }
133
+ }
134
+ // The sidecar is authoritative about the vendor's own clock and length.
135
+ const sidecarRel = entry.sidecarRelPath
136
+ if (sidecarRel) {
137
+ const sidecarPath = join(operationsDir, sidecarRel)
138
+ try {
139
+ if (statSync(sidecarPath).size <= SIDE_SIDECAR_MAX_BYTES) {
140
+ const doc = JSON.parse(readFileSync(sidecarPath, 'utf8')) as Record<string, unknown>
141
+ if (typeof doc.date === 'number' && Number.isFinite(doc.date)) startMs = doc.date
142
+ if (typeof doc.duration === 'number' && Number.isFinite(doc.duration)) durationMinutes = Math.round(doc.duration)
143
+ if (typeof doc.title === 'string' && doc.title.trim()) title = doc.title.trim()
144
+ }
145
+ } catch {
146
+ // Fall back to whatever the scribe said.
147
+ }
148
+ }
149
+ return {
150
+ kind: 'fireflies',
151
+ id,
152
+ ...(recordId ? { recordId } : {}),
153
+ title,
154
+ ...(startMs !== undefined ? { startMs } : {}),
155
+ ...(durationMinutes !== undefined ? { durationMinutes } : {}),
156
+ source: 'cos_operations',
157
+ resolved: true,
158
+ }
159
+ }
160
+
161
+ /** `Date` and `Duration` out of a meeting file's metadata table. */
162
+ function stampFromMeetingHead(path: string): { title?: string; startMs?: number; durationMinutes?: number } {
163
+ try {
164
+ const head = readFileSync(path, 'utf8').slice(0, MEETING_HEAD_BYTES)
165
+ const [datePart, timePart] = (parseField(head, 'Date') ?? '').split(/\s+/)
166
+ return {
167
+ ...(head.match(/^#\s+(.+)$/m)?.[1]?.trim() ? { title: head.match(/^#\s+(.+)$/m)![1].trim() } : {}),
168
+ ...(epochFromDateField(datePart, timePart) !== undefined ? { startMs: epochFromDateField(datePart, timePart)! } : {}),
169
+ ...(minutesFromDurationField(parseField(head, 'Duration')) !== undefined
170
+ ? { durationMinutes: minutesFromDurationField(parseField(head, 'Duration'))! }
171
+ : {}),
172
+ }
173
+ } catch {
174
+ return {}
175
+ }
176
+ }
177
+
178
+ /** Enough to clear a scribe's title and metadata table whatever order its rows are in. */
179
+ export const MEETING_HEAD_BYTES = 4096
180
+
181
+ /** A G2 capture: the operations copy first, then this Mac's own recordings store. */
182
+ function g2Side(sessionId: string, store: MeetingStore): SuggestionSide {
183
+ const operations = findCosOperationsMeetingBySessionId(sessionId)
184
+ if (operations) {
185
+ const stamp = stampFromMeetingHead(operations.meetingPath)
186
+ return {
187
+ kind: 'g2',
188
+ id: sessionId,
189
+ recordId: `ops:${operations.domain}:${operations.month}:${operations.filename}`,
190
+ title: stamp.title ?? operations.title,
191
+ ...(stamp.startMs !== undefined ? { startMs: stamp.startMs } : {}),
192
+ ...(stamp.durationMinutes !== undefined ? { durationMinutes: stamp.durationMinutes } : {}),
193
+ source: 'cos_operations',
194
+ resolved: true,
195
+ }
196
+ }
197
+ try {
198
+ const found = store.findBySessionId(sessionId)
199
+ if (found) {
200
+ const stamp = stampFromMeetingHead(found.filepath)
201
+ return {
202
+ kind: 'g2',
203
+ id: sessionId,
204
+ recordId: `standalone:${sessionId}`,
205
+ title: stamp.title ?? found.title,
206
+ ...(stamp.startMs !== undefined ? { startMs: stamp.startMs } : {}),
207
+ durationMinutes: stamp.durationMinutes ?? found.durationMin,
208
+ source: 'standalone_recordings',
209
+ resolved: true,
210
+ }
211
+ }
212
+ } catch {
213
+ // An unreadable store is an unresolved side, not a failed request.
214
+ }
215
+ return { kind: 'g2', id: sessionId, resolved: false }
216
+ }
217
+
218
+ /** Every side of one suggestion, in a stable order: captures first, then transcripts. */
219
+ export function resolveSuggestionSides(
220
+ inputs: CanonicalInputs,
221
+ deps: SideResolverDeps = {},
222
+ ): SuggestionSide[] {
223
+ const library = deps.library ?? getImportedMeetingLibrary()
224
+ const store = deps.store ?? getMeetingStore()
225
+ const sides: SuggestionSide[] = inputs.sessionIds.map(sessionId => g2Side(sessionId, store))
226
+ for (const id of inputs.firefliesIds) {
227
+ sides.push(
228
+ firefliesFromImports(id, library)
229
+ ?? firefliesFromOperations(id, deps.firefliesScribeRelPaths)
230
+ ?? { kind: 'fireflies', id, resolved: false },
231
+ )
232
+ }
233
+ return sides
234
+ }
235
+
236
+ /** Attach sides to every row of a suggestion list. */
237
+ export function withSuggestionSides(
238
+ rows: readonly MergeSuggestionRecord[],
239
+ deps: SideResolverDeps = {},
240
+ ): SuggestionWithSides[] {
241
+ return rows.map(row => ({ ...row, sides: resolveSuggestionSides(row.inputs, deps) }))
242
+ }
@@ -35,12 +35,11 @@ import {
35
35
  type TasksProbe,
36
36
  } from './morning-brief-coverage.js'
37
37
  import {
38
- listCosOperationsMeetingDays,
39
38
  listCosOperationsMeetingMonths,
40
- listDirectLibraryMeetingDays,
41
39
  listDirectLibraryMeetingMonths,
42
40
  resolveMeetingLibrary,
43
41
  } from './cos-operations-meetings.js'
42
+ import { importedLibraryMonths, supersededDayCounts } from './imported-library-rows.js'
44
43
  import { getMeetingStore } from './meeting-store.js'
45
44
  import { COS_SCRIPTS_DIR, callPython, contextSourceAvailable, pythonBridgeAvailable } from './python-bridge.js'
46
45
  import { resolveProviderWorkDir } from './launch-dir.js'
@@ -68,20 +67,33 @@ function sumDayCounts(months: string[], days: (month: string) => Array<{ count:
68
67
  return total
69
68
  }
70
69
 
70
+ /**
71
+ * How many meetings this Mac holds, counted the way the LIST counts them.
72
+ *
73
+ * Shares `supersededDayCounts` with `/api/meetings`, so the brief and the
74
+ * Meetings list can never disagree about a day: a merged record replaced its
75
+ * import and its capture in the list, and it replaces them here too. On a Mac
76
+ * with no derived records the helper is the same uncapped filename scan it has
77
+ * always been.
78
+ */
71
79
  export function probeMeetings(): MeetingsProbe | null {
72
80
  const library = resolveMeetingLibrary()
73
81
  if (library.layout === 'direct') {
74
- const months = listDirectLibraryMeetingMonths()
75
- return { count: sumDayCounts(months, listDirectLibraryMeetingDays), newestMonth: months[0] ?? null, layout: library.layout }
82
+ const months = uniqueMonths([listDirectLibraryMeetingMonths(), importedLibraryMonths()])
83
+ return { count: sumDayCounts(months, month => supersededDayCounts(month, 'direct')), newestMonth: months[0] ?? null, layout: library.layout }
76
84
  }
77
85
  if (library.layout === 'multi_domain') {
78
- const months = listCosOperationsMeetingMonths('all')
79
- return { count: sumDayCounts(months, month => listCosOperationsMeetingDays(month, 'all')), newestMonth: months[0] ?? null, layout: library.layout }
86
+ const months = uniqueMonths([listCosOperationsMeetingMonths('all'), importedLibraryMonths()])
87
+ return { count: sumDayCounts(months, month => supersededDayCounts(month, 'multi_domain')), newestMonth: months[0] ?? null, layout: library.layout }
80
88
  }
81
89
  if (library.layout === 'invalid_explicit_root') return null
82
90
  const store = getMeetingStore()
83
- const months = store.listMonths()
84
- return { count: sumDayCounts(months, month => store.listDayCounts(month)), newestMonth: months[0] ?? null, layout: 'standalone' }
91
+ const months = uniqueMonths([store.listMonths(), importedLibraryMonths()])
92
+ return { count: sumDayCounts(months, month => supersededDayCounts(month, 'standalone', { store })), newestMonth: months[0] ?? null, layout: 'standalone' }
93
+ }
94
+
95
+ function uniqueMonths(groups: string[][]): string[] {
96
+ return [...new Set(groups.flat())].sort().reverse()
85
97
  }
86
98
 
87
99
  export async function probeContext(): Promise<ContextProbe | null> {
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Spawning one COS pipeline command and reading its single result line (6.47.0, WS4).
3
+ *
4
+ * GENERALIZED FROM `g2-enrichment-runner.ts`, which stays exactly as it is. That runner
5
+ * bakes in one command (`--g2-file`), one prefix (`COS_G2_RESULT=`), one retry ladder and
6
+ * one outcome shape. The merge apply needs a different command, a different prefix, a much
7
+ * tighter wall and a retry decision made by the caller from the EXIT CODE, so the shape is
8
+ * lifted here rather than bent there.
9
+ *
10
+ * NO SERVER-SIDE LOCK AROUND THE SPAWN (v3 blocker 1). The child takes the pipeline's own
11
+ * lock with zero retries and exits 3 within a second when another pipeline process holds
12
+ * it. A server-side lock around a child that also locks is how a 75 s spawn becomes a
13
+ * deadlock that outlives COS Control's 90 s drain timeout.
14
+ *
15
+ * STREAMED STDOUT. A long apply prints progress, and a caller that only sees the output
16
+ * after the child exits cannot say what step it died in. Lines are handed over as they
17
+ * arrive; only a bounded tail is kept for diagnostics.
18
+ *
19
+ * EXPLICIT ENVIRONMENT. The caller passes the whole env. The server runs from a
20
+ * LaunchAgent whose PATH does not include Homebrew, and `PYTHON_BIN` resolving its own
21
+ * helpers depends on it, so `pipelinePath()` is the one helper here that reaches into
22
+ * `process.env`.
23
+ */
24
+
25
+ import { spawn } from 'node:child_process'
26
+
27
+ /** The apply wall. Under COS Control's 90 s drain limit, with room for the kill grace. */
28
+ export const PIPELINE_DEFAULT_TIMEOUT_MS = 75_000
29
+
30
+ /** SIGTERM, then this long, then SIGKILL. */
31
+ export const PIPELINE_DEFAULT_KILL_GRACE_MS = 2_000
32
+
33
+ /** Diagnostics tail kept per stream. */
34
+ export const PIPELINE_MAX_CAPTURE_CHARS = 64 * 1024
35
+
36
+ export interface PipelineSpawnOptions {
37
+ pythonBin: string
38
+ /** Absolute path to the script, passed as the first argument. */
39
+ script: string
40
+ args: readonly string[]
41
+ cwd: string
42
+ /** The complete child environment. Nothing is inherited implicitly. */
43
+ env: NodeJS.ProcessEnv
44
+ /** Lines starting with this are collected as result lines. */
45
+ resultPrefix: string
46
+ timeoutMs?: number
47
+ killGraceMs?: number
48
+ /** Every complete stdout line, as it arrives. */
49
+ onLine?: (line: string) => void
50
+ maxCaptureChars?: number
51
+ }
52
+
53
+ export interface PipelineAttempt {
54
+ /** Null when the child never ran, or was killed by a signal. */
55
+ code: number | null
56
+ signal: NodeJS.Signals | null
57
+ timedOut: boolean
58
+ /** Bounded tail. */
59
+ stdout: string
60
+ stderr: string
61
+ /** Every stdout line that started with `resultPrefix`, in order, prefix removed. */
62
+ resultLines: string[]
63
+ /** Set when the child could not be spawned at all. */
64
+ spawnError?: string
65
+ elapsedMs: number
66
+ }
67
+
68
+ export interface PipelineRunnerDependencies {
69
+ /** Injected by tests so the result-line and exit-code contract can be exercised
70
+ * without a Python interpreter. */
71
+ run?: (options: PipelineSpawnOptions) => Promise<PipelineAttempt>
72
+ }
73
+
74
+ /**
75
+ * PATH for a pipeline child.
76
+ *
77
+ * A Finder- or launchd-started server does NOT inherit a login shell's PATH, and the COS
78
+ * venv's python resolves `git`, `node` and its own helpers through it. Prepending rather
79
+ * than replacing keeps a developer's own PATH intact.
80
+ */
81
+ export function pipelinePath(basePath: string | undefined = process.env.PATH): string {
82
+ const base = basePath ?? ''
83
+ return base.split(':').includes('/opt/homebrew/bin') ? base : `/opt/homebrew/bin:${base}`
84
+ }
85
+
86
+ function appendBounded(current: string, chunk: string, max: number): string {
87
+ const next = current + chunk
88
+ return next.length <= max ? next : next.slice(-max)
89
+ }
90
+
91
+ /**
92
+ * Kill the child's whole process group where the platform has one.
93
+ *
94
+ * sync_meetings.py spawns its own children. Signalling only the direct child leaves those
95
+ * running and holding the pipeline lock, which is the failure the timeout exists to end.
96
+ */
97
+ function signalChildTree(child: ReturnType<typeof spawn>, signal: NodeJS.Signals): void {
98
+ if (!child.pid) return
99
+ try {
100
+ if (process.platform !== 'win32') {
101
+ process.kill(-child.pid, signal)
102
+ return
103
+ }
104
+ } catch {
105
+ // The group is gone, or was never created. Fall through to the direct child.
106
+ }
107
+ try { child.kill(signal) } catch { /* already dead */ }
108
+ }
109
+
110
+ /** Spawn one pipeline command and resolve with what it printed and how it ended. */
111
+ export async function runPipelineCommand(options: PipelineSpawnOptions): Promise<PipelineAttempt> {
112
+ const startedAt = Date.now()
113
+ const maxCapture = options.maxCaptureChars ?? PIPELINE_MAX_CAPTURE_CHARS
114
+ const timeoutMs = options.timeoutMs ?? PIPELINE_DEFAULT_TIMEOUT_MS
115
+ const killGraceMs = Math.max(25, options.killGraceMs ?? PIPELINE_DEFAULT_KILL_GRACE_MS)
116
+
117
+ return await new Promise<PipelineAttempt>(resolve => {
118
+ let child: ReturnType<typeof spawn>
119
+ try {
120
+ child = spawn(options.pythonBin, [options.script, ...options.args], {
121
+ cwd: options.cwd,
122
+ stdio: ['ignore', 'pipe', 'pipe'],
123
+ env: options.env,
124
+ detached: process.platform !== 'win32',
125
+ })
126
+ } catch (error) {
127
+ resolve({
128
+ code: null,
129
+ signal: null,
130
+ timedOut: false,
131
+ stdout: '',
132
+ stderr: '',
133
+ resultLines: [],
134
+ spawnError: error instanceof Error ? error.message : String(error),
135
+ elapsedMs: Date.now() - startedAt,
136
+ })
137
+ return
138
+ }
139
+
140
+ let stdout = ''
141
+ let stderr = ''
142
+ let pending = ''
143
+ const resultLines: string[] = []
144
+ let settled = false
145
+ let timedOut = false
146
+ let timer: ReturnType<typeof setTimeout> | null = null
147
+ let killTimer: ReturnType<typeof setTimeout> | null = null
148
+
149
+ const takeLine = (line: string): void => {
150
+ if (line.startsWith(options.resultPrefix)) resultLines.push(line.slice(options.resultPrefix.length))
151
+ options.onLine?.(line)
152
+ }
153
+
154
+ const finish = (attempt: Omit<PipelineAttempt, 'elapsedMs'>): void => {
155
+ if (settled) return
156
+ settled = true
157
+ if (timer) clearTimeout(timer)
158
+ if (killTimer) clearTimeout(killTimer)
159
+ // A final line with no trailing newline is still a line.
160
+ if (pending.trim() !== '') takeLine(pending.trim())
161
+ pending = ''
162
+ resolve({ ...attempt, resultLines, elapsedMs: Date.now() - startedAt })
163
+ }
164
+
165
+ child.stdout?.on('data', (chunk: Buffer) => {
166
+ const text = chunk.toString()
167
+ stdout = appendBounded(stdout, text, maxCapture)
168
+ pending += text
169
+ const parts = pending.split(/\r?\n/)
170
+ pending = parts.pop() ?? ''
171
+ for (const part of parts) {
172
+ const line = part.trim()
173
+ if (line !== '') takeLine(line)
174
+ }
175
+ })
176
+ child.stderr?.on('data', (chunk: Buffer) => { stderr = appendBounded(stderr, chunk.toString(), maxCapture) })
177
+
178
+ child.on('error', error => finish({
179
+ code: null,
180
+ signal: null,
181
+ timedOut,
182
+ stdout,
183
+ stderr,
184
+ resultLines,
185
+ spawnError: error.message,
186
+ }))
187
+ child.on('close', (code, signal) => finish({ code, signal, timedOut, stdout, stderr, resultLines }))
188
+
189
+ timer = setTimeout(() => {
190
+ timedOut = true
191
+ signalChildTree(child, 'SIGTERM')
192
+ killTimer = setTimeout(() => signalChildTree(child, 'SIGKILL'), killGraceMs)
193
+ killTimer.unref?.()
194
+ }, timeoutMs)
195
+ timer.unref?.()
196
+ })
197
+ }
198
+
199
+ export type ResultLineFailure = 'missing' | 'malformed' | 'duplicated'
200
+
201
+ export type ResultLineOutcome<T> =
202
+ | { ok: true; value: T }
203
+ | { ok: false; reason: ResultLineFailure }
204
+
205
+ /**
206
+ * Read the one result line a pipeline command is contracted to print.
207
+ *
208
+ * THREE DISTINCT FAILURES, not one. A command that printed nothing died before its report;
209
+ * a command that printed two reported twice and neither can be trusted as the outcome; a
210
+ * command that printed something unparseable has changed its contract. All three are
211
+ * `result_unreadable` to the caller, but they are different bugs and the reason says which.
212
+ */
213
+ export function parseResultLine<T>(
214
+ resultLines: readonly string[],
215
+ validate: (value: unknown) => value is T,
216
+ ): ResultLineOutcome<T> {
217
+ if (resultLines.length === 0) return { ok: false, reason: 'missing' }
218
+ if (resultLines.length > 1) return { ok: false, reason: 'duplicated' }
219
+ let parsed: unknown
220
+ try {
221
+ parsed = JSON.parse(resultLines[0])
222
+ } catch {
223
+ return { ok: false, reason: 'malformed' }
224
+ }
225
+ if (!validate(parsed)) return { ok: false, reason: 'malformed' }
226
+ return { ok: true, value: parsed }
227
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * What may be used as VOICE EVIDENCE (6.47.0, WS5).
3
+ *
4
+ * A voice profile is built from audio this Mac captured. An imported meeting has
5
+ * no audio at all — Fireflies sends text — and a derived record (a merge, or one
6
+ * piece of a split) is a FUNCTION of other records rather than a recording. Its
7
+ * speaker names came from the vendor's own diarization or from a voice match
8
+ * suggestion, so training a profile on either would fold a cloud label back into
9
+ * the local identity store and then present it as local evidence. That is the
10
+ * one loop the speaker system must never close (CLAUDE.md: "Cloud speaker names
11
+ * training voice profiles (never)").
12
+ *
13
+ * REFUSES BY ID KIND ONLY. The rule is a property of the identifier in hand:
14
+ * an `imported:` or `blended:` record id, or an `.import.json` / `.derived.json`
15
+ * evidence path. It is NOT a property of the underlying meeting. A G2 session
16
+ * that happens to have a merged record derived from it is still a real capture
17
+ * with real audio, and relabelling it is exactly how its profile gets better —
18
+ * so this guard must never refuse a G2 session merely because derived records
19
+ * exist. The plan states that explicitly, and the execution gate pins it.
20
+ *
21
+ * PURE. No filesystem, no lookups: a guard that has to read the disk to decide
22
+ * fails open the moment the disk is slow, and this one runs before every voice
23
+ * mutation.
24
+ */
25
+
26
+ /** A record id that names an imported or derived record rather than a capture. */
27
+ export const IMPORTED_EVIDENCE_ID_PATTERN = /^(imported|blended):/
28
+
29
+ /** The two sidecars of the imported library. Never chunk audio, never `.g2-chunks.json`. */
30
+ export const IMPORTED_EVIDENCE_PATH_PATTERN = /\.(?:import|derived)\.json$/i
31
+
32
+ export type VoiceEvidenceReason = 'imported_read_only' | 'blended_derived_record'
33
+
34
+ export interface VoiceEvidenceRefusal {
35
+ status: 409
36
+ body: {
37
+ error: string
38
+ reason: VoiceEvidenceReason
39
+ /** The exact value that was refused, so a caller can say which input failed. */
40
+ source: string
41
+ }
42
+ }
43
+
44
+ /** The kind of an id, or null when it names neither. */
45
+ export function importedEvidenceKind(value: unknown): 'imported' | 'blended' | null {
46
+ if (typeof value !== 'string') return null
47
+ const id = value.match(IMPORTED_EVIDENCE_ID_PATTERN)
48
+ if (id) return id[1] as 'imported' | 'blended'
49
+ // A path is only ever evidence of a derived record, so it refuses as `blended`
50
+ // when it is a `.derived.json` and as `imported` when it is an `.import.json`.
51
+ if (IMPORTED_EVIDENCE_PATH_PATTERN.test(value)) {
52
+ return /\.derived\.json$/i.test(value) ? 'blended' : 'imported'
53
+ }
54
+ return null
55
+ }
56
+
57
+ export function isImportedVoiceEvidence(value: unknown): boolean {
58
+ return importedEvidenceKind(value) !== null
59
+ }
60
+
61
+ /**
62
+ * Refuse the first value that names an imported or derived record.
63
+ *
64
+ * Returns null when every value is acceptable, so a caller reads as
65
+ * `const refusal = assertVoiceEvidenceSource([...]); if (refusal) ...`.
66
+ */
67
+ export function assertVoiceEvidenceSource(values: ReadonlyArray<unknown>): VoiceEvidenceRefusal | null {
68
+ for (const value of values) {
69
+ const kind = importedEvidenceKind(value)
70
+ if (!kind) continue
71
+ return {
72
+ status: 409,
73
+ body: kind === 'imported'
74
+ ? {
75
+ error: 'Imported meetings carry no audio, so they cannot train or correct a voice',
76
+ reason: 'imported_read_only',
77
+ source: String(value),
78
+ }
79
+ : {
80
+ error: 'This record was derived from other meetings. Open the recording it came from',
81
+ reason: 'blended_derived_record',
82
+ source: String(value),
83
+ },
84
+ }
85
+ }
86
+ return null
87
+ }