@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,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
|
+
}
|