@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +24 -0
  3. package/bin/cli.cjs +22 -0
  4. package/bin/hooks/cos-session-hook +43 -0
  5. package/managed-runtime-contract.json +7 -1
  6. package/package.json +8 -2
  7. package/server/index.ts +85 -0
  8. package/server/lib/claude-hooks-installer.ts +403 -0
  9. package/server/lib/claude-session-registry.ts +25 -0
  10. package/server/lib/cos-operations-meetings.ts +99 -8
  11. package/server/lib/fireflies-client.ts +862 -0
  12. package/server/lib/fireflies-key.ts +182 -0
  13. package/server/lib/imported-library-rows.ts +616 -0
  14. package/server/lib/imported-meeting-library.ts +608 -0
  15. package/server/lib/maintenance-lifecycle.ts +14 -0
  16. package/server/lib/meeting-actions-store.ts +478 -0
  17. package/server/lib/meeting-actions.ts +2583 -0
  18. package/server/lib/meeting-corrections.ts +32 -1
  19. package/server/lib/meeting-decisions.ts +223 -0
  20. package/server/lib/meeting-engine/align.ts +167 -0
  21. package/server/lib/meeting-engine/attribute.ts +265 -0
  22. package/server/lib/meeting-engine/evidence.ts +428 -0
  23. package/server/lib/meeting-engine/pairing.ts +327 -0
  24. package/server/lib/meeting-engine/render.ts +694 -0
  25. package/server/lib/meeting-engine/split.ts +242 -0
  26. package/server/lib/meeting-engine/worker.ts +238 -0
  27. package/server/lib/meeting-engine-mode.ts +197 -0
  28. package/server/lib/meeting-file-guards.ts +141 -0
  29. package/server/lib/meeting-import.ts +763 -0
  30. package/server/lib/meeting-library-search.ts +146 -11
  31. package/server/lib/meeting-parse.ts +184 -0
  32. package/server/lib/meeting-store.ts +108 -275
  33. package/server/lib/meeting-suggestion-sides.ts +242 -0
  34. package/server/lib/morning-brief-runtime.ts +20 -8
  35. package/server/lib/pipeline-runner.ts +227 -0
  36. package/server/lib/session-hook-events.ts +200 -0
  37. package/server/lib/session-hook-ledger.ts +129 -0
  38. package/server/lib/session-hook-spool.ts +264 -0
  39. package/server/lib/session-hooks-runtime.ts +229 -0
  40. package/server/lib/session-signal-store.ts +361 -0
  41. package/server/lib/session-state-derive.ts +211 -0
  42. package/server/lib/voice-evidence-guard.ts +87 -0
  43. package/server/routes/agent-sessions.ts +56 -6
  44. package/server/routes/claude-sessions.ts +32 -5
  45. package/server/routes/fireflies-key.ts +102 -0
  46. package/server/routes/health.ts +2 -0
  47. package/server/routes/meeting-actions.ts +82 -0
  48. package/server/routes/meeting-engine.ts +52 -0
  49. package/server/routes/meeting-import.ts +67 -0
  50. package/server/routes/meeting-suggestions.ts +66 -0
  51. package/server/routes/meeting.ts +117 -10
  52. package/server/routes/meetings.ts +177 -50
  53. package/server/routes/session-hooks.ts +70 -0
  54. package/server/routes/voice.ts +18 -0
  55. package/server/scripts/hooks-cli.ts +48 -0
  56. package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
@@ -0,0 +1,862 @@
1
+ // Fireflies GraphQL client for the meeting importer (6.47.0).
2
+ //
3
+ // This is the ONLY code in this release that talks to a vendor. Everything it
4
+ // can be told by that vendor is reduced to one small taxonomy before it reaches
5
+ // a caller, and the upstream body is never echoed: a vendor error message can
6
+ // carry an account name, a meeting title, or the key itself, and every surface
7
+ // that renders importer state (COS Control, the lens, a log line) is a place it
8
+ // would then leak to.
9
+ //
10
+ // HOW IT STOPS, the question every new network caller in this repo must answer:
11
+ // - a daily budget ledger, capped by the user's declared plan, stopping two
12
+ // calls short of the cap so a user-initiated key check always has room;
13
+ // - a 50-call-per-minute pace, below the vendor's published 60;
14
+ // - bounded 5xx retries (3, at 5s/15s/30s) and no retry at all on the
15
+ // interactive key check, which a person is waiting on;
16
+ // - key checks throttled to one per 10 minutes unless a person asked for it.
17
+ //
18
+ // PAGE SIZE IS ADAPTIVE, and that is a correctness property rather than a
19
+ // performance one: a list page carries every sentence of every meeting in it
20
+ // (a canary page of 50 measured 2.07 MB in 3.6 s), so one unusually long
21
+ // meeting can push a page past the response cap or the timeout forever. The
22
+ // descent 50 then 10 then 1 isolates that meeting, records it as a retryable
23
+ // skip, and lets the cursor move past it. Without it a single bad transcript
24
+ // stalls every later import.
25
+ //
26
+ // NOTHING HERE TOUCHES THE FILESYSTEM except the budget ledger, and nothing
27
+ // here knows what an import record looks like. Parsing the vendor's shape into
28
+ // a record is `normalizeFirefliesTranscript`; writing one is the library.
29
+
30
+ import { createHash } from 'node:crypto'
31
+ import { readFileSync } from 'node:fs'
32
+ import { dirname } from 'node:path'
33
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
34
+ import { localDay } from './local-day.js'
35
+ import { securePrivateDirectory } from './secure-user-config.js'
36
+
37
+ export const FIREFLIES_ENDPOINT = 'https://api.fireflies.ai/graphql'
38
+
39
+ /** Descent order for a list page. Each step is tried at the same cursor. */
40
+ export const FIREFLIES_PAGE_SIZES = [50, 10, 1] as const
41
+ /** One call span. The cursor advances by this much per successful listPage(). */
42
+ export const FIREFLIES_PAGE_SPAN = FIREFLIES_PAGE_SIZES[0]
43
+ export const FIREFLIES_PAGE_TIMEOUT_MS = 30_000
44
+ /** A person is waiting on the key check, so it gets a shorter leash and no retries. */
45
+ export const FIREFLIES_KEY_CHECK_TIMEOUT_MS = 15_000
46
+ export const FIREFLIES_MAX_RESPONSE_BYTES = 20 * 1024 * 1024
47
+ /** The vendor publishes 60/min. Staying under it is cheaper than handling 429s. */
48
+ export const FIREFLIES_CALLS_PER_MINUTE = 50
49
+ export const FIREFLIES_RATE_WINDOW_MS = 60_000
50
+ export const FIREFLIES_SERVER_RETRY_DELAYS_MS = [5_000, 15_000, 30_000] as const
51
+ /** Calls held back from the daily cap for user-initiated key checks. */
52
+ export const FIREFLIES_DAILY_CAP_RESERVE = 2
53
+ export const FIREFLIES_KEY_CHECK_THROTTLE_MS = 10 * 60_000
54
+ export const FIREFLIES_PLAN_CAPS = { free: 50, pro: 500, business: null } as const
55
+
56
+ /**
57
+ * Accepted vendor id shape. Measured against 463 ids in this Mac's own pipeline
58
+ * state on 2026-09-14: every one is a 26-character uppercase ULID. Older ids in
59
+ * the wild are shorter alphanumerics, so the class is deliberately wider than
60
+ * the measurement and narrower than "any string" - an id outside it would reach
61
+ * a filename and a hash, and is worth an alarm rather than a silent import.
62
+ */
63
+ export const FIREFLIES_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/
64
+
65
+ export type FirefliesPlan = keyof typeof FIREFLIES_PLAN_CAPS
66
+
67
+ export type FirefliesFailureState =
68
+ | 'invalid_key'
69
+ | 'rate_limited'
70
+ | 'vendor_down'
71
+ | 'unreachable'
72
+ | 'vendor_error'
73
+ | 'cap_exhausted'
74
+
75
+ export interface FirefliesFailure {
76
+ state: FirefliesFailureState
77
+ httpStatus?: number
78
+ retryAfterSeconds?: number
79
+ /** A sanitized vendor error code. Never a vendor message. */
80
+ code?: string
81
+ }
82
+
83
+ export interface FirefliesRequest {
84
+ key: string
85
+ body: string
86
+ timeoutMs: number
87
+ maxBytes: number
88
+ }
89
+
90
+ export type FirefliesTransportOutcome =
91
+ | { kind: 'http'; status: number; headers: Record<string, string>; body: string }
92
+ | { kind: 'too_large' }
93
+ | { kind: 'timeout' }
94
+ | { kind: 'network_error' }
95
+
96
+ export type FirefliesTransport = (request: FirefliesRequest) => Promise<FirefliesTransportOutcome>
97
+
98
+ export interface FirefliesSentence {
99
+ speakerName: string
100
+ speakerId: string | null
101
+ text: string
102
+ startTime: number
103
+ endTime: number
104
+ }
105
+
106
+ export interface FirefliesTranscriptRecord {
107
+ id: string
108
+ title: string
109
+ dateMs: number
110
+ /** null means the vendor gave no duration and the sentences implied none. */
111
+ durationSeconds: number | null
112
+ durationSource: 'vendor' | 'sentence_span' | 'unknown'
113
+ organizerEmail: string | null
114
+ participants: string[]
115
+ sentences: FirefliesSentence[]
116
+ /** The vendor's summary. Absent on plans or meetings that have none, which
117
+ * is a normal state and never a reason to refuse a meeting: the transcript
118
+ * is the record, and the summary is something extra on top of it. */
119
+ overview: string | null
120
+ /** ONE string with newlines in it, not a list. Split at the render. */
121
+ actionItems: string | null
122
+ keywords: string[]
123
+ }
124
+
125
+ export type FirefliesSkipReason =
126
+ | 'missing_id'
127
+ | 'id_shape_changed'
128
+ | 'missing_date'
129
+ | 'date_string'
130
+ | 'malformed_sentences'
131
+ | 'still_processing'
132
+
133
+ export type FirefliesNormalizeResult =
134
+ | { ok: true; record: FirefliesTranscriptRecord }
135
+ | { ok: false; reason: FirefliesSkipReason }
136
+
137
+ export interface FirefliesKeyCheck {
138
+ state: 'ok' | 'not_configured' | FirefliesFailureState
139
+ httpStatus?: number
140
+ retryAfterSeconds?: number
141
+ code?: string
142
+ checkedAt: string
143
+ /** True when the throttle served a remembered answer instead of calling out. */
144
+ cached: boolean
145
+ }
146
+
147
+ export interface FirefliesPageResult {
148
+ transcripts: unknown[]
149
+ /** Where the caller should resume. Always advances past what was consumed. */
150
+ nextSkip: number
151
+ endOfList: boolean
152
+ /** Single transcripts that exceed the response cap even alone. */
153
+ oversized: Array<{ skip: number; id: string | null }>
154
+ failure?: FirefliesFailure
155
+ calls: number
156
+ /** Page sizes actually requested, oldest first. Diagnostics and tests. */
157
+ pageSizes: number[]
158
+ }
159
+
160
+ interface BudgetFile {
161
+ version: 1
162
+ day: string
163
+ calls: number
164
+ plan: FirefliesPlan
165
+ updatedAt: string
166
+ }
167
+
168
+ /**
169
+ * Daily call ledger. Written atomically at 0600 because it is the only thing
170
+ * standing between a looping importer and a user's monthly vendor quota.
171
+ */
172
+ export class FirefliesBudget {
173
+ private readonly path: string
174
+ private readonly now: () => number
175
+ private state: BudgetFile
176
+
177
+ constructor(options: { path: string; now?: () => number }) {
178
+ this.path = options.path
179
+ this.now = options.now ?? (() => Date.now())
180
+ this.state = this.load()
181
+ }
182
+
183
+ private load(): BudgetFile {
184
+ let raw: string
185
+ try {
186
+ raw = readFileSync(this.path, 'utf8')
187
+ } catch {
188
+ // Missing or unreadable both mean "no spend recorded here". A ledger we
189
+ // cannot read must not be treated as permission to spend without one, so
190
+ // the fresh ledger is written back on the first consume.
191
+ return { version: 1, day: localDay(this.now()), calls: 0, plan: 'free', updatedAt: new Date(this.now()).toISOString() }
192
+ }
193
+ try {
194
+ const parsed = JSON.parse(raw) as Partial<BudgetFile>
195
+ const plan: FirefliesPlan = parsed.plan === 'pro' || parsed.plan === 'business' ? parsed.plan : 'free'
196
+ return {
197
+ version: 1,
198
+ day: typeof parsed.day === 'string' ? parsed.day : localDay(this.now()),
199
+ calls: typeof parsed.calls === 'number' && Number.isFinite(parsed.calls) ? Math.max(0, Math.trunc(parsed.calls)) : 0,
200
+ plan,
201
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date(this.now()).toISOString(),
202
+ }
203
+ } catch {
204
+ return { version: 1, day: localDay(this.now()), calls: 0, plan: 'free', updatedAt: new Date(this.now()).toISOString() }
205
+ }
206
+ }
207
+
208
+ private rollDay(): void {
209
+ const day = localDay(this.now())
210
+ if (this.state.day !== day) {
211
+ this.state = { ...this.state, day, calls: 0 }
212
+ }
213
+ }
214
+
215
+ private persist(): void {
216
+ this.state.updatedAt = new Date(this.now()).toISOString()
217
+ securePrivateDirectory(dirname(this.path))
218
+ durableAtomicWriteFileSync(this.path, JSON.stringify(this.state), { mode: 0o600 })
219
+ }
220
+
221
+ plan(): FirefliesPlan {
222
+ return this.state.plan
223
+ }
224
+
225
+ setPlan(plan: FirefliesPlan): void {
226
+ this.rollDay()
227
+ this.state.plan = plan
228
+ this.persist()
229
+ }
230
+
231
+ /** Daily cap for the declared plan. null means the plan has no daily cap. */
232
+ cap(): number | null {
233
+ return FIREFLIES_PLAN_CAPS[this.state.plan]
234
+ }
235
+
236
+ snapshot(): { day: string; calls: number; plan: FirefliesPlan; cap: number | null; remaining: number | null } {
237
+ this.rollDay()
238
+ const cap = this.cap()
239
+ const limit = cap == null ? null : Math.max(0, cap - FIREFLIES_DAILY_CAP_RESERVE)
240
+ return {
241
+ day: this.state.day,
242
+ calls: this.state.calls,
243
+ plan: this.state.plan,
244
+ cap,
245
+ remaining: limit == null ? null : Math.max(0, limit - this.state.calls),
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Claim one call. `reserved` spends into the two-call reserve, and is only
251
+ * for a check a person started: it is what keeps "Check key" working on a day
252
+ * an import already spent the budget.
253
+ */
254
+ tryConsume(options: { reserved?: boolean } = {}): boolean {
255
+ this.rollDay()
256
+ const cap = this.cap()
257
+ if (cap != null) {
258
+ const limit = options.reserved ? cap : Math.max(0, cap - FIREFLIES_DAILY_CAP_RESERVE)
259
+ if (this.state.calls >= limit) return false
260
+ }
261
+ this.state.calls += 1
262
+ this.persist()
263
+ return true
264
+ }
265
+ }
266
+
267
+ const LIST_FIELDS = 'id title date duration organizer_email participants'
268
+ const SENTENCE_FIELDS = 'sentences { speaker_name speaker_id text start_time end_time }'
269
+ /**
270
+ * Canary-verified on 2026-09-14 with one live call: the field resolves with no
271
+ * GraphQL error, `overview` and `action_items` come back as STRINGS (600-900
272
+ * and 200-900 characters in the sample) and `keywords` as a list.
273
+ *
274
+ * Asked for only alongside sentences. The id-only probe exists to name a
275
+ * transcript too large to fetch, so adding fields to it would work against the
276
+ * one thing it is for.
277
+ */
278
+ const SUMMARY_FIELDS = 'summary { overview action_items keywords }'
279
+ const DETAIL_FIELDS = `${LIST_FIELDS} ${SENTENCE_FIELDS} ${SUMMARY_FIELDS}`
280
+
281
+ export function firefliesListQuery(limit: number, skip: number, withSentences: boolean): string {
282
+ const fields = withSentences ? `${LIST_FIELDS} ${SENTENCE_FIELDS} ${SUMMARY_FIELDS}` : LIST_FIELDS
283
+ return JSON.stringify({
284
+ query: `query CosImportList($limit: Int, $skip: Int) { transcripts(limit: $limit, skip: $skip) { ${fields} } }`,
285
+ variables: { limit, skip },
286
+ })
287
+ }
288
+
289
+ /** One transcript by id. Same fields as a list page carries for each row. */
290
+ export function firefliesTranscriptQuery(id: string): string {
291
+ return JSON.stringify({
292
+ query: `query CosImportDetail($id: String!) { transcript(id: $id) { ${DETAIL_FIELDS} } }`,
293
+ variables: { id },
294
+ })
295
+ }
296
+
297
+ export function firefliesKeyCheckQuery(): string {
298
+ return JSON.stringify({ query: 'query CosKeyCheck { user { user_id } }' })
299
+ }
300
+
301
+ /** A vendor code is an identifier, never prose. Anything else is dropped. */
302
+ function sanitizeCode(value: unknown): string | undefined {
303
+ if (typeof value !== 'string') return undefined
304
+ const trimmed = value.trim()
305
+ return /^[A-Za-z0-9_.-]{1,64}$/.test(trimmed) ? trimmed : undefined
306
+ }
307
+
308
+ function graphqlErrorCodes(parsed: unknown): string[] {
309
+ if (!parsed || typeof parsed !== 'object') return []
310
+ const errors = (parsed as { errors?: unknown }).errors
311
+ if (!Array.isArray(errors)) return []
312
+ const codes: string[] = []
313
+ for (const entry of errors) {
314
+ if (!entry || typeof entry !== 'object') continue
315
+ const direct = sanitizeCode((entry as { code?: unknown }).code)
316
+ if (direct) codes.push(direct)
317
+ const extensions = (entry as { extensions?: unknown }).extensions
318
+ if (extensions && typeof extensions === 'object') {
319
+ const nested = sanitizeCode((extensions as { code?: unknown }).code)
320
+ if (nested) codes.push(nested)
321
+ }
322
+ }
323
+ return codes
324
+ }
325
+
326
+ export function parseRetryAfterSeconds(headers: Record<string, string>, now: number): number | undefined {
327
+ const header = (name: string): string | undefined => {
328
+ for (const [key, value] of Object.entries(headers)) {
329
+ if (key.toLowerCase() === name) return value
330
+ }
331
+ return undefined
332
+ }
333
+ const retryAfter = header('retry-after')
334
+ if (retryAfter) {
335
+ const seconds = Number(retryAfter.trim())
336
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds)
337
+ const at = Date.parse(retryAfter)
338
+ if (Number.isFinite(at)) return Math.max(0, Math.ceil((at - now) / 1_000))
339
+ }
340
+ const reset = header('x-ratelimit-reset-api')
341
+ if (reset) {
342
+ const value = Number(reset.trim())
343
+ if (Number.isFinite(value) && value >= 0) {
344
+ // The header is documented nowhere we can verify, so read it both ways:
345
+ // a large value is an absolute instant, a small one is a delay.
346
+ if (value > 1e12) return Math.max(0, Math.ceil((value - now) / 1_000))
347
+ if (value > 1e9) return Math.max(0, Math.ceil(value - now / 1_000))
348
+ return Math.ceil(value)
349
+ }
350
+ }
351
+ return undefined
352
+ }
353
+
354
+ type ClassifiedResponse =
355
+ | { kind: 'data'; data: Record<string, unknown> }
356
+ | { kind: 'retry'; failure: FirefliesFailure }
357
+ | { kind: 'failure'; failure: FirefliesFailure }
358
+
359
+ /**
360
+ * The body is parsed BEFORE the status is interpreted. The vendor answers an
361
+ * invalid key with HTTP 500 and a GraphQL `auth_failed`, so a status-first
362
+ * reading reports a vendor outage and retries three times against a key that
363
+ * will never work.
364
+ */
365
+ export function classifyFirefliesResponse(
366
+ status: number,
367
+ headers: Record<string, string>,
368
+ body: string,
369
+ now: number,
370
+ ): ClassifiedResponse {
371
+ let parsed: unknown
372
+ let parseFailed = false
373
+ try {
374
+ parsed = JSON.parse(body)
375
+ } catch {
376
+ parseFailed = true
377
+ }
378
+
379
+ const codes = graphqlErrorCodes(parsed)
380
+ if (codes.includes('auth_failed')) {
381
+ return { kind: 'failure', failure: { state: 'invalid_key', httpStatus: status } }
382
+ }
383
+ if (status === 401 || status === 403) {
384
+ return { kind: 'failure', failure: { state: 'invalid_key', httpStatus: status } }
385
+ }
386
+ if (status === 429) {
387
+ const retryAfterSeconds = parseRetryAfterSeconds(headers, now)
388
+ return {
389
+ kind: 'failure',
390
+ failure: { state: 'rate_limited', httpStatus: status, ...(retryAfterSeconds != null ? { retryAfterSeconds } : {}) },
391
+ }
392
+ }
393
+ if (status >= 500) {
394
+ return { kind: 'retry', failure: { state: 'vendor_down', httpStatus: status } }
395
+ }
396
+ if (codes.length > 0) {
397
+ return { kind: 'failure', failure: { state: 'vendor_error', httpStatus: status, code: codes[0] } }
398
+ }
399
+ if (status < 200 || status >= 300) {
400
+ return { kind: 'failure', failure: { state: 'vendor_error', httpStatus: status, code: `http_${status}` } }
401
+ }
402
+ if (parseFailed || !parsed || typeof parsed !== 'object') {
403
+ return { kind: 'failure', failure: { state: 'vendor_error', httpStatus: status, code: 'invalid_json' } }
404
+ }
405
+ const data = (parsed as { data?: unknown }).data
406
+ if (!data || typeof data !== 'object') {
407
+ return { kind: 'failure', failure: { state: 'vendor_error', httpStatus: status, code: 'invalid_payload' } }
408
+ }
409
+ return { kind: 'data', data: data as Record<string, unknown> }
410
+ }
411
+
412
+ function sentenceOf(raw: unknown): FirefliesSentence | null {
413
+ if (!raw || typeof raw !== 'object') return null
414
+ const item = raw as Record<string, unknown>
415
+ const startTime = Number(item.start_time)
416
+ const endTime = Number(item.end_time)
417
+ if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) return null
418
+ if (typeof item.text !== 'string') return null
419
+ return {
420
+ speakerName: typeof item.speaker_name === 'string' ? item.speaker_name : '',
421
+ speakerId: typeof item.speaker_id === 'string' ? item.speaker_id : null,
422
+ text: item.text,
423
+ startTime,
424
+ endTime,
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Vendor shape to record, or the reason it cannot become one.
430
+ *
431
+ * Every refusal here is a COUNTED skip rather than a thrown error: one bad
432
+ * transcript must never stop a backfill, and the reason is what tells a person
433
+ * whether to wait (still_processing) or to look (id_shape_changed).
434
+ */
435
+ export function normalizeFirefliesTranscript(raw: unknown): FirefliesNormalizeResult {
436
+ if (!raw || typeof raw !== 'object') return { ok: false, reason: 'missing_id' }
437
+ const item = raw as Record<string, unknown>
438
+
439
+ if (typeof item.id !== 'string' || item.id.trim().length === 0) return { ok: false, reason: 'missing_id' }
440
+ const id = item.id.trim()
441
+ if (!FIREFLIES_ID_PATTERN.test(id)) return { ok: false, reason: 'id_shape_changed' }
442
+
443
+ if (typeof item.date === 'string') return { ok: false, reason: 'date_string' }
444
+ const dateMs = typeof item.date === 'number' ? item.date : Number.NaN
445
+ if (!Number.isFinite(dateMs)) return { ok: false, reason: 'missing_date' }
446
+
447
+ const rawSentences = item.sentences
448
+ if (rawSentences != null && !Array.isArray(rawSentences)) return { ok: false, reason: 'malformed_sentences' }
449
+ const sentences: FirefliesSentence[] = []
450
+ if (Array.isArray(rawSentences)) {
451
+ for (const entry of rawSentences) {
452
+ const sentence = sentenceOf(entry)
453
+ if (!sentence) return { ok: false, reason: 'malformed_sentences' }
454
+ sentences.push(sentence)
455
+ }
456
+ }
457
+ // No sentences yet is the vendor still transcribing. The caller retries it on
458
+ // a backoff and gives up only after the retry budget, because a meeting that
459
+ // never produced speech would otherwise be retried forever.
460
+ if (sentences.length === 0) return { ok: false, reason: 'still_processing' }
461
+
462
+ const vendorDuration = typeof item.duration === 'number' && Number.isFinite(item.duration) && item.duration > 0
463
+ ? Math.round(item.duration * 60)
464
+ : null
465
+ let durationSeconds = vendorDuration
466
+ let durationSource: FirefliesTranscriptRecord['durationSource'] = vendorDuration == null ? 'unknown' : 'vendor'
467
+ if (durationSeconds == null) {
468
+ const starts = sentences.map(sentence => sentence.startTime)
469
+ const ends = sentences.map(sentence => sentence.endTime)
470
+ const span = Math.round(Math.max(...ends) - Math.min(...starts))
471
+ if (span > 0) {
472
+ durationSeconds = span
473
+ durationSource = 'sentence_span'
474
+ }
475
+ }
476
+
477
+ const participants = Array.isArray(item.participants)
478
+ ? item.participants.filter((value): value is string => typeof value === 'string')
479
+ : []
480
+
481
+ // A missing or null summary is ABSENT, never an error. The field is not on
482
+ // every plan and not on every meeting, and refusing a transcript over it
483
+ // would throw away the thing we actually came for.
484
+ const summary = item.summary && typeof item.summary === 'object' && !Array.isArray(item.summary)
485
+ ? item.summary as Record<string, unknown>
486
+ : null
487
+
488
+ return {
489
+ ok: true,
490
+ record: {
491
+ id,
492
+ title: typeof item.title === 'string' ? item.title : '',
493
+ dateMs,
494
+ durationSeconds,
495
+ durationSource,
496
+ organizerEmail: typeof item.organizer_email === 'string' ? item.organizer_email : null,
497
+ participants,
498
+ sentences,
499
+ overview: nonEmptyString(summary?.overview),
500
+ actionItems: nonEmptyString(summary?.action_items),
501
+ keywords: Array.isArray(summary?.keywords)
502
+ ? (summary.keywords as unknown[]).filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
503
+ : [],
504
+ },
505
+ }
506
+ }
507
+
508
+ /** A string the vendor actually filled in, or null. Whitespace is not content. */
509
+ function nonEmptyString(value: unknown): string | null {
510
+ return typeof value === 'string' && value.trim().length > 0 ? value : null
511
+ }
512
+
513
+ export interface FirefliesClientOptions {
514
+ transport: FirefliesTransport
515
+ /** Resolved at call time so a key change takes effect without a restart. */
516
+ key: () => string | null
517
+ budget: FirefliesBudget
518
+ now?: () => number
519
+ sleep?: (ms: number) => Promise<void>
520
+ }
521
+
522
+ interface PageAccumulator {
523
+ transcripts: unknown[]
524
+ oversized: Array<{ skip: number; id: string | null }>
525
+ nextSkip: number
526
+ endOfList: boolean
527
+ calls: number
528
+ pageSizes: number[]
529
+ failure?: FirefliesFailure
530
+ stop: boolean
531
+ }
532
+
533
+ type ListAttempt =
534
+ | { kind: 'items'; items: unknown[] }
535
+ | { kind: 'shrink'; reason: 'timeout' | 'too_large' | 'network_error' }
536
+ | { kind: 'failure'; failure: FirefliesFailure }
537
+
538
+ export class FirefliesClient {
539
+ private readonly transport: FirefliesTransport
540
+ private readonly resolveKey: () => string | null
541
+ private readonly budget: FirefliesBudget
542
+ private readonly now: () => number
543
+ private readonly sleep: (ms: number) => Promise<void>
544
+ private readonly callTimes: number[] = []
545
+ private lastCheck: (FirefliesKeyCheck & { fingerprint: string }) | null = null
546
+
547
+ constructor(options: FirefliesClientOptions) {
548
+ this.transport = options.transport
549
+ this.resolveKey = options.key
550
+ this.budget = options.budget
551
+ this.now = options.now ?? (() => Date.now())
552
+ this.sleep = options.sleep ?? ((ms: number) => new Promise(resolve => setTimeout(resolve, ms)))
553
+ }
554
+
555
+ /** Stable, non-reversible identity for "the key changed". Never rendered. */
556
+ static fingerprint(key: string): string {
557
+ return createHash('sha256').update(key).digest('hex').slice(0, 16)
558
+ }
559
+
560
+ private async pace(): Promise<void> {
561
+ const cutoff = this.now() - FIREFLIES_RATE_WINDOW_MS
562
+ while (this.callTimes.length > 0 && this.callTimes[0] <= cutoff) this.callTimes.shift()
563
+ if (this.callTimes.length >= FIREFLIES_CALLS_PER_MINUTE) {
564
+ const wait = this.callTimes[0] + FIREFLIES_RATE_WINDOW_MS - this.now()
565
+ if (wait > 0) await this.sleep(wait)
566
+ const after = this.now() - FIREFLIES_RATE_WINDOW_MS
567
+ while (this.callTimes.length > 0 && this.callTimes[0] <= after) this.callTimes.shift()
568
+ }
569
+ this.callTimes.push(this.now())
570
+ }
571
+
572
+ private async send(
573
+ key: string,
574
+ body: string,
575
+ options: { timeoutMs: number; reserved?: boolean; retryServerErrors: boolean },
576
+ accounting: { calls: number },
577
+ ): Promise<ClassifiedResponse | { kind: 'transport'; reason: 'timeout' | 'too_large' | 'network_error' } | { kind: 'failure'; failure: FirefliesFailure }> {
578
+ for (let attempt = 0; ; attempt++) {
579
+ if (!this.budget.tryConsume({ reserved: options.reserved })) {
580
+ return { kind: 'failure', failure: { state: 'cap_exhausted' } }
581
+ }
582
+ await this.pace()
583
+ accounting.calls += 1
584
+ const outcome = await this.transport({
585
+ key,
586
+ body,
587
+ timeoutMs: options.timeoutMs,
588
+ maxBytes: FIREFLIES_MAX_RESPONSE_BYTES,
589
+ })
590
+ if (outcome.kind !== 'http') return { kind: 'transport', reason: outcome.kind === 'too_large' ? 'too_large' : outcome.kind }
591
+ const classified = classifyFirefliesResponse(outcome.status, outcome.headers, outcome.body, this.now())
592
+ if (classified.kind !== 'retry') return classified
593
+ if (!options.retryServerErrors || attempt >= FIREFLIES_SERVER_RETRY_DELAYS_MS.length) {
594
+ return { kind: 'failure', failure: classified.failure }
595
+ }
596
+ await this.sleep(FIREFLIES_SERVER_RETRY_DELAYS_MS[attempt])
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Is this key usable? Throttled unless a person asked, so a status poll on
602
+ * four surfaces cannot spend the budget on the same answer.
603
+ */
604
+ async checkKey(options: { userInitiated?: boolean } = {}): Promise<FirefliesKeyCheck> {
605
+ const key = this.resolveKey()
606
+ if (!key) {
607
+ this.lastCheck = null
608
+ return { state: 'not_configured', checkedAt: new Date(this.now()).toISOString(), cached: false }
609
+ }
610
+ const fingerprint = FirefliesClient.fingerprint(key)
611
+ const userInitiated = options.userInitiated === true
612
+ if (!userInitiated && this.lastCheck && this.lastCheck.fingerprint === fingerprint) {
613
+ const age = this.now() - Date.parse(this.lastCheck.checkedAt)
614
+ if (Number.isFinite(age) && age < FIREFLIES_KEY_CHECK_THROTTLE_MS) {
615
+ const { fingerprint: _omit, ...cached } = this.lastCheck
616
+ return { ...cached, cached: true }
617
+ }
618
+ }
619
+
620
+ const accounting = { calls: 0 }
621
+ const result = await this.send(
622
+ key,
623
+ firefliesKeyCheckQuery(),
624
+ { timeoutMs: FIREFLIES_KEY_CHECK_TIMEOUT_MS, reserved: userInitiated, retryServerErrors: false },
625
+ accounting,
626
+ )
627
+ const checkedAt = new Date(this.now()).toISOString()
628
+ let check: FirefliesKeyCheck
629
+ if (result.kind === 'transport') {
630
+ check = { state: 'unreachable', checkedAt, cached: false }
631
+ } else if (result.kind === 'failure') {
632
+ check = { state: result.failure.state, ...failureFields(result.failure), checkedAt, cached: false }
633
+ } else if (result.kind === 'data') {
634
+ const user = (result.data as { user?: unknown }).user
635
+ check = user && typeof user === 'object'
636
+ ? { state: 'ok', checkedAt, cached: false }
637
+ : { state: 'vendor_error', code: 'invalid_payload', checkedAt, cached: false }
638
+ } else {
639
+ check = { state: result.failure.state, ...failureFields(result.failure), checkedAt, cached: false }
640
+ }
641
+ this.lastCheck = { ...check, fingerprint }
642
+ return check
643
+ }
644
+
645
+ /** The remembered answer, for a status read that must not spend a call. */
646
+ cachedKeyCheck(): FirefliesKeyCheck | null {
647
+ if (!this.lastCheck) return null
648
+ const key = this.resolveKey()
649
+ if (!key || FirefliesClient.fingerprint(key) !== this.lastCheck.fingerprint) return null
650
+ const { fingerprint: _omit, ...cached } = this.lastCheck
651
+ return { ...cached, cached: true }
652
+ }
653
+
654
+ /** Forget the remembered check. Called when the stored key changes. */
655
+ forgetKeyCheck(): void {
656
+ this.lastCheck = null
657
+ }
658
+
659
+ private async requestList(
660
+ key: string,
661
+ skip: number,
662
+ limit: number,
663
+ withSentences: boolean,
664
+ out: PageAccumulator,
665
+ ): Promise<ListAttempt> {
666
+ out.pageSizes.push(limit)
667
+ const accounting = { calls: 0 }
668
+ const result = await this.send(
669
+ key,
670
+ firefliesListQuery(limit, skip, withSentences),
671
+ { timeoutMs: FIREFLIES_PAGE_TIMEOUT_MS, retryServerErrors: true },
672
+ accounting,
673
+ )
674
+ out.calls += accounting.calls
675
+ if (result.kind === 'transport') return { kind: 'shrink', reason: result.reason }
676
+ if (result.kind === 'failure') return { kind: 'failure', failure: result.failure }
677
+ if (result.kind === 'retry') return { kind: 'failure', failure: result.failure }
678
+ const transcripts = (result.data as { transcripts?: unknown }).transcripts
679
+ if (!Array.isArray(transcripts)) {
680
+ return { kind: 'failure', failure: { state: 'vendor_error', code: 'invalid_payload' } }
681
+ }
682
+ return { kind: 'items', items: transcripts }
683
+ }
684
+
685
+ /** Learn the id of a transcript too large to fetch, so it can be named in a skip. */
686
+ private async identifyOversized(key: string, skip: number, out: PageAccumulator): Promise<string | null> {
687
+ const attempt = await this.requestList(key, skip, 1, false, out)
688
+ if (attempt.kind !== 'items' || attempt.items.length === 0) return null
689
+ const first = attempt.items[0]
690
+ if (!first || typeof first !== 'object') return null
691
+ const id = (first as { id?: unknown }).id
692
+ return typeof id === 'string' && FIREFLIES_ID_PATTERN.test(id) ? id : null
693
+ }
694
+
695
+ private async fetchRange(key: string, start: number, count: number, level: number, out: PageAccumulator): Promise<void> {
696
+ const size = FIREFLIES_PAGE_SIZES[level]
697
+ let offset = 0
698
+ while (offset < count && !out.stop) {
699
+ const chunk = Math.min(size, count - offset)
700
+ const at = start + offset
701
+ const attempt = await this.requestList(key, at, chunk, true, out)
702
+
703
+ if (attempt.kind === 'items') {
704
+ out.transcripts.push(...attempt.items)
705
+ if (attempt.items.length < chunk) {
706
+ out.nextSkip = at + attempt.items.length
707
+ out.endOfList = true
708
+ out.stop = true
709
+ return
710
+ }
711
+ out.nextSkip = at + chunk
712
+ offset += chunk
713
+ continue
714
+ }
715
+
716
+ if (attempt.kind === 'failure') {
717
+ out.failure = attempt.failure
718
+ out.stop = true
719
+ return
720
+ }
721
+
722
+ if (level + 1 < FIREFLIES_PAGE_SIZES.length) {
723
+ await this.fetchRange(key, at, chunk, level + 1, out)
724
+ if (out.stop) return
725
+ offset += chunk
726
+ continue
727
+ }
728
+
729
+ // Smallest page. A body over the cap is one unusually large transcript,
730
+ // which becomes a retryable skip so the cursor can move past it. A
731
+ // timeout or transport error at this size is the network, not the data.
732
+ if (attempt.reason === 'too_large') {
733
+ const id = await this.identifyOversized(key, at, out)
734
+ if (out.stop) return
735
+ out.oversized.push({ skip: at, id })
736
+ out.nextSkip = at + 1
737
+ offset += 1
738
+ continue
739
+ }
740
+ out.failure = { state: 'unreachable' }
741
+ out.stop = true
742
+ return
743
+ }
744
+ }
745
+
746
+ /**
747
+ * One transcript by id, with its sentences and summary.
748
+ *
749
+ * The list is how the importer reads the vendor, so this is for the cases a
750
+ * page cannot serve: re-reading a single meeting whose content changed, and
751
+ * WS3 re-deriving one record without walking a window.
752
+ */
753
+ async getTranscript(id: string): Promise<{ transcript?: unknown; failure?: FirefliesFailure; calls: number }> {
754
+ const key = this.resolveKey()
755
+ if (!key) return { failure: { state: 'invalid_key' }, calls: 0 }
756
+ if (!FIREFLIES_ID_PATTERN.test(id)) {
757
+ return { failure: { state: 'vendor_error', code: 'id_shape_changed' }, calls: 0 }
758
+ }
759
+ const accounting = { calls: 0 }
760
+ const result = await this.send(
761
+ key,
762
+ firefliesTranscriptQuery(id),
763
+ { timeoutMs: FIREFLIES_PAGE_TIMEOUT_MS, retryServerErrors: true },
764
+ accounting,
765
+ )
766
+ if (result.kind === 'transport') {
767
+ return { failure: { state: result.reason === 'too_large' ? 'vendor_error' : 'unreachable', ...(result.reason === 'too_large' ? { code: 'response_too_large' } : {}) }, calls: accounting.calls }
768
+ }
769
+ if (result.kind !== 'data') return { failure: result.failure, calls: accounting.calls }
770
+ const transcript = (result.data as { transcript?: unknown }).transcript
771
+ // A vendor that answers "no such transcript" with a null is not an error
772
+ // state: the caller asked about something that is not there any more.
773
+ return { transcript: transcript ?? undefined, calls: accounting.calls }
774
+ }
775
+
776
+ /**
777
+ * One span of the vendor's newest-first list, with sentences.
778
+ *
779
+ * The span is FIREFLIES_PAGE_SPAN wide however many calls it takes, so the
780
+ * caller's cursor arithmetic does not have to know about the descent.
781
+ */
782
+ async listPage(options: { skip: number }): Promise<FirefliesPageResult> {
783
+ const key = this.resolveKey()
784
+ const out: PageAccumulator = {
785
+ transcripts: [],
786
+ oversized: [],
787
+ nextSkip: options.skip,
788
+ endOfList: false,
789
+ calls: 0,
790
+ pageSizes: [],
791
+ stop: false,
792
+ }
793
+ if (!key) {
794
+ return { ...stripAccumulator(out), failure: { state: 'invalid_key' } }
795
+ }
796
+ await this.fetchRange(key, options.skip, FIREFLIES_PAGE_SPAN, 0, out)
797
+ return stripAccumulator(out)
798
+ }
799
+ }
800
+
801
+ function failureFields(failure: FirefliesFailure): Partial<FirefliesKeyCheck> {
802
+ return {
803
+ ...(failure.httpStatus != null ? { httpStatus: failure.httpStatus } : {}),
804
+ ...(failure.retryAfterSeconds != null ? { retryAfterSeconds: failure.retryAfterSeconds } : {}),
805
+ ...(failure.code ? { code: failure.code } : {}),
806
+ }
807
+ }
808
+
809
+ function stripAccumulator(out: PageAccumulator): FirefliesPageResult {
810
+ return {
811
+ transcripts: out.transcripts,
812
+ nextSkip: out.nextSkip,
813
+ endOfList: out.endOfList,
814
+ oversized: out.oversized,
815
+ ...(out.failure ? { failure: out.failure } : {}),
816
+ calls: out.calls,
817
+ pageSizes: out.pageSizes,
818
+ }
819
+ }
820
+
821
+ /**
822
+ * The real transport. Reads the body with a byte ceiling rather than trusting
823
+ * Content-Length, because a page that lies about its size is exactly the case
824
+ * the ceiling exists for.
825
+ */
826
+ export function createFetchTransport(fetchImpl: typeof globalThis.fetch = globalThis.fetch): FirefliesTransport {
827
+ return async ({ key, body, timeoutMs, maxBytes }) => {
828
+ try {
829
+ const response = await fetchImpl(FIREFLIES_ENDPOINT, {
830
+ method: 'POST',
831
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
832
+ body,
833
+ signal: AbortSignal.timeout(timeoutMs),
834
+ })
835
+ const headers: Record<string, string> = {}
836
+ response.headers.forEach((value, name) => { headers[name] = value })
837
+ const reader = response.body?.getReader()
838
+ if (!reader) {
839
+ const text = await response.text()
840
+ if (Buffer.byteLength(text) > maxBytes) return { kind: 'too_large' }
841
+ return { kind: 'http', status: response.status, headers, body: text }
842
+ }
843
+ const chunks: Uint8Array[] = []
844
+ let bytes = 0
845
+ for (;;) {
846
+ const { done, value } = await reader.read()
847
+ if (done) break
848
+ if (!value) continue
849
+ bytes += value.byteLength
850
+ if (bytes > maxBytes) {
851
+ await reader.cancel().catch(() => undefined)
852
+ return { kind: 'too_large' }
853
+ }
854
+ chunks.push(value)
855
+ }
856
+ return { kind: 'http', status: response.status, headers, body: Buffer.concat(chunks).toString('utf8') }
857
+ } catch (error) {
858
+ const name = (error as { name?: string })?.name
859
+ return { kind: name === 'TimeoutError' || name === 'AbortError' ? 'timeout' : 'network_error' }
860
+ }
861
+ }
862
+ }