@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.
- package/dist/scaffold/package.json +3 -3
- package/dist/templates/transcription-workflow/agent.test.ts +324 -11
- package/dist/templates/transcription-workflow/agent.ts +76 -1
- package/dist/templates/transcription-workflow/api-help.tsx +214 -0
- package/dist/templates/transcription-workflow/client.tsx +292 -8
- package/dist/templates/transcription-workflow/workflows/batch.ts +301 -0
- package/dist/templates/transcription-workflow/workflows/stitch.ts +133 -0
- package/dist/templates/transcription-workflow/workflows/stream.ts +350 -0
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +139 -0
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +200 -183
- package/package.json +3 -3
|
@@ -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 `
|
|
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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
|
60
|
+
import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
|
|
61
|
+
import { emit, mapConcurrent, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
|
|
57
62
|
import {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
// `
|
|
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
|
-
// ----
|
|
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
|
-
*
|
|
392
|
+
* When the run started, as epoch ms.
|
|
315
393
|
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
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
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
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
|
|
334
|
-
|
|
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
|
-
|
|
359
|
-
function countWords(text: string): number {
|
|
360
|
-
return text.split(/\s+/).filter(Boolean).length;
|
|
408
|
+
return Date.now();
|
|
361
409
|
}
|
|
362
410
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
+
"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.
|
|
48
|
-
"@alexkroman1/aai-ui": "6.
|
|
47
|
+
"@alexkroman1/aai": "6.5.0",
|
|
48
|
+
"@alexkroman1/aai-ui": "6.5.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"playwright": "^1.62.1",
|