@alexkroman1/aai-cli 5.14.0 → 6.1.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 (231) hide show
  1. package/dist/{_agent-C8Jk-ZcP.mjs → _agent-vGW5SBnX.mjs} +23 -5
  2. package/dist/_agent.d.ts +18 -0
  3. package/dist/{_api-client-BX8Qv51n.mjs → _api-client-LgLksMhN.mjs} +45 -8
  4. package/dist/_api-client.d.ts +39 -0
  5. package/dist/{_bundler-BuIyDdvs.mjs → _bundler-BapCUUJN.mjs} +42 -14
  6. package/dist/_bundler.d.ts +43 -5
  7. package/dist/_cli-common.d.ts +67 -3
  8. package/dist/{_config-CzwAWOWr.mjs → _config-D_s09e7g.mjs} +36 -18
  9. package/dist/_deploy.d.ts +2 -5
  10. package/dist/_dev-restart.d.ts +6 -1
  11. package/dist/{_dev-server-Ch7K8e8i.mjs → _dev-server-DQ1t9sbw.mjs} +94 -38
  12. package/dist/_dev-server.d.ts +30 -2
  13. package/dist/_e2e-test-utils.d.ts +80 -0
  14. package/dist/_fault-mode.d.ts +175 -0
  15. package/dist/{_init-CbkrE2wq.mjs → _init-CpISqoR8.mjs} +3 -3
  16. package/dist/{_output-CC300DzW.mjs → _output-CKkmWs7i.mjs} +8 -1
  17. package/dist/_output.d.ts +9 -2
  18. package/dist/{_server-common-61fL__39.mjs → _server-common-6e0QI4mq.mjs} +1 -1
  19. package/dist/{_slug-api-D30zPmsn.mjs → _slug-api-D2heILuC.mjs} +2 -2
  20. package/dist/{_studio-D2ciIhbi.mjs → _studio-zAJycZ8j.mjs} +3 -3
  21. package/dist/{_templates-B1kt3BGt.mjs → _templates-iDGjGiPf.mjs} +4 -6
  22. package/dist/{_typecheck-gate-4v9UBZHh.mjs → _typecheck-gate-BXvUNnfs.mjs} +1 -1
  23. package/dist/{_utils-0DHbcqUb.mjs → _utils-B8QmtFhK.mjs} +21 -7
  24. package/dist/_utils.d.ts +18 -2
  25. package/dist/{_vite-env-Dg_QlVv0.mjs → _vite-env-BNveawd1.mjs} +31 -1
  26. package/dist/_vite-env.d.ts +30 -0
  27. package/dist/build-BO1Ni6oJ.mjs +62 -0
  28. package/dist/build.d.ts +19 -0
  29. package/dist/cli-workflow.d.ts +10 -0
  30. package/dist/cli.d.ts +1 -2
  31. package/dist/cli.mjs +406 -224
  32. package/dist/{client-bundler-C9qXL8A2.mjs → client-bundler-B8Q9F8gJ.mjs} +3 -22
  33. package/dist/client-bundler.mjs +1 -1
  34. package/dist/{delete-DCRX3d96.mjs → delete-4I0uNR4a.mjs} +6 -7
  35. package/dist/delete.d.ts +2 -8
  36. package/dist/{deploy-DjsEV-40.mjs → deploy-DJ3HRcia.mjs} +13 -14
  37. package/dist/{dev-CvPH5dbF.mjs → dev-C6SWaAZP.mjs} +3 -3
  38. package/dist/{eject-LgBRfnbH.mjs → eject-Zrzk5KV3.mjs} +41 -15
  39. package/dist/eject.d.ts +30 -3
  40. package/dist/{init-BppxMR8r.mjs → init-BTRGiPQr.mjs} +61 -26
  41. package/dist/init.d.ts +11 -0
  42. package/dist/{login-BQQaxJhL.mjs → login-k0Z3VQu9.mjs} +5 -5
  43. package/dist/project-config.mjs +1 -1
  44. package/dist/scaffold/CLAUDE.md +498 -91
  45. package/dist/scaffold/package.json +11 -8
  46. package/dist/scaffold/pnpm-workspace.yaml +8 -0
  47. package/dist/scaffold/server.mjs +53 -57
  48. package/dist/{secret-Ds9dvqAj.mjs → secret-DzSQx1Ds.mjs} +6 -3
  49. package/dist/{storage-DbrQSHFk.mjs → storage-C-Do0mIA.mjs} +18 -6
  50. package/dist/{studio-GSFd4s5O.mjs → studio-BGVmCA2G.mjs} +10 -10
  51. package/dist/templates/code-interpreter/agent.ts +0 -2
  52. package/dist/templates/dispatch-center/agent.test.ts +17 -19
  53. package/dist/templates/dispatch-center/agent.ts +2 -29
  54. package/dist/templates/dispatch-center/client.tsx +33 -16
  55. package/dist/templates/dispatch-center/shared.ts +53 -19
  56. package/dist/templates/dispatch-center/tools/incident_add_note.ts +12 -14
  57. package/dist/templates/dispatch-center/tools/incident_create.ts +41 -44
  58. package/dist/templates/dispatch-center/tools/incident_escalate.ts +59 -61
  59. package/dist/templates/dispatch-center/tools/incident_get.ts +3 -4
  60. package/dist/templates/dispatch-center/tools/incident_triage.ts +43 -45
  61. package/dist/templates/dispatch-center/tools/incident_update_status.ts +39 -41
  62. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +2 -5
  63. package/dist/templates/dispatch-center/tools/ops_protocols.ts +1 -1
  64. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +20 -23
  65. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +52 -54
  66. package/dist/templates/dispatch-center/tools/resources_get_available.ts +2 -4
  67. package/dist/templates/dispatch-center/tools/resources_update_status.ts +39 -42
  68. package/dist/templates/embedded-assets/agent.test.ts +83 -0
  69. package/dist/templates/embedded-assets/agent.ts +1 -48
  70. package/dist/templates/embedded-assets/shared.ts +17 -0
  71. package/dist/templates/embedded-assets/system-prompt.md +13 -0
  72. package/dist/templates/embedded-assets/tools/list_topics.ts +9 -0
  73. package/dist/templates/embedded-assets/tools/search_knowledge.ts +26 -0
  74. package/dist/templates/health-assistant/agent.test.ts +169 -0
  75. package/dist/templates/health-assistant/agent.ts +1 -166
  76. package/dist/templates/health-assistant/fda.ts +100 -0
  77. package/dist/templates/health-assistant/tools/check_drug_interaction.ts +59 -0
  78. package/dist/templates/health-assistant/tools/medication_lookup.ts +31 -0
  79. package/dist/templates/infocom-adventure/agent.test.ts +205 -0
  80. package/dist/templates/infocom-adventure/agent.ts +3 -106
  81. package/dist/templates/infocom-adventure/client.tsx +28 -12
  82. package/dist/templates/infocom-adventure/shared.ts +28 -11
  83. package/dist/templates/infocom-adventure/tools/game_state_drop.ts +13 -0
  84. package/dist/templates/infocom-adventure/tools/game_state_flag.ts +13 -0
  85. package/dist/templates/infocom-adventure/tools/game_state_get.ts +16 -0
  86. package/dist/templates/infocom-adventure/tools/game_state_history.ts +14 -0
  87. package/dist/templates/infocom-adventure/tools/game_state_move.ts +14 -0
  88. package/dist/templates/infocom-adventure/tools/game_state_restart.ts +13 -0
  89. package/dist/templates/infocom-adventure/tools/game_state_score.ts +13 -0
  90. package/dist/templates/infocom-adventure/tools/game_state_take.ts +13 -0
  91. package/dist/templates/link-digest/agent.test.ts +223 -0
  92. package/dist/templates/link-digest/agent.ts +79 -0
  93. package/dist/templates/link-digest/client.tsx +146 -0
  94. package/dist/templates/link-digest/workflows/digest.ts +248 -0
  95. package/dist/templates/math-buddy/agent.ts +0 -2
  96. package/dist/templates/night-owl/agent.test.ts +80 -0
  97. package/dist/templates/night-owl/agent.ts +1 -68
  98. package/dist/templates/night-owl/system-prompt.md +10 -0
  99. package/dist/templates/night-owl/tools/recommend.ts +64 -0
  100. package/dist/templates/personal-finance/agent.ts +0 -2
  101. package/dist/templates/pizza-ordering/agent.test.ts +52 -47
  102. package/dist/templates/pizza-ordering/agent.ts +9 -158
  103. package/dist/templates/pizza-ordering/shared.ts +35 -12
  104. package/dist/templates/pizza-ordering/tools/add_pizza.ts +31 -0
  105. package/dist/templates/pizza-ordering/tools/place_order.ts +22 -0
  106. package/dist/templates/pizza-ordering/tools/remove_pizza.ts +22 -0
  107. package/dist/templates/pizza-ordering/tools/set_customer_name.ts +13 -0
  108. package/dist/templates/pizza-ordering/tools/update_pizza.ts +28 -0
  109. package/dist/templates/pizza-ordering/tools/view_order.ts +21 -0
  110. package/dist/templates/plan-and-execute/agent.test.ts +411 -0
  111. package/dist/templates/plan-and-execute/agent.ts +23 -0
  112. package/dist/templates/plan-and-execute/client.tsx +115 -0
  113. package/dist/templates/plan-and-execute/graph.ts +178 -0
  114. package/dist/templates/plan-and-execute/prompts.ts +110 -0
  115. package/dist/templates/plan-and-execute/shared.ts +161 -0
  116. package/dist/templates/plan-and-execute/system-prompt.md +18 -0
  117. package/dist/templates/plan-and-execute/tools/plan_status.ts +20 -0
  118. package/dist/templates/plan-and-execute/tools/revise_plan.ts +63 -0
  119. package/dist/templates/plan-and-execute/tools/start_plan.ts +51 -0
  120. package/dist/templates/plan-and-execute/tools/work_next_step.ts +101 -0
  121. package/dist/templates/recap-workflow/agent.test.ts +762 -0
  122. package/dist/templates/recap-workflow/agent.ts +95 -0
  123. package/dist/templates/recap-workflow/shared.ts +43 -0
  124. package/dist/templates/recap-workflow/tools/cancel_recap.ts +23 -0
  125. package/dist/templates/recap-workflow/tools/keep_transcript.ts +31 -0
  126. package/dist/templates/recap-workflow/tools/recap_progress.ts +27 -0
  127. package/dist/templates/recap-workflow/tools/recap_status.ts +46 -0
  128. package/dist/templates/recap-workflow/tools/request_recap.ts +43 -0
  129. package/dist/templates/recap-workflow/workflows/recap.ts +544 -0
  130. package/dist/templates/recap-workflow/workflows/tokens.ts +26 -0
  131. package/dist/templates/redline/agent.test.ts +222 -0
  132. package/dist/templates/redline/agent.ts +89 -0
  133. package/dist/templates/redline/client.tsx +184 -0
  134. package/dist/templates/redline/workflows/prompts.ts +68 -0
  135. package/dist/templates/redline/workflows/redline.ts +258 -0
  136. package/dist/templates/research-workflow/agent.test.ts +525 -0
  137. package/dist/templates/research-workflow/agent.ts +94 -0
  138. package/dist/templates/research-workflow/shared.ts +35 -0
  139. package/dist/templates/research-workflow/tools/file_it_now.ts +22 -0
  140. package/dist/templates/research-workflow/tools/request_research.ts +33 -0
  141. package/dist/templates/research-workflow/tools/research_progress.ts +26 -0
  142. package/dist/templates/research-workflow/tools/research_status.ts +40 -0
  143. package/dist/templates/research-workflow/workflows/prompts.ts +143 -0
  144. package/dist/templates/research-workflow/workflows/research.ts +558 -0
  145. package/dist/templates/retail/agent.test.ts +236 -172
  146. package/dist/templates/retail/agent.ts +5 -42
  147. package/dist/templates/retail/client.tsx +33 -11
  148. package/dist/templates/retail/registry.test.ts +31 -16
  149. package/dist/templates/retail/resolve.ts +28 -64
  150. package/dist/templates/retail/shared.test.ts +4 -3
  151. package/dist/templates/retail/shared.ts +24 -7
  152. package/dist/templates/retail/store.test.ts +12 -10
  153. package/dist/templates/retail/store.ts +69 -30
  154. package/dist/templates/retail/tools/cancel_pending_order.ts +4 -9
  155. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -11
  156. package/dist/templates/retail/tools/find_user_id_by_email.ts +5 -9
  157. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +5 -7
  158. package/dist/templates/retail/tools/get_item_details.ts +5 -7
  159. package/dist/templates/retail/tools/get_order_details.ts +5 -7
  160. package/dist/templates/retail/tools/get_product_details.ts +4 -7
  161. package/dist/templates/retail/tools/get_user_details.ts +4 -7
  162. package/dist/templates/retail/tools/list_all_product_types.ts +5 -7
  163. package/dist/templates/retail/tools/modify_pending_order_address.ts +4 -9
  164. package/dist/templates/retail/tools/modify_pending_order_items.ts +4 -16
  165. package/dist/templates/retail/tools/modify_pending_order_payment.ts +3 -9
  166. package/dist/templates/retail/tools/modify_user_address.ts +4 -9
  167. package/dist/templates/retail/tools/return_delivered_order_items.ts +3 -9
  168. package/dist/templates/retail/tools/transfer_to_human_agents.ts +1 -1
  169. package/dist/templates/solo-rpg/agent.test.ts +152 -29
  170. package/dist/templates/solo-rpg/agent.ts +3 -21
  171. package/dist/templates/solo-rpg/client.tsx +19 -6
  172. package/dist/templates/solo-rpg/shared.ts +19 -11
  173. package/dist/templates/solo-rpg/tools/action_roll.ts +2 -4
  174. package/dist/templates/solo-rpg/tools/burn_momentum.ts +2 -4
  175. package/dist/templates/solo-rpg/tools/check_state.ts +6 -6
  176. package/dist/templates/solo-rpg/tools/load_game.ts +1 -1
  177. package/dist/templates/solo-rpg/tools/oracle.ts +18 -11
  178. package/dist/templates/solo-rpg/tools/save_game.ts +10 -7
  179. package/dist/templates/solo-rpg/tools/setup_character.ts +1 -1
  180. package/dist/templates/solo-rpg/tools/update_state.ts +3 -4
  181. package/dist/templates/support-line/agent.test.ts +343 -0
  182. package/dist/templates/support-line/agent.ts +27 -0
  183. package/dist/templates/support-line/client.tsx +122 -0
  184. package/dist/templates/support-line/graph.ts +224 -0
  185. package/dist/templates/support-line/knowledge.json +65 -0
  186. package/dist/templates/support-line/prompts.ts +95 -0
  187. package/dist/templates/support-line/shared.ts +240 -0
  188. package/dist/templates/support-line/system-prompt.md +17 -0
  189. package/dist/templates/support-line/tools/answer_question.ts +72 -0
  190. package/dist/templates/support-line/tools/list_topics.ts +22 -0
  191. package/dist/templates/support-line/tools/log_ticket.ts +30 -0
  192. package/dist/templates/transcription-workflow/agent.test.ts +563 -0
  193. package/dist/templates/transcription-workflow/agent.ts +92 -0
  194. package/dist/templates/transcription-workflow/client.tsx +234 -0
  195. package/dist/templates/transcription-workflow/workflows/transcribe.ts +416 -0
  196. package/dist/templates/transcription-workflow/workflows/wav.ts +314 -0
  197. package/dist/templates/travel-concierge/agent.test.ts +338 -0
  198. package/dist/templates/travel-concierge/agent.ts +29 -0
  199. package/dist/templates/travel-concierge/client.tsx +161 -0
  200. package/dist/templates/travel-concierge/routing.ts +138 -0
  201. package/dist/templates/travel-concierge/shared.ts +547 -0
  202. package/dist/templates/travel-concierge/system-prompt.md +24 -0
  203. package/dist/templates/travel-concierge/tools/book_car_rental.ts +20 -0
  204. package/dist/templates/travel-concierge/tools/book_excursion.ts +18 -0
  205. package/dist/templates/travel-concierge/tools/book_hotel.ts +20 -0
  206. package/dist/templates/travel-concierge/tools/cancel_action.ts +7 -0
  207. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +16 -0
  208. package/dist/templates/travel-concierge/tools/complete_or_escalate.ts +7 -0
  209. package/dist/templates/travel-concierge/tools/confirm_action.ts +7 -0
  210. package/dist/templates/travel-concierge/tools/lookup_booking.ts +35 -0
  211. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +33 -0
  212. package/dist/templates/travel-concierge/tools/search_excursions.ts +37 -0
  213. package/dist/templates/travel-concierge/tools/search_flights.ts +43 -0
  214. package/dist/templates/travel-concierge/tools/search_hotels.ts +36 -0
  215. package/dist/templates/travel-concierge/tools/to_car_rental_assistant.ts +11 -0
  216. package/dist/templates/travel-concierge/tools/to_excursion_assistant.ts +11 -0
  217. package/dist/templates/travel-concierge/tools/to_flight_assistant.ts +11 -0
  218. package/dist/templates/travel-concierge/tools/to_hotel_assistant.ts +11 -0
  219. package/dist/templates/travel-concierge/tools/update_ticket.ts +22 -0
  220. package/dist/templates/web-researcher/agent.ts +0 -2
  221. package/dist/templates/web-researcher/system-prompt.md +10 -0
  222. package/dist/{test-2LWBcXvY.mjs → test-B6ZBxSpk.mjs} +2 -2
  223. package/dist/typecheck.mjs +17 -15
  224. package/dist/worker-bundler-CIdDhZ0c.mjs +398 -0
  225. package/dist/worker-bundler.d.ts +14 -0
  226. package/dist/worker-bundler.mjs +1 -108
  227. package/dist/workflow-B-O97AqI.mjs +125 -0
  228. package/dist/workflow-bundler.d.ts +133 -0
  229. package/dist/workflow.d.ts +67 -0
  230. package/package.json +13 -9
  231. package/dist/build-DQJ1ZSm9.mjs +0 -39
@@ -0,0 +1,416 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The durable half of the transcription desk: split a recording, transcribe
4
+ * every piece, stitch the pieces back together.
5
+ *
6
+ * Read `research-workflow/workflows/research.ts` first. It states the two rules
7
+ * every directive body obeys — replayed from the top, so no live handles and no
8
+ * undurable decisions; step arguments and return values are serialized, so pass
9
+ * an id and not a payload — and both hold here unchanged. What this template
10
+ * adds is the shape a real provider limit forces on a workflow, and it is three
11
+ * steps in a straight line:
12
+ *
13
+ * ```text
14
+ * splitRecording one step → the format + a byte range per segment
15
+ * transcribeSegment N steps → one sync API request each, bounded
16
+ * mergeTranscript one step → the stitched transcript
17
+ * ```
18
+ *
19
+ * ## Why the SYNC endpoint, and why that forces a fan-out
20
+ *
21
+ * AssemblyAI has two pre-recorded APIs. The BATCH one takes a job and a webhook
22
+ * and calls back minutes later, which is the classic durable-workflow shape and
23
+ * is what this template used to demonstrate against a stub. The SYNC one
24
+ * (`https://sync.assemblyai.com/transcribe`) answers in the request — and pays
25
+ * for it with a hard 120-second, 40 MB cap. So a real recording is not one call,
26
+ * it is N; the desk owns the splitting, the retrying and the reassembly that the
27
+ * batch API would have owned for it.
28
+ *
29
+ * That is the more interesting workflow, not the lesser one. A fan-out of N
30
+ * network calls is exactly the work a journal earns its keep on: a run that dies
31
+ * on segment 27 of 60 resumes having replayed 1-26 from the journal — not
32
+ * re-downloaded, not re-transcribed, not re-billed — and issues only what is
33
+ * missing. Nothing about `Promise.all` in a tool body survives the same crash.
34
+ *
35
+ * ## Three properties this leans on
36
+ *
37
+ * - **A step can read the agent's env now.** `stepEnv`/`requireStepEnv`
38
+ * (`@alexkroman1/aai/utils`) is what makes any of this real: a step is
39
+ * dispatched separately from the agent bundle and is handed no `ToolContext`,
40
+ * so before that seam existed no step anywhere could authenticate an outbound
41
+ * call, and every workflow template's I/O was a fixture saying so.
42
+ * - **The audio is addressed by BYTE RANGE, never carried.** A workflow's input
43
+ * is journaled and replayed on every resume, so the recording lives in the
44
+ * app's own upload store and the run carries only its id; each step reads
45
+ * exactly its own window with `readUpload`. Sixty steps therefore move the
46
+ * recording once between them, not sixty times.
47
+ * - **The fan-out is bounded by `mapInBatches`, and the bound is not a detail.**
48
+ * The DevKit correlates a journal entry to a step call by the ORDER the call
49
+ * was issued in, so a work-stealing pool — which issues its next call only
50
+ * when a previous one settles — puts the calls in a different order on a
51
+ * replay than it did on the first execution. That primitive is sequential
52
+ * batches of `Promise.all` for exactly that reason; its module doc carries the
53
+ * argument.
54
+ */
55
+
56
+ import { throwFatalStepError, toStepError } from "@alexkroman1/aai/step-errors";
57
+ import {
58
+ mapInBatches,
59
+ multipartBody,
60
+ readUpload,
61
+ report,
62
+ requireStepEnv,
63
+ stepFetch,
64
+ uploadInfo,
65
+ } from "@alexkroman1/aai/utils";
66
+ import {
67
+ parseWav,
68
+ planSegments,
69
+ type Segment,
70
+ UnsupportedRecordingError,
71
+ type WavFormat,
72
+ wavWithHeader,
73
+ } from "./wav.ts";
74
+
75
+ /** The synchronous transcription endpoint. Global — it routes to the nearest region. */
76
+ const SYNC_ENDPOINT = "https://sync.assemblyai.com/transcribe";
77
+
78
+ /** Required on every sync request; the endpoint routes on it. */
79
+ const SYNC_MODEL = "universal-3-5-pro";
80
+
81
+ /** The key a step reads out of the agent env. Declared in `agent.ts`'s `requiredEnv`. */
82
+ const API_KEY_ENV = "ASSEMBLYAI_API_KEY";
83
+
84
+ /**
85
+ * Segments in flight at once.
86
+ *
87
+ * Bounded because the far side has a capacity limit, and it is MEASURED now —
88
+ * 65 segments (1h37m of 48 kHz stereo, 17.66 MB each) through this workflow,
89
+ * one concurrency per run, from one laptop and one account:
90
+ *
91
+ * | in flight | wall | vs realtime | `503`s |
92
+ * | --- | --- | --- | --- |
93
+ * | 8 | 43.3s | 134x | 0 |
94
+ * | 32 | 27.5s | 211x | 0 |
95
+ * | 48 | 26.1-28.5s | 204-223x | 0-4 |
96
+ * | 64 | 31.9s | 182x | 20 |
97
+ *
98
+ * Two readings, and the second is why this is 8 and not 32. Throughput
99
+ * PLATEAUS around 32: past it the uplink is the bottleneck, every request just
100
+ * gets a thinner share of it (p50 4.2s at 8, 10.0s at 32, 12.5s at 64), and at
101
+ * 64 the far side starts answering `503 Capacity Exceeded` — so the extra
102
+ * concurrency buys retries rather than speed. And the number the plateau sits
103
+ * at belongs to the machine, not to this code: a deployed guest reserves one
104
+ * CPU and has neither this uplink nor its ~47 MB/s, so a default measured here
105
+ * would be an overcommit there.
106
+ *
107
+ * So 8 is a floor with headroom, and the ceiling is known: raise it toward 32
108
+ * against your own account and watch `503`s in the log. Overshooting is no
109
+ * longer expensive — a `503` carries `retry-after` and `toStepError` below
110
+ * honours it, so the run completes having paid one extra request per limited
111
+ * segment (measured: 20 `503`s at 64, each retried exactly once, run
112
+ * completed). That is only true over HTTP/1.1, which is what `stepFetch` pins.
113
+ */
114
+ const SEGMENT_CONCURRENCY = 8;
115
+
116
+ /**
117
+ * Bytes probed for the WAV header.
118
+ *
119
+ * The canonical header is 44 bytes; a recorder that writes a `LIST` or `bext`
120
+ * chunk in front of the samples pushes the `data` chunk further out, and 64 KB
121
+ * covers every such file anyone has produced by accident.
122
+ */
123
+ const HEADER_PROBE_BYTES = 64 * 1024;
124
+
125
+ /** The endpoint's own per-request deadline, plus room to upload. */
126
+ const SYNC_TIMEOUT_MS = 60_000;
127
+
128
+ /** Most words `stitchTranscript` will look back over to find a repeated seam. */
129
+ const MAX_SEAM_WORDS = 40;
130
+
131
+ /** What one segment's request came back with. */
132
+ export type SegmentTranscript = {
133
+ index: number;
134
+ text: string;
135
+ };
136
+
137
+ /**
138
+ * Transcribe a recording and return one transcript.
139
+ *
140
+ * The input is what `POST /workflows/runs` carries — see `agent.ts` for the
141
+ * schema it is validated against before a run exists.
142
+ */
143
+ export async function transcribeFlow(input: { recording: string }) {
144
+ "use workflow";
145
+
146
+ const plan = await splitRecording(input.recording);
147
+
148
+ // One step per segment, bounded, in an order a replay reproduces exactly.
149
+ // A failed segment fails the RUN, deliberately: every sibling that finished is
150
+ // already journaled, so the resume replays those for free and re-issues only
151
+ // what is missing, where catching here to salvage a partial transcript would
152
+ // return a recording with a silent hole in it and report success.
153
+ const parts = await mapInBatches(plan.segments, SEGMENT_CONCURRENCY, (segment) =>
154
+ transcribeSegment(input.recording, plan.format, segment),
155
+ );
156
+
157
+ // Whatever this returns is what a caller reads as `output` on a completed run
158
+ // — so it is what the page renders, typed through `WorkflowOutputOf`.
159
+ return await mergeTranscript(input.recording, plan.durationMs, parts);
160
+ }
161
+
162
+ /**
163
+ * Read the recording's header and decide where to cut it.
164
+ *
165
+ * A step rather than body code for two reasons that both matter. It does I/O,
166
+ * which a body may not; and its RESULT is what the fan-out's width is derived
167
+ * from, so journaling it is what makes that width stable across a resume — the
168
+ * body re-derives the same segment list from the same journaled format rather
169
+ * than re-probing a URL whose content may have changed underneath it.
170
+ */
171
+ export async function splitRecording(uploadId: string): Promise<{
172
+ format: WavFormat;
173
+ segments: Segment[];
174
+ durationMs: number;
175
+ }> {
176
+ "use step";
177
+
178
+ const head = await readUpload(uploadId, { end: HEADER_PROBE_BYTES });
179
+ const format = fatalOnUnsupported(() => parseWav(head.bytes, head.info.size));
180
+ const segments = fatalOnUnsupported(() => planSegments(format));
181
+ const durationMs = segments.at(-1)?.endMs ?? 0;
182
+
183
+ await report(
184
+ `Split ${clock(durationMs)} of audio into ${segments.length} segment${segments.length === 1 ? "" : "s"}.`,
185
+ );
186
+ return { format, segments, durationMs };
187
+ }
188
+
189
+ /**
190
+ * Transcribe one segment through the sync API.
191
+ *
192
+ * One step each, so a run that dies part-way resumes having replayed the
193
+ * finished ones from the journal — no re-downloading, no re-billing — and issues
194
+ * exactly the calls that are missing.
195
+ */
196
+ export async function transcribeSegment(
197
+ uploadId: string,
198
+ format: WavFormat,
199
+ segment: Segment,
200
+ ): Promise<SegmentTranscript> {
201
+ "use step";
202
+
203
+ // One line per segment, which is what makes the fan-out legible to a page: the
204
+ // status is `running` for the whole thing, so without this a sixty-segment
205
+ // recording and a one-segment recording look identical while they run.
206
+ //
207
+ // ORDER is not guaranteed here and does not need to be. A batch issues its
208
+ // calls together, so their lines interleave by completion — the page renders a
209
+ // log, not a sequence, and `segment.index` is what puts the TRANSCRIPT back in
210
+ // order.
211
+ await report(`Transcribing ${clock(segment.startMs)}–${clock(segment.endMs)}.`);
212
+
213
+ const apiKey = apiKeyOrFatal();
214
+ // `[start, end)`, the same half-open pair `planSegments` produced — the store
215
+ // owns the conversion to HTTP's inclusive range, so there is no `- 1` here to
216
+ // get wrong.
217
+ const audio = await readUpload(uploadId, { start: segment.start, end: segment.end });
218
+
219
+ // The audio and nothing else. A `config` part carrying `language_code` used
220
+ // to ride along, and it is gone with the picker that fed it: the model detects
221
+ // the language, so the field was a question asked of a person that the service
222
+ // answers better — and getting it wrong is a whole transcript in the wrong
223
+ // language. Add one back only for a desk that really knows.
224
+ const part = multipartBody({
225
+ name: "audio",
226
+ filename: `segment-${segment.index}.wav`,
227
+ type: "audio/wav",
228
+ bytes: wavWithHeader(format, audio.bytes),
229
+ });
230
+
231
+ // `stepFetch`, not `fetch`, and here it is load-bearing rather than tidy:
232
+ // `fetch` speaks HTTP/2 wherever the far side offers it, which puts a whole
233
+ // batch of segments on ONE connection — and a capacity limit then arrives as a
234
+ // stream reset carrying no HTTP status for `toStepError` below to read. The
235
+ // fan-out is exactly the shape that breaks on. `sdk/step-fetch.ts` holds the
236
+ // measurements; a `StepTransportError` out of here is already retryable and
237
+ // already names its cause.
238
+ const response = await stepFetch(SYNC_ENDPOINT, {
239
+ method: "POST",
240
+ headers: {
241
+ // The raw key — this endpoint takes it unprefixed, and a `Bearer ` in
242
+ // front of it is a 401 that reads like a wrong key.
243
+ Authorization: apiKey,
244
+ "X-AAI-Model": SYNC_MODEL,
245
+ ...part.headers,
246
+ },
247
+ body: part.body,
248
+ // Nothing here has a deadline of its own, and a hung upload inside a step is
249
+ // a run that never finishes rather than one that retries.
250
+ signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
251
+ });
252
+ if (!response.ok) throw await syncFailure(response, segment);
253
+
254
+ const body = (await response.json()) as { text?: string };
255
+ return { index: segment.index, text: (body.text ?? "").trim() };
256
+ }
257
+
258
+ /**
259
+ * Retries beyond the default 3, because a rate limit is the expected failure and
260
+ * a segment that 429s is not a segment that is wrong.
261
+ */
262
+ transcribeSegment.maxRetries = 5;
263
+
264
+ /**
265
+ * Stitch the segments into one transcript.
266
+ *
267
+ * A step rather than a pure call in the body, and the reason is the narration:
268
+ * the body replays from the top on every resume, so a `report()` written there
269
+ * is re-emitted on each one. Journaling the finished transcript also means a
270
+ * caller re-reading a completed run gets the same bytes rather than a value
271
+ * recomputed from parts.
272
+ */
273
+ export async function mergeTranscript(
274
+ uploadId: string,
275
+ durationMs: number,
276
+ parts: readonly SegmentTranscript[],
277
+ ): Promise<{
278
+ source: string;
279
+ segments: number;
280
+ durationMs: number;
281
+ words: number;
282
+ transcript: string;
283
+ }> {
284
+ "use step";
285
+
286
+ await report(`Stitching ${parts.length} segment${parts.length === 1 ? "" : "s"} together.`);
287
+
288
+ // `mapInBatches` resolves in ITEM order however the calls settled, so this is
289
+ // already ordered — sorted anyway, because the merge is where an ordering
290
+ // mistake would be invisible rather than loud.
291
+ const ordered = [...parts].sort((a, b) => a.index - b.index);
292
+ const transcript = stitchTranscript(ordered.map((part) => part.text));
293
+
294
+ // The FILENAME, not the id: the page prints this, and `upl_9f3…` tells a
295
+ // reader nothing about which recording they are looking at.
296
+ const source = (await uploadInfo(uploadId)).name || uploadId;
297
+ return {
298
+ source,
299
+ segments: parts.length,
300
+ durationMs,
301
+ words: countWords(transcript),
302
+ transcript,
303
+ };
304
+ }
305
+
306
+ // ---- Pure helpers -----------------------------------------------------------
307
+
308
+ /** A word, stripped of the punctuation the decoder added, for seam comparison. */
309
+ function seamKey(word: string): string {
310
+ return word.toLowerCase().replace(/[^\p{L}\p{N}']/gu, "");
311
+ }
312
+
313
+ /**
314
+ * Join segment transcripts, dropping the words the overlap made duplicates.
315
+ *
316
+ * Segments overlap by `SEGMENT_OVERLAP_SECONDS` (see `wav.ts` for why), so the
317
+ * last few words of one segment are the first few of the next — verbatim when
318
+ * the decoder heard them the same way, which is the common case because it heard
319
+ * the same audio. This finds the longest such run and removes one copy.
320
+ *
321
+ * Comparison is on `seamKey`, not the raw words: the two passes punctuate
322
+ * differently at their own edges (one ends a sentence where the other is
323
+ * mid-clause), so `"today."` and `"today"` are the same word and a raw compare
324
+ * finds no seam at all. The text KEPT is the raw text — only the match is
325
+ * normalized.
326
+ *
327
+ * A missed seam repeats a few words, which a reader can see and forgive. A
328
+ * false one would delete speech, so the search is bounded at
329
+ * `MAX_SEAM_WORDS` and always prefers the LONGEST match: a single repeated
330
+ * "the" is not evidence of anything, and requiring the longest run is what stops
331
+ * it counting as one when a longer match is available.
332
+ */
333
+ export function stitchTranscript(parts: readonly string[]): string {
334
+ const merged: string[] = [];
335
+ for (const part of parts) {
336
+ const next = part.split(/\s+/).filter(Boolean);
337
+ if (next.length === 0) continue;
338
+ if (merged.length === 0) {
339
+ merged.push(...next);
340
+ continue;
341
+ }
342
+ merged.push(...next.slice(seamLength(merged, next)));
343
+ }
344
+ return merged.join(" ");
345
+ }
346
+
347
+ /** How many leading words of `next` repeat the tail of `merged`. */
348
+ function seamLength(merged: readonly string[], next: readonly string[]): number {
349
+ const limit = Math.min(MAX_SEAM_WORDS, merged.length, next.length);
350
+ // Longest first, so a short accidental match never wins over a real seam.
351
+ for (let length = limit; length > 0; length--) {
352
+ const tail = merged.slice(merged.length - length);
353
+ if (tail.every((word, at) => seamKey(word) === seamKey(next[at] ?? ""))) return length;
354
+ }
355
+ return 0;
356
+ }
357
+
358
+ /** Words in a string. */
359
+ function countWords(text: string): number {
360
+ return text.split(/\s+/).filter(Boolean).length;
361
+ }
362
+
363
+ /** `m:ss` for the progress log — a byte offset means nothing to a reader. */
364
+ export function clock(ms: number): string {
365
+ const seconds = Math.max(0, Math.round(ms / 1000));
366
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
367
+ }
368
+
369
+ // ---- I/O helpers ------------------------------------------------------------
370
+
371
+ /** The API key, or a terminal failure — three more attempts find the same gap. */
372
+ function apiKeyOrFatal(): string {
373
+ try {
374
+ return requireStepEnv(API_KEY_ENV);
375
+ } catch (err: unknown) {
376
+ // `throwFatalStepError` rather than `throw new FatalError(…)`: that class
377
+ // takes only a message — no `cause` — so constructing one inside a `catch`
378
+ // loses the original where the linter (rightly) expects it preserved. Here
379
+ // the original is the ARGUMENT, and nothing is swallowed.
380
+ return throwFatalStepError(err);
381
+ }
382
+ }
383
+
384
+ /** Run a `wav.ts` helper, turning its "cannot cut this" into a terminal failure. */
385
+ function fatalOnUnsupported<T>(read: () => T): T {
386
+ try {
387
+ return read();
388
+ } catch (err: unknown) {
389
+ if (err instanceof UnsupportedRecordingError) return throwFatalStepError(err);
390
+ throw err;
391
+ }
392
+ }
393
+
394
+ /**
395
+ * The sync endpoint's failure, with whatever it said about it.
396
+ *
397
+ * `toStepError` makes the three-way call: a `FatalError` stops the DevKit
398
+ * retrying something that will answer the same way, a bare `RetryableError`
399
+ * retries in ONE SECOND (that class's own default), and a `RetryableError`
400
+ * carrying `retryAfter` waits exactly as long as the far side asked. The last
401
+ * matters here because `SEGMENT_CONCURRENCY` segments hit the rate limit
402
+ * together — a second later all four ask again, where on the server's number
403
+ * they drain.
404
+ */
405
+ async function syncFailure(response: Response, segment: Segment): Promise<Error> {
406
+ // Two shapes, documented: `{ error_code, message }` for a request problem and
407
+ // `{ detail }` for auth and rate limits.
408
+ const body = (await response.json().catch(() => ({}))) as { message?: string; detail?: string };
409
+ const detail = body.message ?? body.detail;
410
+ return toStepError(
411
+ response,
412
+ `Segment ${segment.index} (${clock(segment.startMs)}) failed: HTTP ${response.status}${
413
+ detail ? ` — ${detail}` : ""
414
+ }`,
415
+ );
416
+ }