@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +21 -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/g2-ops-handoff.ts +15 -1
  8. package/server/lib/imported-library-rows.ts +616 -0
  9. package/server/lib/imported-meeting-library.ts +608 -0
  10. package/server/lib/maintenance-lifecycle.ts +14 -0
  11. package/server/lib/meeting-actions-store.ts +478 -0
  12. package/server/lib/meeting-actions.ts +2583 -0
  13. package/server/lib/meeting-corrections.ts +32 -1
  14. package/server/lib/meeting-decisions.ts +223 -0
  15. package/server/lib/meeting-engine/align.ts +167 -0
  16. package/server/lib/meeting-engine/attribute.ts +265 -0
  17. package/server/lib/meeting-engine/evidence.ts +428 -0
  18. package/server/lib/meeting-engine/pairing.ts +327 -0
  19. package/server/lib/meeting-engine/render.ts +694 -0
  20. package/server/lib/meeting-engine/split.ts +242 -0
  21. package/server/lib/meeting-engine/worker.ts +238 -0
  22. package/server/lib/meeting-engine-mode.ts +197 -0
  23. package/server/lib/meeting-file-guards.ts +141 -0
  24. package/server/lib/meeting-import.ts +763 -0
  25. package/server/lib/meeting-library-search.ts +146 -11
  26. package/server/lib/meeting-parse.ts +184 -0
  27. package/server/lib/meeting-store.ts +108 -275
  28. package/server/lib/meeting-suggestion-sides.ts +242 -0
  29. package/server/lib/morning-brief-runtime.ts +20 -8
  30. package/server/lib/pipeline-runner.ts +227 -0
  31. package/server/lib/voice-evidence-guard.ts +87 -0
  32. package/server/routes/fireflies-key.ts +102 -0
  33. package/server/routes/meeting-actions.ts +82 -0
  34. package/server/routes/meeting-engine.ts +52 -0
  35. package/server/routes/meeting-import.ts +67 -0
  36. package/server/routes/meeting-suggestions.ts +66 -0
  37. package/server/routes/meeting.ts +117 -10
  38. package/server/routes/meetings.ts +205 -37
  39. 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
+ }