@gotcos/glasses-server 6.46.0 → 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 +21 -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/g2-ops-handoff.ts +15 -1
- 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 +205 -37
- package/server/routes/voice.ts +18 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splitting one long recording into the meetings it actually holds (6.47.0, WS3).
|
|
3
|
+
*
|
|
4
|
+
* PURE. No filesystem, no network, no clock.
|
|
5
|
+
*
|
|
6
|
+
* A recorder left running across a morning produces one three-hour "meeting" that is
|
|
7
|
+
* really three. The boundaries come from CONTENT: where each separate capture's shared
|
|
8
|
+
* phrases begin and end inside the long recording, snapped outward to a real silence.
|
|
9
|
+
* Measured against Miles's own manual split of the Aug 20 recording, the four boundaries
|
|
10
|
+
* land 27, 25, 42 and 42 s from where he put them.
|
|
11
|
+
*
|
|
12
|
+
* Calendar events and silence alone were measured and rejected: the calendar flagged 40 of
|
|
13
|
+
* 42 long recordings as spanning, which is noise, not a signal. Those cases stay
|
|
14
|
+
* suggestions in this release.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
type AnchorSample,
|
|
19
|
+
type FirefliesMeetingInput,
|
|
20
|
+
type G2RecordingInput,
|
|
21
|
+
anchorsInBand,
|
|
22
|
+
contentEvidence,
|
|
23
|
+
g2TimedWords,
|
|
24
|
+
speechGaps,
|
|
25
|
+
} from './evidence.js'
|
|
26
|
+
|
|
27
|
+
/** Two hours. Below this, a recording holding two meetings is rare enough to leave alone. */
|
|
28
|
+
export const LONG_RECORDING_S = 7200
|
|
29
|
+
|
|
30
|
+
/** Shared phrases a capture needs before its span may cut the recording. */
|
|
31
|
+
export const SPLIT_MIN_ANCHORS = 30
|
|
32
|
+
|
|
33
|
+
/** One capture inside a long recording is a partial capture, not a split. */
|
|
34
|
+
export const SPLIT_MIN_INSIDE_RECORDINGS = 2
|
|
35
|
+
|
|
36
|
+
/** A silence this long is a real gap between meetings, not a pause for breath. */
|
|
37
|
+
export const SPLIT_GAP_S = 90
|
|
38
|
+
|
|
39
|
+
/** How far a span edge may be moved to land on that silence. */
|
|
40
|
+
export const SPLIT_SNAP_S = 90
|
|
41
|
+
|
|
42
|
+
/** A piece shorter than this is an artefact; it joins its neighbour. */
|
|
43
|
+
export const MIN_PIECE_S = 120
|
|
44
|
+
|
|
45
|
+
/** When one capture covers this much of the recording, they are one meeting: pair, do not split. */
|
|
46
|
+
export const SPLIT_PAIRING_COVERAGE = 0.8
|
|
47
|
+
|
|
48
|
+
/** Span edges are percentiles, so one stray early or late anchor cannot stretch a piece. */
|
|
49
|
+
export const SPAN_LOW_PERCENTILE = 0.01
|
|
50
|
+
export const SPAN_HIGH_PERCENTILE = 0.99
|
|
51
|
+
|
|
52
|
+
export interface ContentSpan {
|
|
53
|
+
sessionId: string
|
|
54
|
+
/** Seconds from the recording start, after snapping to silence. */
|
|
55
|
+
startS: number
|
|
56
|
+
endS: number
|
|
57
|
+
/** Before snapping, for the report and the tests. */
|
|
58
|
+
rawStartS: number
|
|
59
|
+
rawEndS: number
|
|
60
|
+
anchors: number
|
|
61
|
+
offsetMs: number
|
|
62
|
+
/** The capture's extent on the recording's timeline, as a fraction of the recording. */
|
|
63
|
+
coverage: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type PieceKind = 'matched' | 'unmatched'
|
|
67
|
+
|
|
68
|
+
export interface SplitPiece {
|
|
69
|
+
index: number
|
|
70
|
+
startS: number
|
|
71
|
+
endS: number
|
|
72
|
+
kind: PieceKind
|
|
73
|
+
/** The captures whose content defines this piece, in span order. */
|
|
74
|
+
sessionIds: string[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type SplitRefusal =
|
|
78
|
+
| 'not_long_enough'
|
|
79
|
+
| 'too_few_inside'
|
|
80
|
+
| 'one_capture_covers_recording'
|
|
81
|
+
|
|
82
|
+
export interface SplitPlan {
|
|
83
|
+
firefliesId: string
|
|
84
|
+
split: boolean
|
|
85
|
+
refusal?: SplitRefusal
|
|
86
|
+
spans: ContentSpan[]
|
|
87
|
+
pieces: SplitPiece[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function percentileAt(sorted: readonly number[], fraction: number): number {
|
|
91
|
+
return sorted[Math.trunc(fraction * (sorted.length - 1))]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Move a span edge onto the near side of a nearby silence.
|
|
96
|
+
*
|
|
97
|
+
* A start moves to where speech RESUMES and an end to where it STOPS, so the silence
|
|
98
|
+
* between two meetings belongs to neither piece's content and the boundary sits where a
|
|
99
|
+
* person would put it.
|
|
100
|
+
*/
|
|
101
|
+
export function snapToGap(
|
|
102
|
+
timeS: number,
|
|
103
|
+
gaps: readonly { startS: number; endS: number }[],
|
|
104
|
+
edge: 'start' | 'end',
|
|
105
|
+
withinS: number = SPLIT_SNAP_S,
|
|
106
|
+
): number {
|
|
107
|
+
let best: number | null = null
|
|
108
|
+
let bestDistance = Infinity
|
|
109
|
+
for (const gap of gaps) {
|
|
110
|
+
const candidate = edge === 'start' ? gap.endS : gap.startS
|
|
111
|
+
const distance = Math.abs(candidate - timeS)
|
|
112
|
+
if (distance <= withinS && distance < bestDistance) {
|
|
113
|
+
best = candidate
|
|
114
|
+
bestDistance = distance
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return best ?? timeS
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Where one capture's content sits inside one recording. */
|
|
121
|
+
export function contentSpanFor(
|
|
122
|
+
recording: G2RecordingInput,
|
|
123
|
+
meeting: FirefliesMeetingInput,
|
|
124
|
+
minAnchors: number = SPLIT_MIN_ANCHORS,
|
|
125
|
+
): ContentSpan | null {
|
|
126
|
+
const { words } = g2TimedWords(recording)
|
|
127
|
+
const evidence = contentEvidence(words, meeting.sentences)
|
|
128
|
+
if (evidence.offsetMs === null) return null
|
|
129
|
+
const cluster: AnchorSample[] = anchorsInBand(evidence.samples, evidence.offsetMs)
|
|
130
|
+
if (cluster.length < minAnchors) return null
|
|
131
|
+
const times = cluster.map(sample => sample.firefliesMs)
|
|
132
|
+
const rawStartS = percentileAt(times, SPAN_LOW_PERCENTILE) / 1000
|
|
133
|
+
const rawEndS = percentileAt(times, SPAN_HIGH_PERCENTILE) / 1000
|
|
134
|
+
const gaps = speechGaps(meeting.sentences, SPLIT_GAP_S)
|
|
135
|
+
const offsetS = evidence.offsetMs / 1000
|
|
136
|
+
const captureEndS = offsetS + recording.durationMs / 1000
|
|
137
|
+
const covered = Math.max(0, Math.min(meeting.durationS, captureEndS) - Math.max(0, offsetS))
|
|
138
|
+
return {
|
|
139
|
+
sessionId: recording.sessionId,
|
|
140
|
+
startS: snapToGap(rawStartS, gaps, 'start'),
|
|
141
|
+
endS: snapToGap(rawEndS, gaps, 'end'),
|
|
142
|
+
rawStartS,
|
|
143
|
+
rawEndS,
|
|
144
|
+
anchors: cluster.length,
|
|
145
|
+
offsetMs: evidence.offsetMs,
|
|
146
|
+
coverage: meeting.durationS > 0 ? covered / meeting.durationS : 0,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve two spans that claim the same seconds.
|
|
152
|
+
*
|
|
153
|
+
* A silence inside the contested stretch is the honest boundary. Without one, the midpoint
|
|
154
|
+
* at least keeps every second in exactly one piece, which is the property the pieces must
|
|
155
|
+
* have.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveOverlap(
|
|
158
|
+
endOfEarlier: number,
|
|
159
|
+
startOfLater: number,
|
|
160
|
+
gaps: readonly { startS: number; endS: number }[],
|
|
161
|
+
): number {
|
|
162
|
+
const low = Math.min(startOfLater, endOfEarlier)
|
|
163
|
+
const high = Math.max(startOfLater, endOfEarlier)
|
|
164
|
+
for (const gap of gaps) {
|
|
165
|
+
if (gap.startS >= low && gap.endS <= high) return gap.startS
|
|
166
|
+
}
|
|
167
|
+
return (low + high) / 2
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Merge pieces under the minimum into the earlier neighbour, or the later one when first. */
|
|
171
|
+
export function mergeShortPieces(pieces: readonly SplitPiece[], minPieceS: number = MIN_PIECE_S): SplitPiece[] {
|
|
172
|
+
const working = pieces.map(piece => ({ ...piece, sessionIds: [...piece.sessionIds] }))
|
|
173
|
+
for (;;) {
|
|
174
|
+
if (working.length <= 1) break
|
|
175
|
+
const index = working.findIndex(piece => piece.endS - piece.startS < minPieceS)
|
|
176
|
+
if (index === -1) break
|
|
177
|
+
const short = working[index]
|
|
178
|
+
const intoIndex = index > 0 ? index - 1 : 1
|
|
179
|
+
const into = working[intoIndex]
|
|
180
|
+
into.startS = Math.min(into.startS, short.startS)
|
|
181
|
+
into.endS = Math.max(into.endS, short.endS)
|
|
182
|
+
for (const sessionId of short.sessionIds) {
|
|
183
|
+
if (!into.sessionIds.includes(sessionId)) into.sessionIds.push(sessionId)
|
|
184
|
+
}
|
|
185
|
+
if (into.sessionIds.length > 0) into.kind = 'matched'
|
|
186
|
+
working.splice(index, 1)
|
|
187
|
+
}
|
|
188
|
+
return working.map((piece, index) => ({ ...piece, index }))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The pieces of one long recording.
|
|
193
|
+
*
|
|
194
|
+
* Every second of the recording lands in exactly one piece, including the stretches no
|
|
195
|
+
* capture matched: dropping them would lose content, which this release never does.
|
|
196
|
+
*/
|
|
197
|
+
export function splitPlanFor(
|
|
198
|
+
meeting: FirefliesMeetingInput,
|
|
199
|
+
recordings: readonly G2RecordingInput[],
|
|
200
|
+
options: { minInside?: number; longRecordingS?: number } = {},
|
|
201
|
+
): SplitPlan {
|
|
202
|
+
const longRecordingS = options.longRecordingS ?? LONG_RECORDING_S
|
|
203
|
+
const minInside = options.minInside ?? SPLIT_MIN_INSIDE_RECORDINGS
|
|
204
|
+
const spans = recordings
|
|
205
|
+
.map(recording => contentSpanFor(recording, meeting))
|
|
206
|
+
.filter((span): span is ContentSpan => span !== null)
|
|
207
|
+
.sort((a, b) => (a.startS - b.startS) || (a.sessionId < b.sessionId ? -1 : 1))
|
|
208
|
+
|
|
209
|
+
if (meeting.durationS < longRecordingS) return { firefliesId: meeting.id, split: false, refusal: 'not_long_enough', spans, pieces: [] }
|
|
210
|
+
if (spans.length < minInside) return { firefliesId: meeting.id, split: false, refusal: 'too_few_inside', spans, pieces: [] }
|
|
211
|
+
if (spans.some(span => span.coverage >= SPLIT_PAIRING_COVERAGE)) {
|
|
212
|
+
return { firefliesId: meeting.id, split: false, refusal: 'one_capture_covers_recording', spans, pieces: [] }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const gaps = speechGaps(meeting.sentences, SPLIT_GAP_S)
|
|
216
|
+
const bounded = spans.map(span => ({
|
|
217
|
+
...span,
|
|
218
|
+
startS: Math.max(0, Math.min(meeting.durationS, span.startS)),
|
|
219
|
+
endS: Math.max(0, Math.min(meeting.durationS, span.endS)),
|
|
220
|
+
}))
|
|
221
|
+
for (let i = 0; i + 1 < bounded.length; i++) {
|
|
222
|
+
if (bounded[i].endS > bounded[i + 1].startS) {
|
|
223
|
+
const boundary = resolveOverlap(bounded[i].endS, bounded[i + 1].startS, gaps)
|
|
224
|
+
bounded[i].endS = boundary
|
|
225
|
+
bounded[i + 1].startS = boundary
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const pieces: SplitPiece[] = []
|
|
230
|
+
let cursor = 0
|
|
231
|
+
for (const span of bounded) {
|
|
232
|
+
const start = Math.max(cursor, span.startS)
|
|
233
|
+
if (start > cursor) pieces.push({ index: pieces.length, startS: cursor, endS: start, kind: 'unmatched', sessionIds: [] })
|
|
234
|
+
const end = Math.max(start, span.endS)
|
|
235
|
+
pieces.push({ index: pieces.length, startS: start, endS: end, kind: 'matched', sessionIds: [span.sessionId] })
|
|
236
|
+
cursor = end
|
|
237
|
+
}
|
|
238
|
+
if (cursor < meeting.durationS) {
|
|
239
|
+
pieces.push({ index: pieces.length, startS: cursor, endS: meeting.durationS, kind: 'unmatched', sessionIds: [] })
|
|
240
|
+
}
|
|
241
|
+
return { firefliesId: meeting.id, split: true, spans: bounded, pieces: mergeShortPieces(pieces) }
|
|
242
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The merge engine's worker-thread entry, and the in-process orchestration it runs
|
|
3
|
+
* (6.47.0, WS3).
|
|
4
|
+
*
|
|
5
|
+
* PURE ABOVE THE THREAD BOUNDARY. `runEngine` touches no filesystem, no network and no
|
|
6
|
+
* clock: the runner (WS4) snapshots the inputs, posts them here, and writes the results.
|
|
7
|
+
*
|
|
8
|
+
* WHY A WORKER. Scoring a backlog is CPU-bound — trigrams over every capture against every
|
|
9
|
+
* candidate transcript. On the main thread that competes with live capture: chunk writes,
|
|
10
|
+
* transcription and the display bus all wait behind it. The engine never blocks a meeting.
|
|
11
|
+
*
|
|
12
|
+
* LOADING A `.ts` WORKER. Node 24 strips types natively but does NOT map a `./x.js`
|
|
13
|
+
* specifier to `x.ts`, so a bare `new Worker(url)` fails on the engine's own imports, and
|
|
14
|
+
* `execArgv: ['--import', 'tsx/esm']` does not fix it (measured: all three of bare,
|
|
15
|
+
* resolved and inherited execArgv fail to resolve the sibling import). Registering tsx's
|
|
16
|
+
* hooks INSIDE the worker before importing this file does work, with or without a loader
|
|
17
|
+
* in the parent, so the worker starts from a small bootstrap that does exactly that.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createRequire } from 'node:module'
|
|
21
|
+
import { pathToFileURL } from 'node:url'
|
|
22
|
+
import { Worker, isMainThread, parentPort } from 'node:worker_threads'
|
|
23
|
+
import type { FirefliesMeetingInput, G2RecordingInput } from './evidence.js'
|
|
24
|
+
import {
|
|
25
|
+
type MergeGroup,
|
|
26
|
+
type MergeTier,
|
|
27
|
+
type PairingResult,
|
|
28
|
+
groupAutoMerges,
|
|
29
|
+
scoreRecording,
|
|
30
|
+
} from './pairing.js'
|
|
31
|
+
import {
|
|
32
|
+
type SplitPlan,
|
|
33
|
+
LONG_RECORDING_S,
|
|
34
|
+
splitPlanFor,
|
|
35
|
+
} from './split.js'
|
|
36
|
+
import type { AttributeMode } from './attribute.js'
|
|
37
|
+
import {
|
|
38
|
+
type DeriveResult,
|
|
39
|
+
type MergeDeriveInput,
|
|
40
|
+
type PieceDeriveInput,
|
|
41
|
+
renderMergedRecord,
|
|
42
|
+
renderSplitPiece,
|
|
43
|
+
} from './render.js'
|
|
44
|
+
|
|
45
|
+
/** How long one engine job may run before the caller stops waiting on the thread. */
|
|
46
|
+
export const ENGINE_WORKER_TIMEOUT_MS = 300_000
|
|
47
|
+
|
|
48
|
+
export interface EngineSuggestion {
|
|
49
|
+
kind: 'merge' | 'split'
|
|
50
|
+
sessionIds: string[]
|
|
51
|
+
firefliesIds: string[]
|
|
52
|
+
evidence: { K1: number; K2: number }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface EngineScoreRequest {
|
|
56
|
+
kind: 'score'
|
|
57
|
+
g2: G2RecordingInput[]
|
|
58
|
+
fireflies: FirefliesMeetingInput[]
|
|
59
|
+
options?: { attributeMode?: AttributeMode }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface EngineDeriveMergeRequest {
|
|
63
|
+
kind: 'derive_merge'
|
|
64
|
+
input: MergeDeriveInput
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface EngineDerivePieceRequest {
|
|
68
|
+
kind: 'derive_piece'
|
|
69
|
+
input: PieceDeriveInput
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type EngineRequest = EngineScoreRequest | EngineDeriveMergeRequest | EngineDerivePieceRequest
|
|
73
|
+
|
|
74
|
+
export interface EngineScoreResult {
|
|
75
|
+
kind: 'score'
|
|
76
|
+
pairings: PairingResult[]
|
|
77
|
+
/** One merged record per meeting, holding every capture of it. */
|
|
78
|
+
groups: MergeGroup[]
|
|
79
|
+
/** Long recordings that hold more than one meeting. */
|
|
80
|
+
splits: SplitPlan[]
|
|
81
|
+
suggestions: EngineSuggestion[]
|
|
82
|
+
tierCounts: Record<MergeTier, number>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface EngineDeriveResult {
|
|
86
|
+
kind: 'derive'
|
|
87
|
+
result: DeriveResult
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type EngineResult = EngineScoreResult | EngineDeriveResult
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Score every capture, group the automatic merges, and plan the splits.
|
|
94
|
+
*
|
|
95
|
+
* Split beats merge on a long recording: when a recorder ran across three meetings, merging
|
|
96
|
+
* a capture into the whole three hours would file that meeting under the wrong title and
|
|
97
|
+
* bury it. The exception is a capture that covers the recording, which means they are the
|
|
98
|
+
* same meeting after all.
|
|
99
|
+
*/
|
|
100
|
+
export function runEngine(request: EngineRequest): EngineResult {
|
|
101
|
+
if (request.kind === 'derive_merge') return { kind: 'derive', result: renderMergedRecord(request.input) }
|
|
102
|
+
if (request.kind === 'derive_piece') return { kind: 'derive', result: renderSplitPiece(request.input) }
|
|
103
|
+
if (request.kind !== 'score') throw new Error(`unknown engine request kind: ${String((request as { kind?: unknown }).kind)}`)
|
|
104
|
+
|
|
105
|
+
const pairings = request.g2.map(recording => scoreRecording(recording, request.fireflies))
|
|
106
|
+
const splits: SplitPlan[] = []
|
|
107
|
+
for (const meeting of request.fireflies) {
|
|
108
|
+
if (meeting.durationS < LONG_RECORDING_S) continue
|
|
109
|
+
const meetingStart = meeting.startMs
|
|
110
|
+
const meetingEnd = meeting.startMs + meeting.durationS * 1000
|
|
111
|
+
const inside = request.g2.filter(recording =>
|
|
112
|
+
recording.startMs + recording.durationMs > meetingStart && recording.startMs < meetingEnd)
|
|
113
|
+
if (inside.length === 0) continue
|
|
114
|
+
const plan = splitPlanFor(meeting, inside)
|
|
115
|
+
if (plan.split) splits.push(plan)
|
|
116
|
+
}
|
|
117
|
+
const splitIds = new Set(splits.map(plan => plan.firefliesId))
|
|
118
|
+
const groups = groupAutoMerges(pairings, request.g2).filter(group => !splitIds.has(group.primaryFirefliesId))
|
|
119
|
+
|
|
120
|
+
const suggestions: EngineSuggestion[] = []
|
|
121
|
+
for (const pairing of pairings) {
|
|
122
|
+
if (pairing.tier !== 'suggest' || !pairing.primaryFirefliesId) continue
|
|
123
|
+
suggestions.push({
|
|
124
|
+
kind: 'merge',
|
|
125
|
+
sessionIds: [pairing.sessionId],
|
|
126
|
+
firefliesIds: [pairing.primaryFirefliesId],
|
|
127
|
+
evidence: { K1: pairing.k1, K2: pairing.k2 },
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
const tierCounts: Record<MergeTier, number> = { auto_merge: 0, suggest: 0, none: 0 }
|
|
131
|
+
for (const pairing of pairings) tierCounts[pairing.tier] += 1
|
|
132
|
+
|
|
133
|
+
return { kind: 'score', pairings, groups, splits, suggestions, tierCounts }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface WorkerEnvelope {
|
|
137
|
+
id: number
|
|
138
|
+
request: EngineRequest
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface WorkerReply {
|
|
142
|
+
id: number
|
|
143
|
+
ok: boolean
|
|
144
|
+
result?: EngineResult
|
|
145
|
+
error?: string
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!isMainThread && parentPort) {
|
|
149
|
+
const port = parentPort
|
|
150
|
+
port.on('message', (envelope: WorkerEnvelope) => {
|
|
151
|
+
try {
|
|
152
|
+
port.postMessage({ id: envelope.id, ok: true, result: runEngine(envelope.request) } satisfies WorkerReply)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
port.postMessage({ id: envelope.id, ok: false, error: error instanceof Error ? error.message : String(error) } satisfies WorkerReply)
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Where tsx's loader API lives, as an absolute URL.
|
|
161
|
+
*
|
|
162
|
+
* Two ways, because neither works everywhere: `import.meta.resolve` is absent under the
|
|
163
|
+
* test runner's transform, and `createRequire` is the one that survives it. A `.js` worker
|
|
164
|
+
* (a future build step) needs no loader at all.
|
|
165
|
+
*/
|
|
166
|
+
function tsxRegistrationUrl(workerUrl: URL): string | null {
|
|
167
|
+
if (!workerUrl.pathname.endsWith('.ts')) return null
|
|
168
|
+
try {
|
|
169
|
+
if (typeof import.meta.resolve === 'function') return import.meta.resolve('tsx/esm/api')
|
|
170
|
+
} catch {
|
|
171
|
+
// fall through to require resolution
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
return pathToFileURL(createRequire(import.meta.url).resolve('tsx/esm/api')).href
|
|
175
|
+
} catch {
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Start the engine worker.
|
|
182
|
+
*
|
|
183
|
+
* The bootstrap runs as CommonJS (`eval`), so it uses dynamic import rather than top-level
|
|
184
|
+
* await, and both URLs are absolute: a bare specifier would resolve against the process
|
|
185
|
+
* working directory, which for an installed server is wherever the user started it.
|
|
186
|
+
*/
|
|
187
|
+
export function createEngineWorker(): Worker {
|
|
188
|
+
const workerUrl = new URL(import.meta.url)
|
|
189
|
+
const tsxUrl = tsxRegistrationUrl(workerUrl)
|
|
190
|
+
const bootstrap = `
|
|
191
|
+
const workerUrl = ${JSON.stringify(workerUrl.href)}
|
|
192
|
+
const tsxUrl = ${JSON.stringify(tsxUrl)}
|
|
193
|
+
;(async () => {
|
|
194
|
+
if (tsxUrl) {
|
|
195
|
+
const tsx = await import(tsxUrl)
|
|
196
|
+
tsx.register()
|
|
197
|
+
}
|
|
198
|
+
await import(workerUrl)
|
|
199
|
+
})().catch(error => { throw error })
|
|
200
|
+
`
|
|
201
|
+
return new Worker(bootstrap, { eval: true })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Run one engine job on a worker thread and shut the thread down. */
|
|
205
|
+
export function runEngineInWorker(
|
|
206
|
+
request: EngineRequest,
|
|
207
|
+
options: { timeoutMs?: number } = {},
|
|
208
|
+
): Promise<EngineResult> {
|
|
209
|
+
const timeoutMs = options.timeoutMs ?? ENGINE_WORKER_TIMEOUT_MS
|
|
210
|
+
return new Promise((resolve, reject) => {
|
|
211
|
+
let worker: Worker
|
|
212
|
+
try {
|
|
213
|
+
worker = createEngineWorker()
|
|
214
|
+
} catch (error) {
|
|
215
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
const timer = setTimeout(() => {
|
|
219
|
+
void worker.terminate()
|
|
220
|
+
reject(new Error('meeting engine worker timed out'))
|
|
221
|
+
}, timeoutMs)
|
|
222
|
+
timer.unref?.()
|
|
223
|
+
const done = (action: () => void) => {
|
|
224
|
+
clearTimeout(timer)
|
|
225
|
+
void worker.terminate()
|
|
226
|
+
action()
|
|
227
|
+
}
|
|
228
|
+
worker.on('message', (reply: WorkerReply) => {
|
|
229
|
+
if (reply.ok && reply.result) done(() => resolve(reply.result as EngineResult))
|
|
230
|
+
else done(() => reject(new Error(reply.error ?? 'meeting engine worker failed')))
|
|
231
|
+
})
|
|
232
|
+
worker.on('error', error => done(() => reject(error)))
|
|
233
|
+
worker.on('exit', code => {
|
|
234
|
+
if (code !== 0) done(() => reject(new Error(`meeting engine worker exited with ${code}`)))
|
|
235
|
+
})
|
|
236
|
+
worker.postMessage({ id: 1, request } satisfies WorkerEnvelope)
|
|
237
|
+
})
|
|
238
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// One predicate, read by every surface that behaves differently on a Mac with
|
|
2
|
+
// the COS pipeline than on a Mac without it.
|
|
3
|
+
//
|
|
4
|
+
// `imports` means nothing else here brings meetings in, so the server does: it
|
|
5
|
+
// imports Fireflies meetings and writes derived records under the imports root.
|
|
6
|
+
//
|
|
7
|
+
// On a Mac whose own pipeline already files Fireflies meetings into operations/,
|
|
8
|
+
// the server must not import them a second time — two writers producing
|
|
9
|
+
// near-identical records is how one meeting becomes two rows that each look
|
|
10
|
+
// canonical. Such a Mac is in one of two modes:
|
|
11
|
+
//
|
|
12
|
+
// `advise` the engine reads and shows what it WOULD do. It writes nothing
|
|
13
|
+
// outside the imports root. This is the starting mode everywhere.
|
|
14
|
+
// `apply` the engine decides and the pipeline applies each decision into
|
|
15
|
+
// operations/, additively and revertibly.
|
|
16
|
+
//
|
|
17
|
+
// WHY A FILE AND NOT AN ENVIRONMENT FLAG (v3 blocker 7). Four processes have to
|
|
18
|
+
// agree: this server, the hourly meeting sync, the meeting watcher and COS
|
|
19
|
+
// Control. An env flag is read per process from whatever launched it, so a
|
|
20
|
+
// LaunchAgent updated for one of them silently disagrees with the other three.
|
|
21
|
+
// One file at the shared default data path is read the same way by all of them.
|
|
22
|
+
//
|
|
23
|
+
// THE SERVER IS THE ONLY WRITER. The pipeline reads the file and never writes
|
|
24
|
+
// it; Control changes it through POST /api/meeting-engine/mode.
|
|
25
|
+
//
|
|
26
|
+
// ABSENT OR UNREADABLE MEANS ADVISE. The safe mode is the one that writes
|
|
27
|
+
// nothing into a person's meeting tree, so every failure to read the file lands
|
|
28
|
+
// there rather than on `apply`.
|
|
29
|
+
//
|
|
30
|
+
// The pipeline check is the STRICTER of the two available:
|
|
31
|
+
// `g2RecordingsReachOperations` requires both the venv Python and
|
|
32
|
+
// sync_meetings.py to exist, which is the same condition the save path uses to
|
|
33
|
+
// decide whether a recording can reach operations at all. Reading env live
|
|
34
|
+
// (rather than caching) matters because COS Control can change COS_SCRIPTS_DIR
|
|
35
|
+
// under a running server.
|
|
36
|
+
|
|
37
|
+
import { readFileSync } from 'node:fs'
|
|
38
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
39
|
+
import { dataPath } from './data-dir.js'
|
|
40
|
+
import { g2RecordingsReachOperations } from './g2-ops-handoff.js'
|
|
41
|
+
|
|
42
|
+
export type MeetingEngineMode = 'advise' | 'imports' | 'apply'
|
|
43
|
+
|
|
44
|
+
/** The modes a pipeline Mac can be switched between. `imports` is not a choice. */
|
|
45
|
+
export type MeetingEnginePipelineMode = 'advise' | 'apply'
|
|
46
|
+
|
|
47
|
+
export const MERGE_MODE_FILENAME = 'merge-engine.json'
|
|
48
|
+
export const MERGE_MODE_SCHEMA = 1
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What kind of Mac this install has been observed to be.
|
|
52
|
+
*
|
|
53
|
+
* WHY IT IS REMEMBERED. `g2RecordingsReachOperations()` is a LIVE probe: it stats the COS
|
|
54
|
+
* venv's python and `sync_meetings.py`. Those two go missing for reasons that have nothing
|
|
55
|
+
* to do with this Mac's identity — iCloud evicting the checkout, a `pip` rebuild, COS
|
|
56
|
+
* Control changing `COS_SCRIPTS_DIR` between two reads. Every one of those made the predicate
|
|
57
|
+
* answer `imports`, and `imports` is the one mode that lets the server IMPORT Fireflies
|
|
58
|
+
* meetings the pipeline already files, which is how one meeting becomes two rows that each
|
|
59
|
+
* look canonical.
|
|
60
|
+
*
|
|
61
|
+
* A Mac that has once been seen with a pipeline is remembered as one. A transient negative
|
|
62
|
+
* then keeps the recorded mode (advise, the safe default) rather than flipping to imports,
|
|
63
|
+
* and the disagreement is reported in engine status as an alarm instead of acted on.
|
|
64
|
+
*/
|
|
65
|
+
export type MeetingEngineMacClass = 'pipeline' | 'standalone'
|
|
66
|
+
|
|
67
|
+
export interface MergeModeFile {
|
|
68
|
+
schema: number
|
|
69
|
+
mode: MeetingEnginePipelineMode
|
|
70
|
+
changedAt: string
|
|
71
|
+
changedBy: 'control' | 'server'
|
|
72
|
+
/** The Mac class this install has been observed to be. Absent on a pre-QA1 file. */
|
|
73
|
+
macClass?: MeetingEngineMacClass
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function mergeModeFilePath(): string {
|
|
77
|
+
return dataPath(MERGE_MODE_FILENAME)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The mode recorded in the file, for a Mac that has a pipeline.
|
|
82
|
+
*
|
|
83
|
+
* Every failure — missing file, unreadable file, bad JSON, an unknown schema, an
|
|
84
|
+
* unknown mode — answers `advise`. A file that cannot be understood must never
|
|
85
|
+
* be read as permission to write into operations/.
|
|
86
|
+
*/
|
|
87
|
+
/** The whole file, or null when it cannot be understood. */
|
|
88
|
+
export function readMergeModeRecord(path: string = mergeModeFilePath()): MergeModeFile | null {
|
|
89
|
+
let raw: string
|
|
90
|
+
try {
|
|
91
|
+
raw = readFileSync(path, 'utf8')
|
|
92
|
+
} catch {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(raw) as Partial<MergeModeFile>
|
|
97
|
+
if (parsed?.schema !== MERGE_MODE_SCHEMA) return null
|
|
98
|
+
return {
|
|
99
|
+
schema: MERGE_MODE_SCHEMA,
|
|
100
|
+
mode: parsed.mode === 'apply' ? 'apply' : 'advise',
|
|
101
|
+
changedAt: typeof parsed.changedAt === 'string' ? parsed.changedAt : new Date(0).toISOString(),
|
|
102
|
+
changedBy: parsed.changedBy === 'server' ? 'server' : 'control',
|
|
103
|
+
...(parsed.macClass === 'pipeline' || parsed.macClass === 'standalone' ? { macClass: parsed.macClass } : {}),
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function readMergeModeFile(path: string = mergeModeFilePath()): MeetingEnginePipelineMode {
|
|
111
|
+
return readMergeModeRecord(path)?.mode ?? 'advise'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Write the mode file atomically. The server is the only caller. */
|
|
115
|
+
export function writeMergeModeFile(
|
|
116
|
+
mode: MeetingEnginePipelineMode,
|
|
117
|
+
options: { path?: string; now?: () => number; macClass?: MeetingEngineMacClass; changedBy?: 'control' | 'server' } = {},
|
|
118
|
+
): MergeModeFile {
|
|
119
|
+
const path = options.path ?? mergeModeFilePath()
|
|
120
|
+
const macClass = options.macClass ?? readMergeModeRecord(path)?.macClass
|
|
121
|
+
const record: MergeModeFile = {
|
|
122
|
+
schema: MERGE_MODE_SCHEMA,
|
|
123
|
+
mode,
|
|
124
|
+
changedAt: new Date((options.now ?? Date.now)()).toISOString(),
|
|
125
|
+
changedBy: options.changedBy ?? 'control',
|
|
126
|
+
...(macClass ? { macClass } : {}),
|
|
127
|
+
}
|
|
128
|
+
durableAtomicWriteFileSync(path, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
|
|
129
|
+
return record
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Remember the Mac class, at most once per process per class.
|
|
134
|
+
*
|
|
135
|
+
* Bounded on purpose: `meetingEngineMode()` is called on every list, every detail, every
|
|
136
|
+
* status poll and every import refusal, and a write per call would be a write per request.
|
|
137
|
+
* The class only ever changes when a person installs or removes the COS pipeline, so once
|
|
138
|
+
* per process is enough to notice it.
|
|
139
|
+
*/
|
|
140
|
+
let rememberedMacClass: MeetingEngineMacClass | null = null
|
|
141
|
+
|
|
142
|
+
/** Test seam: forget what this process has already written. */
|
|
143
|
+
export function resetRememberedMacClass(): void {
|
|
144
|
+
rememberedMacClass = null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function rememberMacClass(macClass: MeetingEngineMacClass, current: MergeModeFile | null): void {
|
|
148
|
+
if (rememberedMacClass === macClass) return
|
|
149
|
+
rememberedMacClass = macClass
|
|
150
|
+
try {
|
|
151
|
+
writeMergeModeFile(current?.mode ?? 'advise', { macClass, changedBy: 'server' })
|
|
152
|
+
} catch {
|
|
153
|
+
// The data home is not writable. The predicate still answers; only the memory is lost.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The mode, and what it was decided from.
|
|
159
|
+
*
|
|
160
|
+
* A LIVE POSITIVE ALWAYS WINS: a Mac that can reach operations right now is a pipeline Mac,
|
|
161
|
+
* whatever the file says. A live NEGATIVE only wins when the file has never seen a pipeline
|
|
162
|
+
* here. That asymmetry is deliberate — being wrong towards `advise` writes nothing, and being
|
|
163
|
+
* wrong towards `imports` imports meetings the pipeline already owns.
|
|
164
|
+
*/
|
|
165
|
+
export function meetingEngineModeDetail(): {
|
|
166
|
+
mode: MeetingEngineMode
|
|
167
|
+
observedMacClass: MeetingEngineMacClass
|
|
168
|
+
recordedMacClass?: MeetingEngineMacClass
|
|
169
|
+
/** True when the recorded class and the live probe disagree. Reported, never acted on. */
|
|
170
|
+
macClassChanged: boolean
|
|
171
|
+
} {
|
|
172
|
+
const reaches = g2RecordingsReachOperations()
|
|
173
|
+
const record = readMergeModeRecord()
|
|
174
|
+
const observedMacClass: MeetingEngineMacClass = reaches ? 'pipeline' : 'standalone'
|
|
175
|
+
const recordedMacClass = record?.macClass
|
|
176
|
+
|
|
177
|
+
if (reaches) {
|
|
178
|
+
rememberMacClass('pipeline', record)
|
|
179
|
+
return { mode: record?.mode ?? 'advise', observedMacClass, ...(recordedMacClass ? { recordedMacClass } : {}), macClassChanged: recordedMacClass === 'standalone' }
|
|
180
|
+
}
|
|
181
|
+
if (recordedMacClass === 'pipeline') {
|
|
182
|
+
// A transient negative. Keep the recorded mode and raise the alarm instead of importing
|
|
183
|
+
// meetings a pipeline that is merely unreadable right now still owns.
|
|
184
|
+
return { mode: record?.mode ?? 'advise', observedMacClass, recordedMacClass, macClassChanged: true }
|
|
185
|
+
}
|
|
186
|
+
rememberMacClass('standalone', record)
|
|
187
|
+
return { mode: 'imports', observedMacClass, ...(recordedMacClass ? { recordedMacClass } : {}), macClassChanged: false }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function meetingEngineMode(): MeetingEngineMode {
|
|
191
|
+
return meetingEngineModeDetail().mode
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Whether this Mac may be switched between advise and apply at all. */
|
|
195
|
+
export function meetingEngineIsPipelineMac(): boolean {
|
|
196
|
+
return meetingEngineModeDetail().mode !== 'imports'
|
|
197
|
+
}
|