@alexkroman1/aai-cli 6.3.1 → 6.4.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.
@@ -0,0 +1,341 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The streaming desk: transcribe the recording WHILE it uploads.
4
+ *
5
+ * Read `transcribe.ts` first. This does the same three jobs — plan, transcribe,
6
+ * stitch — with the same two steps doing the middle and the end, and the only thing
7
+ * it changes is WHEN. That one change is the reason it exists:
8
+ *
9
+ * | | `transcribe` | `transcribeStream` |
10
+ * | --- | --- | --- |
11
+ * | the run starts | after the last byte is stored | before the first one is |
12
+ * | the client sends | `POST /workflows/uploads` | `PUT /workflows/uploads/<id>` |
13
+ * | who names the upload | the store | the CLIENT |
14
+ * | the body | plans once, fans out once | polls, fans out over what has arrived |
15
+ *
16
+ * ## The client names the upload, and that is the whole trick
17
+ *
18
+ * An ordinary upload cannot help here: `POST` answers with an id once the last byte
19
+ * is stored, so there is nothing to put in a run input until the upload is over. A
20
+ * STREAMED upload is named by its caller — `useWorkflowStream` mints an id, starts
21
+ * the run on it, and PUTs the file in one request — so the record exists from the
22
+ * first byte with `complete: false` and its `size` grows as bytes land.
23
+ *
24
+ * The reader needed almost nothing for this, which is why this flow is so close to
25
+ * the other one: `readUpload` already clamped its window to what is stored (so a
26
+ * plan computed from a header could end one byte past the file), and that clamp is
27
+ * exactly "read what has arrived". So `transcribeSegment` below is `transcribe.ts`'s
28
+ * OWN step, unchanged, called on windows this body has checked are present.
29
+ *
30
+ * ## `complete`, never a stalled `size`
31
+ *
32
+ * The exit is the upload's `complete` flag. A `size` that has stopped growing means
33
+ * only that nothing arrived recently, which is what a slow link and a dead client
34
+ * both look like — so a body that took a stalled size for the end would return a
35
+ * transcript of most of a recording and report success. The stall is what
36
+ * {@link MAX_IDLE_POLLS} is for, and it FAILS the run rather than finishing it.
37
+ *
38
+ * ## It really does overlap, and the granularity is a SEGMENT
39
+ *
40
+ * Watched directly — the same 10-minute recording at 2 MB/s, polling the upload's
41
+ * `size` and counting `Transcribed …` lines in the run's own log:
42
+ *
43
+ * ```text
44
+ * 1s 2 MB uploaded 0 segments transcribed
45
+ * 14s 26 MB 1 <- first one, at 24% of the file
46
+ * 23s 45 MB 2
47
+ * 32s 63 MB 3
48
+ * 41s 82 MB 4
49
+ * 48s 94 MB 5
50
+ * 54s 106 MB 6
51
+ * 55s ---- PUT returns ----
52
+ * 60s 109 MB 7 <- run completed
53
+ * ```
54
+ *
55
+ * **Six of seven segments were transcribed before the upload finished.** So the run
56
+ * does not wait for the file — and it does not start on the first CHUNK either, which
57
+ * is worth being exact about: a segment is the smallest thing the sync endpoint can
58
+ * decode, so the floor is "one segment has landed", not "some bytes have". Three
59
+ * granularities stack up to that:
60
+ *
61
+ * - a segment is `SEGMENT_SECONDS + SEGMENT_OVERLAP_SECONDS` of audio — ~17.6 MB at
62
+ * 48 kHz stereo, which is ~9s of a 2 MB/s uplink;
63
+ * - the store publishes `size` a `UPLOAD_CHUNK_BYTES` chunk at a time (1 MiB), so the
64
+ * view a poll reads is at most a megabyte stale;
65
+ * - the body sleeps {@link POLL_INTERVAL} between polls when nothing is ready, cut
66
+ * short by the client's wake.
67
+ *
68
+ * 9s + one poll is the 14s above. Nothing here can go below a segment without a
69
+ * different provider API — which is what the third flow (`batch.ts`) is.
70
+ *
71
+ * ## What it actually saves, measured
72
+ *
73
+ * Run against a real dev server on a 10-minute 48 kHz stereo recording (115 MB, 7
74
+ * segments), with `curl --limit-rate` standing in for an uplink:
75
+ *
76
+ * | uplink | classic (upload + run) | streaming (upload + tail) | saved |
77
+ * | --- | --- | --- | --- |
78
+ * | loopback | 0.3 + 5.3 = 5.5s | 0.2 + 5.3 = 5.5s | 0s |
79
+ * | 8 MB/s | 13.8 + 4.2 = 18.0s | 13.9 + 3.2 = 17.1s | 0.9s |
80
+ * | 2 MB/s | 55.1 + 4.3 = 59.4s | 55.1 + 2.2 = 57.3s | 2.1s |
81
+ *
82
+ * Read the TAIL column, which is the whole mechanism: it shrinks as the uplink slows
83
+ * (5.3s -> 3.2s -> 2.2s) because more of the transcription has already happened
84
+ * behind the upload by the time the last byte lands. The floor is one segment.
85
+ *
86
+ * **So the saving is roughly ONE segment's latency, not a proportion of the file** —
87
+ * and the reason is structural rather than a tuning problem. Every segment but the
88
+ * last is transcribed during the upload, and the last one cannot start until its
89
+ * bytes land, so both flows end at `upload + one segment`. The transcription is the
90
+ * small term for any file this endpoint accepts: it runs 20-200x faster than
91
+ * realtime, so a recording long enough for the difference to matter is a recording
92
+ * whose upload dominates either way.
93
+ *
94
+ * It grows with LENGTH rather than with size, because concurrency is capped: a
95
+ * 97-minute recording is ~65 segments in ~9 rounds, and eight of those rounds happen
96
+ * behind the upload instead of after it.
97
+ *
98
+ * What it always buys, at any length, is the thing a table cannot show: the page
99
+ * shows real progress — segment timings, arriving — while the bytes are still
100
+ * moving, instead of a bar and then a wait. The classic flow remains the simpler
101
+ * shape and is never slower, which is why the page offers both rather than replacing
102
+ * one with the other.
103
+ *
104
+ * ## And a batch has to finish before the next poll
105
+ *
106
+ * The DevKit correlates a journal entry to a step call by ISSUE ORDER, which is what
107
+ * rules out a work-stealing pool — `mapInBatches`'s own doc carries the argument. So
108
+ * a segment that becomes readable while a batch is running waits for that batch. On
109
+ * a slow uplink that costs nothing (segments arrive slower than they transcribe); on
110
+ * a fast one it is why the two flows converge rather than the streaming one winning.
111
+ */
112
+
113
+ import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
114
+ import { mapInBatches, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
115
+ import { sleep } from "workflow";
116
+ import {
117
+ clock,
118
+ mergeTranscript,
119
+ type SegmentTranscript,
120
+ segmentConcurrency,
121
+ startClock,
122
+ transcribeSegment,
123
+ } from "./transcribe.ts";
124
+ import {
125
+ offsetToMs,
126
+ parseWav,
127
+ planSegments,
128
+ type Segment,
129
+ UnsupportedRecordingError,
130
+ type WavFormat,
131
+ } from "./wav.ts";
132
+
133
+ /** How long the body waits between polls when nothing new has arrived. */
134
+ const POLL_INTERVAL = "5s";
135
+
136
+ /**
137
+ * Consecutive polls with NO new bytes before the run gives up.
138
+ *
139
+ * An upload that died stays incomplete forever, so without a bound the run polls for
140
+ * as long as the world will replay it. At {@link POLL_INTERVAL} this is five minutes
141
+ * of silence — far longer than any stall a live uplink produces, and short enough
142
+ * that the failure reaches whoever is watching.
143
+ *
144
+ * It resets on every byte, so a slow upload is bounded by its own quietest gap
145
+ * rather than by its total length: a two-hour recording on a bad connection is fine
146
+ * as long as something arrives every five minutes.
147
+ */
148
+ const MAX_IDLE_POLLS = 60;
149
+
150
+ /** Bytes probed for the WAV header — the same window `splitRecording` uses. */
151
+ const HEADER_PROBE_BYTES = 64 * 1024;
152
+
153
+ /** What one poll of the upload found. */
154
+ export type UploadProgressView = {
155
+ /** Bytes stored so far. */
156
+ size: number;
157
+ /** Whether that is all of them. The ONLY field an exit may be decided on. */
158
+ complete: boolean;
159
+ };
160
+
161
+ /** The cut, derived once from the header. */
162
+ export type StreamPlan = {
163
+ format: WavFormat;
164
+ segments: Segment[];
165
+ };
166
+
167
+ /**
168
+ * Transcribe a recording that is still uploading.
169
+ *
170
+ * The input is what `POST /workflows/runs` carries — see `agent.ts`. `recording` is
171
+ * an upload id exactly as in the classic flow; what differs is that the client chose
172
+ * it and the bytes are still on their way.
173
+ */
174
+ export async function transcribeStreamFlow(input: { recording: string }) {
175
+ "use workflow";
176
+
177
+ const startedAt = await startClock();
178
+ let plan: StreamPlan | undefined;
179
+ // Body state, and legal because every value in it came out of a journaled step
180
+ // result — a replay rebuilds the identical sets in the identical order.
181
+ const done = new Set<number>();
182
+ const parts: SegmentTranscript[] = [];
183
+ let idlePolls = 0;
184
+ let lastSize = -1;
185
+
186
+ for (;;) {
187
+ const at = await probeUpload(input.recording);
188
+
189
+ // The header has to be present before anything can be planned, and it is the
190
+ // first thing to arrive. `complete` also qualifies, for a recording shorter
191
+ // than the probe window.
192
+ if (!plan && (at.size >= HEADER_PROBE_BYTES || at.complete)) {
193
+ plan = await planStreamed(input.recording);
194
+ }
195
+
196
+ if (plan) {
197
+ // A segment is READY when its whole window is stored — except once the upload
198
+ // is complete, where `at.size` is the true total and the plan came from the
199
+ // header's DECLARED length: a recording that came up short leaves a final
200
+ // segment ending past the file, and `readUpload` clamping is what makes that
201
+ // the right answer rather than an error.
202
+ const ready = plan.segments.filter(
203
+ (segment) =>
204
+ !done.has(segment.index) &&
205
+ (segment.end <= at.size || (at.complete && segment.start < at.size)),
206
+ );
207
+ if (ready.length > 0) {
208
+ idlePolls = 0;
209
+ lastSize = at.size;
210
+ for (const segment of ready) done.add(segment.index);
211
+ // One step per segment, bounded, in an order a replay reproduces exactly —
212
+ // `ready` is derived from a journaled poll, and `mapInBatches` issues its
213
+ // calls in array order. THE SAME STEP the classic flow uses.
214
+ parts.push(
215
+ ...(await mapInBatches(
216
+ ready,
217
+ segmentConcurrency((plan as StreamPlan).format),
218
+ (segment) => transcribeSegment(input.recording, (plan as StreamPlan).format, segment),
219
+ )),
220
+ );
221
+ // Straight back to the top WITHOUT sleeping, and this line was measured
222
+ // rather than reasoned about. A batch takes seconds, so by the time it
223
+ // finishes the upload has moved on and the view above is stale — deciding
224
+ // anything on it means sleeping through news that has already arrived.
225
+ // Measured by deleting this one statement, 10-minute recording at 8 MB/s:
226
+ // the tail goes 3.2s -> 9.5s and the run 17.1s -> 23.4s. A poll is one cheap
227
+ // step; sleeping is only right when there was nothing to do.
228
+ continue;
229
+ }
230
+ }
231
+
232
+ // Nothing to work on, so this view is current and the exit can be trusted.
233
+ if (at.complete && plan && done.size >= expectedSegments(plan, at.size)) break;
234
+ // A stall, not an ending — see MAX_IDLE_POLLS.
235
+ if (at.size === lastSize) idlePolls += 1;
236
+ else {
237
+ idlePolls = 0;
238
+ lastSize = at.size;
239
+ }
240
+ if (idlePolls > MAX_IDLE_POLLS) abandon(input.recording, at);
241
+ await sleep(POLL_INTERVAL);
242
+ }
243
+
244
+ const finished = plan;
245
+ if (!finished) abandon(input.recording, { size: 0, complete: false });
246
+ return await mergeTranscript(
247
+ input.recording,
248
+ offsetToMs(finished.format, Math.min(finished.format.dataEnd, lastSize)),
249
+ parts,
250
+ startedAt,
251
+ );
252
+ }
253
+
254
+ /**
255
+ * How much of the upload is stored, and whether that is all of it.
256
+ *
257
+ * A step because it is I/O, which a body may not do — and because what the body does
258
+ * next is derived from its RESULT, so journaling it is what makes the run take the
259
+ * same branches on a replay. It narrates nothing: sixty "still uploading" lines
260
+ * would bury the ones that matter, and `transcribeSegment` is where the log comes
261
+ * from.
262
+ */
263
+ export async function probeUpload(id: string): Promise<UploadProgressView> {
264
+ "use step";
265
+
266
+ const info = await uploadInfo(id);
267
+ return { size: info.size, complete: info.complete };
268
+ }
269
+
270
+ /**
271
+ * Read the header and decide where to cut — from the DECLARED length.
272
+ *
273
+ * The one real difference from `splitRecording` next door, and it is a one-argument
274
+ * difference: that step passes the upload's own size, which for a file still
275
+ * arriving is only what has landed so far and would plan a fraction of the
276
+ * recording. `Number.POSITIVE_INFINITY` makes `parseWav` return the length the
277
+ * header DECLARES, which is known from the first 64 KB — so the whole plan exists
278
+ * before most of the audio does.
279
+ *
280
+ * A WAV declaring no length at all cannot be planned this way and is refused by
281
+ * name: there is nothing to compute a segment list from until the file has finished,
282
+ * which is what the classic flow is for.
283
+ */
284
+ export async function planStreamed(id: string): Promise<StreamPlan> {
285
+ "use step";
286
+
287
+ const head = await readUpload(id, { end: HEADER_PROBE_BYTES });
288
+ const format = fatalOnUnsupported(() => parseWav(head.bytes, Number.POSITIVE_INFINITY));
289
+ if (!Number.isFinite(format.dataEnd)) {
290
+ return throwFatalStepError(
291
+ new UnsupportedRecordingError(
292
+ "That WAV declares no data length, so its segments cannot be planned before it has " +
293
+ "finished uploading. Use the `transcribe` workflow, which stores the file first.",
294
+ ),
295
+ );
296
+ }
297
+ const segments = fatalOnUnsupported(() => planSegments(format));
298
+ await report(
299
+ `Planned ${clock(segments.at(-1)?.endMs ?? 0)} of audio as ${segments.length} segment${
300
+ segments.length === 1 ? "" : "s"
301
+ } while it uploads.`,
302
+ );
303
+ return { format, segments };
304
+ }
305
+
306
+ /**
307
+ * How many segments a finished upload of `size` bytes really has.
308
+ *
309
+ * Not `plan.segments.length`: the plan came from the header's declared length, and a
310
+ * recording that came up short has segments that start past the end of the file.
311
+ * Counting those would leave the run waiting for audio nobody is going to send.
312
+ */
313
+ function expectedSegments(plan: StreamPlan, size: number): number {
314
+ return plan.segments.filter((segment) => segment.start < size).length;
315
+ }
316
+
317
+ /** Run a `wav.ts` helper, turning its "cannot cut this" into a terminal failure. */
318
+ function fatalOnUnsupported<T>(read: () => T): T {
319
+ try {
320
+ return read();
321
+ } catch (err: unknown) {
322
+ if (err instanceof UnsupportedRecordingError) return throwFatalStepError(err);
323
+ throw err;
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Give up on an upload that stopped arriving.
329
+ *
330
+ * A PLAIN throw, not `throwFatalStepError`: this is the BODY, and the
331
+ * fatal/retryable distinction belongs to a step — it is what tells the DevKit
332
+ * whether to run that step again. A body that throws fails the run, which is what
333
+ * should happen here, and dressing it up as a step error would suggest a retry
334
+ * policy with nothing to apply to.
335
+ */
336
+ function abandon(id: string, at: UploadProgressView): never {
337
+ throw new Error(
338
+ `Gave up waiting for ${id}: ${at.size} byte(s) stored and still incomplete. ` +
339
+ `Nothing new arrived for ${MAX_IDLE_POLLS} polls — the uploader stopped.`,
340
+ );
341
+ }
@@ -0,0 +1,139 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * One request to AssemblyAI's synchronous transcription endpoint.
4
+ *
5
+ * Extracted when the second flow arrived, and the split is the one that was
6
+ * already there: both flows send exactly the same request and differ only in
7
+ * where the bytes came from — a byte WINDOW of one stored recording
8
+ * (`transcribe.ts`), or one PART of a group that is still being uploaded
9
+ * (`stream.ts`). Everything that is a property of the endpoint rather than of the
10
+ * caller lives here: the URL, the model header, the raw-key auth, the deadline,
11
+ * the multipart shape, and the three-way failure classification.
12
+ *
13
+ * No directive, which is what lets it live under `workflows/` beside the bodies:
14
+ * the WDK builder scans this directory and transforms only what carries one
15
+ * (`wav.ts` is the same shape). It is called FROM steps, so it inherits their
16
+ * environment — `requireStepEnv` works here for the same reason it works there.
17
+ */
18
+
19
+ import { throwFatalStepError, toStepError } from "@alexkroman1/aai/step-errors";
20
+ import { multipartBody, requireStepEnv, stepFetch } from "@alexkroman1/aai/utils";
21
+
22
+ /** The synchronous transcription endpoint. Global — it routes to the nearest region. */
23
+ const SYNC_ENDPOINT = "https://sync.assemblyai.com/transcribe";
24
+
25
+ /** Required on every sync request; the endpoint routes on it. */
26
+ const SYNC_MODEL = "universal-3-5-pro";
27
+
28
+ /** The key a step reads out of the agent env. Declared in `agent.ts`'s `requiredEnv`. */
29
+ const API_KEY_ENV = "ASSEMBLYAI_API_KEY";
30
+
31
+ /** The endpoint's own per-request deadline, plus room to upload. */
32
+ const SYNC_TIMEOUT_MS = 60_000;
33
+
34
+ /**
35
+ * Time one transcription, so the progress log carries LATENCY.
36
+ *
37
+ * The reason this is worth reporting rather than left to a server log: the whole
38
+ * shape of both flows is a bounded fan-out against an endpoint whose speed is
39
+ * outside this code, and per-part latency is the one number that says which
40
+ * bound is actually binding. Eight parts each taking 4s means the concurrency is
41
+ * the limit; eight parts each taking 20s means the endpoint is. A log that only
42
+ * says "transcribing" cannot tell those apart, and the choice between raising
43
+ * `SEGMENT_CONCURRENCY` and leaving it alone is exactly that question.
44
+ *
45
+ * `Date.now()` is fine HERE and would not be in a body: a step's internals are
46
+ * not replayed — only its RESULT is — which is what makes a step the place any
47
+ * clock, random draw or outside read belongs.
48
+ */
49
+ export async function timed<T>(work: () => Promise<T>): Promise<{ value: T; ms: number }> {
50
+ const started = Date.now();
51
+ const value = await work();
52
+ return { value, ms: Date.now() - started };
53
+ }
54
+
55
+ /** `4.2s`, or `840ms` under a second — a reader wants one significant change. */
56
+ export function elapsed(ms: number): string {
57
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
58
+ }
59
+
60
+ /**
61
+ * Transcribe one complete WAV.
62
+ *
63
+ * `bytes` must be a whole file, header included — the endpoint decodes each
64
+ * request independently, so a headerless tail is bytes it will refuse. Both
65
+ * callers arrive at that differently: one re-attaches a header to a window it
66
+ * read, the other is handed parts that already carry one.
67
+ *
68
+ * @param label - How this piece is named in a failure. The CALLER's vocabulary
69
+ * (a segment's timestamp, a part's index), because it is what a reader of the
70
+ * log has in front of them.
71
+ */
72
+ export async function transcribeWav(
73
+ bytes: Uint8Array,
74
+ filename: string,
75
+ label: string,
76
+ ): Promise<string> {
77
+ const apiKey = apiKeyOrFatal();
78
+ const part = multipartBody({ name: "audio", filename, type: "audio/wav", bytes });
79
+
80
+ // `stepFetch`, not `fetch`, and here it is load-bearing rather than tidy:
81
+ // `fetch` speaks HTTP/2 wherever the far side offers it, which puts a whole
82
+ // batch of segments on ONE connection — and a capacity limit then arrives as a
83
+ // stream reset carrying no HTTP status for `toStepError` below to read. A
84
+ // fan-out is exactly the shape that breaks on. `sdk/step-fetch.ts` holds the
85
+ // measurements; a `StepTransportError` out of here is already retryable and
86
+ // already names its cause.
87
+ const response = await stepFetch(SYNC_ENDPOINT, {
88
+ method: "POST",
89
+ headers: {
90
+ // The raw key — this endpoint takes it unprefixed, and a `Bearer ` in
91
+ // front of it is a 401 that reads like a wrong key.
92
+ Authorization: apiKey,
93
+ "X-AAI-Model": SYNC_MODEL,
94
+ ...part.headers,
95
+ },
96
+ body: part.body,
97
+ // Nothing here has a deadline of its own, and a hung upload inside a step is
98
+ // a run that never finishes rather than one that retries.
99
+ signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
100
+ });
101
+ if (!response.ok) throw await syncFailure(response, label);
102
+
103
+ const body = (await response.json()) as { text?: string };
104
+ return (body.text ?? "").trim();
105
+ }
106
+
107
+ /** The API key, or a terminal failure — three more attempts find the same gap. */
108
+ function apiKeyOrFatal(): string {
109
+ try {
110
+ return requireStepEnv(API_KEY_ENV);
111
+ } catch (err: unknown) {
112
+ // `throwFatalStepError` rather than `throw new FatalError(…)`: that class
113
+ // takes only a message — no `cause` — so constructing one inside a `catch`
114
+ // loses the original where the linter (rightly) expects it preserved. Here
115
+ // the original is the ARGUMENT, and nothing is swallowed.
116
+ return throwFatalStepError(err);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * The sync endpoint's failure, with whatever it said about it.
122
+ *
123
+ * `toStepError` makes the three-way call: a `FatalError` stops the DevKit
124
+ * retrying something that will answer the same way, a bare `RetryableError`
125
+ * retries in ONE SECOND (that class's own default), and a `RetryableError`
126
+ * carrying `retryAfter` waits exactly as long as the far side asked. The last
127
+ * matters here because a whole batch hits the rate limit together — a second
128
+ * later all of them ask again, where on the server's number they drain.
129
+ */
130
+ async function syncFailure(response: Response, label: string): Promise<Error> {
131
+ // Two shapes, documented: `{ error_code, message }` for a request problem and
132
+ // `{ detail }` for auth and rate limits.
133
+ const body = (await response.json().catch(() => ({}))) as { message?: string; detail?: string };
134
+ const detail = body.message ?? body.detail;
135
+ return toStepError(
136
+ response,
137
+ `${label} failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`,
138
+ );
139
+ }