@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.
- package/CHANGELOG.md +13 -0
- package/package.json +6 -2
- package/server/index.ts +76 -0
- package/server/lib/cos-operations-meetings.ts +99 -8
- package/server/lib/fireflies-client.ts +862 -0
- package/server/lib/fireflies-key.ts +182 -0
- package/server/lib/imported-library-rows.ts +616 -0
- package/server/lib/imported-meeting-library.ts +608 -0
- package/server/lib/maintenance-lifecycle.ts +14 -0
- package/server/lib/meeting-actions-store.ts +478 -0
- package/server/lib/meeting-actions.ts +2583 -0
- package/server/lib/meeting-corrections.ts +32 -1
- package/server/lib/meeting-decisions.ts +223 -0
- package/server/lib/meeting-engine/align.ts +167 -0
- package/server/lib/meeting-engine/attribute.ts +265 -0
- package/server/lib/meeting-engine/evidence.ts +428 -0
- package/server/lib/meeting-engine/pairing.ts +327 -0
- package/server/lib/meeting-engine/render.ts +694 -0
- package/server/lib/meeting-engine/split.ts +242 -0
- package/server/lib/meeting-engine/worker.ts +238 -0
- package/server/lib/meeting-engine-mode.ts +197 -0
- package/server/lib/meeting-file-guards.ts +141 -0
- package/server/lib/meeting-import.ts +763 -0
- package/server/lib/meeting-library-search.ts +146 -11
- package/server/lib/meeting-parse.ts +184 -0
- package/server/lib/meeting-store.ts +108 -275
- package/server/lib/meeting-suggestion-sides.ts +242 -0
- package/server/lib/morning-brief-runtime.ts +20 -8
- package/server/lib/pipeline-runner.ts +227 -0
- package/server/lib/voice-evidence-guard.ts +87 -0
- package/server/routes/fireflies-key.ts +102 -0
- package/server/routes/meeting-actions.ts +82 -0
- package/server/routes/meeting-engine.ts +52 -0
- package/server/routes/meeting-import.ts +67 -0
- package/server/routes/meeting-suggestions.ts +66 -0
- package/server/routes/meeting.ts +117 -10
- package/server/routes/meetings.ts +177 -50
- 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,
|
|
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 =>
|
|
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 =>
|
|
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
|
+
}
|