@alexkroman1/aai-cli 6.11.0 → 8.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 (234) hide show
  1. package/README.md +1 -0
  2. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  3. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-CDuYl5Gb.mjs} +2 -2
  4. package/dist/_dev-env.d.ts +22 -2
  5. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-Me06wgeF.mjs} +38 -47
  6. package/dist/_dev-server.d.ts +0 -17
  7. package/dist/{_init-C57bWAQ8.mjs → _init-BhEfQ6Yi.mjs} +3 -2
  8. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  9. package/dist/_preflight.d.ts +1 -1
  10. package/dist/_resource-commands.d.ts +23 -0
  11. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-z-USxPtL.mjs} +16 -12
  12. package/dist/_slug-api.d.ts +5 -2
  13. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  14. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-4WcKOjS5.mjs} +5 -6
  15. package/dist/_templates.d.ts +3 -4
  16. package/dist/_workflow-scan.d.ts +108 -0
  17. package/dist/{build-Dfza2pRI.mjs → build-BBxf8vom.mjs} +6 -3
  18. package/dist/cli.mjs +346 -349
  19. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  20. package/dist/client-bundler.mjs +1 -1
  21. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  22. package/dist/{deploy-B8lubiRT.mjs → deploy-DRC8jWr8.mjs} +5 -5
  23. package/dist/{dev-Uq5ujP8a.mjs → dev-BvcctCn2.mjs} +1 -1
  24. package/dist/eval-DfQ8cm5p.mjs +56 -0
  25. package/dist/eval.d.ts +43 -0
  26. package/dist/{init-l6xfU4xX.mjs → init-CUo87gcI.mjs} +3 -3
  27. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  28. package/dist/{logs-BWVGSjLR.mjs → logs-CQ2T46dr.mjs} +2 -2
  29. package/dist/project-config.mjs +1 -1
  30. package/dist/scaffold/CLAUDE.md +442 -80
  31. package/dist/scaffold/package.json +6 -3
  32. package/dist/scaffold/server.mjs +11 -4
  33. package/dist/scaffold/tsconfig.json +0 -1
  34. package/dist/{secret-BuMuFR4B.mjs → secret-Ct34kdgU.mjs} +2 -2
  35. package/dist/{storage-DzRZ-eCw.mjs → storage-i_79ZwAR.mjs} +2 -2
  36. package/dist/{studio-BCNUpDgP.mjs → studio-CzTzPqe1.mjs} +4 -4
  37. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  38. package/dist/templates/briefing-desk/agent.ts +30 -0
  39. package/dist/templates/briefing-desk/shared.ts +200 -0
  40. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  41. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  42. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  43. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  44. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  45. package/dist/templates/call-audit/agent.test.ts +57 -204
  46. package/dist/templates/call-audit/agent.ts +32 -19
  47. package/dist/templates/call-audit/client.tsx +17 -54
  48. package/dist/templates/call-audit/workflows/audit.ts +11 -18
  49. package/dist/templates/call-audit/workflows/ingest.ts +106 -114
  50. package/dist/templates/call-audit/workflows/media.ts +2 -12
  51. package/dist/templates/call-audit/workflows/summarize.ts +47 -52
  52. package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
  53. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  54. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  55. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  56. package/dist/templates/dispatch-center/client.tsx +239 -129
  57. package/dist/templates/dispatch-center/shared.ts +99 -1
  58. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  59. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  60. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  61. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  62. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  63. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  64. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  65. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  66. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  67. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  68. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  69. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  70. package/dist/templates/health-assistant/agent.test.ts +22 -4
  71. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  72. package/dist/templates/infocom-adventure/agent.test.ts +53 -24
  73. package/dist/templates/infocom-adventure/agent.ts +21 -0
  74. package/dist/templates/infocom-adventure/client.tsx +246 -164
  75. package/dist/templates/infocom-adventure/shared.ts +20 -9
  76. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  77. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  78. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  79. package/dist/templates/link-digest/agent.test.ts +24 -19
  80. package/dist/templates/link-digest/client.tsx +47 -61
  81. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  82. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  83. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  84. package/dist/templates/night-owl/agent.test.ts +70 -19
  85. package/dist/templates/night-owl/agent.ts +5 -0
  86. package/dist/templates/night-owl/client.tsx +56 -56
  87. package/dist/templates/night-owl/shared.ts +24 -0
  88. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  89. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  90. package/dist/templates/personal-finance/system-prompt.md +4 -0
  91. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  92. package/dist/templates/pipeline-simple/agent.ts +2 -2
  93. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  94. package/dist/templates/pizza-ordering/agent.test.ts +42 -27
  95. package/dist/templates/pizza-ordering/client.tsx +9 -26
  96. package/dist/templates/pizza-ordering/shared.ts +20 -1
  97. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  98. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  99. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  100. package/dist/templates/plan-and-execute/agent.ts +1 -1
  101. package/dist/templates/plan-and-execute/client.tsx +12 -15
  102. package/dist/templates/plan-and-execute/shared.ts +71 -2
  103. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  104. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  105. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  106. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  107. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  108. package/dist/templates/podcast-digest/agent.test.ts +717 -0
  109. package/dist/templates/podcast-digest/agent.ts +139 -0
  110. package/dist/templates/podcast-digest/client.tsx +154 -0
  111. package/dist/templates/podcast-digest/workflows/digest.ts +444 -0
  112. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  113. package/dist/templates/podcast-digest/workflows/slack.ts +88 -0
  114. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  115. package/dist/templates/recap-workflow/agent.test.ts +97 -94
  116. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  117. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  118. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  119. package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
  120. package/dist/templates/redline/agent.eval.test.ts +209 -0
  121. package/dist/templates/redline/agent.test.ts +17 -15
  122. package/dist/templates/redline/client.tsx +12 -12
  123. package/dist/templates/redline/workflows/redline.ts +19 -31
  124. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  125. package/dist/templates/research-workflow/agent.test.ts +60 -59
  126. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  127. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  128. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  129. package/dist/templates/retail/address.ts +87 -1
  130. package/dist/templates/retail/agent.eval.test.ts +314 -0
  131. package/dist/templates/retail/agent.test.ts +195 -112
  132. package/dist/templates/retail/agent.ts +35 -1
  133. package/dist/templates/retail/cancel.ts +116 -0
  134. package/dist/templates/retail/client.tsx +265 -117
  135. package/dist/templates/retail/payment.ts +107 -0
  136. package/dist/templates/retail/pending.test.ts +196 -0
  137. package/dist/templates/retail/pending.ts +137 -0
  138. package/dist/templates/retail/registry.test.ts +150 -7
  139. package/dist/templates/retail/returns.ts +102 -0
  140. package/dist/templates/retail/shared.test.ts +42 -0
  141. package/dist/templates/retail/shared.ts +35 -0
  142. package/dist/templates/retail/store.test.ts +84 -15
  143. package/dist/templates/retail/store.ts +231 -48
  144. package/dist/templates/retail/swap.test.ts +4 -4
  145. package/dist/templates/retail/swap.ts +248 -15
  146. package/dist/templates/retail/system-prompt.md +69 -28
  147. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  148. package/dist/templates/retail/tools/cancel_pending_order.ts +16 -60
  149. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  150. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -67
  151. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  152. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  153. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  154. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  155. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  156. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  157. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  158. package/dist/templates/retail/tools/modify_pending_order_address.ts +13 -25
  159. package/dist/templates/retail/tools/modify_pending_order_items.ts +23 -62
  160. package/dist/templates/retail/tools/modify_pending_order_payment.ts +16 -78
  161. package/dist/templates/retail/tools/modify_user_address.ts +13 -16
  162. package/dist/templates/retail/tools/return_delivered_order_items.ts +17 -72
  163. package/dist/templates/retail/tools/transfer_to_human_agents.ts +12 -6
  164. package/dist/templates/simple/agent.eval.test.ts +54 -0
  165. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  166. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  167. package/dist/templates/solo-rpg/client.tsx +38 -37
  168. package/dist/templates/solo-rpg/shared.ts +145 -19
  169. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  170. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  171. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  172. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  173. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  174. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  175. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  176. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  177. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  178. package/dist/templates/spoken-summary/agent.test.ts +68 -97
  179. package/dist/templates/spoken-summary/agent.ts +30 -17
  180. package/dist/templates/spoken-summary/client.tsx +10 -50
  181. package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
  182. package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
  183. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  184. package/dist/templates/support-line/agent.test.ts +11 -16
  185. package/dist/templates/support-line/agent.ts +1 -1
  186. package/dist/templates/support-line/client.tsx +9 -9
  187. package/dist/templates/support-line/nodes.ts +100 -0
  188. package/dist/templates/support-line/procedure.ts +407 -0
  189. package/dist/templates/support-line/system-prompt.md +2 -1
  190. package/dist/templates/support-line/tools/answer_question.ts +66 -4
  191. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  192. package/dist/templates/transcription-workflow/agent.test.ts +94 -194
  193. package/dist/templates/transcription-workflow/agent.ts +1 -1
  194. package/dist/templates/transcription-workflow/client.tsx +17 -42
  195. package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
  196. package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
  197. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  198. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
  199. package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
  200. package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
  201. package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
  202. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  203. package/dist/templates/travel-concierge/agent.test.ts +174 -43
  204. package/dist/templates/travel-concierge/agent.ts +18 -9
  205. package/dist/templates/travel-concierge/client.tsx +11 -23
  206. package/dist/templates/travel-concierge/routing.ts +34 -15
  207. package/dist/templates/travel-concierge/shared.ts +180 -12
  208. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  209. package/dist/templates/travel-concierge/tools/book_car_rental.ts +8 -5
  210. package/dist/templates/travel-concierge/tools/book_excursion.ts +8 -5
  211. package/dist/templates/travel-concierge/tools/book_hotel.ts +8 -5
  212. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +8 -5
  213. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  214. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  215. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  216. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  217. package/dist/templates/travel-concierge/tools/update_ticket.ts +8 -5
  218. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  219. package/dist/templates/web-researcher/system-prompt.md +15 -4
  220. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  221. package/dist/test.d.ts +29 -5
  222. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-CCVEDjm1.mjs} +241 -3
  223. package/dist/worker-bundler.mjs +1 -1
  224. package/dist/{workflow-CFpxOFfQ.mjs → workflow-BflATMmU.mjs} +47 -12
  225. package/dist/workflow-bundler.d.ts +6 -1
  226. package/dist/workflow.d.ts +1 -1
  227. package/package.json +5 -4
  228. package/dist/eject-31gjtaHF.mjs +0 -116
  229. package/dist/eject.d.ts +0 -60
  230. package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
  231. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
  232. package/dist/templates/support-line/graph.ts +0 -224
  233. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  234. /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
@@ -18,7 +18,7 @@
18
18
  *
19
19
  * Splitting steps buys a cheaper retry: a failure re-runs one stage instead of
20
20
  * five. It costs a MATERIALIZATION each, because a temp file cannot cross a step
21
- * boundary (see `temp-media.ts`) — so a five-step version reads the whole
21
+ * boundary (see `@alexkroman1/aai/step-files`) — so a five-step version reads the whole
22
22
  * recording out of the upload store five times, and on a 700 MB file that is the
23
23
  * expensive part by an order of magnitude. The decode passes are cheap: ffmpeg
24
24
  * resamples two orders of magnitude faster than realtime, so a two-hour recording
@@ -42,16 +42,33 @@
42
42
  * printed last, so a capped tail holds it) and the pauses arrive in a **file**
43
43
  * (one event per pause, so their size grows with the recording and a tail would
44
44
  * silently drop the earliest ones). Both are read here.
45
+ *
46
+ * ## Everything ffmpeg-shaped is named from inside the step BODY
47
+ *
48
+ * `@alexkroman1/aai/ffmpeg` and `@alexkroman1/aai/step-files` both reach a
49
+ * `node:` builtin, and a name this module holds at MODULE scope keeps its import
50
+ * in the workflow bundle — which is compiled as a `node:vm` Script with no
51
+ * `require`. The import statements are at the top, as the SDK's own examples
52
+ * write them, and every name they bind is referenced only inside
53
+ * {@link ingestRecording}'s body, which the workflow transform removes along
54
+ * with the imports it is the only user of. A module-scope FUNCTION naming one is
55
+ * what breaks a run at replay; this template used to carry a whole
56
+ * `ffmpeg-verdict.ts` because of it, and `throwFfmpegStepError` — which reaches
57
+ * no `node:` builtin at all — is what dissolved the boundary.
58
+ *
59
+ * `analyse` stays here for the other half of that rule: everything IT names is
60
+ * pure, so it may survive into the bundle.
45
61
  */
46
62
 
47
63
  import { readFile, stat } from "node:fs/promises";
48
64
  import { join } from "node:path";
49
- import { isFfmpegError, probeMedia, runFfmpeg } from "@alexkroman1/aai/ffmpeg";
50
- import { throwFatalStepError, throwStepError } from "@alexkroman1/aai/step-errors";
51
- import { pcmDurationMs, report, uploadInfo, writeUpload } from "@alexkroman1/aai/utils";
65
+ import { probeMedia, runFfmpeg } from "@alexkroman1/aai/ffmpeg";
66
+ import { pcmDurationMs, report, uploadInfo } from "@alexkroman1/aai/step";
67
+ import { throwFatalStepError, throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
68
+ import { readUploadToFile, withTempDir, writeUploadFromFile } from "@alexkroman1/aai/step-files";
69
+ import { formatBytes, formatDuration, plural } from "@alexkroman1/aai/utils";
52
70
  import {
53
71
  ANALYSIS_FORMAT,
54
- clock,
55
72
  type Loudness,
56
73
  MediaAnalysisError,
57
74
  measureLoudnessArgs,
@@ -61,14 +78,13 @@ import {
61
78
  type Silence,
62
79
  speechFraction,
63
80
  } from "./media.ts";
64
- import { fileChunks, materializeUpload, withTempDir } from "./temp-media.ts";
65
81
 
66
82
  /**
67
83
  * How long any one ffmpeg invocation may run before it is killed.
68
84
  *
69
85
  * Well past what the work takes, because the reason for a bound at all is a file
70
86
  * that makes a decoder pathological rather than one that is merely long. A
71
- * `timeout` is retryable and an `exit` is not; see {@link classifyFfmpeg}.
87
+ * `timeout` is retryable and an `exit` is not; `throwFfmpegStepError` decides.
72
88
  */
73
89
  const FFMPEG_TIMEOUT_MS = 20 * 60_000;
74
90
 
@@ -108,119 +124,99 @@ export async function ingestRecording(uploadId: string): Promise<Ingested> {
108
124
  "use step";
109
125
 
110
126
  const stored = await uploadInfo(uploadId);
111
- await report(`Reading ${stored.name || uploadId} (${mb(stored.size)}).`);
112
-
113
- return await withTempDir(async (dir) => {
114
- const source = join(dir, "source");
115
- const normalized = join(dir, "audio.pcm");
116
- const silenceLog = join(dir, "silence.txt");
117
-
118
- await materializeUpload(uploadId, stored.size, source);
119
-
120
- // What it WAS, for the progress log and the page. Worth one ffprobe: "41
121
- // minutes of aac" explains the shape of the run, where "the recording" leaves
122
- // a reader guessing what the desk decided. On a temp FILE rather than a pipe,
123
- // so a trailing index is readable.
124
- const probed = await probeMedia(source, { timeoutMs: FFMPEG_TIMEOUT_MS }).catch(classifyFfmpeg);
125
- const codec = probed.audio?.codec ?? "unknown";
126
- await report(
127
- `Levelling ${describeSource(codec, probed.durationSec)} to ${ANALYSIS_FORMAT.sampleRate / 1000} kHz mono.`,
128
- );
129
-
130
- // Pass one: measure. `-f null -` decodes every frame and writes no audio, so
131
- // this costs a decode and produces five numbers.
132
- const measured = await runFfmpeg(measureLoudnessArgs(source), {
133
- timeoutMs: FFMPEG_TIMEOUT_MS,
134
- }).catch(classifyFfmpeg);
135
- const loudness = analyse(() => parseLoudness(measured.stderr));
136
-
137
- // Pass two: apply the measurement, find the pauses, write the audio.
138
- await runFfmpeg(normalizeArgs(source, loudness, normalized, silenceLog), {
139
- timeoutMs: FFMPEG_TIMEOUT_MS,
140
- }).catch(classifyFfmpeg);
141
-
142
- // The duration comes from the BYTE COUNT, not from the original's header or
143
- // from ffprobe. It is the only measurement that agrees with the byte offsets
144
- // the fan-out will use — a container's declared duration can disagree with
145
- // what was actually decoded (an AAC file's encoder padding puts this one ~16ms
146
- // over), and a segment planned against the wrong one runs off the end.
147
- const bytes = (await stat(normalized)).size;
148
- const durationMs = pcmDurationMs(bytes, ANALYSIS_FORMAT);
149
-
150
- // Verified on ffmpeg 6.1: `ametadata` creates the file at filter-init, so a
151
- // recording with no pause in it leaves an EMPTY log rather than no log. A
152
- // missing file here is therefore a real failure and not a case to tolerate.
153
- const log = await readFile(silenceLog, "utf-8");
154
- const silences = analyse(() => parseSilences(log, durationMs / 1000));
155
-
156
- const written = await writeUpload(fileChunks(normalized), {
157
- // Named after the original, so a download reads as the recording it came
158
- // from. `.pcm` because that is what it is — raw samples with no header, and
159
- // a `.wav` name on a headerless file is one no player will open.
160
- name: `${baseName(stored.name || uploadId)}.pcm`,
161
- // Not `audio/wav`: the type is served back on the byte route, and claiming a
162
- // container this file does not have would be a lie a browser acts on. Not
163
- // `audio/L16` either, which looks right and is not — that type is defined as
164
- // BIG-endian 16-bit PCM, where this is `s16le`. Nothing plays this file; the
165
- // fan-out reads byte ranges out of it and puts a real header back on each one
166
- // with `encodeWav`.
167
- type: "application/octet-stream",
168
- });
169
-
170
- await report(
171
- `Levelled ${clock(durationMs)} from ${loudness.inputLufs} LUFS, ` +
172
- `${Math.round(speechFraction(silences, durationMs / 1000) * 100)}% speech across ` +
173
- `${silences.length} pause${silences.length === 1 ? "" : "s"}.`,
174
- );
175
-
176
- return {
177
- audio: written.id,
178
- source: stored.name || uploadId,
179
- codec,
180
- durationMs,
181
- bytes,
182
- loudness,
183
- silences,
184
- };
185
- });
127
+ await report(`Reading ${stored.name || uploadId} (${formatBytes(stored.size)}).`);
128
+
129
+ return await withTempDir(
130
+ async (dir) => {
131
+ const source = join(dir, "source");
132
+ const normalized = join(dir, "audio.pcm");
133
+ const silenceLog = join(dir, "silence.txt");
134
+
135
+ await readUploadToFile(uploadId, source, { size: stored.size });
136
+
137
+ // What it WAS, for the progress log and the page. Worth one ffprobe: "41
138
+ // minutes of aac" explains the shape of the run, where "the recording" leaves
139
+ // a reader guessing what the desk decided. On a temp FILE rather than a pipe,
140
+ // so a trailing index is readable.
141
+ const probed = await probeMedia(source, { timeoutMs: FFMPEG_TIMEOUT_MS }).catch(
142
+ throwFfmpegStepError,
143
+ );
144
+ const codec = probed.audio?.codec ?? "unknown";
145
+ await report(
146
+ `Levelling ${describeSource(codec, probed.durationSec)} to ${ANALYSIS_FORMAT.sampleRate / 1000} kHz mono.`,
147
+ );
148
+
149
+ // Pass one: measure. `-f null -` decodes every frame and writes no audio, so
150
+ // this costs a decode and produces five numbers.
151
+ const measured = await runFfmpeg(measureLoudnessArgs(source), {
152
+ timeoutMs: FFMPEG_TIMEOUT_MS,
153
+ }).catch(throwFfmpegStepError);
154
+ const loudness = analyse(() => parseLoudness(measured.stderr));
155
+
156
+ // Pass two: apply the measurement, find the pauses, write the audio.
157
+ await runFfmpeg(normalizeArgs(source, loudness, normalized, silenceLog), {
158
+ timeoutMs: FFMPEG_TIMEOUT_MS,
159
+ }).catch(throwFfmpegStepError);
160
+
161
+ // The duration comes from the BYTE COUNT, not from the original's header or
162
+ // from ffprobe. It is the only measurement that agrees with the byte offsets
163
+ // the fan-out will use — a container's declared duration can disagree with
164
+ // what was actually decoded (an AAC file's encoder padding puts this one ~16ms
165
+ // over), and a segment planned against the wrong one runs off the end.
166
+ const bytes = (await stat(normalized)).size;
167
+ const durationMs = pcmDurationMs(bytes, ANALYSIS_FORMAT);
168
+
169
+ // Verified on ffmpeg 6.1: `ametadata` creates the file at filter-init, so a
170
+ // recording with no pause in it leaves an EMPTY log rather than no log. A
171
+ // missing file here is therefore a real failure and not a case to tolerate.
172
+ const log = await readFile(silenceLog, "utf-8");
173
+ const silences = analyse(() => parseSilences(log, durationMs / 1000));
174
+
175
+ const written = await writeUploadFromFile(normalized, {
176
+ // Named after the original, so a download reads as the recording it came
177
+ // from. `.pcm` because that is what it is raw samples with no header, and
178
+ // a `.wav` name on a headerless file is one no player will open.
179
+ name: `${baseName(stored.name || uploadId)}.pcm`,
180
+ // Not `audio/wav`: the type is served back on the byte route, and claiming a
181
+ // container this file does not have would be a lie a browser acts on. Not
182
+ // `audio/L16` either, which looks right and is not — that type is defined as
183
+ // BIG-endian 16-bit PCM, where this is `s16le`. Nothing plays this file; the
184
+ // fan-out reads byte ranges out of it and puts a real header back on each one
185
+ // with `encodeWav`.
186
+ type: "application/octet-stream",
187
+ });
188
+
189
+ await report(
190
+ `Levelled ${formatDuration(durationMs)} from ${loudness.inputLufs} LUFS, ` +
191
+ `${Math.round(speechFraction(silences, durationMs / 1000) * 100)}% speech across ` +
192
+ `${silences.length} ${plural(silences.length, "pause")}.`,
193
+ );
194
+
195
+ return {
196
+ audio: written.id,
197
+ source: stored.name || uploadId,
198
+ codec,
199
+ durationMs,
200
+ bytes,
201
+ loudness,
202
+ silences,
203
+ };
204
+ },
205
+ { prefix: "aai-call-audit-" },
206
+ );
186
207
  }
187
208
 
188
209
  /**
189
210
  * Retries beyond the default 3.
190
211
  *
191
212
  * Not because a conversion is flaky — a corrupt file fails identically forever,
192
- * and {@link classifyFfmpeg} is what stops the DevKit retrying that. It is the
213
+ * and `throwFfmpegStepError` is what stops the DevKit retrying that. It is the
193
214
  * two I/O halves that are worth another attempt: this step reads a whole
194
215
  * recording out of the store and writes a whole one back, and either can lose a
195
216
  * connection on a file this size.
196
217
  */
197
218
  ingestRecording.maxRetries = 5;
198
219
 
199
- /**
200
- * Turn an ffmpeg failure into the DevKit's verdict.
201
- *
202
- * The whole reason `FfmpegError.kind` exists, used the way it was meant to be: an
203
- * `exit` is ffmpeg having read the file and refused it, so every retry re-reads
204
- * the same bytes and reaches the same conclusion while burning the budget a real
205
- * transient needs. A `timeout` or an `aborted` is worth another attempt, and a
206
- * `missing-binary` is `aai dev` on a laptop with no ffmpeg — fatal, and already
207
- * carrying the install instructions in its message.
208
- *
209
- * **The retryable arm goes through `throwStepError` even though it classifies
210
- * nothing**, which is deliberate. `toStepError` reaches a verdict from a
211
- * `Response` or from an SDK error that already carries one; an `FfmpegError` is
212
- * neither, so it is rethrown UNCHANGED — which the DevKit treats as retryable by
213
- * default, the outcome this arm wants. Constructing a `RetryableError` here
214
- * instead would replace ffmpeg's own message and its `argv` with a sentence, and
215
- * the argv is the thing you paste into a shell.
216
- */
217
- export function classifyFfmpeg(err: unknown): never {
218
- if (isFfmpegError(err) && (err.kind === "timeout" || err.kind === "aborted")) {
219
- return throwStepError(err);
220
- }
221
- return throwFatalStepError(err);
222
- }
223
-
224
220
  /**
225
221
  * Run a `media.ts` reader, turning "I cannot read this analysis" into a terminal
226
222
  * failure.
@@ -243,7 +239,8 @@ export function analyse<T>(read: () => T): T {
243
239
 
244
240
  /** `41:20 of aac`, or as much of that as ffprobe would say. */
245
241
  function describeSource(codec: string, durationSec: number | undefined): string {
246
- const length = durationSec === undefined ? undefined : clock(Math.round(durationSec * 1000));
242
+ const length =
243
+ durationSec === undefined ? undefined : formatDuration(Math.round(durationSec * 1000));
247
244
  return length === undefined ? codec : `${length} of ${codec}`;
248
245
  }
249
246
 
@@ -252,8 +249,3 @@ function baseName(name: string): string {
252
249
  const dot = name.lastIndexOf(".");
253
250
  return dot > 0 ? name.slice(0, dot) : name;
254
251
  }
255
-
256
- /** A size a person can read, because the number that matters is the scale. */
257
- function mb(bytes: number): string {
258
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
259
- }
@@ -58,7 +58,8 @@
58
58
  * longer than the cap gets the blind cut, and says so.
59
59
  */
60
60
 
61
- import { isRecord, type PcmFormat } from "@alexkroman1/aai/utils";
61
+ import type { PcmFormat } from "@alexkroman1/aai/step";
62
+ import { isRecord } from "@alexkroman1/aai/utils";
62
63
 
63
64
  /**
64
65
  * The format every recording is converted to before anything measures it.
@@ -578,17 +579,6 @@ export function speechFraction(silences: readonly Silence[], durationSec: number
578
579
  return Math.max(0, Math.min(1, (durationSec - quiet) / durationSec));
579
580
  }
580
581
 
581
- /** `1:04:09`, or `4:09` under an hour — the shape a reader scans for. */
582
- export function clock(ms: number): string {
583
- const total = Math.max(0, Math.round(ms / 1000));
584
- const seconds = String(total % 60).padStart(2, "0");
585
- const minutes = Math.floor(total / 60) % 60;
586
- const hours = Math.floor(total / 3600);
587
- return hours > 0
588
- ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}`
589
- : `${minutes}:${seconds}`;
590
- }
591
-
592
582
  /**
593
583
  * A second, as a byte offset on a sample-frame boundary.
594
584
  *
@@ -35,18 +35,12 @@
35
35
  import { stat, writeFile } from "node:fs/promises";
36
36
  import { join } from "node:path";
37
37
  import { runFfmpeg } from "@alexkroman1/aai/ffmpeg";
38
- import { throwStepError } from "@alexkroman1/aai/step-errors";
39
- import {
40
- omitUndefined,
41
- report,
42
- stepGenerateJson,
43
- stepSpeak,
44
- writeUpload,
45
- } from "@alexkroman1/aai/utils";
38
+ import { report, stepSpeak } from "@alexkroman1/aai/step";
39
+ import { stepGenerateJsonClassified, throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
40
+ import { withTempDir, writeUploadFromFile } from "@alexkroman1/aai/step-files";
41
+ import { formatBytes, formatDuration, omitUndefined, plural } from "@alexkroman1/aai/utils";
46
42
  import { z } from "zod";
47
- import { classifyFfmpeg } from "./ingest.ts";
48
- import { clock, masterArgs } from "./media.ts";
49
- import { fileChunks, withTempDir } from "./temp-media.ts";
43
+ import { masterArgs } from "./media.ts";
50
44
 
51
45
  /** Risks the summary is reduced to. Enough to be useful, few enough to act on. */
52
46
  const MAX_RISKS = 4;
@@ -114,8 +108,8 @@ export async function summarize(
114
108
  "use step";
115
109
 
116
110
  await report("Reading the transcript.");
117
- const reply = await stepGenerateJson(
118
- `Audit this transcript of a recorded call (${source}, ${clock(durationMs)}).\n\n` +
111
+ const reply = await stepGenerateJsonClassified(
112
+ `Audit this transcript of a recorded call (${source}, ${formatDuration(durationMs)}).\n\n` +
119
113
  "Answer with JSON only, in this shape:\n" +
120
114
  `{"headline": "...", "risks": ["..."], "actions": ["..."], "spoken": "..."}\n\n` +
121
115
  "- headline: one line naming what the call was about.\n" +
@@ -133,13 +127,14 @@ export async function summarize(
133
127
  system: "You audit recorded calls. You answer with JSON and nothing else.",
134
128
  schema: AuditReply,
135
129
  },
136
- // Classified off the gateway's own status: a 429 is worth another attempt and a
137
- // 400 is not, and `throwStepError` is what tells the DevKit which.
138
- ).catch(throwStepError);
130
+ // The `Classified` caller is `stepGenerateJson` plus `throwStepError`, which is
131
+ // what reads the gateway's own status: a 429 is worth another attempt and a 400
132
+ // is not, and that is what tells the DevKit which.
133
+ );
139
134
 
140
135
  await report(
141
- `Found ${reply.risks.length} risk${reply.risks.length === 1 ? "" : "s"} and ` +
142
- `${reply.actions.length} action${reply.actions.length === 1 ? "" : "s"}.`,
136
+ `Found ${reply.risks.length} ${plural(reply.risks.length, "risk")} and ` +
137
+ `${reply.actions.length} ${plural(reply.actions.length, "action")}.`,
143
138
  );
144
139
  return {
145
140
  headline: reply.headline,
@@ -156,7 +151,7 @@ export async function summarize(
156
151
  * records: a step is replayed by its RETURN VALUE, so an id is replayed and bytes
157
152
  * are not. Split in two, the audio would have to cross the queue between them —
158
153
  * megabytes of it, on every resume — and the temp file the mastering pass needs
159
- * cannot cross a step boundary at all (see `temp-media.ts`). Together, a resumed
154
+ * cannot cross a step boundary at all (see `@alexkroman1/aai/step-files`). Together, a resumed
160
155
  * run replays the id and re-reads a file that is already there.
161
156
  *
162
157
  * The cost is that a retried step writes a second upload and abandons the first.
@@ -170,37 +165,37 @@ export async function narrate(
170
165
 
171
166
  const spoken = await stepSpeak(script, omitUndefined({ voice }));
172
167
 
173
- return await withTempDir(async (dir) => {
174
- const wav = join(dir, "spoken.wav");
175
- const mp3 = join(dir, "summary.mp3");
176
-
177
- // `writeFile` rather than a stream, and this is the one place in the template
178
- // where holding the whole thing in memory is right: `stepSpeak` already
179
- // returned it as a single `Uint8Array`, so streaming it to disk would be
180
- // copying from the heap to the heap on the way. It is bounded by the script,
181
- // which the schema keeps under 150 words.
182
- await writeFile(wav, spoken.audio);
183
-
184
- await runFfmpeg(masterArgs(wav, mp3), { timeoutMs: MASTER_TIMEOUT_MS }).catch(classifyFfmpeg);
185
- const bytes = (await stat(mp3)).size;
186
-
187
- const stored = await writeUpload(fileChunks(mp3), {
188
- // Named, because this is what a person sees on the download link rather than
189
- // an opaque id — and typed, because the byte route serves the type it was
190
- // given and a browser will not play a file it was handed as bytes.
191
- name: "audit.mp3",
192
- type: "audio/mpeg",
193
- });
194
-
195
- await report(
196
- `Recorded a ${Math.round(spoken.durationMs / 1000)}s audit in ${spoken.voice}'s voice — ` +
197
- `${kb(bytes)} of MP3, from ${kb(spoken.audio.byteLength)} of WAV.`,
198
- );
199
- return { audio: stored.id, durationMs: spoken.durationMs, bytes };
200
- });
201
- }
202
-
203
- /** A size a person can read, in the unit this file's output actually lands in. */
204
- function kb(bytes: number): string {
205
- return `${Math.round(bytes / 1024)} KB`;
168
+ return await withTempDir(
169
+ async (dir) => {
170
+ const wav = join(dir, "spoken.wav");
171
+ const mp3 = join(dir, "summary.mp3");
172
+
173
+ // `writeFile` rather than a stream, and this is the one place in the template
174
+ // where holding the whole thing in memory is right: `stepSpeak` already
175
+ // returned it as a single `Uint8Array`, so streaming it to disk would be
176
+ // copying from the heap to the heap on the way. It is bounded by the script,
177
+ // which the schema keeps under 150 words.
178
+ await writeFile(wav, spoken.audio);
179
+
180
+ await runFfmpeg(masterArgs(wav, mp3), { timeoutMs: MASTER_TIMEOUT_MS }).catch(
181
+ throwFfmpegStepError,
182
+ );
183
+ const bytes = (await stat(mp3)).size;
184
+
185
+ const stored = await writeUploadFromFile(mp3, {
186
+ // Named, because this is what a person sees on the download link rather than
187
+ // an opaque id — and typed, because the byte route serves the type it was
188
+ // given and a browser will not play a file it was handed as bytes.
189
+ name: "audit.mp3",
190
+ type: "audio/mpeg",
191
+ });
192
+
193
+ await report(
194
+ `Recorded a ${Math.round(spoken.durationMs / 1000)}s audit in ${spoken.voice}'s voice — ` +
195
+ `${formatBytes(bytes)} of MP3, from ${formatBytes(spoken.audio.byteLength)} of WAV.`,
196
+ );
197
+ return { audio: stored.id, durationMs: spoken.durationMs, bytes };
198
+ },
199
+ { prefix: "aai-call-audit-" },
200
+ );
206
201
  }
@@ -7,12 +7,11 @@
7
7
  * `transcription-workflow` has one: `stepTranscribeSync` is the SDK's — the URL,
8
8
  * the raw-key auth (no `Bearer`, which is a 401 that reads like a wrong key), the
9
9
  * multipart shape, the deadline and the three-way failure verdict all live there —
10
- * so what is left at the call site is the `.catch` that hands the verdict to the
11
- * DevKit, and that belongs somewhere a spec can reach it.
10
+ * so what is left at the call site is the classification that hands the verdict to
11
+ * the DevKit, and that belongs somewhere a spec can reach it.
12
12
  */
13
13
 
14
- import { throwStepError } from "@alexkroman1/aai/step-errors";
15
- import { stepTranscribeSync } from "@alexkroman1/aai/utils";
14
+ import { stepTranscribeSyncClassified } from "@alexkroman1/aai/step-errors";
16
15
 
17
16
  /**
18
17
  * Transcribe one complete WAV.
@@ -22,8 +21,9 @@ import { stepTranscribeSync } from "@alexkroman1/aai/utils";
22
21
  * stores headerless PCM on purpose (see `media.ts`) and puts a header back with
23
22
  * `encodeWav` for exactly this call.
24
23
  *
25
- * `.catch(throwStepError)` is the whole of what this adds, and it is where the
26
- * three-way call is made: a `FatalError` stops the DevKit retrying something that
24
+ * `stepTranscribeSyncClassified` the SDK's own `stepTranscribeSync` plus
25
+ * `throwStepError`, and nothing else — is the whole of what this adds, and it is
26
+ * where the three-way call is made: a `FatalError` stops the DevKit retrying something that
27
27
  * will answer the same way, a bare `RetryableError` retries in ONE SECOND (that
28
28
  * class's own default), and a `RetryableError` carrying `retryAfter` waits exactly
29
29
  * as long as the far side asked. The last matters here because a whole fan-out
@@ -39,6 +39,6 @@ export async function transcribeSpan(
39
39
  filename: string,
40
40
  label: string,
41
41
  ): Promise<string> {
42
- const { text } = await stepTranscribeSync(bytes, { filename, label }).catch(throwStepError);
42
+ const { text } = await stepTranscribeSyncClassified(bytes, { filename, label });
43
43
  return text;
44
44
  }