@gotcos/glasses-server 6.46.1 → 6.48.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 +25 -0
- package/README.md +24 -0
- package/bin/cli.cjs +22 -0
- package/bin/hooks/cos-session-hook +43 -0
- package/managed-runtime-contract.json +7 -1
- package/package.json +8 -2
- package/server/index.ts +85 -0
- package/server/lib/claude-hooks-installer.ts +403 -0
- package/server/lib/claude-session-registry.ts +25 -0
- package/server/lib/cos-operations-meetings.ts +99 -8
- package/server/lib/fireflies-client.ts +862 -0
- package/server/lib/fireflies-key.ts +182 -0
- package/server/lib/imported-library-rows.ts +616 -0
- package/server/lib/imported-meeting-library.ts +608 -0
- package/server/lib/maintenance-lifecycle.ts +14 -0
- package/server/lib/meeting-actions-store.ts +478 -0
- package/server/lib/meeting-actions.ts +2583 -0
- package/server/lib/meeting-corrections.ts +32 -1
- package/server/lib/meeting-decisions.ts +223 -0
- package/server/lib/meeting-engine/align.ts +167 -0
- package/server/lib/meeting-engine/attribute.ts +265 -0
- package/server/lib/meeting-engine/evidence.ts +428 -0
- package/server/lib/meeting-engine/pairing.ts +327 -0
- package/server/lib/meeting-engine/render.ts +694 -0
- package/server/lib/meeting-engine/split.ts +242 -0
- package/server/lib/meeting-engine/worker.ts +238 -0
- package/server/lib/meeting-engine-mode.ts +197 -0
- package/server/lib/meeting-file-guards.ts +141 -0
- package/server/lib/meeting-import.ts +763 -0
- package/server/lib/meeting-library-search.ts +146 -11
- package/server/lib/meeting-parse.ts +184 -0
- package/server/lib/meeting-store.ts +108 -275
- package/server/lib/meeting-suggestion-sides.ts +242 -0
- package/server/lib/morning-brief-runtime.ts +20 -8
- package/server/lib/pipeline-runner.ts +227 -0
- package/server/lib/session-hook-events.ts +200 -0
- package/server/lib/session-hook-ledger.ts +129 -0
- package/server/lib/session-hook-spool.ts +264 -0
- package/server/lib/session-hooks-runtime.ts +229 -0
- package/server/lib/session-signal-store.ts +361 -0
- package/server/lib/session-state-derive.ts +211 -0
- package/server/lib/voice-evidence-guard.ts +87 -0
- package/server/routes/agent-sessions.ts +56 -6
- package/server/routes/claude-sessions.ts +32 -5
- package/server/routes/fireflies-key.ts +102 -0
- package/server/routes/health.ts +2 -0
- package/server/routes/meeting-actions.ts +82 -0
- package/server/routes/meeting-engine.ts +52 -0
- package/server/routes/meeting-import.ts +67 -0
- package/server/routes/meeting-suggestions.ts +66 -0
- package/server/routes/meeting.ts +117 -10
- package/server/routes/meetings.ts +177 -50
- package/server/routes/session-hooks.ts +70 -0
- package/server/routes/voice.ts +18 -0
- package/server/scripts/hooks-cli.ts +48 -0
- package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
|
@@ -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
|
+
}
|