@alexkroman1/aai-cli 6.3.1 → 6.5.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.
@@ -44,49 +44,103 @@
44
44
  * app's own upload store and the run carries only its id; each step reads
45
45
  * exactly its own window with `readUpload`. Sixty steps therefore move the
46
46
  * recording once between them, not sixty times.
47
- * - **The fan-out is bounded by `mapInBatches`, and the bound is not a detail.**
47
+ * - **The fan-out is bounded by `mapConcurrent`, and the bound is not a detail.**
48
48
  * The DevKit correlates a journal entry to a step call by the ORDER the call
49
- * was issued in, so a work-stealing pool which issues its next call only
50
- * when a previous one settlesputs the calls in a different order on a
51
- * replay than it did on the first execution. That primitive is sequential
52
- * batches of `Promise.all` for exactly that reason; its module doc carries the
53
- * argument.
49
+ * was issued in, so the primitive keeps a WINDOW over a cursor that only ever
50
+ * hands out the next index — the Nth call issued is segment N-1 however the
51
+ * calls settle. Its module doc carries the argument; what matters here is that
52
+ * there is no barrier, so a slow segment costs only itself.
53
+ * - **The transcript STREAMS as it is produced.** Each segment is emitted the
54
+ * moment it lands (`emit(TRANSCRIPT_STREAM, …)`), so the page renders the
55
+ * answer growing rather than a status line and then everything at once. That is
56
+ * the difference a fan-out can make to a reader and a run output cannot: an
57
+ * `output` exists only when the last segment does.
54
58
  */
55
59
 
56
- import { throwFatalStepError, toStepError } from "@alexkroman1/aai/step-errors";
60
+ import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
61
+ import { emit, mapConcurrent, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
57
62
  import {
58
- mapInBatches,
59
- multipartBody,
60
- readUpload,
61
- report,
62
- requireStepEnv,
63
- stepFetch,
64
- uploadInfo,
65
- } from "@alexkroman1/aai/utils";
63
+ clock,
64
+ countWords,
65
+ stitchTranscript,
66
+ TRANSCRIPT_STREAM,
67
+ type TranscriptChunk,
68
+ } from "./stitch.ts";
69
+ import { elapsed, timed, transcribeWav } from "./sync-api.ts";
66
70
  import {
71
+ bytesPerSecond,
67
72
  parseWav,
68
73
  planSegments,
74
+ SEGMENT_OVERLAP_SECONDS,
75
+ SEGMENT_SECONDS,
69
76
  type Segment,
70
77
  UnsupportedRecordingError,
71
78
  type WavFormat,
72
79
  wavWithHeader,
73
80
  } from "./wav.ts";
74
81
 
75
- /** The synchronous transcription endpoint. Global — it routes to the nearest region. */
76
- const SYNC_ENDPOINT = "https://sync.assemblyai.com/transcribe";
77
-
78
- /** Required on every sync request; the endpoint routes on it. */
79
- const SYNC_MODEL = "universal-3-5-pro";
80
-
81
- /** The key a step reads out of the agent env. Declared in `agent.ts`'s `requiredEnv`. */
82
- const API_KEY_ENV = "ASSEMBLYAI_API_KEY";
82
+ /**
83
+ * Bytes the desk keeps uploading at once, which is what {@link segmentConcurrency}
84
+ * divides to get a width.
85
+ *
86
+ * A `503` from this endpoint says `queue wait timed out; server at capacity`, and
87
+ * that sentence is the whole model: requests are QUEUED rather than refused, and one
88
+ * fails only when it waited out the queue's own deadline. So what limits the fan-out
89
+ * is total work in flight, and at this segment length that is dominated by BYTES —
90
+ * not by the request count and not by the audio duration. Five arms, one account, one
91
+ * laptop, `curl` straight at the endpoint:
92
+ *
93
+ * | requests | per request | bytes in flight | audio-s | `503`s |
94
+ * | --- | --- | --- | --- | --- |
95
+ * | 320 | 160 KB (5s) | 51 MB | 1,600 | 0 |
96
+ * | 64 | 2.94 MB (92s, 16 kHz mono) | 188 MB | 5,888 | 0 |
97
+ * | 48 | 17.66 MB (92s, 48 kHz stereo) | 848 MB | 4,416 | 0 |
98
+ * | 56 | 17.66 MB | 989 MB | 5,152 | 6 |
99
+ * | 64 | 17.66 MB | 1.13 GB | 5,888 | 20 |
100
+ * | 320 | 2.94 MB | 941 MB | 29,440 | 64 |
101
+ *
102
+ * Read the columns against each other, because each one rules something out. Request
103
+ * COUNT cannot be the cap: 320 tiny requests were admitted whole, and so were 64 at
104
+ * 2.94 MB, where a flat ceiling of ~50 would have refused the excess. Audio DURATION
105
+ * cannot be it either: 5,888 audio-seconds passed cleanly at 2.94 MB a request and
106
+ * drew 20 `503`s at 17.66 MB — same audio, six times the bytes. What tracks is the
107
+ * byte column, and it tracks in ADMITTED bytes too, tightly, across request counts
108
+ * that differ by 5x: 848 MB clean, then 883 MB / 777 MB / 753 MB admitted on the
109
+ * three arms that limited. The last row is the proof, since it reaches the same
110
+ * ceiling with 320 small requests as 64 big ones do.
111
+ *
112
+ * 640 MB sits between the largest clean run (848 MB) and the smallest limited one
113
+ * (941 MB), nearer the clean side. It is the declared quantity because the WIDTH is
114
+ * not the durable fact — this desk cuts whatever format it is handed, and the same
115
+ * 32 segments are 565 MB of 48 kHz stereo, 94 MB of 16 kHz mono, or 1.28 GB of a
116
+ * format at the {@link MAX_SEGMENT_BYTES} ceiling. Only one of those three is safe,
117
+ * and a constant cannot tell them apart.
118
+ *
119
+ * The threshold is this machine's, and one caveat sharpens which half. Bytes in
120
+ * flight is bytes UPLOADING, so it is also the number that saturated a ~65 MB/s
121
+ * uplink — a deployed guest reserving one CPU has neither, and a slower uplink holds
122
+ * every request open LONGER, which is the direction that makes a queue deadline
123
+ * easier to hit rather than harder. Re-measure there.
124
+ */
125
+ export const BYTES_IN_FLIGHT = 640 * 1024 * 1024;
83
126
 
84
127
  /**
85
- * Segments in flight at once.
128
+ * The widest fan-out, however small the segments are.
129
+ *
130
+ * Because {@link BYTES_IN_FLIGHT} stops being the binding constraint once segments
131
+ * are small — 16 kHz mono would divide out to 173 — and the endpoint's own tail
132
+ * takes over before that helps: p95/p50 measured 1.1x at 20 concurrent against
133
+ * 1.5x at 320, with max/p50 reaching 6.7x (5.2s against 35.0s). A `503` carrying
134
+ * `retry-after: 1` is exactly such a straggler.
86
135
  *
87
- * Bounded because the far side has a capacity limit, and it is MEASURED now
88
- * 65 segments (1h37m of 48 kHz stereo, 17.66 MB each) through this workflow,
89
- * one concurrency per run, from one laptop and one account:
136
+ * **That tail used to be paid once per ROUND, and is not any more.** `mapConcurrent`
137
+ * was `mapInBatches` sequential batches of `Promise.all` so a batch's wall time
138
+ * was its slowest member and a run's was the sum of those. It is a window over a
139
+ * cursor now: a straggler holds up nothing but itself, and the numbers below were
140
+ * measured under the old barrier, so they are if anything pessimistic.
141
+ *
142
+ * 32 is the measured knee over 65 segments (1h37m of 48 kHz stereo), one concurrency
143
+ * per run, through this workflow:
90
144
  *
91
145
  * | in flight | wall | vs realtime | `503`s |
92
146
  * | --- | --- | --- | --- |
@@ -95,23 +149,42 @@ const API_KEY_ENV = "ASSEMBLYAI_API_KEY";
95
149
  * | 48 | 26.1-28.5s | 204-223x | 0-4 |
96
150
  * | 64 | 31.9s | 182x | 20 |
97
151
  *
98
- * Two readings, and the second is why this is 8 and not 32. Throughput
99
- * PLATEAUS around 32: past it the uplink is the bottleneck, every request just
100
- * gets a thinner share of it (p50 4.2s at 8, 10.0s at 32, 12.5s at 64), and at
101
- * 64 the far side starts answering `503 Capacity Exceeded` so the extra
102
- * concurrency buys retries rather than speed. And the number the plateau sits
103
- * at belongs to the machine, not to this code: a deployed guest reserves one
104
- * CPU and has neither this uplink nor its ~47 MB/s, so a default measured here
105
- * would be an overcommit there.
152
+ * This was 8 for a long time, which cost 37% of the wall clock for headroom the
153
+ * endpoint does not need. Past 32 there is nothing left to buy: 48 is within noise
154
+ * of it while starting to pay retries, and 64 is outright SLOWER. Note the width is
155
+ * also inert below a threshold at 90-second segments, 32 only binds past 48
156
+ * minutes of audio so on a typical recording the whole fan-out is in flight
157
+ * either way and this number changes nothing.
158
+ *
159
+ * The table above was measured under the old per-round barrier. Re-measuring it is
160
+ * worth doing before this number moves again: the window makes a wide fan-out
161
+ * cheaper at the tail, which if anything argues for a HIGHER knee.
162
+ */
163
+ export const MAX_SEGMENT_CONCURRENCY = 32;
164
+
165
+ /**
166
+ * How many segments of THIS recording to keep in flight.
167
+ *
168
+ * Derived rather than declared, because the byte cost of a segment is a property of
169
+ * the format and not of this code: see {@link BYTES_IN_FLIGHT} for the measurements,
170
+ * and note that a fixed 32 is safe for 48 kHz stereo and a guaranteed queue timeout
171
+ * for a format twice as heavy. Both flows call this, so both scale the same way.
172
+ *
173
+ * Safe to call from a workflow BODY: `format` arrives from a journaled step result,
174
+ * so a replay derives the same width from the same bytes — which is what keeps
175
+ * `mapConcurrent` issuing its calls in the order the journal recorded them.
106
176
  *
107
- * So 8 is a floor with headroom, and the ceiling is known: raise it toward 32
108
- * against your own account and watch `503`s in the log. Overshooting is no
109
- * longer expensive a `503` carries `retry-after` and `toStepError` below
110
- * honours it, so the run completes having paid one extra request per limited
111
- * segment (measured: 20 `503`s at 64, each retried exactly once, run
112
- * completed). That is only true over HTTP/1.1, which is what `stepFetch` pins.
177
+ * Overshooting stays recoverable whatever this returns: a `503` carries
178
+ * `retry-after` and `toStepError` below honours it, so the run completes having paid
179
+ * one extra request per limited segment (measured: 20 `503`s at 64, each retried
180
+ * exactly once, run completed). That is only true over HTTP/1.1, which is what
181
+ * `stepFetch` pins.
113
182
  */
114
- const SEGMENT_CONCURRENCY = 8;
183
+ export function segmentConcurrency(format: WavFormat): number {
184
+ const perSegment = bytesPerSecond(format) * (SEGMENT_SECONDS + SEGMENT_OVERLAP_SECONDS);
185
+ if (perSegment <= 0) return MAX_SEGMENT_CONCURRENCY;
186
+ return Math.max(1, Math.min(MAX_SEGMENT_CONCURRENCY, Math.floor(BYTES_IN_FLIGHT / perSegment)));
187
+ }
115
188
 
116
189
  /**
117
190
  * Bytes probed for the WAV header.
@@ -122,13 +195,27 @@ const SEGMENT_CONCURRENCY = 8;
122
195
  */
123
196
  const HEADER_PROBE_BYTES = 64 * 1024;
124
197
 
125
- /** The endpoint's own per-request deadline, plus room to upload. */
126
- const SYNC_TIMEOUT_MS = 60_000;
127
-
128
- /** Most words `stitchTranscript` will look back over to find a repeated seam. */
129
- const MAX_SEAM_WORDS = 40;
198
+ /**
199
+ * What a finished run reports, whichever flow produced it.
200
+ *
201
+ * Declared once and shared by all three, because the page renders any of them with
202
+ * one component: a field added to one flow and not the others is a panel that shows
203
+ * it for some runs and not others, with nothing saying why.
204
+ */
205
+ export type Transcript = {
206
+ /** The recording's own filename, so a reader knows which run they are looking at. */
207
+ source: string;
208
+ /** How many requests the transcript was assembled from. `1` for the async flow. */
209
+ segments: number;
210
+ /** Length of the AUDIO. */
211
+ durationMs: number;
212
+ /** How long the RUN took, wall clock. The number that compares the flows. */
213
+ elapsedMs: number;
214
+ words: number;
215
+ transcript: string;
216
+ };
130
217
 
131
- /** What one segment's request came back with. */
218
+ /** What one segment's request came back with — the STEP's result, journaled. */
132
219
  export type SegmentTranscript = {
133
220
  index: number;
134
221
  text: string;
@@ -143,20 +230,24 @@ export type SegmentTranscript = {
143
230
  export async function transcribeFlow(input: { recording: string }) {
144
231
  "use workflow";
145
232
 
146
- const plan = await splitRecording(input.recording);
233
+ // Both at once: neither needs the other, and issued together they are one
234
+ // round trip instead of two before any audio is read. The ORDER is still a
235
+ // pure function of this line — the two calls go out synchronously, left to
236
+ // right — which is what a replay reproduces.
237
+ const [startedAt, plan] = await Promise.all([startClock(), splitRecording(input.recording)]);
147
238
 
148
239
  // One step per segment, bounded, in an order a replay reproduces exactly.
149
240
  // A failed segment fails the RUN, deliberately: every sibling that finished is
150
241
  // already journaled, so the resume replays those for free and re-issues only
151
242
  // what is missing, where catching here to salvage a partial transcript would
152
243
  // return a recording with a silent hole in it and report success.
153
- const parts = await mapInBatches(plan.segments, SEGMENT_CONCURRENCY, (segment) =>
244
+ const parts = await mapConcurrent(plan.segments, segmentConcurrency(plan.format), (segment) =>
154
245
  transcribeSegment(input.recording, plan.format, segment),
155
246
  );
156
247
 
157
248
  // Whatever this returns is what a caller reads as `output` on a completed run
158
249
  // — so it is what the page renders, typed through `WorkflowOutputOf`.
159
- return await mergeTranscript(input.recording, plan.durationMs, parts);
250
+ return await mergeTranscript(input.recording, plan.durationMs, parts, startedAt);
160
251
  }
161
252
 
162
253
  /**
@@ -210,7 +301,6 @@ export async function transcribeSegment(
210
301
  // order.
211
302
  await report(`Transcribing ${clock(segment.startMs)}–${clock(segment.endMs)}.`);
212
303
 
213
- const apiKey = apiKeyOrFatal();
214
304
  // `[start, end)`, the same half-open pair `planSegments` produced — the store
215
305
  // owns the conversion to HTTP's inclusive range, so there is no `- 1` here to
216
306
  // get wrong.
@@ -221,38 +311,33 @@ export async function transcribeSegment(
221
311
  // the language, so the field was a question asked of a person that the service
222
312
  // answers better — and getting it wrong is a whole transcript in the wrong
223
313
  // language. Add one back only for a desk that really knows.
224
- const part = multipartBody({
225
- name: "audio",
226
- filename: `segment-${segment.index}.wav`,
227
- type: "audio/wav",
228
- bytes: wavWithHeader(format, audio.bytes),
229
- });
230
-
231
- // `stepFetch`, not `fetch`, and here it is load-bearing rather than tidy:
232
- // `fetch` speaks HTTP/2 wherever the far side offers it, which puts a whole
233
- // batch of segments on ONE connection — and a capacity limit then arrives as a
234
- // stream reset carrying no HTTP status for `toStepError` below to read. The
235
- // fan-out is exactly the shape that breaks on. `sdk/step-fetch.ts` holds the
236
- // measurements; a `StepTransportError` out of here is already retryable and
237
- // already names its cause.
238
- const response = await stepFetch(SYNC_ENDPOINT, {
239
- method: "POST",
240
- headers: {
241
- // The raw key this endpoint takes it unprefixed, and a `Bearer ` in
242
- // front of it is a 401 that reads like a wrong key.
243
- Authorization: apiKey,
244
- "X-AAI-Model": SYNC_MODEL,
245
- ...part.headers,
246
- },
247
- body: part.body,
248
- // Nothing here has a deadline of its own, and a hung upload inside a step is
249
- // a run that never finishes rather than one that retries.
250
- signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
251
- });
252
- if (!response.ok) throw await syncFailure(response, segment);
253
-
254
- const body = (await response.json()) as { text?: string };
255
- return { index: segment.index, text: (body.text ?? "").trim() };
314
+ //
315
+ // `wavWithHeader` is what makes a WINDOW decodable: the endpoint decodes each
316
+ // request independently, so a slice of the middle of a recording is a headerless
317
+ // tail until one is put back on it. The streaming flow needs no equivalent — its
318
+ // parts were cut with a header each.
319
+ const { value: text, ms } = await timed(() =>
320
+ transcribeWav(
321
+ wavWithHeader(format, audio.bytes),
322
+ `segment-${segment.index}.wav`,
323
+ `Segment ${segment.index} (${clock(segment.startMs)})`,
324
+ ),
325
+ );
326
+ // The LATENCY, which is what says whether the concurrency bound or the endpoint
327
+ // is the thing limiting the run — see `timed`'s doc.
328
+ await report(`Transcribed ${clock(segment.startMs)}–${clock(segment.endMs)} in ${elapsed(ms)}.`);
329
+ // And the WORDS, into their own stream, which is what makes this run's answer
330
+ // streamable rather than only its narration: the page stitches whatever has
331
+ // arrived and renders the transcript growing, minutes before `output` exists.
332
+ // Its own namespace because `report`'s stream carries sentences a page prints
333
+ // verbatim — see `emit`'s doc.
334
+ await emit(TRANSCRIPT_STREAM, {
335
+ index: segment.index,
336
+ startMs: segment.startMs,
337
+ endMs: segment.endMs,
338
+ text,
339
+ } satisfies TranscriptChunk);
340
+ return { index: segment.index, text };
256
341
  }
257
342
 
258
343
  /**
@@ -274,18 +359,13 @@ export async function mergeTranscript(
274
359
  uploadId: string,
275
360
  durationMs: number,
276
361
  parts: readonly SegmentTranscript[],
277
- ): Promise<{
278
- source: string;
279
- segments: number;
280
- durationMs: number;
281
- words: number;
282
- transcript: string;
283
- }> {
362
+ startedAt: number,
363
+ ): Promise<Transcript> {
284
364
  "use step";
285
365
 
286
366
  await report(`Stitching ${parts.length} segment${parts.length === 1 ? "" : "s"} together.`);
287
367
 
288
- // `mapInBatches` resolves in ITEM order however the calls settled, so this is
368
+ // `mapConcurrent` resolves in ITEM order however the calls settled, so this is
289
369
  // already ordered — sorted anyway, because the merge is where an ordering
290
370
  // mistake would be invisible rather than loud.
291
371
  const ordered = [...parts].sort((a, b) => a.index - b.index);
@@ -298,89 +378,50 @@ export async function mergeTranscript(
298
378
  source,
299
379
  segments: parts.length,
300
380
  durationMs,
381
+ // Wall clock, so the three flows can be compared over one file — see
382
+ // `startClock`. Measured in a STEP, which is what makes it survive a replay.
383
+ elapsedMs: Date.now() - startedAt,
301
384
  words: countWords(transcript),
302
385
  transcript,
303
386
  };
304
387
  }
305
388
 
306
- // ---- Pure helpers -----------------------------------------------------------
307
-
308
- /** A word, stripped of the punctuation the decoder added, for seam comparison. */
309
- function seamKey(word: string): string {
310
- return word.toLowerCase().replace(/[^\p{L}\p{N}']/gu, "");
311
- }
389
+ // ---- The run's own clock ----------------------------------------------------
312
390
 
313
391
  /**
314
- * Join segment transcripts, dropping the words the overlap made duplicates.
392
+ * When the run started, as epoch ms.
315
393
  *
316
- * Segments overlap by `SEGMENT_OVERLAP_SECONDS` (see `wav.ts` for why), so the
317
- * last few words of one segment are the first few of the next verbatim when
318
- * the decoder heard them the same way, which is the common case because it heard
319
- * the same audio. This finds the longest such run and removes one copy.
394
+ * A STEP, and that is the whole reason this exists rather than a `Date.now()` in the
395
+ * body: a body replays from the top on every resume, so a clock read there returns a
396
+ * different value each time and every duration derived from it would be a different
397
+ * duration. A step's result is journaled, so this is the moment the run really began
398
+ * however many times it is replayed.
320
399
  *
321
- * Comparison is on `seamKey`, not the raw words: the two passes punctuate
322
- * differently at their own edges (one ends a sentence where the other is
323
- * mid-clause), so `"today."` and `"today"` are the same word and a raw compare
324
- * finds no seam at all. The text KEPT is the raw text — only the match is
325
- * normalized.
326
- *
327
- * A missed seam repeats a few words, which a reader can see and forgive. A
328
- * false one would delete speech, so the search is bounded at
329
- * `MAX_SEAM_WORDS` and always prefers the LONGEST match: a single repeated
330
- * "the" is not evidence of anything, and requiring the longest run is what stops
331
- * it counting as one when a longer match is available.
400
+ * Shared by all three flows deliberately. A run snapshot carries `createdAt` and no
401
+ * end time, so "how long did this take" is not answerable from the outside — and the
402
+ * whole point of shipping three flows over one job is that a reader can compare them,
403
+ * which needs one number measured one way.
332
404
  */
333
- export function stitchTranscript(parts: readonly string[]): string {
334
- const merged: string[] = [];
335
- for (const part of parts) {
336
- const next = part.split(/\s+/).filter(Boolean);
337
- if (next.length === 0) continue;
338
- if (merged.length === 0) {
339
- merged.push(...next);
340
- continue;
341
- }
342
- merged.push(...next.slice(seamLength(merged, next)));
343
- }
344
- return merged.join(" ");
345
- }
346
-
347
- /** How many leading words of `next` repeat the tail of `merged`. */
348
- function seamLength(merged: readonly string[], next: readonly string[]): number {
349
- const limit = Math.min(MAX_SEAM_WORDS, merged.length, next.length);
350
- // Longest first, so a short accidental match never wins over a real seam.
351
- for (let length = limit; length > 0; length--) {
352
- const tail = merged.slice(merged.length - length);
353
- if (tail.every((word, at) => seamKey(word) === seamKey(next[at] ?? ""))) return length;
354
- }
355
- return 0;
356
- }
405
+ export async function startClock(): Promise<number> {
406
+ "use step";
357
407
 
358
- /** Words in a string. */
359
- function countWords(text: string): number {
360
- return text.split(/\s+/).filter(Boolean).length;
408
+ return Date.now();
361
409
  }
362
410
 
363
- /** `m:ss` for the progress log a byte offset means nothing to a reader. */
364
- export function clock(ms: number): string {
365
- const seconds = Math.max(0, Math.round(ms / 1000));
366
- return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
367
- }
411
+ // `clock` and `countWords` are re-exported rather than re-declared: `stream.ts`
412
+ // and `batch.ts` already import them from this module, and the split that let the
413
+ // PAGE stitch a partial transcript should not ripple through every flow.
414
+ export {
415
+ clock,
416
+ countWords,
417
+ stitchChunks,
418
+ stitchTranscript,
419
+ TRANSCRIPT_STREAM,
420
+ type TranscriptChunk,
421
+ } from "./stitch.ts";
368
422
 
369
423
  // ---- I/O helpers ------------------------------------------------------------
370
424
 
371
- /** The API key, or a terminal failure — three more attempts find the same gap. */
372
- function apiKeyOrFatal(): string {
373
- try {
374
- return requireStepEnv(API_KEY_ENV);
375
- } catch (err: unknown) {
376
- // `throwFatalStepError` rather than `throw new FatalError(…)`: that class
377
- // takes only a message — no `cause` — so constructing one inside a `catch`
378
- // loses the original where the linter (rightly) expects it preserved. Here
379
- // the original is the ARGUMENT, and nothing is swallowed.
380
- return throwFatalStepError(err);
381
- }
382
- }
383
-
384
425
  /** Run a `wav.ts` helper, turning its "cannot cut this" into a terminal failure. */
385
426
  function fatalOnUnsupported<T>(read: () => T): T {
386
427
  try {
@@ -390,27 +431,3 @@ function fatalOnUnsupported<T>(read: () => T): T {
390
431
  throw err;
391
432
  }
392
433
  }
393
-
394
- /**
395
- * The sync endpoint's failure, with whatever it said about it.
396
- *
397
- * `toStepError` makes the three-way call: a `FatalError` stops the DevKit
398
- * retrying something that will answer the same way, a bare `RetryableError`
399
- * retries in ONE SECOND (that class's own default), and a `RetryableError`
400
- * carrying `retryAfter` waits exactly as long as the far side asked. The last
401
- * matters here because `SEGMENT_CONCURRENCY` segments hit the rate limit
402
- * together — a second later all four ask again, where on the server's number
403
- * they drain.
404
- */
405
- async function syncFailure(response: Response, segment: Segment): Promise<Error> {
406
- // Two shapes, documented: `{ error_code, message }` for a request problem and
407
- // `{ detail }` for auth and rate limits.
408
- const body = (await response.json().catch(() => ({}))) as { message?: string; detail?: string };
409
- const detail = body.message ?? body.detail;
410
- return toStepError(
411
- response,
412
- `Segment ${segment.index} (${clock(segment.startMs)}) failed: HTTP ${response.status}${
413
- detail ? ` — ${detail}` : ""
414
- }`,
415
- );
416
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-cli",
3
- "version": "6.3.1",
3
+ "version": "6.5.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "aai": "bin.mjs"
@@ -44,8 +44,8 @@
44
44
  "p-timeout": "^7.0.1",
45
45
  "vite": "^8.2.1",
46
46
  "zod": "^4.4.3",
47
- "@alexkroman1/aai": "6.3.1",
48
- "@alexkroman1/aai-ui": "6.3.1"
47
+ "@alexkroman1/aai": "6.5.0",
48
+ "@alexkroman1/aai-ui": "6.5.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "playwright": "^1.62.1",