@gotcos/glasses-server 6.46.1 → 6.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/package.json +6 -2
  3. package/server/index.ts +76 -0
  4. package/server/lib/cos-operations-meetings.ts +99 -8
  5. package/server/lib/fireflies-client.ts +862 -0
  6. package/server/lib/fireflies-key.ts +182 -0
  7. package/server/lib/imported-library-rows.ts +616 -0
  8. package/server/lib/imported-meeting-library.ts +608 -0
  9. package/server/lib/maintenance-lifecycle.ts +14 -0
  10. package/server/lib/meeting-actions-store.ts +478 -0
  11. package/server/lib/meeting-actions.ts +2583 -0
  12. package/server/lib/meeting-corrections.ts +32 -1
  13. package/server/lib/meeting-decisions.ts +223 -0
  14. package/server/lib/meeting-engine/align.ts +167 -0
  15. package/server/lib/meeting-engine/attribute.ts +265 -0
  16. package/server/lib/meeting-engine/evidence.ts +428 -0
  17. package/server/lib/meeting-engine/pairing.ts +327 -0
  18. package/server/lib/meeting-engine/render.ts +694 -0
  19. package/server/lib/meeting-engine/split.ts +242 -0
  20. package/server/lib/meeting-engine/worker.ts +238 -0
  21. package/server/lib/meeting-engine-mode.ts +197 -0
  22. package/server/lib/meeting-file-guards.ts +141 -0
  23. package/server/lib/meeting-import.ts +763 -0
  24. package/server/lib/meeting-library-search.ts +146 -11
  25. package/server/lib/meeting-parse.ts +184 -0
  26. package/server/lib/meeting-store.ts +108 -275
  27. package/server/lib/meeting-suggestion-sides.ts +242 -0
  28. package/server/lib/morning-brief-runtime.ts +20 -8
  29. package/server/lib/pipeline-runner.ts +227 -0
  30. package/server/lib/voice-evidence-guard.ts +87 -0
  31. package/server/routes/fireflies-key.ts +102 -0
  32. package/server/routes/meeting-actions.ts +82 -0
  33. package/server/routes/meeting-engine.ts +52 -0
  34. package/server/routes/meeting-import.ts +67 -0
  35. package/server/routes/meeting-suggestions.ts +66 -0
  36. package/server/routes/meeting.ts +117 -10
  37. package/server/routes/meetings.ts +177 -50
  38. package/server/routes/voice.ts +18 -0
@@ -0,0 +1,694 @@
1
+ /**
2
+ * Additive render of a merged record or a split piece, plus its `.derived.json` sidecar
3
+ * (6.47.0, WS3).
4
+ *
5
+ * PURE. No filesystem, no network. It reads the clock only through the timestamps its
6
+ * inputs carry.
7
+ *
8
+ * ADDITIVE, NEVER REDUCTIVE (D12). Every input's content survives into the output: the
9
+ * Fireflies transcript with resolved labels, one `## G2 Capture` section per capture, the
10
+ * alternate transcript of every duplicate recording, the union of attendees, and a Sources
11
+ * table naming each input and its role. Nothing here edits, archives or replaces a source.
12
+ *
13
+ * DETERMINISTIC. The same inputs must produce the same bytes, because re-derive writes only
14
+ * when the bytes differ, and a re-render that reordered a map would rewrite every record on
15
+ * every run.
16
+ */
17
+
18
+ import { createHash } from 'node:crypto'
19
+ import {
20
+ type FirefliesMeetingInput,
21
+ type FirefliesSentenceInput,
22
+ type G2RecordingInput,
23
+ g2TimedWords,
24
+ labelIntervals,
25
+ median,
26
+ } from './evidence.js'
27
+ import { type AttributionState, type Alignment, alignRecording } from './align.js'
28
+ import {
29
+ type AttributeMode,
30
+ type SentenceLabel,
31
+ type SpeakerVerification,
32
+ ATTRIBUTE_MODE,
33
+ attributeSentences,
34
+ isGenericLabel,
35
+ } from './attribute.js'
36
+ import type { SplitPiece } from './split.js'
37
+
38
+ /** Sidecar schema version. A reader that does not know this version must not guess. */
39
+ export const DERIVED_SIDECAR_VERSION = 1
40
+
41
+ /** The markdown a derived record may reach before its transcripts move to the sidecar. */
42
+ export const MARKDOWN_MAX_BYTES = 9 * 1024 * 1024
43
+
44
+ /** The sidecar's own ceiling, read by a bounded reader separate from the markdown cap. */
45
+ export const DERIVED_SIDECAR_MAX_BYTES = 32 * 1024 * 1024
46
+
47
+ /** Every derived record carries this domain; `originDomain` keeps the source's own. */
48
+ export const DERIVED_DOMAIN = 'imported'
49
+
50
+ export const MERGED_SOURCE_LABEL = 'G2 Glasses + Fireflies'
51
+ export const SPLIT_SOURCE_LABEL = 'Fireflies (split)'
52
+
53
+ export type DerivedKind = 'merge' | 'split'
54
+ export type DerivedTier = 'auto' | 'accepted_suggestion'
55
+
56
+ export interface DerivedInputFingerprint {
57
+ kind: 'g2' | 'fireflies'
58
+ id: string
59
+ sha256: string | null
60
+ correctionRevision?: number
61
+ batchApplied?: boolean
62
+ }
63
+
64
+ export interface DerivedAlignment {
65
+ sessionId: string
66
+ offsetMs: number | null
67
+ anchors: number
68
+ madMs: number | null
69
+ attribution: AttributionState
70
+ }
71
+
72
+ export interface DerivedSidecar {
73
+ version: number
74
+ actionId: string
75
+ kind: DerivedKind
76
+ inputs: DerivedInputFingerprint[]
77
+ tier: DerivedTier
78
+ evidence: { K1: number; K2: number }
79
+ offsetMs: number | null
80
+ anchors: number
81
+ mad: number | null
82
+ attribution: AttributionState
83
+ alignments: DerivedAlignment[]
84
+ labels: Array<Pick<SentenceLabel, 'index' | 'ffLabel' | 'g2Label' | 'share' | 'similarity' | 'windowOffsetMs'> & { outcome: SentenceLabel['outcome'] }>
85
+ conflicts: number[]
86
+ pieces: SplitPiece[]
87
+ overflow?: { sections: string[] }
88
+ }
89
+
90
+ export interface DerivedRecord {
91
+ markdown: string
92
+ sidecar: DerivedSidecar
93
+ verification: SpeakerVerification[]
94
+ }
95
+
96
+ export type DeriveResult =
97
+ | { ok: true; record: DerivedRecord }
98
+ | { ok: false; error: 'derived_too_large'; markdownBytes: number; sidecarBytes: number }
99
+
100
+ export interface RenderLimits {
101
+ markdownMaxBytes?: number
102
+ sidecarMaxBytes?: number
103
+ }
104
+
105
+ export interface MergeDeriveInput {
106
+ actionId: string
107
+ tier: DerivedTier
108
+ /** The fullest recording of the meeting; it supplies the transcript, summary and items. */
109
+ primary: FirefliesMeetingInput
110
+ /** Duplicate recordings of the same meeting, kept in full as alternates. */
111
+ alternates?: readonly FirefliesMeetingInput[]
112
+ /** Every G2 capture of this meeting, in start order. */
113
+ captures: readonly G2RecordingInput[]
114
+ evidence: { k1: number; k2: number }
115
+ /** Coarse offset per capture from pairing; the clock offset is used when absent. */
116
+ coarseOffsetMsBySession?: Record<string, number>
117
+ attributeMode?: AttributeMode
118
+ limits?: RenderLimits
119
+ }
120
+
121
+ export interface PieceDeriveInput {
122
+ actionId: string
123
+ tier: DerivedTier
124
+ source: FirefliesMeetingInput
125
+ piece: SplitPiece
126
+ pieceCount: number
127
+ /** Captures whose content defines this piece. */
128
+ captures: readonly G2RecordingInput[]
129
+ attributeMode?: AttributeMode
130
+ limits?: RenderLimits
131
+ }
132
+
133
+ export function sha256Hex(value: string): string {
134
+ return createHash('sha256').update(value, 'utf8').digest('hex')
135
+ }
136
+
137
+ /** Identity of a derived record's inputs. Re-derive when this changes; skip when it does not. */
138
+ export function fingerprintKey(inputs: readonly DerivedInputFingerprint[]): string {
139
+ const canonical = [...inputs]
140
+ .map(input => `${input.kind}:${input.id}:${input.sha256 ?? ''}:${input.correctionRevision ?? ''}:${input.batchApplied ?? ''}`)
141
+ .sort()
142
+ .join('|')
143
+ return sha256Hex(canonical)
144
+ }
145
+
146
+ function two(value: number): string {
147
+ return String(value).padStart(2, '0')
148
+ }
149
+
150
+ /** `YYYY-MM-DD HH:MM` in local time, the form the meeting parsers read. */
151
+ export function formatDateTime(epochMs: number): string {
152
+ const date = new Date(epochMs)
153
+ return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}`
154
+ }
155
+
156
+ export function formatClock(epochMs: number): string {
157
+ const date = new Date(epochMs)
158
+ return `${two(date.getHours())}:${two(date.getMinutes())}`
159
+ }
160
+
161
+ /** `[HH:MM:SS]` from the start of the recording. */
162
+ export function formatElapsed(ms: number): string {
163
+ const total = Math.max(0, Math.floor(ms / 1000))
164
+ return `[${two(Math.floor(total / 3600))}:${two(Math.floor((total % 3600) / 60))}:${two(total % 60)}]`
165
+ }
166
+
167
+ function minutesOf(seconds: number): number {
168
+ return Math.max(0, Math.round(seconds / 60))
169
+ }
170
+
171
+ function transcriptLine(elapsedMs: number, speaker: string, text: string): string {
172
+ const name = speaker.trim() === '' ? 'Unknown' : speaker.trim()
173
+ return `${formatElapsed(elapsedMs)} ${name}: ${String(text ?? '').replace(/\s*\n\s*/g, ' ').trim()}`
174
+ }
175
+
176
+ function firefliesLines(sentences: readonly FirefliesSentenceInput[], labels?: readonly SentenceLabel[]): string[] {
177
+ return sentences.map((sentence, index) => {
178
+ const label = labels?.[index]?.resolvedLabel ?? String(sentence.speaker_name ?? '')
179
+ return transcriptLine((Number(sentence.start_time ?? 0) || 0) * 1000, label, String(sentence.text ?? ''))
180
+ })
181
+ }
182
+
183
+ /**
184
+ * The heading above one capture's transcript.
185
+ *
186
+ * NO EM DASH, NO ARROW (QA round 2, blocker 7). This line is not chrome: in apply mode the
187
+ * pipeline splices it verbatim into a permanent operations scribe, and in imports mode it is
188
+ * written into a permanent record. Miles's house rule bans both characters from anything COS
189
+ * generates for a person to read, and there is no later pass that would strip them. One
190
+ * writer, so the guard in `render.test.ts` has exactly one place to fail.
191
+ */
192
+ function captureHeading(index: number, capture: G2RecordingInput): string {
193
+ return `### Capture ${index + 1}, ${formatClock(capture.startMs)}, ${minutesOf(capture.durationMs / 1000)} min`
194
+ }
195
+
196
+ function captureLines(capture: G2RecordingInput): string[] {
197
+ const chunks = (capture.chunks ?? []).filter(chunk => typeof chunk.elapsed === 'number' && Number.isFinite(chunk.elapsed))
198
+ if (chunks.length > 0) return chunks.map(chunk => transcriptLine(chunk.elapsed as number, String(chunk.speaker ?? ''), String(chunk.text ?? '')))
199
+ // No chunk text: fall back to the batch pass's own words so the capture is never empty.
200
+ const { words } = g2TimedWords(capture)
201
+ return words.length > 0 ? [transcriptLine(words[0].timeMs, '', words.map(word => word.token).join(' '))] : []
202
+ }
203
+
204
+ function attendeeUnion(primary: FirefliesMeetingInput, alternates: readonly FirefliesMeetingInput[], captures: readonly G2RecordingInput[], labels: readonly SentenceLabel[]): string[] {
205
+ const names = new Set<string>()
206
+ for (const meeting of [primary, ...alternates]) {
207
+ for (const participant of meeting.participants ?? []) if (participant.trim()) names.add(participant.trim())
208
+ for (const sentence of meeting.sentences) {
209
+ const name = String(sentence.speaker_name ?? '').trim()
210
+ if (name) names.add(name)
211
+ }
212
+ }
213
+ for (const label of labels) if (label.resolvedLabel.trim()) names.add(label.resolvedLabel.trim())
214
+ for (const capture of captures) {
215
+ for (const chunk of capture.chunks ?? []) {
216
+ const name = String(chunk.speaker ?? '').trim()
217
+ if (name) names.add(name)
218
+ }
219
+ }
220
+ return [...names].sort((a, b) => a.localeCompare(b))
221
+ }
222
+
223
+ function verificationRow(verification: readonly SpeakerVerification[]): string {
224
+ if (verification.length === 0) return 'none'
225
+ return verification
226
+ .map(entry => entry.humanConfirmed && entry.medianSimilarity <= 0
227
+ ? `${entry.name} (confirmed, ${entry.sentences} sentences)`
228
+ : `${entry.name} (voice match ${entry.medianSimilarity.toFixed(2)}, ${entry.sentences} sentences)`)
229
+ .join('; ')
230
+ }
231
+
232
+ function fieldTable(rows: ReadonlyArray<readonly [string, string]>): string[] {
233
+ return ['| Field | Value |', '|-------|-------|', ...rows.map(([key, value]) => `| **${key}** | ${value} |`)]
234
+ }
235
+
236
+ function sourcesTable(rows: ReadonlyArray<{ role: string; kind: string; id: string; start: string; length: string }>): string[] {
237
+ return [
238
+ '| Role | Kind | Id | Start | Length |',
239
+ '|------|------|----|-------|--------|',
240
+ ...rows.map(row => `| ${row.role} | ${row.kind} | ${row.id} | ${row.start} | ${row.length} |`),
241
+ ]
242
+ }
243
+
244
+ function sectionsOf(lines: string[]): string {
245
+ return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
246
+ }
247
+
248
+ function byteLength(value: string): number {
249
+ return Buffer.byteLength(value, 'utf8')
250
+ }
251
+
252
+ function attributionStateOf(alignment: Alignment, mode: AttributeMode): AttributionState {
253
+ if (mode === 'capture_only') return 'capture_only'
254
+ return alignment.aligned ? 'aligned' : 'unaligned'
255
+ }
256
+
257
+ function fingerprintsOf(meetings: readonly FirefliesMeetingInput[], captures: readonly G2RecordingInput[]): DerivedInputFingerprint[] {
258
+ return [
259
+ ...meetings.map(meeting => ({ kind: 'fireflies' as const, id: meeting.id, sha256: meeting.sha256 ?? null })),
260
+ ...captures.map(capture => ({
261
+ kind: 'g2' as const,
262
+ id: capture.sessionId,
263
+ sha256: capture.sha256 ?? null,
264
+ correctionRevision: capture.correctionRevision,
265
+ batchApplied: capture.batchApplied,
266
+ })),
267
+ ]
268
+ }
269
+
270
+ /**
271
+ * Build a merged record: the Fireflies transcript with resolved labels, every capture, and
272
+ * every duplicate recording's transcript.
273
+ */
274
+ export function renderMergedRecord(input: MergeDeriveInput): DeriveResult {
275
+ const alternates = input.alternates ?? []
276
+ const mode = input.attributeMode ?? ATTRIBUTE_MODE
277
+ const alignments = input.captures.map(capture => {
278
+ const { words } = g2TimedWords(capture)
279
+ const coarse = input.coarseOffsetMsBySession?.[capture.sessionId]
280
+ ?? (capture.startMs - input.primary.startMs)
281
+ return { capture, alignment: alignRecording(words, input.primary.sentences, coarse) }
282
+ })
283
+ const attribution = attributeSentences(
284
+ input.primary.sentences,
285
+ alignments.map(entry => ({ sessionId: entry.capture.sessionId, alignment: entry.alignment, labels: labelIntervals(entry.capture) })),
286
+ mode,
287
+ )
288
+
289
+ const head = [
290
+ `# ${input.primary.title?.trim() || 'Merged meeting'}`,
291
+ '',
292
+ ...fieldTable([
293
+ ['Date', formatDateTime(input.primary.startMs)],
294
+ ['Duration', `${minutesOf(input.primary.durationS)} minutes`],
295
+ ['Source', MERGED_SOURCE_LABEL],
296
+ ['Domain', DERIVED_DOMAIN],
297
+ ['Speaker Verification', verificationRow(attribution.verification)],
298
+ ]),
299
+ '',
300
+ ]
301
+ const summary = input.primary.summary?.trim() ? ['## Summary', '', input.primary.summary.trim(), ''] : []
302
+ const actionItems = (input.primary.actionItems ?? []).filter(item => item.trim())
303
+ const items = actionItems.length > 0 ? ['## Action Items', '', ...actionItems.map(item => `- ${item.trim()}`), ''] : []
304
+ const attendees = attendeeUnion(input.primary, alternates, input.captures, attribution.labels)
305
+ const attendeeSection = attendees.length > 0 ? ['## Attendees', '', ...attendees.map(name => `- ${name}`), ''] : []
306
+ const sources = [
307
+ '## Sources',
308
+ '',
309
+ ...sourcesTable([
310
+ { role: 'Primary transcript', kind: 'Fireflies', id: input.primary.id, start: formatDateTime(input.primary.startMs), length: `${minutesOf(input.primary.durationS)} min` },
311
+ ...alternates.map(meeting => ({ role: 'Alternate transcript', kind: 'Fireflies', id: meeting.id, start: formatDateTime(meeting.startMs), length: `${minutesOf(meeting.durationS)} min` })),
312
+ ...input.captures.map(capture => ({ role: 'Capture', kind: 'G2', id: capture.sessionId, start: formatDateTime(capture.startMs), length: `${minutesOf(capture.durationMs / 1000)} min` })),
313
+ ]),
314
+ '',
315
+ ]
316
+
317
+ const captureSection = input.captures.length > 0
318
+ ? ['## G2 Capture', '', ...input.captures.flatMap((capture, index) => [
319
+ captureHeading(index, capture),
320
+ '',
321
+ ...captureLines(capture),
322
+ '',
323
+ ])]
324
+ : []
325
+ const alternateSection = alternates.length > 0
326
+ ? ['## Alternate Transcript', '', ...alternates.flatMap(meeting => [
327
+ `### Fireflies ${meeting.id}`,
328
+ '',
329
+ ...firefliesLines(meeting.sentences),
330
+ '',
331
+ ])]
332
+ : []
333
+ const transcript = ['## Transcript', '', ...firefliesLines(input.primary.sentences, attribution.labels), '']
334
+
335
+ const sidecar: DerivedSidecar = {
336
+ version: DERIVED_SIDECAR_VERSION,
337
+ actionId: input.actionId,
338
+ kind: 'merge',
339
+ inputs: fingerprintsOf([input.primary, ...alternates], input.captures),
340
+ tier: input.tier,
341
+ evidence: { K1: input.evidence.k1, K2: input.evidence.k2 },
342
+ offsetMs: alignments[0]?.alignment.offsetMs ?? null,
343
+ anchors: alignments[0]?.alignment.anchors ?? 0,
344
+ mad: alignments[0]?.alignment.madMs ?? null,
345
+ attribution: alignments[0] ? attributionStateOf(alignments[0].alignment, mode) : 'unaligned',
346
+ alignments: alignments.map(entry => ({
347
+ sessionId: entry.capture.sessionId,
348
+ offsetMs: entry.alignment.offsetMs,
349
+ anchors: entry.alignment.anchors,
350
+ madMs: entry.alignment.madMs,
351
+ attribution: attributionStateOf(entry.alignment, mode),
352
+ })),
353
+ labels: attribution.labels.map(label => ({
354
+ index: label.index,
355
+ ffLabel: label.ffLabel,
356
+ g2Label: label.g2Label,
357
+ share: label.share,
358
+ similarity: label.similarity,
359
+ windowOffsetMs: label.windowOffsetMs,
360
+ outcome: label.outcome,
361
+ })),
362
+ conflicts: attribution.conflicts,
363
+ pieces: [],
364
+ }
365
+
366
+ return finish(
367
+ [...head, ...summary, ...items, ...attendeeSection, ...sources],
368
+ [captureSection, alternateSection],
369
+ transcript,
370
+ sidecar,
371
+ attribution.verification,
372
+ input.limits,
373
+ )
374
+ }
375
+
376
+ /** Build one piece of a split recording. */
377
+ export function renderSplitPiece(input: PieceDeriveInput): DeriveResult {
378
+ const mode = input.attributeMode ?? ATTRIBUTE_MODE
379
+ const startS = input.piece.startS
380
+ const endS = input.piece.endS
381
+ const sentences = input.source.sentences.filter(sentence => {
382
+ const at = Number(sentence.start_time ?? 0) || 0
383
+ return at >= startS && at < endS
384
+ })
385
+ const shifted: FirefliesSentenceInput[] = sentences.map(sentence => ({
386
+ ...sentence,
387
+ start_time: (Number(sentence.start_time ?? 0) || 0) - startS,
388
+ end_time: (Number(sentence.end_time ?? 0) || 0) - startS,
389
+ }))
390
+ const pieceMeeting: FirefliesMeetingInput = {
391
+ ...input.source,
392
+ startMs: input.source.startMs + startS * 1000,
393
+ durationS: Math.max(0, endS - startS),
394
+ sentences: shifted,
395
+ }
396
+ const alignments = input.captures.map(capture => {
397
+ const { words } = g2TimedWords(capture)
398
+ return { capture, alignment: alignRecording(words, shifted, capture.startMs - pieceMeeting.startMs) }
399
+ })
400
+ const attribution = attributeSentences(
401
+ shifted,
402
+ alignments.map(entry => ({ sessionId: entry.capture.sessionId, alignment: entry.alignment, labels: labelIntervals(entry.capture) })),
403
+ mode,
404
+ )
405
+
406
+ const title = `${input.source.title?.trim() || 'Recording'} (part ${input.piece.index + 1} of ${input.pieceCount})`
407
+ const head = [
408
+ `# ${title}`,
409
+ '',
410
+ ...fieldTable([
411
+ ['Date', formatDateTime(pieceMeeting.startMs)],
412
+ ['Duration', `${minutesOf(pieceMeeting.durationS)} minutes`],
413
+ ['Source', SPLIT_SOURCE_LABEL],
414
+ ['Domain', DERIVED_DOMAIN],
415
+ ['Speaker Verification', verificationRow(attribution.verification)],
416
+ ]),
417
+ '',
418
+ ]
419
+ const attendees = attendeeUnion(pieceMeeting, [], input.captures, attribution.labels)
420
+ const attendeeSection = attendees.length > 0 ? ['## Attendees', '', ...attendees.map(name => `- ${name}`), ''] : []
421
+ const sources = [
422
+ '## Sources',
423
+ '',
424
+ ...sourcesTable([
425
+ { role: `Long recording, part ${input.piece.index + 1} of ${input.pieceCount}`, kind: 'Fireflies', id: input.source.id, start: formatDateTime(input.source.startMs), length: `${minutesOf(input.source.durationS)} min` },
426
+ ...input.captures.map(capture => ({ role: 'Capture', kind: 'G2', id: capture.sessionId, start: formatDateTime(capture.startMs), length: `${minutesOf(capture.durationMs / 1000)} min` })),
427
+ ]),
428
+ '',
429
+ ]
430
+ const captureSection = input.captures.length > 0
431
+ ? ['## G2 Capture', '', ...input.captures.flatMap((capture, index) => [
432
+ captureHeading(index, capture),
433
+ '',
434
+ ...captureLines(capture),
435
+ '',
436
+ ])]
437
+ : []
438
+ const transcript = ['## Transcript', '', ...firefliesLines(shifted, attribution.labels), '']
439
+
440
+ const sidecar: DerivedSidecar = {
441
+ version: DERIVED_SIDECAR_VERSION,
442
+ actionId: input.actionId,
443
+ kind: 'split',
444
+ inputs: fingerprintsOf([input.source], input.captures),
445
+ tier: input.tier,
446
+ evidence: { K1: 0, K2: 0 },
447
+ offsetMs: alignments[0]?.alignment.offsetMs ?? null,
448
+ anchors: alignments[0]?.alignment.anchors ?? 0,
449
+ mad: alignments[0]?.alignment.madMs ?? null,
450
+ attribution: alignments[0] ? attributionStateOf(alignments[0].alignment, mode) : 'unaligned',
451
+ alignments: alignments.map(entry => ({
452
+ sessionId: entry.capture.sessionId,
453
+ offsetMs: entry.alignment.offsetMs,
454
+ anchors: entry.alignment.anchors,
455
+ madMs: entry.alignment.madMs,
456
+ attribution: attributionStateOf(entry.alignment, mode),
457
+ })),
458
+ labels: attribution.labels.map(label => ({
459
+ index: label.index,
460
+ ffLabel: label.ffLabel,
461
+ g2Label: label.g2Label,
462
+ share: label.share,
463
+ similarity: label.similarity,
464
+ windowOffsetMs: label.windowOffsetMs,
465
+ outcome: label.outcome,
466
+ })),
467
+ conflicts: attribution.conflicts,
468
+ pieces: [input.piece],
469
+ }
470
+
471
+ return finish([...head, ...attendeeSection, ...sources], [captureSection], transcript, sidecar, attribution.verification, input.limits)
472
+ }
473
+
474
+ // ── The pipeline patch (6.47.0, WS4, apply mode) ──────────────────────────────
475
+ //
476
+ // WHY A PATCH AND NOT A FILE (v3 blocker 11). In apply mode the merged content goes into a
477
+ // scribe the COS pipeline already wrote, and that scribe is RICHER than anything this
478
+ // module renders: an LLM summary, decisions, action items, notes, a domain folder and a
479
+ // title a person may have corrected. Handing the pipeline a whole file would replace all of
480
+ // it with a thinner one. The engine emits only what it ADDS, and the pipeline splices.
481
+ //
482
+ // The row forms below are the ones the private blend already writes
483
+ // (`sync_meetings.py:1934-1968`), not new ones, so a merged scribe is indistinguishable
484
+ // from a hand-blended one to every existing reader.
485
+
486
+ /** The Source row a blended operations scribe carries. Source order is the pipeline's. */
487
+ export const PIPELINE_SOURCES_LABEL = 'Fireflies + G2 Glasses'
488
+
489
+ /** Stamped on a blended scribe by the old path and by this one; the old path reads it. */
490
+ export const MARKER_BLENDED = '<!-- g2-transcript-blended -->'
491
+ export const MARKER_MERGE_ACTION_PREFIX = '<!-- merge-action: '
492
+ export const MARKER_G2_SESSION_PREFIX = '<!-- g2-session: '
493
+ export const MARKER_G2_SOURCE_PREFIX = '<!-- g2-source: '
494
+
495
+ /**
496
+ * The `g2-source` marker, in the form the COS pipeline already writes
497
+ * (`sync_meetings.py`, `g2_source_marker()`): the sidecar's BASENAME, with any `--` escaped
498
+ * to `- -`.
499
+ *
500
+ * WHY THE BASENAME AND NOT THE OPERATIONS-RELATIVE PATH. The pipeline's own blend
501
+ * verification compares the marker it finds against the marker it would write, and it writes
502
+ * the basename. A server-written marker carrying a full relative path is therefore a marker
503
+ * the pipeline reads as "not mine", which makes `blend_verified` fail and lets a later
504
+ * refresh append a second G2 Capture section to a scribe that already has one.
505
+ *
506
+ * WHY `--` HAS TO GO. `--` cannot appear inside an HTML comment: a strict parser rejects the
507
+ * whole document, and an iCloud-conflict name (`x 2.g2-chunks.json`) is not the only way a
508
+ * meeting filename acquires one. The pipeline escapes it, so this does too, byte for byte.
509
+ *
510
+ * The operations-relative path is NOT lost: it stays in the decision's inputs
511
+ * (`sidecarRelPath`) and in the derived sidecar, which is where a reader that needs to open
512
+ * the file looks.
513
+ */
514
+ export function g2SourceMarker(sidecarRelPath: string): string {
515
+ const base = sidecarRelPath.split('/').pop() ?? sidecarRelPath
516
+ return `${MARKER_G2_SOURCE_PREFIX}${base.replace(/--/g, '- -')} -->`
517
+ }
518
+
519
+ /**
520
+ * A generic Fireflies label is mapped to a G2 name only when that name holds the label
521
+ * nearly outright.
522
+ *
523
+ * WHY BOTH A SHARE AND A FLOOR ON COUNT. The map is applied to EVERY line carrying that
524
+ * label, including lines no capture covered, so it is an assertion about the whole speaker
525
+ * rather than about the sentences that were measured. A 100% agreement over two sentences is
526
+ * not evidence of that; 70% over five is the weakest thing that is.
527
+ */
528
+ export const SPEAKER_MAP_MIN_SHARE = 0.7
529
+ export const SPEAKER_MAP_MIN_SENTENCES = 5
530
+
531
+ export interface PipelineSpeakerMapEntry {
532
+ name: string
533
+ similarity: number
534
+ sentences: number
535
+ }
536
+
537
+ export interface PipelinePatch {
538
+ /** Markdown inserted before `## Transcript`, in order. */
539
+ sections: string[]
540
+ /** Metadata-table rows, as whole lines. */
541
+ rows: string[]
542
+ /** Comment markers appended to the scribe. */
543
+ markers: string[]
544
+ /** `{ firefliesLabel: { name, similarity, sentences } }` for generic labels only. */
545
+ speakerMap: Record<string, PipelineSpeakerMapEntry>
546
+ /** What the Speaker Verification row says, for Control to render without re-parsing. */
547
+ verification: SpeakerVerification[]
548
+ }
549
+
550
+ export interface PipelinePatchInput extends MergeDeriveInput {
551
+ /** Operations-relative `.g2-chunks.json` path per capture, for the `g2-source` marker. */
552
+ sidecarRelPathBySession?: Record<string, string>
553
+ }
554
+
555
+ /**
556
+ * Build the patch the pipeline splices into an existing Fireflies scribe.
557
+ *
558
+ * It runs the SAME alignment and attribution as `renderMergedRecord`, so a record derived in
559
+ * imports mode and a scribe blended in apply mode carry identical speaker conclusions. What
560
+ * differs is only what is emitted: additions, never a replacement.
561
+ */
562
+ export function renderPipelinePatch(input: PipelinePatchInput): PipelinePatch {
563
+ const alternates = input.alternates ?? []
564
+ const mode = input.attributeMode ?? ATTRIBUTE_MODE
565
+ const alignments = input.captures.map(capture => {
566
+ const { words } = g2TimedWords(capture)
567
+ const coarse = input.coarseOffsetMsBySession?.[capture.sessionId]
568
+ ?? (capture.startMs - input.primary.startMs)
569
+ return { capture, alignment: alignRecording(words, input.primary.sentences, coarse) }
570
+ })
571
+ const attribution = attributeSentences(
572
+ input.primary.sentences,
573
+ alignments.map(entry => ({ sessionId: entry.capture.sessionId, alignment: entry.alignment, labels: labelIntervals(entry.capture) })),
574
+ mode,
575
+ )
576
+
577
+ const sections: string[] = []
578
+ if (input.captures.length > 0) {
579
+ sections.push(sectionsOf(['## G2 Capture', '', ...input.captures.flatMap((capture, index) => [
580
+ captureHeading(index, capture),
581
+ '',
582
+ ...captureLines(capture),
583
+ '',
584
+ ])]))
585
+ }
586
+ if (alternates.length > 0) {
587
+ sections.push(sectionsOf(['## Alternate Transcript', '', ...alternates.flatMap(meeting => [
588
+ `### Fireflies ${meeting.id}`,
589
+ '',
590
+ ...firefliesLines(meeting.sentences),
591
+ '',
592
+ ])]))
593
+ }
594
+
595
+ const rows = [
596
+ `| **Sources** | ${PIPELINE_SOURCES_LABEL} |`,
597
+ `| **Speaker Verification** | ${verificationRow(attribution.verification)} |`,
598
+ ]
599
+
600
+ const markers = [
601
+ MARKER_BLENDED,
602
+ ...input.captures.flatMap(capture => {
603
+ const relPath = input.sidecarRelPathBySession?.[capture.sessionId]
604
+ return [
605
+ ...(relPath ? [g2SourceMarker(relPath)] : []),
606
+ `${MARKER_G2_SESSION_PREFIX}${capture.sessionId} -->`,
607
+ ]
608
+ }),
609
+ `${MARKER_MERGE_ACTION_PREFIX}${input.actionId} -->`,
610
+ ]
611
+
612
+ return {
613
+ sections,
614
+ rows,
615
+ markers,
616
+ speakerMap: speakerMapFrom(attribution.labels),
617
+ verification: attribution.verification,
618
+ }
619
+ }
620
+
621
+ /**
622
+ * One G2 name per generic Fireflies label, where the evidence is overwhelming.
623
+ *
624
+ * Only GENERIC labels are mapped. A Fireflies label that already names a person is a name a
625
+ * person's calendar or a human gave, and D13's rule that a voiceprint never overwrites a
626
+ * human name is enforced here as well as in `attribute.ts`.
627
+ */
628
+ export function speakerMapFrom(labels: readonly SentenceLabel[]): Record<string, PipelineSpeakerMapEntry> {
629
+ const byLabel = new Map<string, { total: number; names: Map<string, { count: number; similarities: number[] }> }>()
630
+ for (const label of labels) {
631
+ if (!isGenericLabel(label.ffLabel)) continue
632
+ const entry = byLabel.get(label.ffLabel) ?? { total: 0, names: new Map() }
633
+ entry.total += 1
634
+ if (label.g2Label && !isGenericLabel(label.g2Label)) {
635
+ const name = entry.names.get(label.g2Label) ?? { count: 0, similarities: [] }
636
+ name.count += 1
637
+ name.similarities.push(label.similarity)
638
+ entry.names.set(label.g2Label, name)
639
+ }
640
+ byLabel.set(label.ffLabel, entry)
641
+ }
642
+
643
+ const map: Record<string, PipelineSpeakerMapEntry> = {}
644
+ for (const [ffLabel, entry] of [...byLabel.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
645
+ if (entry.names.size !== 1) continue
646
+ const [name, stats] = [...entry.names.entries()][0]
647
+ if (stats.count < SPEAKER_MAP_MIN_SENTENCES) continue
648
+ if (entry.total === 0 || stats.count / entry.total < SPEAKER_MAP_MIN_SHARE) continue
649
+ map[ffLabel] = {
650
+ name,
651
+ similarity: Number(median(stats.similarities).toFixed(2)),
652
+ sentences: stats.count,
653
+ }
654
+ }
655
+ return map
656
+ }
657
+
658
+ /**
659
+ * Assemble, and move the attached transcripts to the sidecar rather than lose them when the
660
+ * markdown is too big for its readers. Failing loudly beats writing a record that silently
661
+ * dropped a source.
662
+ */
663
+ function finish(
664
+ head: string[],
665
+ attachable: string[][],
666
+ transcript: string[],
667
+ sidecar: DerivedSidecar,
668
+ verification: SpeakerVerification[],
669
+ limits: RenderLimits | undefined,
670
+ ): DeriveResult {
671
+ const markdownMax = limits?.markdownMaxBytes ?? MARKDOWN_MAX_BYTES
672
+ const sidecarMax = limits?.sidecarMaxBytes ?? DERIVED_SIDECAR_MAX_BYTES
673
+ const attached = attachable.filter(section => section.length > 0)
674
+ let markdown = sectionsOf([...head, ...attached.flat(), ...transcript])
675
+ let record: DerivedSidecar = sidecar
676
+ if (byteLength(markdown) > markdownMax && attached.length > 0) {
677
+ const overflow = attached.map(section => sectionsOf(section))
678
+ record = { ...sidecar, overflow: { sections: overflow } }
679
+ markdown = sectionsOf([
680
+ ...head,
681
+ '## Attached Transcripts',
682
+ '',
683
+ `Kept in the sidecar: this record is over ${markdownMax} bytes of markdown.`,
684
+ '',
685
+ ...transcript,
686
+ ])
687
+ }
688
+ const sidecarBytes = byteLength(JSON.stringify(record))
689
+ const markdownBytes = byteLength(markdown)
690
+ if (markdownBytes > markdownMax || sidecarBytes > sidecarMax) {
691
+ return { ok: false, error: 'derived_too_large', markdownBytes, sidecarBytes }
692
+ }
693
+ return { ok: true, record: { markdown, sidecar: record, verification } }
694
+ }