@alexkroman1/aai-cli 6.11.0 → 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 +40 -19
  26. package/dist/scaffold/package.json +5 -3
  27. package/dist/scaffold/server.mjs +1 -1
  28. package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
  29. package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
  30. package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
  31. package/dist/templates/call-audit/agent.test.ts +57 -204
  32. package/dist/templates/call-audit/agent.ts +32 -19
  33. package/dist/templates/call-audit/client.tsx +17 -54
  34. package/dist/templates/call-audit/workflows/audit.ts +11 -18
  35. package/dist/templates/call-audit/workflows/ingest.ts +106 -114
  36. package/dist/templates/call-audit/workflows/media.ts +2 -12
  37. package/dist/templates/call-audit/workflows/summarize.ts +47 -52
  38. package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
  39. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  40. package/dist/templates/dispatch-center/client.tsx +239 -129
  41. package/dist/templates/dispatch-center/shared.ts +99 -1
  42. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  43. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  44. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  45. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  46. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  47. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  48. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  49. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  50. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  51. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  52. package/dist/templates/health-assistant/agent.test.ts +22 -4
  53. package/dist/templates/infocom-adventure/agent.test.ts +4 -6
  54. package/dist/templates/infocom-adventure/client.tsx +246 -164
  55. package/dist/templates/link-digest/agent.test.ts +24 -19
  56. package/dist/templates/link-digest/client.tsx +47 -61
  57. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  58. package/dist/templates/night-owl/agent.test.ts +70 -19
  59. package/dist/templates/night-owl/agent.ts +5 -0
  60. package/dist/templates/night-owl/client.tsx +56 -56
  61. package/dist/templates/night-owl/shared.ts +24 -0
  62. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  63. package/dist/templates/pizza-ordering/agent.test.ts +27 -27
  64. package/dist/templates/pizza-ordering/client.tsx +9 -26
  65. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  66. package/dist/templates/plan-and-execute/agent.ts +1 -1
  67. package/dist/templates/plan-and-execute/client.tsx +12 -15
  68. package/dist/templates/plan-and-execute/shared.ts +71 -2
  69. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  70. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  71. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  72. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  73. package/dist/templates/podcast-digest/agent.test.ts +746 -0
  74. package/dist/templates/podcast-digest/agent.ts +139 -0
  75. package/dist/templates/podcast-digest/client.tsx +154 -0
  76. package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
  77. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  78. package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
  79. package/dist/templates/recap-workflow/agent.test.ts +97 -94
  80. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  81. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  82. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  83. package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
  84. package/dist/templates/redline/agent.test.ts +17 -15
  85. package/dist/templates/redline/client.tsx +12 -12
  86. package/dist/templates/redline/workflows/redline.ts +19 -31
  87. package/dist/templates/research-workflow/agent.test.ts +60 -59
  88. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  89. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  90. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  91. package/dist/templates/retail/agent.test.ts +26 -23
  92. package/dist/templates/retail/client.tsx +226 -117
  93. package/dist/templates/retail/registry.test.ts +38 -6
  94. package/dist/templates/retail/store.test.ts +82 -15
  95. package/dist/templates/retail/store.ts +174 -47
  96. package/dist/templates/retail/system-prompt.md +11 -2
  97. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
  98. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
  99. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  100. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  101. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  102. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  103. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  104. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  105. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  106. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
  107. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
  108. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
  109. package/dist/templates/retail/tools/modify_user_address.ts +2 -2
  110. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
  111. package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
  112. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  113. package/dist/templates/solo-rpg/client.tsx +38 -37
  114. package/dist/templates/solo-rpg/shared.ts +145 -19
  115. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  116. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  117. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  118. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  119. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  120. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  121. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  122. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  123. package/dist/templates/spoken-summary/agent.test.ts +68 -97
  124. package/dist/templates/spoken-summary/agent.ts +30 -17
  125. package/dist/templates/spoken-summary/client.tsx +10 -50
  126. package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
  127. package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
  128. package/dist/templates/support-line/agent.test.ts +11 -16
  129. package/dist/templates/support-line/agent.ts +1 -1
  130. package/dist/templates/support-line/client.tsx +9 -9
  131. package/dist/templates/support-line/nodes.ts +100 -0
  132. package/dist/templates/support-line/procedure.ts +407 -0
  133. package/dist/templates/support-line/tools/answer_question.ts +17 -4
  134. package/dist/templates/transcription-workflow/agent.test.ts +94 -194
  135. package/dist/templates/transcription-workflow/agent.ts +1 -1
  136. package/dist/templates/transcription-workflow/client.tsx +17 -42
  137. package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
  138. package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
  139. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  140. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
  141. package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
  142. package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
  143. package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
  144. package/dist/templates/travel-concierge/agent.test.ts +64 -33
  145. package/dist/templates/travel-concierge/client.tsx +11 -23
  146. package/dist/templates/travel-concierge/routing.ts +34 -15
  147. package/dist/templates/travel-concierge/shared.ts +70 -3
  148. package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
  149. package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
  150. package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
  151. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
  152. package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
  153. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
  154. package/dist/worker-bundler.mjs +1 -1
  155. package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
  156. package/dist/workflow-bundler.d.ts +6 -1
  157. package/dist/workflow.d.ts +1 -1
  158. package/package.json +5 -4
  159. package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
  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
@@ -26,7 +26,7 @@
26
26
  * ## The endpoint is the SDK's; the STEPS are ours
27
27
  *
28
28
  * `stepTranscribeUpload` / `stepTranscribeSubmit` / `stepTranscribePoll` on
29
- * `@alexkroman1/aai/utils` own the URL, the raw-key auth, the windowed streaming
29
+ * `@alexkroman1/aai/step` own the URL, the raw-key auth, the windowed streaming
30
30
  * upload, the PLURAL `speech_models` field and the failure classification. This file
31
31
  * used to spell all of that out, and so did `spoken-summary` — the same ~200 lines
32
32
  * twice, reworded, identical in behaviour, and drifting apart at the edges.
@@ -54,17 +54,15 @@
54
54
  * request rather than two and the value journaled by the last poll IS the result.
55
55
  */
56
56
 
57
- import { throwStepError } from "@alexkroman1/aai/step-errors";
57
+ import { report, TRANSCRIBE_API, uploadInfo } from "@alexkroman1/aai/step";
58
58
  import {
59
- report,
60
- stepTranscribePoll,
61
- stepTranscribeSubmit,
62
- stepTranscribeUpload,
63
- TRANSCRIBE_API,
64
- uploadInfo,
65
- } from "@alexkroman1/aai/utils";
59
+ stepTranscribePollClassified,
60
+ stepTranscribeSubmitClassified,
61
+ stepTranscribeUploadClassified,
62
+ } from "@alexkroman1/aai/step-errors";
63
+ import { countWords, formatBytes } from "@alexkroman1/aai/utils";
66
64
  import { sleep } from "workflow";
67
- import { countWords, startClock, type Transcript } from "./transcribe.ts";
65
+ import { startClock, type Transcript } from "./transcribe.ts";
68
66
 
69
67
  /** How long between polls of a submitted job. */
70
68
  const POLL_INTERVAL = "10s";
@@ -118,16 +116,20 @@ export async function transcribeBatchFlow(input: { recording: string }): Promise
118
116
  * far smaller: if it expires before the next step runs, the run fails and a fresh one
119
117
  * re-uploads — which is what would have happened anyway, once, instead of five times.
120
118
  *
121
- * `.catch(throwStepError)` is what turns the SDK's `TranscribeError` into the
122
- * DevKit's verdict: a missing key and a 400 stop, a 429 waits as long as the service
123
- * asked. Every step here ends the same way for the same reason.
119
+ * The `Classified` callers on `@alexkroman1/aai/step-errors` are the SDK's own
120
+ * `stepTranscribe*` plus `throwStepError` and nothing else, which is what turns the
121
+ * SDK's `TranscribeError` into the DevKit's verdict: a missing key and a 400 stop, a
122
+ * 429 waits as long as the service asked. Every step here ends the same way for the
123
+ * same reason.
124
124
  */
125
125
  export async function uploadToProvider(uploadId: string): Promise<{ audioUrl: string }> {
126
126
  "use step";
127
127
 
128
128
  const stored = await uploadInfo(uploadId);
129
- await report(`Uploading ${stored.name || uploadId} (${mb(stored.size)}) to the async API.`);
130
- return await stepTranscribeUpload(uploadId).catch(throwStepError);
129
+ await report(
130
+ `Uploading ${stored.name || uploadId} (${formatBytes(stored.size)}) to the async API.`,
131
+ );
132
+ return await stepTranscribeUploadClassified(uploadId);
131
133
  }
132
134
 
133
135
  /** Retries beyond the default 3: an upload is the one call here worth another attempt. */
@@ -137,7 +139,7 @@ uploadToProvider.maxRetries = 5;
137
139
  export async function createJob(audioUrl: string): Promise<{ id: string }> {
138
140
  "use step";
139
141
 
140
- const job = await stepTranscribeSubmit(audioUrl).catch(throwStepError);
142
+ const job = await stepTranscribeSubmitClassified(audioUrl);
141
143
  await report(`Submitted — job ${job.id}.`);
142
144
  return job;
143
145
  }
@@ -157,7 +159,7 @@ export async function pollTranscript(
157
159
  ): Promise<{ done: false } | { done: true; transcript: Transcript }> {
158
160
  "use step";
159
161
 
160
- const progress = await stepTranscribePoll(id).catch(throwStepError);
162
+ const progress = await stepTranscribePollClassified(id);
161
163
  if (!progress.done) {
162
164
  await report(`Transcript ${id} is ${progress.status}.`);
163
165
  return { done: false };
@@ -185,8 +187,3 @@ export async function pollTranscript(
185
187
  },
186
188
  };
187
189
  }
188
-
189
- /** A size a person can read, because the number that matters is the scale. */
190
- function mb(bytes: number): string {
191
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
192
- }
@@ -39,39 +39,33 @@
39
39
  *
40
40
  * ## File → file, not bytes → bytes
41
41
  *
42
- * `transcodeToWav(bytes)` is one line and is the wrong call here, twice over:
43
- *
44
- * - **The output would be buffered.** Piped stdout is capped
45
- * (`DEFAULT_MAX_FFMPEG_OUTPUT_BYTES`, 64 MiB), which is about an hour of
46
- * 16 kHz mono and this desk exists for the two-hour recording.
47
- * - **The input could not be READ.** A pipe cannot seek, and an `.m4a` written
48
- * by a phone usually carries its `moov` index at the END of the file, so
49
- * ffmpeg fails on the flagship input with `moov atom not found`. That is the
50
- * one caveat `@alexkroman1/aai/ffmpeg`'s own doc names, and this is the case
51
- * it names it for.
52
- *
53
- * So the recording is materialized to a temp file in windows, converted file to
54
- * file, and streamed back into the upload store. Nothing here holds a whole
55
- * recording in memory at any point, which is the property that makes the step
56
- * work on the input it was written for.
57
- *
58
- * ## A temp file cannot cross a step boundary
59
- *
60
- * Everything above happens in ONE step, and that is structural rather than
61
- * tidy. A step is journaled by its RETURN VALUE and may be dispatched into a
62
- * different process than its neighbours, so a path in a return value is a path
63
- * that is replayed after the file behind it is gone. What crosses the boundary
64
- * is an upload ID; the temp directory is created and removed inside the step
65
- * that uses it.
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.
66
61
  */
67
62
 
68
- import { mkdtemp, open, rm } from "node:fs/promises";
69
- import { tmpdir } from "node:os";
70
63
  import { basename, extname, join } from "node:path";
71
- import { isFfmpegError, probeMedia, runFfmpeg, wavEncodeArgs } from "@alexkroman1/aai/ffmpeg";
72
- import { throwFatalStepError, throwStepError } from "@alexkroman1/aai/step-errors";
73
- import { readUpload, report, uploadInfo, writeUpload } from "@alexkroman1/aai/utils";
74
- import { clock } from "./stitch.ts";
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";
75
69
  import { HEADER_PROBE_BYTES, parseWav, UnsupportedRecordingError } from "./wav.ts";
76
70
 
77
71
  /**
@@ -97,16 +91,6 @@ export const NORMALIZED_SAMPLE_RATE = 16_000;
97
91
  */
98
92
  export const NORMALIZED_CHANNELS = 1;
99
93
 
100
- /**
101
- * Bytes moved per `readUpload` while materializing, and per write while storing.
102
- *
103
- * 8 MiB is large enough that a two-hour recording is a few hundred round trips
104
- * rather than tens of thousands, and small enough that the step's resident set
105
- * is a constant that does not depend on the recording. The number this must NOT
106
- * be is "the whole file", which is the shape every first draft of this step has.
107
- */
108
- const WINDOW_BYTES = 8 * 1024 * 1024;
109
-
110
94
  /**
111
95
  * How long a conversion may run before it is killed.
112
96
  *
@@ -114,7 +98,7 @@ const WINDOW_BYTES = 8 * 1024 * 1024;
114
98
  * realtime by two orders of magnitude, so a two-hour recording is under a
115
99
  * minute — and the reason for a bound at all is a file that makes a decoder
116
100
  * pathological rather than one that is merely long. A `timeout` is retryable
117
- * and an `exit` is not; see {@link classifyFfmpeg}.
101
+ * and an `exit` is not; `throwFfmpegStepError` decides.
118
102
  */
119
103
  const CONVERT_TIMEOUT_MS = 15 * 60_000;
120
104
 
@@ -158,72 +142,78 @@ export async function normalizeRecording(uploadId: string): Promise<NormalizedRe
158
142
  // stuck. It is also the line that distinguishes "this file needs converting" from
159
143
  // the fast path above.
160
144
  await report(
161
- `Converting ${stored.name || uploadId} (${mb(stored.size)}) — not a WAV we can cut.`,
145
+ `Converting ${stored.name || uploadId} (${formatBytes(stored.size)}) — not a WAV we can cut.`,
162
146
  );
163
147
 
164
- const dir = await mkdtemp(join(tmpdir(), "aai-normalize-"));
165
- try {
166
- const source = join(dir, "source");
167
- const converted = join(dir, "converted.wav");
168
-
169
- await materialize(uploadId, stored.size, source);
170
-
171
- // What it WAS, for the progress line. Worth one ffprobe: "converted 41
172
- // minutes of aac" is a line that explains the run's shape, where
173
- // "converted the recording" leaves a reader wondering what the desk decided.
174
- // On a temp file rather than a pipe, so a trailing index is readable.
175
- const info = await probeMedia(source, { timeoutMs: CONVERT_TIMEOUT_MS }).catch(classifyFfmpeg);
176
- await report(
177
- `It is ${describeSource(info.audio?.codec, info.durationSec)} re-encoding to ` +
178
- `${NORMALIZED_SAMPLE_RATE / 1000} kHz mono WAV.`,
179
- );
180
-
181
- await runFfmpeg(
182
- [
183
- // The argv is the caller's, verbatim — `runFfmpeg` adds nothing. So the
184
- // standing flags are here: quiet, non-interactive, overwrite. `-nostdin`
185
- // matters most in a guest, where there is no terminal and an ffmpeg that
186
- // decides to read stdin is a process that never exits.
187
- "-hide_banner",
188
- "-loglevel",
189
- "error",
190
- "-nostdin",
191
- "-y",
192
- "-i",
193
- source,
194
- ...wavEncodeArgs({
195
- sampleRate: NORMALIZED_SAMPLE_RATE,
196
- channels: NORMALIZED_CHANNELS,
197
- }),
198
- converted,
199
- ],
200
- { timeoutMs: CONVERT_TIMEOUT_MS },
201
- ).catch(classifyFfmpeg);
202
-
203
- const written = await writeUpload(chunks(converted), {
204
- // Named after the ORIGINAL, so a download reads as the recording it came
205
- // from. The extension has to change with the bytes: a file served as
206
- // `audio/wav` under a `.m4a` name is one no player will open.
207
- name: `${basename(stored.name || uploadId, extname(stored.name || uploadId))}.wav`,
208
- type: "audio/wav",
209
- });
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
+ });
210
199
 
211
- await report(`Converted to ${mb(written.size)} of WAV (from ${mb(stored.size)}).`);
212
- return { recording: written.id, converted: true };
213
- } finally {
214
- // Always, including on the failure paths above: a guest's disk is small and
215
- // a step that leaves a copy of every recording it touched fills it. `force`
216
- // so a conversion that never created its output does not fail HERE and
217
- // replace the real error with this one.
218
- await rm(dir, { recursive: true, force: true });
219
- }
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
+ );
220
210
  }
221
211
 
222
212
  /**
223
213
  * Retries beyond the default 3.
224
214
  *
225
215
  * Not because a conversion is flaky — a corrupt file fails identically forever,
226
- * and {@link classifyFfmpeg} is what stops the DevKit retrying that. It is the
216
+ * and `throwFfmpegStepError` is what stops the DevKit retrying that. It is the
227
217
  * two I/O halves that are worth another attempt: this step reads a whole
228
218
  * recording out of the store and writes a whole one back, and either can lose a
229
219
  * connection on a file this size.
@@ -248,96 +238,10 @@ export function cuttable(head: Uint8Array, totalBytes: number): boolean {
248
238
  }
249
239
  }
250
240
 
251
- /**
252
- * Turn an ffmpeg failure into the DevKit's verdict.
253
- *
254
- * The whole reason `FfmpegError.kind` exists, used the way it was meant to be:
255
- * an `exit` is ffmpeg having read the file and refused it, so every retry
256
- * re-reads the same bytes and reaches the same conclusion while burning the
257
- * budget a real transient needs. A `timeout` or an `aborted` is worth another
258
- * attempt, and a `missing-binary` is `aai dev` on a laptop with no ffmpeg —
259
- * fatal, and already carrying the install instructions in its message.
260
- *
261
- * **The retryable arm goes through `throwStepError` even though it classifies
262
- * nothing**, and that is deliberate rather than a leftover. `toStepError` reaches
263
- * a verdict from a `Response` or from an SDK error that already carries one; an
264
- * `FfmpegError` is neither, so it is rethrown UNCHANGED — which the DevKit treats
265
- * as retryable by default, the outcome this arm wants. Writing
266
- * `new RetryableError(...)` here instead would replace ffmpeg's own message and
267
- * its `argv` with a sentence, and the argv is the thing you paste into a shell.
268
- * So the call reads as the decision it is: everything this function does not
269
- * declare terminal keeps its retries.
270
- *
271
- * Exported for its spec. It is the one decision in this file a unit test can
272
- * reach — everything around it spawns a subprocess — and it is also the one worth
273
- * reaching: getting it backwards means either five re-reads of a corrupt file or
274
- * no second attempt at a conversion that was merely slow.
275
- */
276
- export function classifyFfmpeg(err: unknown): never {
277
- if (isFfmpegError(err) && (err.kind === "timeout" || err.kind === "aborted")) {
278
- return throwStepError(err);
279
- }
280
- return throwFatalStepError(err);
281
- }
282
-
283
- /**
284
- * Write an upload to a local path, a window at a time.
285
- *
286
- * The `readUpload` window is the same primitive `transcribeSegment` cuts with;
287
- * what differs is only that this one walks the whole file in order. A `for` loop
288
- * rather than a fan-out deliberately — the bytes land in one file at one offset
289
- * each, so concurrency buys nothing here and costs the memory the windows are
290
- * there to bound.
291
- */
292
- async function materialize(uploadId: string, size: number, path: string): Promise<void> {
293
- const handle = await open(path, "w");
294
- try {
295
- for (let at = 0; at < size; at += WINDOW_BYTES) {
296
- const slice = await readUpload(uploadId, {
297
- start: at,
298
- end: Math.min(at + WINDOW_BYTES, size),
299
- });
300
- await handle.write(slice.bytes);
301
- }
302
- } finally {
303
- await handle.close();
304
- }
305
- }
306
-
307
- /**
308
- * A local file as the stream `writeUpload` takes.
309
- *
310
- * A generator rather than `readFile`, for the reason the windows exist: the
311
- * converted WAV is the largest thing this step touches, and handing the store an
312
- * `AsyncIterable` is what keeps it off the heap.
313
- *
314
- * The `.slice()` is load-bearing. One buffer is reused across reads, so yielding
315
- * a view of it hands the consumer memory the next read overwrites — a bug whose
316
- * symptom is a stored file made of the LAST chunk repeated, and which does not
317
- * reproduce whenever the consumer happens to copy before the next iteration.
318
- */
319
- async function* chunks(path: string): AsyncIterable<Uint8Array> {
320
- const handle = await open(path, "r");
321
- try {
322
- const buffer = new Uint8Array(WINDOW_BYTES);
323
- for (;;) {
324
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
325
- if (bytesRead === 0) return;
326
- yield buffer.subarray(0, bytesRead).slice();
327
- }
328
- } finally {
329
- await handle.close();
330
- }
331
- }
332
-
333
241
  /** `41:20 of aac`, or as much of that as ffprobe would say. */
334
242
  function describeSource(codec: string | undefined, durationSec: number | undefined): string {
335
- const length = durationSec === undefined ? undefined : clock(Math.round(durationSec * 1000));
243
+ const length =
244
+ durationSec === undefined ? undefined : formatDuration(Math.round(durationSec * 1000));
336
245
  if (length !== undefined && codec !== undefined) return `${length} of ${codec}`;
337
246
  return length ?? codec ?? "the recording";
338
247
  }
339
-
340
- /** A size a person can read, because the number that matters is the scale. */
341
- function mb(bytes: number): string {
342
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
343
- }
@@ -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,
@@ -324,9 +324,8 @@ export async function planStreamed(id: string): Promise<StreamPlan> {
324
324
  }
325
325
  const segments = fatalOnUnsupported(() => planSegments(format));
326
326
  await report(
327
- `Planned ${clock(segments.at(-1)?.endMs ?? 0)} of audio as ${segments.length} segment${
328
- segments.length === 1 ? "" : "s"
329
- } while it uploads.`,
327
+ `Planned ${formatDuration(segments.at(-1)?.endMs ?? 0)} of audio as ` +
328
+ `${segments.length} ${plural(segments.length, "segment")} while it uploads.`,
330
329
  );
331
330
  return { format, segments };
332
331
  }
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * This module used to BE the endpoint: the URL, the model header, the raw-key auth,
6
6
  * the deadline, the multipart shape and the three-way failure classification. All of
7
- * that is `stepTranscribeSync` on `@alexkroman1/aai/utils` — the same request, with
7
+ * that is `stepTranscribeSync` on `@alexkroman1/aai/step` — the same request, with
8
8
  * `transcription-workflow`'s own hard-won details (the unprefixed key, the
9
9
  * `X-AAI-Model` header, `stepFetch` rather than `fetch` so a fan-out's rate limit
10
10
  * arrives as a status rather than as a stream reset) carried into it.
@@ -18,8 +18,7 @@
18
18
  * the same shape). It is called FROM steps, so it inherits their environment.
19
19
  */
20
20
 
21
- import { throwStepError } from "@alexkroman1/aai/step-errors";
22
- import { stepTranscribeSync } from "@alexkroman1/aai/utils";
21
+ import { stepTranscribeSyncClassified } from "@alexkroman1/aai/step-errors";
23
22
 
24
23
  /**
25
24
  * Time one transcription, so the progress log carries LATENCY.
@@ -55,8 +54,9 @@ export function elapsed(ms: number): string {
55
54
  * callers arrive at that differently: one re-attaches a header to a window it
56
55
  * read, the other is handed parts that already carry one.
57
56
  *
58
- * `.catch(throwStepError)` is the whole of what this adds to the SDK call, and it is
59
- * where the three-way call is made: a `FatalError` stops the DevKit retrying
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
60
  * something that will answer the same way, a bare `RetryableError` retries in ONE
61
61
  * SECOND (that class's own default), and a `RetryableError` carrying `retryAfter`
62
62
  * waits exactly as long as the far side asked. The last matters here because a whole
@@ -72,6 +72,6 @@ export async function transcribeWav(
72
72
  filename: string,
73
73
  label: string,
74
74
  ): Promise<string> {
75
- const { text } = await stepTranscribeSync(bytes, { filename, label }).catch(throwStepError);
75
+ const { text } = await stepTranscribeSyncClassified(bytes, { filename, label });
76
76
  return text;
77
77
  }
@@ -41,7 +41,7 @@
41
41
  * ## Three properties this leans on
42
42
  *
43
43
  * - **A step can read the agent's env now.** `stepEnv`/`requireStepEnv`
44
- * (`@alexkroman1/aai/utils`) is what makes any of this real: a step is
44
+ * (`@alexkroman1/aai/step`) is what makes any of this real: a step is
45
45
  * dispatched separately from the agent bundle and is handed no `ToolContext`,
46
46
  * so before that seam existed no step anywhere could authenticate an outbound
47
47
  * call, and every workflow template's I/O was a fixture saying so.
@@ -63,16 +63,18 @@
63
63
  * `output` exists only when the last segment does.
64
64
  */
65
65
 
66
+ import {
67
+ emit,
68
+ encodeWav,
69
+ mapConcurrent,
70
+ readUpload,
71
+ report,
72
+ uploadInfo,
73
+ } from "@alexkroman1/aai/step";
66
74
  import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
67
- import { emit, mapConcurrent, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
75
+ import { countWords, formatDuration, plural } from "@alexkroman1/aai/utils";
68
76
  import { normalizeRecording } from "./normalize.ts";
69
- import {
70
- clock,
71
- countWords,
72
- stitchTranscript,
73
- TRANSCRIPT_STREAM,
74
- type TranscriptChunk,
75
- } from "./stitch.ts";
77
+ import { stitchTranscript, TRANSCRIPT_STREAM, type TranscriptChunk } from "./stitch.ts";
76
78
  import { elapsed, timed, transcribeWav } from "./sync-api.ts";
77
79
  import {
78
80
  bytesPerSecond,
@@ -84,7 +86,6 @@ import {
84
86
  type Segment,
85
87
  UnsupportedRecordingError,
86
88
  type WavFormat,
87
- wavWithHeader,
88
89
  } from "./wav.ts";
89
90
 
90
91
  /**
@@ -281,7 +282,7 @@ export async function splitRecording(uploadId: string): Promise<{
281
282
  const durationMs = segments.at(-1)?.endMs ?? 0;
282
283
 
283
284
  await report(
284
- `Split ${clock(durationMs)} of audio into ${segments.length} segment${segments.length === 1 ? "" : "s"}.`,
285
+ `Split ${formatDuration(durationMs)} of audio into ${segments.length} ${plural(segments.length, "segment")}.`,
285
286
  );
286
287
  return { format, segments, durationMs };
287
288
  }
@@ -308,7 +309,7 @@ export async function transcribeSegment(
308
309
  // calls together, so their lines interleave by completion — the page renders a
309
310
  // log, not a sequence, and `segment.index` is what puts the TRANSCRIPT back in
310
311
  // order.
311
- await report(`Transcribing ${clock(segment.startMs)}–${clock(segment.endMs)}.`);
312
+ await report(`Transcribing ${formatDuration(segment.startMs)}–${formatDuration(segment.endMs)}.`);
312
313
 
313
314
  // `[start, end)`, the same half-open pair `planSegments` produced — the store
314
315
  // owns the conversion to HTTP's inclusive range, so there is no `- 1` here to
@@ -321,20 +322,25 @@ export async function transcribeSegment(
321
322
  // answers better — and getting it wrong is a whole transcript in the wrong
322
323
  // language. Add one back only for a desk that really knows.
323
324
  //
324
- // `wavWithHeader` is what makes a WINDOW decodable: the endpoint decodes each
325
+ // `encodeWav` is what makes a WINDOW decodable: the endpoint decodes each
325
326
  // request independently, so a slice of the middle of a recording is a headerless
326
327
  // tail until one is put back on it. The streaming flow needs no equivalent — its
327
- // parts were cut with a header each.
328
+ // parts were cut with a header each. The header is the SDK's rather than this
329
+ // template's: a `WavFormat` is structurally a `PcmFormat`, and 22 lines of
330
+ // `DataView` writes with a comment about which of the two declared lengths a
331
+ // decoder trusts is not a thing worth a second copy of.
328
332
  const { value: text, ms } = await timed(() =>
329
333
  transcribeWav(
330
- wavWithHeader(format, audio.bytes),
334
+ encodeWav(audio.bytes, format),
331
335
  `segment-${segment.index}.wav`,
332
- `Segment ${segment.index} (${clock(segment.startMs)})`,
336
+ `Segment ${segment.index} (${formatDuration(segment.startMs)})`,
333
337
  ),
334
338
  );
335
339
  // The LATENCY, which is what says whether the concurrency bound or the endpoint
336
340
  // is the thing limiting the run — see `timed`'s doc.
337
- await report(`Transcribed ${clock(segment.startMs)}–${clock(segment.endMs)} in ${elapsed(ms)}.`);
341
+ await report(
342
+ `Transcribed ${formatDuration(segment.startMs)}–${formatDuration(segment.endMs)} in ${elapsed(ms)}.`,
343
+ );
338
344
  // And the WORDS, into their own stream, which is what makes this run's answer
339
345
  // streamable rather than only its narration: the page stitches whatever has
340
346
  // arrived and renders the transcript growing, minutes before `output` exists.
@@ -372,7 +378,7 @@ export async function mergeTranscript(
372
378
  ): Promise<Transcript> {
373
379
  "use step";
374
380
 
375
- await report(`Stitching ${parts.length} segment${parts.length === 1 ? "" : "s"} together.`);
381
+ await report(`Stitching ${parts.length} ${plural(parts.length, "segment")} together.`);
376
382
 
377
383
  // `mapConcurrent` resolves in ITEM order however the calls settled, so this is
378
384
  // already ordered — sorted anyway, because the merge is where an ordering
@@ -417,12 +423,13 @@ export async function startClock(): Promise<number> {
417
423
  return Date.now();
418
424
  }
419
425
 
420
- // `clock` and `countWords` are re-exported rather than re-declared: `stream.ts`
421
- // and `batch.ts` already import them from this module, and the split that let the
422
- // PAGE stitch a partial transcript should not ripple through every flow.
426
+ // Re-exported rather than re-declared: `stream.ts` and `batch.ts` already import
427
+ // these from this module, and the split that let the PAGE stitch a partial
428
+ // transcript should not ripple through every flow. `clock` and `countWords` used
429
+ // to be in this list and are `formatDuration`/`countWords` on
430
+ // `@alexkroman1/aai/utils` now — a run narrates itself and the page renders the
431
+ // same run, so those two were a private copy of a formatter the SDK ships.
423
432
  export {
424
- clock,
425
- countWords,
426
433
  stitchChunks,
427
434
  stitchTranscript,
428
435
  TRANSCRIPT_STREAM,
@@ -434,9 +441,8 @@ export {
434
441
  /**
435
442
  * Run a `wav.ts` helper, turning its "cannot cut this" into a terminal failure.
436
443
  *
437
- * Exported for the same reason `countWords` is: `stream.ts` plans with the same
438
- * `wav.ts` helpers and owes the same classification, and it had this byte for
439
- * byte.
444
+ * Exported because `stream.ts` plans with the same `wav.ts` helpers and owes the
445
+ * same classification, and it had this byte for byte.
440
446
  */
441
447
  export function fatalOnUnsupported<T>(read: () => T): T {
442
448
  try {