@alexkroman1/aai-cli 6.10.1 → 7.0.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.
Files changed (162) hide show
  1. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  2. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
  3. package/dist/_dev-env.d.ts +22 -2
  4. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
  5. package/dist/_dev-server.d.ts +0 -17
  6. package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
  7. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  8. package/dist/_preflight.d.ts +1 -1
  9. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
  10. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  11. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
  12. package/dist/_workflow-scan.d.ts +108 -0
  13. package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
  14. package/dist/cli.mjs +30 -25
  15. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  16. package/dist/client-bundler.mjs +1 -1
  17. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  18. package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
  19. package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
  20. package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
  21. package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
  22. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  23. package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
  24. package/dist/project-config.mjs +1 -1
  25. package/dist/scaffold/CLAUDE.md +93 -14
  26. package/dist/scaffold/package.json +5 -3
  27. package/dist/scaffold/server.mjs +13 -4
  28. package/dist/scaffold/vite.config.ts +1 -1
  29. package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
  30. package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
  31. package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
  32. package/dist/templates/call-audit/agent.test.ts +818 -0
  33. package/dist/templates/call-audit/agent.ts +171 -0
  34. package/dist/templates/call-audit/client.tsx +198 -0
  35. package/dist/templates/call-audit/workflows/audit.ts +298 -0
  36. package/dist/templates/call-audit/workflows/ingest.ts +251 -0
  37. package/dist/templates/call-audit/workflows/media.ts +637 -0
  38. package/dist/templates/call-audit/workflows/summarize.ts +201 -0
  39. package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
  40. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  41. package/dist/templates/dispatch-center/client.tsx +239 -129
  42. package/dist/templates/dispatch-center/shared.ts +99 -1
  43. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  44. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  45. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  46. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  47. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  48. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  49. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  50. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  51. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  52. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  53. package/dist/templates/health-assistant/agent.test.ts +22 -4
  54. package/dist/templates/infocom-adventure/agent.test.ts +4 -6
  55. package/dist/templates/infocom-adventure/client.tsx +246 -164
  56. package/dist/templates/link-digest/agent.test.ts +24 -19
  57. package/dist/templates/link-digest/client.tsx +47 -61
  58. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  59. package/dist/templates/night-owl/agent.test.ts +70 -19
  60. package/dist/templates/night-owl/agent.ts +5 -0
  61. package/dist/templates/night-owl/client.tsx +56 -56
  62. package/dist/templates/night-owl/shared.ts +24 -0
  63. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  64. package/dist/templates/pizza-ordering/agent.test.ts +27 -27
  65. package/dist/templates/pizza-ordering/client.tsx +9 -26
  66. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  67. package/dist/templates/plan-and-execute/agent.ts +1 -1
  68. package/dist/templates/plan-and-execute/client.tsx +12 -15
  69. package/dist/templates/plan-and-execute/shared.ts +71 -2
  70. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  71. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  72. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  73. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  74. package/dist/templates/podcast-digest/agent.test.ts +746 -0
  75. package/dist/templates/podcast-digest/agent.ts +139 -0
  76. package/dist/templates/podcast-digest/client.tsx +154 -0
  77. package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
  78. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  79. package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
  80. package/dist/templates/recap-workflow/agent.test.ts +107 -96
  81. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  82. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  83. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  84. package/dist/templates/recap-workflow/workflows/recap.ts +48 -41
  85. package/dist/templates/redline/agent.test.ts +17 -15
  86. package/dist/templates/redline/client.tsx +12 -12
  87. package/dist/templates/redline/workflows/redline.ts +19 -31
  88. package/dist/templates/research-workflow/agent.test.ts +60 -59
  89. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  90. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  91. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  92. package/dist/templates/retail/agent.test.ts +26 -23
  93. package/dist/templates/retail/client.tsx +226 -117
  94. package/dist/templates/retail/registry.test.ts +38 -6
  95. package/dist/templates/retail/store.test.ts +82 -15
  96. package/dist/templates/retail/store.ts +174 -47
  97. package/dist/templates/retail/system-prompt.md +11 -2
  98. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
  99. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
  100. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  101. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  102. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  103. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  104. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  105. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  106. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  107. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
  108. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
  109. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
  110. package/dist/templates/retail/tools/modify_user_address.ts +2 -2
  111. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
  112. package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
  113. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  114. package/dist/templates/solo-rpg/client.tsx +38 -37
  115. package/dist/templates/solo-rpg/shared.ts +145 -19
  116. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  117. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  118. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  119. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  120. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  121. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  122. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  123. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  124. package/dist/templates/spoken-summary/agent.test.ts +314 -0
  125. package/dist/templates/spoken-summary/agent.ts +155 -0
  126. package/dist/templates/spoken-summary/client.tsx +185 -0
  127. package/dist/templates/spoken-summary/workflows/summarize.ts +237 -0
  128. package/dist/templates/spoken-summary/workflows/transcribe.ts +138 -0
  129. package/dist/templates/support-line/agent.test.ts +11 -16
  130. package/dist/templates/support-line/agent.ts +1 -1
  131. package/dist/templates/support-line/client.tsx +9 -9
  132. package/dist/templates/support-line/nodes.ts +100 -0
  133. package/dist/templates/support-line/procedure.ts +407 -0
  134. package/dist/templates/support-line/tools/answer_question.ts +17 -4
  135. package/dist/templates/transcription-workflow/agent.test.ts +246 -123
  136. package/dist/templates/transcription-workflow/agent.ts +21 -7
  137. package/dist/templates/transcription-workflow/client.tsx +17 -42
  138. package/dist/templates/transcription-workflow/workflows/batch.ts +79 -180
  139. package/dist/templates/transcription-workflow/workflows/normalize.ts +247 -0
  140. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  141. package/dist/templates/transcription-workflow/workflows/stream.ts +10 -9
  142. package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
  143. package/dist/templates/transcription-workflow/workflows/transcribe.ts +55 -40
  144. package/dist/templates/transcription-workflow/workflows/wav.ts +31 -31
  145. package/dist/templates/travel-concierge/agent.test.ts +64 -33
  146. package/dist/templates/travel-concierge/client.tsx +11 -23
  147. package/dist/templates/travel-concierge/routing.ts +34 -15
  148. package/dist/templates/travel-concierge/shared.ts +70 -3
  149. package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
  150. package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
  151. package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
  152. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
  153. package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
  154. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
  155. package/dist/worker-bundler.mjs +1 -1
  156. package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
  157. package/dist/workflow-bundler.d.ts +6 -1
  158. package/dist/workflow.d.ts +1 -1
  159. package/package.json +5 -4
  160. package/dist/templates/support-line/graph.ts +0 -224
  161. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  162. /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
@@ -0,0 +1,247 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The step that makes the rest of the desk possible on a real file: whatever was
4
+ * uploaded, converted to the one format the arithmetic works on.
5
+ *
6
+ * `wav.ts` explains why this desk cuts linear-PCM WAV and nothing else — a byte
7
+ * offset is only a timestamp when every sample is the same size — and for a long
8
+ * time the remedy for anything else was a SENTENCE telling the caller to run
9
+ * `ffmpeg -i in.m4a -c:a pcm_s16le out.wav` on their own machine first. Every
10
+ * recording anyone actually has is an `.m4a` off a phone or an `.mp3` out of a
11
+ * conferencing tool, so that sentence was the desk's real front door, and it
12
+ * opened onto the user's shell. The platform installs ffmpeg in every guest
13
+ * image; this file is the desk using it.
14
+ *
15
+ * ```text
16
+ * normalizeRecording one step → an upload id the rest of the flow can cut
17
+ * ```
18
+ *
19
+ * ## `parseWav` is asked as a QUESTION
20
+ *
21
+ * The obvious implementation probes the file with `ffprobe` and passes it
22
+ * through when the codec looks like PCM. It is wrong in a way that only shows up
23
+ * on a Windows recorder's output: a `WAVE_FORMAT_EXTENSIBLE` file reports
24
+ * `pcm_s16le` to ffprobe and is refused by {@link parseWav}, whose encoding
25
+ * check reads the format tag ffprobe does not surface. The desk would convert
26
+ * nothing and then fail to cut it.
27
+ *
28
+ * So the test is {@link parseWav} ITSELF, run against the same
29
+ * {@link HEADER_PROBE_BYTES} window `splitRecording` will use. A throw is the
30
+ * signal to convert. That makes the pass-through decision and the cut decision
31
+ * the same decision by construction — there is no second opinion to disagree —
32
+ * and it means the desk fixes anything the parser rejects for any reason,
33
+ * including a 192 kHz 32-bit stereo WAV that trips
34
+ * {@link MAX_BYTES_PER_SECOND}, which downsampling genuinely repairs.
35
+ *
36
+ * The fast path costs one 64 KB read and no subprocess at all: a WAV that was
37
+ * already cuttable is returned by the id it came in under, so nothing is copied
38
+ * and nothing is re-encoded.
39
+ *
40
+ * ## File → file, not bytes → bytes
41
+ *
42
+ * `transcodeToWav(bytes)` is one line and is the wrong call here, and
43
+ * `@alexkroman1/aai/step-files` is the three functions that replace it —
44
+ * `readUploadToFile`, `withTempDir` and `writeUploadFromFile`. Its module doc
45
+ * carries the whole argument (a pipe cannot seek, so an `.m4a` with a trailing
46
+ * `moov` index fails; piped output is capped at 64 MiB, half an hour of this
47
+ * desk's audio) plus the rule that a temp file may not outlive its step. This
48
+ * step is the case those were written for.
49
+ *
50
+ * ## Everything node-shaped is named from inside the step BODY
51
+ *
52
+ * `@alexkroman1/aai/ffmpeg` and `@alexkroman1/aai/step-files` both reach a
53
+ * `node:` builtin, and a name this module holds at MODULE scope keeps its import
54
+ * in the workflow bundle — which is compiled as a `node:vm` Script with no
55
+ * `require`. Every name they bind is referenced only inside
56
+ * {@link normalizeRecording}'s body, which the workflow transform removes along
57
+ * with the imports it is the only user of. A module-scope FUNCTION naming one is
58
+ * what breaks a run at replay; this template used to carry a whole
59
+ * `ffmpeg-verdict.ts` because of it, and `throwFfmpegStepError` — which reaches
60
+ * no `node:` builtin at all — is what dissolved the boundary.
61
+ */
62
+
63
+ import { basename, extname, join } from "node:path";
64
+ import { probeMedia, runFfmpeg, wavEncodeArgs } from "@alexkroman1/aai/ffmpeg";
65
+ import { readUpload, report, uploadInfo } from "@alexkroman1/aai/step";
66
+ import { throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
67
+ import { readUploadToFile, withTempDir, writeUploadFromFile } from "@alexkroman1/aai/step-files";
68
+ import { formatBytes, formatDuration } from "@alexkroman1/aai/utils";
69
+ import { HEADER_PROBE_BYTES, parseWav, UnsupportedRecordingError } from "./wav.ts";
70
+
71
+ /**
72
+ * The rate everything is converted TO.
73
+ *
74
+ * 16 kHz because that is what speech models are trained at — a higher rate
75
+ * carries no information the decoder uses and costs proportional bytes in a
76
+ * fan-out whose width is bounded by bytes in flight (`BYTES_IN_FLIGHT` in
77
+ * `transcribe.ts`). A converted two-hour recording is 230 MB of 16 kHz mono
78
+ * against 1.4 GB of 48 kHz stereo, which is the difference between a fan-out
79
+ * that saturates on width and one that saturates on the queue.
80
+ */
81
+ export const NORMALIZED_SAMPLE_RATE = 16_000;
82
+
83
+ /**
84
+ * Channels everything is converted TO.
85
+ *
86
+ * Mono, and it is a real loss rather than a free win: a stereo call recording
87
+ * with one party per channel is exactly the file where the channels are the most
88
+ * interesting thing about it, and downmixing throws that away. This desk
89
+ * transcribes rather than diarizes, so it takes the 2x saving; a desk that wants
90
+ * the speakers apart splits the channels first and transcribes each one.
91
+ */
92
+ export const NORMALIZED_CHANNELS = 1;
93
+
94
+ /**
95
+ * How long a conversion may run before it is killed.
96
+ *
97
+ * Well past what the work takes — ffmpeg decodes and resamples faster than
98
+ * realtime by two orders of magnitude, so a two-hour recording is under a
99
+ * minute — and the reason for a bound at all is a file that makes a decoder
100
+ * pathological rather than one that is merely long. A `timeout` is retryable
101
+ * and an `exit` is not; `throwFfmpegStepError` decides.
102
+ */
103
+ const CONVERT_TIMEOUT_MS = 15 * 60_000;
104
+
105
+ /** What the flow is handed: the id to cut, and whether it had to be made. */
106
+ export type NormalizedRecording = {
107
+ /**
108
+ * The upload id every later step reads.
109
+ *
110
+ * The SAME id that came in when the file was already cuttable, and a new one
111
+ * when it was converted — which is why the flow threads this rather than its
112
+ * own input from here on.
113
+ */
114
+ recording: string;
115
+ /** Whether ffmpeg ran. Reported, so a reader can tell a fast path from a slow one. */
116
+ converted: boolean;
117
+ };
118
+
119
+ /**
120
+ * Make sure the recording is something the desk can cut, converting if not.
121
+ *
122
+ * A step, for the ordinary two reasons — it does I/O, and its RESULT is what
123
+ * every later step addresses — plus one specific to what it produces: the
124
+ * conversion writes a file, and journaling the id means a resumed run reads the
125
+ * file that already exists instead of paying for a second one.
126
+ */
127
+ export async function normalizeRecording(uploadId: string): Promise<NormalizedRecording> {
128
+ "use step";
129
+
130
+ const stored = await uploadInfo(uploadId);
131
+ const head = await readUpload(uploadId, { end: HEADER_PROBE_BYTES });
132
+
133
+ if (cuttable(head.bytes, stored.size)) {
134
+ // No subprocess, no copy, no second upload. The overwhelmingly common case
135
+ // for a desk whose form says WAV, and the reason the check is a 64 KB read.
136
+ await report(`${stored.name || uploadId} is already linear-PCM WAV — cutting it as it is.`);
137
+ return { recording: uploadId, converted: false };
138
+ }
139
+
140
+ // Named before any work starts, because everything below is minutes of it on a
141
+ // long recording and a run that says nothing until the conversion finishes looks
142
+ // stuck. It is also the line that distinguishes "this file needs converting" from
143
+ // the fast path above.
144
+ await report(
145
+ `Converting ${stored.name || uploadId} (${formatBytes(stored.size)}) — not a WAV we can cut.`,
146
+ );
147
+
148
+ // The temp directory's lifetime is this lexical scope, and the `finally` inside
149
+ // `withTempDir` is what makes that true on the failure paths too: a guest's disk
150
+ // is small and a step that leaves a copy of every recording it touched fills it.
151
+ return await withTempDir(
152
+ async (dir) => {
153
+ const source = join(dir, "source");
154
+ const converted = join(dir, "converted.wav");
155
+
156
+ await readUploadToFile(uploadId, source, { size: stored.size });
157
+
158
+ // What it WAS, for the progress line. Worth one ffprobe: "converted 41
159
+ // minutes of aac" is a line that explains the run's shape, where
160
+ // "converted the recording" leaves a reader wondering what the desk decided.
161
+ // On a temp file rather than a pipe, so a trailing index is readable.
162
+ const info = await probeMedia(source, { timeoutMs: CONVERT_TIMEOUT_MS }).catch(
163
+ throwFfmpegStepError,
164
+ );
165
+ await report(
166
+ `It is ${describeSource(info.audio?.codec, info.durationSec)} — re-encoding to ` +
167
+ `${NORMALIZED_SAMPLE_RATE / 1000} kHz mono WAV.`,
168
+ );
169
+
170
+ await runFfmpeg(
171
+ [
172
+ // The argv is the caller's, verbatim — `runFfmpeg` adds nothing. So the
173
+ // standing flags are here: quiet, non-interactive, overwrite. `-nostdin`
174
+ // matters most in a guest, where there is no terminal and an ffmpeg that
175
+ // decides to read stdin is a process that never exits.
176
+ "-hide_banner",
177
+ "-loglevel",
178
+ "error",
179
+ "-nostdin",
180
+ "-y",
181
+ "-i",
182
+ source,
183
+ ...wavEncodeArgs({
184
+ sampleRate: NORMALIZED_SAMPLE_RATE,
185
+ channels: NORMALIZED_CHANNELS,
186
+ }),
187
+ converted,
188
+ ],
189
+ { timeoutMs: CONVERT_TIMEOUT_MS },
190
+ ).catch(throwFfmpegStepError);
191
+
192
+ const written = await writeUploadFromFile(converted, {
193
+ // Named after the ORIGINAL, so a download reads as the recording it came
194
+ // from. The extension has to change with the bytes: a file served as
195
+ // `audio/wav` under a `.m4a` name is one no player will open.
196
+ name: `${basename(stored.name || uploadId, extname(stored.name || uploadId))}.wav`,
197
+ type: "audio/wav",
198
+ });
199
+
200
+ await report(
201
+ `Converted to ${formatBytes(written.size)} of WAV (from ${formatBytes(stored.size)}).`,
202
+ );
203
+ return { recording: written.id, converted: true };
204
+ },
205
+ // Named after the pipeline: the directory is gone by the time anyone looks,
206
+ // so the prefix's real audience is a person reading `ls /tmp` during a run
207
+ // that hung, and the spec that asserts nothing was left behind.
208
+ { prefix: "aai-normalize-" },
209
+ );
210
+ }
211
+
212
+ /**
213
+ * Retries beyond the default 3.
214
+ *
215
+ * Not because a conversion is flaky — a corrupt file fails identically forever,
216
+ * and `throwFfmpegStepError` is what stops the DevKit retrying that. It is the
217
+ * two I/O halves that are worth another attempt: this step reads a whole
218
+ * recording out of the store and writes a whole one back, and either can lose a
219
+ * connection on a file this size.
220
+ */
221
+ normalizeRecording.maxRetries = 5;
222
+
223
+ /**
224
+ * Whether `splitRecording` will be able to read this header.
225
+ *
226
+ * The question, not a guess at it — see the module doc. Only
227
+ * {@link UnsupportedRecordingError} is answered `false`: anything else thrown by
228
+ * the parser is a bug in the parser, and swallowing it here would turn that into
229
+ * a mysterious re-encode of a file that was fine.
230
+ */
231
+ export function cuttable(head: Uint8Array, totalBytes: number): boolean {
232
+ try {
233
+ parseWav(head, totalBytes);
234
+ return true;
235
+ } catch (err: unknown) {
236
+ if (err instanceof UnsupportedRecordingError) return false;
237
+ throw err;
238
+ }
239
+ }
240
+
241
+ /** `41:20 of aac`, or as much of that as ffprobe would say. */
242
+ function describeSource(codec: string | undefined, durationSec: number | undefined): string {
243
+ const length =
244
+ durationSec === undefined ? undefined : formatDuration(Math.round(durationSec * 1000));
245
+ if (length !== undefined && codec !== undefined) return `${length} of ${codec}`;
246
+ return length ?? codec ?? "the recording";
247
+ }
@@ -120,14 +120,3 @@ function seamLength(merged: readonly string[], next: readonly string[]): number
120
120
  export function stitchChunks(chunks: readonly TranscriptChunk[]): string {
121
121
  return stitchTranscript([...chunks].sort((a, b) => a.index - b.index).map((chunk) => chunk.text));
122
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
- }
@@ -138,11 +138,11 @@
138
138
  * what keeps that order a pure function of journaled values.
139
139
  */
140
140
 
141
+ import { mapConcurrent, readUpload, report, uploadInfo } from "@alexkroman1/aai/step";
141
142
  import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
142
- import { mapConcurrent, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
143
+ import { formatDuration, plural } from "@alexkroman1/aai/utils";
143
144
  import { sleep } from "workflow";
144
145
  import {
145
- clock,
146
146
  fatalOnUnsupported,
147
147
  mergeTranscript,
148
148
  type SegmentTranscript,
@@ -151,6 +151,7 @@ import {
151
151
  transcribeSegment,
152
152
  } from "./transcribe.ts";
153
153
  import {
154
+ HEADER_PROBE_BYTES,
154
155
  offsetToMs,
155
156
  parseWav,
156
157
  planSegments,
@@ -176,9 +177,6 @@ const POLL_INTERVAL = "5s";
176
177
  */
177
178
  const MAX_IDLE_POLLS = 60;
178
179
 
179
- /** Bytes probed for the WAV header — the same window `splitRecording` uses. */
180
- const HEADER_PROBE_BYTES = 64 * 1024;
181
-
182
180
  /** What one poll of the upload found. */
183
181
  export type UploadProgressView = {
184
182
  /** Bytes stored so far. */
@@ -326,9 +324,8 @@ export async function planStreamed(id: string): Promise<StreamPlan> {
326
324
  }
327
325
  const segments = fatalOnUnsupported(() => planSegments(format));
328
326
  await report(
329
- `Planned ${clock(segments.at(-1)?.endMs ?? 0)} of audio as ${segments.length} segment${
330
- segments.length === 1 ? "" : "s"
331
- } while it uploads.`,
327
+ `Planned ${formatDuration(segments.at(-1)?.endMs ?? 0)} of audio as ` +
328
+ `${segments.length} ${plural(segments.length, "segment")} while it uploads.`,
332
329
  );
333
330
  return { format, segments };
334
331
  }
@@ -339,8 +336,12 @@ export async function planStreamed(id: string): Promise<StreamPlan> {
339
336
  * Not `plan.segments.length`: the plan came from the header's declared length, and a
340
337
  * recording that came up short has segments that start past the end of the file.
341
338
  * Counting those would leave the run waiting for audio nobody is going to send.
339
+ *
340
+ * Exported for its spec. It is the one piece of this flow's exit condition that is
341
+ * a pure function of journaled values, so it is the one a test can pin — and the
342
+ * failure it guards is a run that never ends rather than one that fails.
342
343
  */
343
- function expectedSegments(plan: StreamPlan, size: number): number {
344
+ export function expectedSegments(plan: StreamPlan, size: number): number {
344
345
  return plan.segments.filter((segment) => segment.start < size).length;
345
346
  }
346
347
 
@@ -1,35 +1,24 @@
1
1
  // Copyright 2026 the AAI authors. MIT license.
2
2
  /**
3
- * One request to AssemblyAI's synchronous transcription endpoint.
3
+ * What both SYNC flows share, now that the endpoint itself is the SDK's.
4
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 froma 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.
5
+ * This module used to BE the endpoint: the URL, the model header, the raw-key auth,
6
+ * the deadline, the multipart shape and the three-way failure classification. All of
7
+ * that is `stepTranscribeSync` on `@alexkroman1/aai/step`the same request, with
8
+ * `transcription-workflow`'s own hard-won details (the unprefixed key, the
9
+ * `X-AAI-Model` header, `stepFetch` rather than `fetch` so a fan-out's rate limit
10
+ * arrives as a status rather than as a stream reset) carried into it.
12
11
  *
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.
12
+ * What is left is what belongs to the CALLER rather than to the endpoint, and it is
13
+ * all measurement: both flows time each request, because per-part latency is the one
14
+ * number that says which bound is actually binding.
15
+ *
16
+ * No directive, which is what lets it live under `workflows/` beside the bodies: the
17
+ * WDK builder scans this directory and transforms only what carries one (`wav.ts` is
18
+ * the same shape). It is called FROM steps, so it inherits their environment.
17
19
  */
18
20
 
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;
21
+ import { stepTranscribeSyncClassified } from "@alexkroman1/aai/step-errors";
33
22
 
34
23
  /**
35
24
  * Time one transcription, so the progress log carries LATENCY.
@@ -65,6 +54,15 @@ export function elapsed(ms: number): string {
65
54
  * callers arrive at that differently: one re-attaches a header to a window it
66
55
  * read, the other is handed parts that already carry one.
67
56
  *
57
+ * `stepTranscribeSyncClassified` — the SDK's own `stepTranscribeSync` plus
58
+ * `throwStepError`, and nothing else — is the whole of what this adds to the SDK
59
+ * call, and it is where the three-way call is made: a `FatalError` stops the DevKit retrying
60
+ * something that will answer the same way, a bare `RetryableError` retries in ONE
61
+ * SECOND (that class's own default), and a `RetryableError` carrying `retryAfter`
62
+ * waits exactly as long as the far side asked. The last matters here because a whole
63
+ * batch hits the rate limit together — a second later all of them ask again, where
64
+ * on the server's number they drain.
65
+ *
68
66
  * @param label - How this piece is named in a failure. The CALLER's vocabulary
69
67
  * (a segment's timestamp, a part's index), because it is what a reader of the
70
68
  * log has in front of them.
@@ -74,72 +72,6 @@ export async function transcribeWav(
74
72
  filename: string,
75
73
  label: string,
76
74
  ): 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
- /**
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 {
115
- try {
116
- return requireStepEnv(API_KEY_ENV);
117
- } catch (err: unknown) {
118
- // `throwFatalStepError` rather than `throw new FatalError(…)`: that class
119
- // takes only a message — no `cause` — so constructing one inside a `catch`
120
- // loses the original where the linter (rightly) expects it preserved. Here
121
- // the original is the ARGUMENT, and nothing is swallowed.
122
- return throwFatalStepError(err);
123
- }
124
- }
125
-
126
- /**
127
- * The sync endpoint's failure, with whatever it said about it.
128
- *
129
- * `toStepError` makes the three-way call: a `FatalError` stops the DevKit
130
- * retrying something that will answer the same way, a bare `RetryableError`
131
- * retries in ONE SECOND (that class's own default), and a `RetryableError`
132
- * carrying `retryAfter` waits exactly as long as the far side asked. The last
133
- * matters here because a whole batch hits the rate limit together — a second
134
- * later all of them ask again, where on the server's number they drain.
135
- */
136
- async function syncFailure(response: Response, label: string): Promise<Error> {
137
- // Two shapes, documented: `{ error_code, message }` for a request problem and
138
- // `{ detail }` for auth and rate limits.
139
- const body = (await response.json().catch(() => ({}))) as { message?: string; detail?: string };
140
- const detail = body.message ?? body.detail;
141
- return toStepError(
142
- response,
143
- `${label} failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`,
144
- );
75
+ const { text } = await stepTranscribeSyncClassified(bytes, { filename, label });
76
+ return text;
145
77
  }