@alexkroman1/aai-cli 6.4.0 → 6.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/{_agent-vGW5SBnX.mjs → _agent-DpH2pBJd.mjs} +1 -5
  2. package/dist/{_bundler-BapCUUJN.mjs → _bundler-C2BDwU7r.mjs} +5 -7
  3. package/dist/{_dev-server-DQ1t9sbw.mjs → _dev-server-LLLWnFBs.mjs} +3 -3
  4. package/dist/_e2e-test-utils.d.ts +17 -17
  5. package/dist/{_init-CpISqoR8.mjs → _init-C57bWAQ8.mjs} +2 -2
  6. package/dist/{_slug-api-D2heILuC.mjs → _slug-api-BxrsJXVI.mjs} +3 -3
  7. package/dist/_slug-api.d.ts +1 -1
  8. package/dist/{_api-client-LgLksMhN.mjs → _studio-U3jhKlA3.mjs} +105 -2
  9. package/dist/_studio.d.ts +11 -0
  10. package/dist/{_templates-iDGjGiPf.mjs → _templates-Dxb_P2Wz.mjs} +1 -1
  11. package/dist/bin.d.mts +2 -0
  12. package/dist/{build-BO1Ni6oJ.mjs → build-Dfza2pRI.mjs} +2 -2
  13. package/dist/cli.mjs +43 -42
  14. package/dist/{client-bundler-B8Q9F8gJ.mjs → client-bundler-BEIqgOtd.mjs} +2 -2
  15. package/dist/client-bundler.mjs +1 -1
  16. package/dist/{delete-4I0uNR4a.mjs → delete-vbpjGzqs.mjs} +3 -3
  17. package/dist/{deploy-DJ3HRcia.mjs → deploy-B8lubiRT.mjs} +3 -4
  18. package/dist/{dev-C6SWaAZP.mjs → dev-Uq5ujP8a.mjs} +1 -1
  19. package/dist/{eject-Zrzk5KV3.mjs → eject-31gjtaHF.mjs} +2 -2
  20. package/dist/{init-BTRGiPQr.mjs → init-l6xfU4xX.mjs} +3 -3
  21. package/dist/{login-k0Z3VQu9.mjs → login-C71-qz8F.mjs} +1 -1
  22. package/dist/scaffold/package.json +3 -3
  23. package/dist/{secret-DzSQx1Ds.mjs → secret-BuMuFR4B.mjs} +2 -2
  24. package/dist/{storage-C-Do0mIA.mjs → storage-DzRZ-eCw.mjs} +2 -2
  25. package/dist/{studio-BGVmCA2G.mjs → studio-BCNUpDgP.mjs} +4 -5
  26. package/dist/templates/retail/resolve.ts +16 -0
  27. package/dist/templates/retail/store.ts +20 -0
  28. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -5
  29. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -5
  30. package/dist/templates/retail/tools/get_order_details.ts +4 -6
  31. package/dist/templates/retail/tools/get_user_details.ts +2 -7
  32. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -5
  33. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -5
  34. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -5
  35. package/dist/templates/retail/tools/modify_user_address.ts +2 -7
  36. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -5
  37. package/dist/templates/transcription-workflow/agent.test.ts +75 -4
  38. package/dist/templates/transcription-workflow/api-help.tsx +8 -0
  39. package/dist/templates/transcription-workflow/client.tsx +149 -11
  40. package/dist/templates/transcription-workflow/workflows/batch.ts +32 -21
  41. package/dist/templates/transcription-workflow/workflows/stitch.ts +133 -0
  42. package/dist/templates/transcription-workflow/workflows/stream.ts +20 -20
  43. package/dist/templates/transcription-workflow/workflows/sync-api.ts +8 -2
  44. package/dist/templates/transcription-workflow/workflows/transcribe.ts +71 -86
  45. package/dist/{test-B6ZBxSpk.mjs → test-9kPKJI-w.mjs} +1 -3
  46. package/dist/typecheck.mjs +4 -5
  47. package/dist/{worker-bundler-CIdDhZ0c.mjs → worker-bundler-CGD4r8Kc.mjs} +25 -11
  48. package/dist/worker-bundler.mjs +1 -1
  49. package/dist/{workflow-B-O97AqI.mjs → workflow-CFpxOFfQ.mjs} +1 -1
  50. package/package.json +4 -4
  51. package/dist/_studio-zAJycZ8j.mjs +0 -94
@@ -42,15 +42,52 @@
42
42
  * because they take the same input and return the same shape, and the only thing
43
43
  * the page chooses is which HOOK submits it.
44
44
  *
45
- * `useWorkflowStream` is the streaming half: it cuts the file with the cutter this
46
- * template supplies (`cut-wav.ts`, which is `workflows/wav.ts` run in the browser),
47
- * uploads each part under one group token, and wakes the run as each lands.
48
- * `useWorkflowSubmit` is the classic half and is unchanged.
45
+ * `useWorkflowStream` is the streaming half: it mints the upload id, starts the run
46
+ * on it, sends the file, and wakes the run when the bytes land. `useWorkflowSubmit`
47
+ * is the classic half and is unchanged.
49
48
  *
50
49
  * Streaming is the DEFAULT because it is faster on any real recording. The classic
51
- * path stays selectable because it is the shape to read first, and because it is
52
- * the one that works on a file this browser cannot parse — the cutter needs a WAV
53
- * header, where the server-side flow reaches the same conclusion in its first step.
50
+ * path stays selectable because it is the shape to read first.
51
+ *
52
+ * ## The third control is about the UPLOAD, not the flow
53
+ *
54
+ * "Split the file across connections" (`parallel`) is orthogonal to the three modes
55
+ * and applies to all of them, which is why it is a checkbox beside the radios
56
+ * rather than a fourth option. A single request moves a file at one connection's
57
+ * throughput, which over any distance is a fraction of the link — so the SDK cuts
58
+ * the file into megabyte-aligned parts and sends four at once. Nothing about the
59
+ * workflow changes: the agent reassembles them, `readUpload` reads the same
60
+ * windows, and the streaming flow still watches the file grow (what it polls is the
61
+ * CONTIGUOUS prefix, which is honest whether one connection or four are filling
62
+ * it).
63
+ *
64
+ * It is selectable rather than always-on for the reason the modes are: this is the
65
+ * template where a reader runs both over the same recording and sees what each
66
+ * costs. It also degrades on its own — a small file, or an agent deployed before
67
+ * the `/parts` routes existed, sends the single request instead — so leaving it on
68
+ * is safe.
69
+ *
70
+ * ## The transcript ARRIVES, rather than appearing at the end
71
+ *
72
+ * A run's `output` exists only when its last segment does, so a page with only
73
+ * that shows a status line for the whole fan-out and then everything at once — on
74
+ * a 97-minute recording, minutes of it. Each segment is emitted the moment it
75
+ * lands (`emit(TRANSCRIPT_STREAM, …)` in `workflows/transcribe.ts`) and
76
+ * `useWorkflowProgress` reads that stream, so the panel renders the transcript
77
+ * growing.
78
+ *
79
+ * Three things make it honest rather than decorative:
80
+ *
81
+ * - **The page stitches with the RUN's own function.** `stitchChunks` is
82
+ * `workflows/stitch.ts`, imported by both, so the live text and the stored one
83
+ * cannot drift into two different transcripts of one recording.
84
+ * - **It is a SEPARATE stream from the progress log.** `report()`'s lines go to
85
+ * the default one, which `<WorkflowProgress>` renders verbatim; objects in
86
+ * there would come out as `[object Object]` between the sentences.
87
+ * - **The finished run wins.** Once `output` exists the panel renders that
88
+ * instead — it is the authoritative text, counted and measured, and a live
89
+ * transcript that stayed on screen beside it would be a second answer with no
90
+ * way to tell which was current.
54
91
  *
55
92
  * ## Two waits, two bars
56
93
  *
@@ -77,6 +114,7 @@ import {
77
114
  page,
78
115
  SubmitButton,
79
116
  UploadProgressBar,
117
+ useWorkflowProgress,
80
118
  useWorkflowRuns,
81
119
  useWorkflowStream,
82
120
  useWorkflowSubmit,
@@ -84,9 +122,16 @@ import {
84
122
  WorkflowProgress,
85
123
  type WorkflowRun,
86
124
  } from "@alexkroman1/aai-ui";
87
- import { useEffect, useState } from "react";
125
+ import { useEffect, useMemo, useState } from "react";
88
126
  import type { transcribe } from "./agent.ts";
89
127
  import { ApiHelp } from "./api-help.tsx";
128
+ import {
129
+ clock,
130
+ countWords,
131
+ stitchChunks,
132
+ TRANSCRIPT_STREAM,
133
+ type TranscriptChunk,
134
+ } from "./workflows/stitch.ts";
90
135
 
91
136
  /**
92
137
  * What a finished run reports.
@@ -142,12 +187,16 @@ const HISTORY_LIMIT = 10;
142
187
 
143
188
  function TranscriptionDesk() {
144
189
  const [mode, setMode] = useState<Mode>("streaming");
190
+ // Whether the browser cuts the recording up and sends the pieces at once. One
191
+ // piece of state for all three hooks, because it describes the UPLOAD and every
192
+ // mode has one — see the module doc.
193
+ const [parallel, setParallel] = useState(true);
145
194
  // ALL THREE hooks are called every render, because a hook may not be conditional —
146
195
  // and that costs nothing here: none of them does anything until its `submit` is
147
196
  // called, and `useWorkflowRun` underneath them holds no id until then either.
148
- const streamed = useWorkflowStream<Transcript>(WORKFLOWS.streaming);
149
- const stored = useWorkflowSubmit<Transcript>(WORKFLOWS.classic);
150
- const batched = useWorkflowSubmit<Transcript>(WORKFLOWS.batch);
197
+ const streamed = useWorkflowStream<Transcript>(WORKFLOWS.streaming, { parallel });
198
+ const stored = useWorkflowSubmit<Transcript>(WORKFLOWS.classic, { parallel });
199
+ const batched = useWorkflowSubmit<Transcript>(WORKFLOWS.batch, { parallel });
151
200
  // The batch flow uploads the same way the classic one does — the id comes from the
152
201
  // store — so it is the SAME hook against a different workflow. Only the streaming
153
202
  // mode needs the other one, because only it needs the id before the bytes.
@@ -184,6 +233,8 @@ function TranscriptionDesk() {
184
233
 
185
234
  <ModePicker mode={mode} onPick={setMode} disabled={pending} />
186
235
 
236
+ <UploadPicker parallel={parallel} onPick={setParallel} disabled={pending} />
237
+
187
238
  {/* No mapping: the collected values already match the input schema. All three
188
239
  workflows declare `recording` as an upload, so the same picker serves every
189
240
  mode — how the bytes travel is not a question to ask a person. */}
@@ -256,6 +307,49 @@ function ModePicker({
256
307
  );
257
308
  }
258
309
 
310
+ /**
311
+ * How the recording travels, as one checkbox.
312
+ *
313
+ * Beside the mode radios rather than among them because it answers a different
314
+ * question — those pick the WORKFLOW, this picks how its input gets there — and
315
+ * every mode is uploading a file either way.
316
+ *
317
+ * Disabled mid-submission for the same reason the radios are: the bytes are
318
+ * already moving, and a control that looks live while changing nothing is worse
319
+ * than one that is plainly unavailable.
320
+ */
321
+ function UploadPicker({
322
+ parallel,
323
+ onPick,
324
+ disabled,
325
+ }: {
326
+ parallel: boolean;
327
+ onPick: (next: boolean) => void;
328
+ disabled: boolean;
329
+ }) {
330
+ return (
331
+ <fieldset className="flex flex-col gap-3" disabled={disabled}>
332
+ <legend className="text-sm font-medium uppercase tracking-[1.2px]">Upload</legend>
333
+ <label className="flex items-start gap-3 text-sm">
334
+ <input
335
+ type="checkbox"
336
+ className="mt-1"
337
+ name="parallel"
338
+ checked={parallel}
339
+ onChange={(event) => onPick(event.target.checked)}
340
+ />
341
+ <span className="flex flex-col gap-0.5">
342
+ <span>Split the file across connections</span>
343
+ <span className="text-xs opacity-70">
344
+ Sends the recording as several parts at once instead of in one request, which is most of
345
+ the wait on a long file. Falls back to the single request on a small one.
346
+ </span>
347
+ </span>
348
+ </label>
349
+ </fieldset>
350
+ );
351
+ }
352
+
259
353
  /**
260
354
  * Every recent run, newest first, with its transcript one click away.
261
355
  *
@@ -338,6 +432,11 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: ()
338
432
  finished run up in the panel below shows how it got there. */}
339
433
  <WorkflowProgress runId={run.runId} />
340
434
 
435
+ {/* While it runs, the transcript so far. Unguarded on the run's status
436
+ beyond this: the component renders nothing until a segment has landed,
437
+ and stops the moment there is an `output` to render instead. */}
438
+ {!isTerminal(run) && <LiveTranscript runId={run.runId} />}
439
+
341
440
  {/* Discriminated on `status`, so `output` and `error` are reachable
342
441
  without a cast — the reason a snapshot is a union rather than a flat
343
442
  object with optional fields. */}
@@ -356,6 +455,45 @@ function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: ()
356
455
  );
357
456
  }
358
457
 
458
+ /**
459
+ * The transcript as it arrives, stitched from the segments that have landed.
460
+ *
461
+ * The other half of `<WorkflowProgress>` above it: that one renders what the run
462
+ * SAYS about itself, this one renders what it has produced. Both are the same
463
+ * mechanism — a run's output stream — separated by the namespace, which is what
464
+ * lets this one be typed.
465
+ *
466
+ * It renders NOTHING until a segment lands, so a page can mount it unguarded:
467
+ * before the first chunk there is nothing to say that the progress log is not
468
+ * already saying better.
469
+ *
470
+ * The count is derived from the stitched text rather than summed per chunk,
471
+ * because the seams overlap — adding up the segments would over-count every one
472
+ * of them by a couple of seconds' worth of words.
473
+ */
474
+ function LiveTranscript({ runId }: { runId: string }) {
475
+ const { progress } = useWorkflowProgress<TranscriptChunk>(runId, {
476
+ namespace: TRANSCRIPT_STREAM,
477
+ });
478
+ // Memoized on the ARRAY, which the hook appends to per read: stitching is a
479
+ // seam search per segment, and a fan-out re-renders this panel on every
480
+ // progress poll whether or not anything arrived.
481
+ const transcript = useMemo(() => stitchChunks(progress), [progress]);
482
+ if (progress.length === 0) return null;
483
+
484
+ // The furthest point reached, not the count: segments land out of order, so
485
+ // "6 segments" says nothing about how much of the recording is covered.
486
+ const covered = Math.max(...progress.map((chunk) => chunk.endMs));
487
+ return (
488
+ <div className="flex flex-col gap-2">
489
+ <p className="text-xs opacity-60">
490
+ {countWords(transcript)} words so far · through {clock(covered)}
491
+ </p>
492
+ <pre className="whitespace-pre-wrap text-sm leading-relaxed opacity-80">{transcript}</pre>
493
+ </div>
494
+ );
495
+ }
496
+
359
497
  /**
360
498
  * A duration a person can read.
361
499
  *
@@ -40,12 +40,14 @@
40
40
  *
41
41
  * That is also why the step that does it is the step the DevKit retries: a streaming
42
42
  * body is consumed once, so a retry has to re-read the upload from the start, which
43
- * it does.
43
+ * it does. One window of READ-AHEAD keeps the store and the socket busy at the same
44
+ * time; `windows` carries the argument.
44
45
  */
45
46
 
46
47
  import { throwFatalStepError, toStepError } from "@alexkroman1/aai/step-errors";
47
- import { readUpload, report, requireStepEnv, stepFetch, uploadInfo } from "@alexkroman1/aai/utils";
48
+ import { readUpload, report, stepFetch, uploadInfo } from "@alexkroman1/aai/utils";
48
49
  import { sleep } from "workflow";
50
+ import { apiKeyOrFatal } from "./sync-api.ts";
49
51
  import { countWords, startClock, type Transcript } from "./transcribe.ts";
50
52
 
51
53
  /** The async API's base. */
@@ -65,9 +67,6 @@ const API = "https://api.assemblyai.com";
65
67
  */
66
68
  const MODELS = ["universal-3-5-pro"];
67
69
 
68
- /** The key a step reads out of the agent env. Declared in `agent.ts`'s `requiredEnv`. */
69
- const API_KEY_ENV = "ASSEMBLYAI_API_KEY";
70
-
71
70
  /** How much of our stored upload one outbound window carries. */
72
71
  const UPLOAD_WINDOW_BYTES = 4 * 1024 * 1024;
73
72
 
@@ -100,8 +99,13 @@ const MAX_POLLS = 360;
100
99
  export async function transcribeBatchFlow(input: { recording: string }): Promise<Transcript> {
101
100
  "use workflow";
102
101
 
103
- const startedAt = await startClock();
104
- const { audioUrl } = await uploadToProvider(input.recording);
102
+ // Both at once: the clock does not depend on the upload, and issuing them
103
+ // together costs one round trip instead of two before a byte moves. Their issue
104
+ // order is still decided by this line rather than by which lands first.
105
+ const [startedAt, { audioUrl }] = await Promise.all([
106
+ startClock(),
107
+ uploadToProvider(input.recording),
108
+ ]);
105
109
  const job = await createJob(audioUrl);
106
110
 
107
111
  for (let poll = 0; poll < MAX_POLLS; poll += 1) {
@@ -241,26 +245,33 @@ export async function readTranscript(
241
245
  }
242
246
 
243
247
  /**
244
- * The stored upload as a sequence of windows.
248
+ * The stored upload as a sequence of windows, with the next one already in flight.
245
249
  *
246
250
  * A generator rather than one `readUpload`, because the whole point is that the file
247
251
  * is never held: each window is read, sent, and dropped. `readUpload` clamps to what
248
252
  * is stored, so the loop ends on the real end of the file even if `size` moved.
253
+ *
254
+ * **One window of READ-AHEAD**, which is the whole concurrency available here: the
255
+ * consumer is a socket and the producer is the app's own store, and read-then-send
256
+ * makes them strictly alternate — the store idles while bytes go out, and the socket
257
+ * idles while the next window is fetched. Starting the next read before yielding the
258
+ * current window overlaps them, so a gigabyte upload pays the larger of the two
259
+ * rather than their sum. Exactly one, not a queue: a deeper buffer holds more of a
260
+ * file this generator exists to avoid holding, and there is nothing to gain past
261
+ * keeping both ends busy.
249
262
  */
250
263
  async function* windows(uploadId: string, size: number): AsyncGenerator<Uint8Array> {
251
- for (let at = 0; at < size; at += UPLOAD_WINDOW_BYTES) {
252
- const slice = await readUpload(uploadId, { start: at, end: at + UPLOAD_WINDOW_BYTES });
253
- if (slice.bytes.length === 0) return;
254
- yield slice.bytes;
255
- }
256
- }
257
-
258
- /** The API key, or a terminal failure — three more attempts find the same gap. */
259
- function apiKeyOrFatal(): string {
260
- try {
261
- return requireStepEnv(API_KEY_ENV);
262
- } catch (err: unknown) {
263
- return throwFatalStepError(err);
264
+ const read = (at: number): Promise<Uint8Array> =>
265
+ readUpload(uploadId, { start: at, end: at + UPLOAD_WINDOW_BYTES }).then((slice) => slice.bytes);
266
+ let at = 0;
267
+ let next = at < size ? read(at) : undefined;
268
+ while (next !== undefined) {
269
+ const bytes = await next;
270
+ if (bytes.length === 0) return;
271
+ at += UPLOAD_WINDOW_BYTES;
272
+ // Issued BEFORE the yield, so the store is fetching while the socket sends.
273
+ next = at < size ? read(at) : undefined;
274
+ yield bytes;
264
275
  }
265
276
  }
266
277
 
@@ -0,0 +1,133 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * Joining segment transcripts back into one — and the only module here the PAGE
4
+ * imports.
5
+ *
6
+ * That is the whole reason it exists as its own file. Segments are transcribed
7
+ * one per step and each one is emitted the moment it lands (`emit("transcript",
8
+ * …)` in `transcribe.ts`), so the page can render the answer growing rather than
9
+ * a spinner — and to render it, the page has to do exactly what `mergeTranscript`
10
+ * does at the end: put the pieces in order and drop the words the overlap made
11
+ * duplicates.
12
+ *
13
+ * Two copies of that would drift, and they would drift INVISIBLY: a live
14
+ * transcript that stitches differently from the stored one reads as the model
15
+ * having changed its mind. So the seam logic is here, imported by the run and by
16
+ * the browser, with nothing else in the module — no directive, no I/O, no SDK
17
+ * import — so pulling it into the client bundle costs a few hundred bytes.
18
+ *
19
+ * `wav.ts` and `sync-api.ts` sit under `workflows/` on the same terms: the WDK
20
+ * builder scans this directory and transforms only what carries a directive.
21
+ */
22
+
23
+ /**
24
+ * The stream a run publishes its segments into as they land.
25
+ *
26
+ * Declared HERE because it is the one string the run and the page have to agree
27
+ * on: a step emits into it and `client.tsx` subscribes by it, and a typo is a
28
+ * panel that renders nothing with nothing saying why. Both sync flows write it;
29
+ * the async flow has one segment and nothing to stream.
30
+ */
31
+ export const TRANSCRIPT_STREAM = "transcript";
32
+
33
+ /**
34
+ * One segment as it goes over {@link TRANSCRIPT_STREAM}.
35
+ *
36
+ * Its own type rather than the step's journaled result widened, because the two
37
+ * have different readers: `mergeTranscript` needs an index and words, while
38
+ * somebody watching a partial transcript needs to know WHICH part of the
39
+ * recording each piece is — the list has holes in it until the run finishes, and
40
+ * "0:00–1:30" beside a paragraph is what explains a jump.
41
+ */
42
+ export type TranscriptChunk = {
43
+ index: number;
44
+ /** Where this piece starts in the recording. */
45
+ startMs: number;
46
+ /** Where it ends. */
47
+ endMs: number;
48
+ text: string;
49
+ };
50
+
51
+ /** Most words {@link stitchTranscript} will look back over to find a repeated seam. */
52
+ const MAX_SEAM_WORDS = 40;
53
+
54
+ /** A word, stripped of the punctuation the decoder added, for seam comparison. */
55
+ function seamKey(word: string): string {
56
+ return word.toLowerCase().replace(/[^\p{L}\p{N}']/gu, "");
57
+ }
58
+
59
+ /**
60
+ * Join segment transcripts, dropping the words the overlap made duplicates.
61
+ *
62
+ * Segments overlap by `SEGMENT_OVERLAP_SECONDS` (see `wav.ts` for why), so the
63
+ * last few words of one segment are the first few of the next — verbatim when
64
+ * the decoder heard them the same way, which is the common case because it heard
65
+ * the same audio. This finds the longest such run and removes one copy.
66
+ *
67
+ * Comparison is on `seamKey`, not the raw words: the two passes punctuate
68
+ * differently at their own edges (one ends a sentence where the other is
69
+ * mid-clause), so `"today."` and `"today"` are the same word and a raw compare
70
+ * finds no seam at all. The text KEPT is the raw text — only the match is
71
+ * normalized.
72
+ *
73
+ * A missed seam repeats a few words, which a reader can see and forgive. A
74
+ * false one would delete speech, so the search is bounded at
75
+ * {@link MAX_SEAM_WORDS} and always prefers the LONGEST match: a single repeated
76
+ * "the" is not evidence of anything, and requiring the longest run is what stops
77
+ * it counting as one when a longer match is available.
78
+ *
79
+ * **A gap is not a seam, which is what makes this safe to run on a PARTIAL
80
+ * list.** The page stitches whatever segments have arrived, and while a run is in
81
+ * flight that list has holes in it — segment 4 may land before segment 3. Two
82
+ * pieces that were never adjacent share no overlap, so no seam is found and both
83
+ * are kept whole: the live text reads with a jump in it until the missing piece
84
+ * arrives, rather than quietly losing a sentence.
85
+ */
86
+ export function stitchTranscript(parts: readonly string[]): string {
87
+ const merged: string[] = [];
88
+ for (const part of parts) {
89
+ const next = part.split(/\s+/).filter(Boolean);
90
+ if (next.length === 0) continue;
91
+ if (merged.length === 0) {
92
+ merged.push(...next);
93
+ continue;
94
+ }
95
+ merged.push(...next.slice(seamLength(merged, next)));
96
+ }
97
+ return merged.join(" ");
98
+ }
99
+
100
+ /** How many leading words of `next` repeat the tail of `merged`. */
101
+ function seamLength(merged: readonly string[], next: readonly string[]): number {
102
+ const limit = Math.min(MAX_SEAM_WORDS, merged.length, next.length);
103
+ // Longest first, so a short accidental match never wins over a real seam.
104
+ for (let length = limit; length > 0; length--) {
105
+ const tail = merged.slice(merged.length - length);
106
+ if (tail.every((word, at) => seamKey(word) === seamKey(next[at] ?? ""))) return length;
107
+ }
108
+ return 0;
109
+ }
110
+
111
+ /**
112
+ * Order the chunks that have arrived and stitch them — what the PAGE renders.
113
+ *
114
+ * Sorted here rather than by the caller because arrival order is the one thing a
115
+ * live reader definitely does not have: segments are transcribed concurrently and
116
+ * each is emitted the moment it lands, so chunk 4 routinely precedes chunk 3.
117
+ *
118
+ * A COPY, because the caller's list is React state.
119
+ */
120
+ export function stitchChunks(chunks: readonly TranscriptChunk[]): string {
121
+ return stitchTranscript([...chunks].sort((a, b) => a.index - b.index).map((chunk) => chunk.text));
122
+ }
123
+
124
+ /** Words in a string. The run and the page count them the same way. */
125
+ export function countWords(text: string): number {
126
+ return text.split(/\s+/).filter(Boolean).length;
127
+ }
128
+
129
+ /** `m:ss` for the progress log — a byte offset means nothing to a reader. */
130
+ export function clock(ms: number): string {
131
+ const seconds = Math.max(0, Math.round(ms / 1000));
132
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
133
+ }
@@ -101,20 +101,29 @@
101
101
  * shape and is never slower, which is why the page offers both rather than replacing
102
102
  * one with the other.
103
103
  *
104
- * ## And a batch has to finish before the next poll
104
+ * ## A ROUND has to finish before the next poll
105
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.
106
+ * Everything the body decides comes from a journaled poll, so the set of segments it
107
+ * fans out over is fixed for the length of that fan-out: one that becomes readable
108
+ * while a round is in flight waits for the round. That is a smaller wait than it was
109
+ * `mapConcurrent` is a window over a cursor rather than sequential batches, so a
110
+ * round now ends when its LAST segment lands rather than at the sum of each batch's
111
+ * slowest — but it is not zero, and it is why the two flows converge on a fast
112
+ * uplink rather than the streaming one winning. On a slow uplink it costs nothing:
113
+ * segments arrive slower than they transcribe.
114
+ *
115
+ * Feeding new segments into a running fan-out would remove it and is deliberately
116
+ * not done: which items are in flight would then depend on when bytes arrived, and
117
+ * the DevKit correlates a journal entry to a step call by ISSUE ORDER. A round is
118
+ * what keeps that order a pure function of journaled values.
111
119
  */
112
120
 
113
121
  import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
114
- import { mapInBatches, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
122
+ import { mapConcurrent, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
115
123
  import { sleep } from "workflow";
116
124
  import {
117
125
  clock,
126
+ fatalOnUnsupported,
118
127
  mergeTranscript,
119
128
  type SegmentTranscript,
120
129
  segmentConcurrency,
@@ -209,10 +218,11 @@ export async function transcribeStreamFlow(input: { recording: string }) {
209
218
  lastSize = at.size;
210
219
  for (const segment of ready) done.add(segment.index);
211
220
  // 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.
221
+ // `ready` is derived from a journaled poll, and `mapConcurrent` issues its
222
+ // calls in list order. THE SAME STEP the classic flow uses, so a segment
223
+ // transcribed here reaches the page's live transcript identically.
214
224
  parts.push(
215
- ...(await mapInBatches(
225
+ ...(await mapConcurrent(
216
226
  ready,
217
227
  segmentConcurrency((plan as StreamPlan).format),
218
228
  (segment) => transcribeSegment(input.recording, (plan as StreamPlan).format, segment),
@@ -314,16 +324,6 @@ function expectedSegments(plan: StreamPlan, size: number): number {
314
324
  return plan.segments.filter((segment) => segment.start < size).length;
315
325
  }
316
326
 
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
327
  /**
328
328
  * Give up on an upload that stopped arriving.
329
329
  *
@@ -104,8 +104,14 @@ export async function transcribeWav(
104
104
  return (body.text ?? "").trim();
105
105
  }
106
106
 
107
- /** The API key, or a terminal failure — three more attempts find the same gap. */
108
- function apiKeyOrFatal(): string {
107
+ /**
108
+ * The API key, or a terminal failure — three more attempts find the same gap.
109
+ *
110
+ * Exported because `batch.ts` calls the same provider on the same key and had
111
+ * written this, and its own `API_KEY_ENV`, again. The key belongs to the
112
+ * PROVIDER; only the endpoint and its failure shapes belong to this module.
113
+ */
114
+ export function apiKeyOrFatal(): string {
109
115
  try {
110
116
  return requireStepEnv(API_KEY_ENV);
111
117
  } catch (err: unknown) {