@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
@@ -23,27 +23,24 @@
23
23
  * that would have finished.
24
24
  */
25
25
 
26
- import { readdir, readFile, stat, writeFile } from "node:fs/promises";
26
+ import { readdir } from "node:fs/promises";
27
27
  import { tmpdir } from "node:os";
28
- import { join } from "node:path";
29
- import { FfmpegError } from "@alexkroman1/aai/ffmpeg";
30
- import { stubReporter, stubSpeech, stubStepFetch, stubUploads } from "@alexkroman1/aai/testing";
31
- import { installStubGateway } from "@alexkroman1/aai/testing/vitest";
32
- import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
28
+ import { stubSpeech } from "@alexkroman1/aai/testing";
29
+ import {
30
+ installStubGateway,
31
+ installStubReporter,
32
+ installStubSpeech,
33
+ installStubTranscribe,
34
+ installStubUploads,
35
+ } from "@alexkroman1/aai/testing/vitest";
36
+ import { beforeEach, describe, expect, test, vi } from "vitest";
33
37
  import { FatalError, RetryableError } from "workflow";
34
38
  import agentDef, { audit } from "./agent.ts";
35
- import {
36
- countWords,
37
- joinSegments,
38
- now,
39
- SEGMENT_CONCURRENCY,
40
- transcribeSegment,
41
- } from "./workflows/audit.ts";
42
- import { analyse, classifyFfmpeg, ingestRecording } from "./workflows/ingest.ts";
39
+ import { joinSegments, now, SEGMENT_CONCURRENCY, transcribeSegment } from "./workflows/audit.ts";
40
+ import { analyse, ingestRecording } from "./workflows/ingest.ts";
43
41
  import {
44
42
  ANALYSIS_FORMAT,
45
43
  BYTES_PER_SECOND,
46
- clock,
47
44
  durationSeconds,
48
45
  type Loudness,
49
46
  MAX_SEGMENT_SECONDS,
@@ -59,7 +56,6 @@ import {
59
56
  speechFraction,
60
57
  } from "./workflows/media.ts";
61
58
  import { narrate, summarize } from "./workflows/summarize.ts";
62
- import { fileChunks, materializeUpload, withTempDir } from "./workflows/temp-media.ts";
63
59
 
64
60
  /** The id every spec below uploads under. */
65
61
  const UPLOAD_ID = "upl_test";
@@ -163,12 +159,6 @@ function pauses(...starts: number[]): Silence[] {
163
159
  return starts.map((startSec) => ({ startSec, endSec: startSec + MIN_SILENCE_SECONDS }));
164
160
  }
165
161
 
166
- /** Slots left published reach the next file, so every one is released here. */
167
- const restores: (() => void)[] = [];
168
- afterEach(() => {
169
- while (restores.length > 0) restores.pop()?.();
170
- });
171
-
172
162
  beforeEach(() => {
173
163
  // The step env, which is where `requireStepEnv`, `stepSpeak` and the gateway
174
164
  // read the key. `vi.stubEnv` rather than an assignment: `unstubEnvs` undoes it
@@ -518,13 +508,6 @@ describe("what the page is told about the recording", () => {
518
508
  test("a zero-length recording answers 0 rather than dividing by it", () => {
519
509
  expect(speechFraction([], 0)).toBe(0);
520
510
  });
521
-
522
- test("the clock grows an hours field only when there is one", () => {
523
- expect(clock(0)).toBe("0:00");
524
- expect(clock(9000)).toBe("0:09");
525
- expect(clock(249_000)).toBe("4:09");
526
- expect(clock(3_849_000)).toBe("1:04:09");
527
- });
528
511
  });
529
512
 
530
513
  describe("joining the segments", () => {
@@ -577,39 +560,34 @@ describe("joining the segments", () => {
577
560
  ]),
578
561
  ).toBe("words");
579
562
  });
580
-
581
- test("counts words, or none for an empty transcript", () => {
582
- expect(countWords(" two words ")).toBe(2);
583
- expect(countWords(" ")).toBe(0);
584
- });
585
563
  });
586
564
 
587
565
  describe("transcribing one segment", () => {
588
566
  /**
589
567
  * One second of stored PCM, and a sync endpoint that answers.
590
568
  *
591
- * `stubStepFetch`, not `vi.stubGlobal("fetch", …)`: the step calls `stepFetch`,
592
- * which reaches a published slot rather than the global. Stubbing the global
593
- * still passes, because an unpublished slot falls back to it, and would be
594
- * asserting against a path production does not take.
569
+ * A published `stepFetch`, not `vi.stubGlobal("fetch", …)`: the step calls
570
+ * `stepFetch`, which reaches a published slot rather than the global. Stubbing
571
+ * the global still passes, because an unpublished slot falls back to it, and
572
+ * would be asserting against a path production does not take.
595
573
  */
596
- function stubProvider(answer: { status?: number; body?: unknown } = {}) {
597
- restores.push(
598
- stubUploads({
599
- [UPLOAD_ID]: {
600
- bytes: new Uint8Array(BYTES_PER_SECOND),
601
- name: "call.pcm",
602
- type: "application/octet-stream",
603
- },
604
- }),
605
- stubReporter().restore,
606
- );
607
- const stub = stubStepFetch(() => ({
608
- status: answer.status ?? 200,
609
- body: answer.body ?? { text: "hello there" },
610
- }));
611
- restores.push(stub.restore);
612
- return stub.calls;
574
+ function stubProvider(failure?: { status: number; message: string }) {
575
+ installStubUploads({
576
+ [UPLOAD_ID]: {
577
+ bytes: new Uint8Array(BYTES_PER_SECOND),
578
+ name: "call.pcm",
579
+ type: "application/octet-stream",
580
+ },
581
+ });
582
+ installStubReporter();
583
+ // `installStubTranscribe` answers AssemblyAI's own endpoints off the SDK's
584
+ // endpoint constants, so this spec no longer re-types the wire — and a refusal
585
+ // is staged as a STATUS, which is what makes the classification below a test of
586
+ // the SDK's reading of it rather than of a `TranscribeError` a fake minted.
587
+ return installStubTranscribe({
588
+ text: "hello there",
589
+ failure: failure === undefined ? undefined : { leg: "sync", ...failure },
590
+ }).calls;
613
591
  }
614
592
 
615
593
  const SEGMENT = {
@@ -651,13 +629,13 @@ describe("transcribing one segment", () => {
651
629
  test("a rate limit is RETRYABLE, so one busy minute does not fail the run", async () => {
652
630
  // The expected failure of a 32-wide fan-out, and the reason this step's
653
631
  // `maxRetries` is above the default.
654
- stubProvider({ status: 429, body: { error: "slow down" } });
632
+ stubProvider({ status: 429, message: "slow down" });
655
633
  await expect(transcribeSegment(UPLOAD_ID, SEGMENT)).rejects.toBeInstanceOf(RetryableError);
656
634
  expect(transcribeSegment.maxRetries).toBe(5);
657
635
  });
658
636
 
659
637
  test("a rejected request is FATAL, so it is not asked five more times", async () => {
660
- stubProvider({ status: 400, body: { error: "that is not audio" } });
638
+ stubProvider({ status: 400, message: "that is not audio" });
661
639
  await expect(transcribeSegment(UPLOAD_ID, SEGMENT)).rejects.toBeInstanceOf(FatalError);
662
640
  });
663
641
 
@@ -675,7 +653,7 @@ describe("auditing the transcript", () => {
675
653
  test("asks for a script as well as lists, and keeps both", async () => {
676
654
  // The two-summaries decision: a voice reading a bullet list says "one. two.
677
655
  // three." with no connective tissue, so the schema demands sentences too.
678
- restores.push(stubReporter().restore);
656
+ installStubReporter();
679
657
  // The stub answers with TEXT, because that is what a gateway returns — and
680
658
  // `stepGenerateJson` parsing it is part of what this exercises.
681
659
  installStubGateway(
@@ -696,7 +674,7 @@ describe("auditing the transcript", () => {
696
674
  test("an empty risk list is an ANSWER, not a retry", async () => {
697
675
  // A schema that demanded a risk would get an invented one, which is worse than
698
676
  // silence on a call that really had none.
699
- restores.push(stubReporter().restore);
677
+ installStubReporter();
700
678
  installStubGateway(
701
679
  JSON.stringify({
702
680
  headline: "Weekly standup",
@@ -711,49 +689,22 @@ describe("auditing the transcript", () => {
711
689
  test("a reply with no spoken script is a RETRY rather than silence", async () => {
712
690
  // `spoken` is required rather than defaulted precisely so this fails: a default
713
691
  // would turn a missing field into half a second of audio nobody notices.
714
- restores.push(stubReporter().restore);
692
+ installStubReporter();
715
693
  installStubGateway(JSON.stringify({ headline: "A call", risks: [], actions: [] }));
716
694
  await expect(summarize("…", "call.m4a", 60_000)).rejects.toThrow();
717
695
  });
718
696
  });
719
697
 
720
698
  describe("classifying a failure", () => {
721
- /** An `FfmpegError` of one kind, with the argv a reader would want. */
722
- function failed(kind: "exit" | "timeout" | "aborted" | "missing-binary") {
723
- return new FfmpegError({
724
- kind,
725
- message: `ffmpeg ${kind}`,
726
- binary: "ffmpeg",
727
- argv: ["-i", "source"],
728
- });
729
- }
730
-
731
- test("a file ffmpeg REFUSED is fatal, so it is not attempted five times", () => {
732
- // `exit` means ffmpeg read the file and would read it the same way again.
733
- expect(() => classifyFfmpeg(failed("exit"))).toThrow(FatalError);
734
- });
735
-
736
- test("no ffmpeg at all is fatal — a retry cannot install one", () => {
737
- // The `aai dev` case, whose message already carries the install instructions.
738
- expect(() => classifyFfmpeg(failed("missing-binary"))).toThrow(FatalError);
739
- });
740
-
741
- test("a run that timed out keeps its retries, and its argv", () => {
742
- // Rethrown UNCHANGED rather than wrapped, which is what `toStepError` does with
743
- // an error carrying no verdict — and the DevKit's default for anything that is
744
- // not a `FatalError` is to retry. Asserting the class survives is asserting the
745
- // diagnosis does: `argv` is the command you paste into a shell.
746
- for (const kind of ["timeout", "aborted"] as const) {
747
- const err = failed(kind);
748
- expect(() => classifyFfmpeg(err)).toThrow(err);
749
- expect(() => classifyFfmpeg(err)).not.toThrow(FatalError);
750
- }
751
- });
752
-
753
- test("something that is not an ffmpeg failure at all is fatal", () => {
754
- expect(() => classifyFfmpeg(new Error("no space left on device"))).toThrow(FatalError);
755
- });
756
-
699
+ /**
700
+ * The FFMPEG verdict is no longer tested here, and its absence is the change
701
+ * rather than a gap: `throwFfmpegStepError` on `@alexkroman1/aai/step-errors` owns
702
+ * it now, with both arms pinned in `sdk/step-errors.test.ts` — including the case
703
+ * this template contributed, a cause that is not an ffmpeg failure at all.
704
+ *
705
+ * What stays is what is still THIS desk's: `analyse`, which decides that an
706
+ * analysis `media.ts` cannot read is terminal.
707
+ */
757
708
  test("an analysis this desk cannot read is fatal, because a retry reads it again", () => {
758
709
  // ffmpeg SUCCEEDED and printed something unrecognized — a renamed key, a lost
759
710
  // `-loglevel info`. Every retry runs the same binary and prints the same thing.
@@ -802,104 +753,6 @@ describe("the mastered narration", () => {
802
753
  });
803
754
  });
804
755
 
805
- describe("moving bytes between the store and a local file", () => {
806
- /**
807
- * These DO touch the filesystem, which the unit tier otherwise avoids — and the
808
- * exception is deliberate rather than a slip. A temp directory is hermetic, costs
809
- * milliseconds, and is the only way to exercise the one bug this module is written
810
- * to prevent: `fileChunks` reuses one buffer across reads, so yielding a view of
811
- * it rather than a copy hands the consumer memory the next read overwrites. That
812
- * failure produces a stored file made of the LAST chunk repeated, and it does not
813
- * reproduce whenever the consumer happens to copy before the next iteration — so
814
- * a mock consumer would not see it and only real bytes will.
815
- *
816
- * `aai-cli`'s unit specs take the same exception through their own `withTempDir`.
817
- */
818
- test("withTempDir gives the work a private directory and removes it after", async () => {
819
- let seen: string | undefined;
820
- await withTempDir(async (dir) => {
821
- seen = dir;
822
- await writeFile(join(dir, "probe"), "x");
823
- await expect(stat(join(dir, "probe"))).resolves.toBeTruthy();
824
- });
825
- expect(seen).toBeTruthy();
826
- // Gone. A guest's disk is small, and a step that left a copy of every recording
827
- // it touched would fill it.
828
- await expect(stat(seen ?? "")).rejects.toThrow();
829
- });
830
-
831
- test("the directory is removed even when the work THROWS", async () => {
832
- let seen: string | undefined;
833
- const boom = new Error("the conversion failed");
834
- await expect(
835
- withTempDir((dir) => {
836
- seen = dir;
837
- throw boom;
838
- }),
839
- ).rejects.toThrow(boom);
840
- await expect(stat(seen ?? "")).rejects.toThrow();
841
- });
842
-
843
- test("materializeUpload writes the stored bytes to a path, in order", async () => {
844
- // The upload store is the seam that makes this testable at all: `readUpload`
845
- // reads a process-wide slot rather than dialling anything.
846
- const bytes = new Uint8Array(4096).map((_, at) => at % 251);
847
- restores.push(stubUploads({ [UPLOAD_ID]: { bytes, name: "call.m4a" } }));
848
-
849
- await withTempDir(async (dir) => {
850
- const path = join(dir, "source");
851
- // A small window, so the loop really runs four times — at the real 8 MiB one
852
- // a 4 KB upload would exercise a single pass and prove nothing about it.
853
- await materializeUpload(UPLOAD_ID, bytes.byteLength, path, 1024);
854
- expect(new Uint8Array(await readFile(path))).toEqual(bytes);
855
- });
856
- });
857
-
858
- test("an empty upload materializes an empty file rather than failing", async () => {
859
- restores.push(stubUploads({ [UPLOAD_ID]: { bytes: new Uint8Array(0) } }));
860
- await withTempDir(async (dir) => {
861
- const path = join(dir, "source");
862
- await materializeUpload(UPLOAD_ID, 0, path);
863
- expect((await stat(path)).size).toBe(0);
864
- });
865
- });
866
-
867
- test("fileChunks yields the file's real bytes, not a reused buffer", async () => {
868
- // The aliasing bug, and the reason these specs touch a disk. Verified to CATCH
869
- // it: deleting the `.slice()` in `fileChunks` fails this test.
870
- await withTempDir(async (dir) => {
871
- const path = join(dir, "audio.pcm");
872
- const bytes = new Uint8Array(5000).map((_, at) => (at * 7) % 251);
873
- await writeFile(path, bytes);
874
-
875
- // A 1 KB window over 5 KB of bytes, so there are five reads through ONE
876
- // buffer. That is what makes the aliasing reachable; collecting the chunks and
877
- // concatenating afterwards is what makes it visible, since every earlier chunk
878
- // would then read as the last one.
879
- const collected: Uint8Array[] = [];
880
- for await (const chunk of fileChunks(path, 1024)) collected.push(chunk);
881
-
882
- const rejoined = new Uint8Array(collected.reduce((n, c) => n + c.byteLength, 0));
883
- let at = 0;
884
- for (const chunk of collected) {
885
- rejoined.set(chunk, at);
886
- at += chunk.byteLength;
887
- }
888
- expect(rejoined).toEqual(bytes);
889
- });
890
- });
891
-
892
- test("fileChunks over an empty file yields nothing rather than one empty chunk", async () => {
893
- await withTempDir(async (dir) => {
894
- const path = join(dir, "empty");
895
- await writeFile(path, new Uint8Array(0));
896
- const collected: Uint8Array[] = [];
897
- for await (const chunk of fileChunks(path)) collected.push(chunk);
898
- expect(collected).toEqual([]);
899
- });
900
- });
901
- });
902
-
903
756
  describe("the ffmpeg steps, up to the spawn", () => {
904
757
  /**
905
758
  * These reach the point where ffmpeg would run and stop there, DETERMINISTICALLY
@@ -924,12 +777,10 @@ describe("the ffmpeg steps, up to the spawn", () => {
924
777
  });
925
778
 
926
779
  test("ingestRecording materializes the upload, then fails fatally with no ffprobe", async () => {
927
- restores.push(
928
- stubUploads({
929
- [UPLOAD_ID]: { bytes: new Uint8Array(2048), name: "call.m4a", type: "audio/mp4" },
930
- }),
931
- stubReporter().restore,
932
- );
780
+ installStubUploads({
781
+ [UPLOAD_ID]: { bytes: new Uint8Array(2048), name: "call.m4a", type: "audio/mp4" },
782
+ });
783
+ installStubReporter();
933
784
  // Fatal, not retryable: four more attempts find the same missing binary, and the
934
785
  // message already carries the install instructions.
935
786
  await expect(ingestRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
@@ -941,8 +792,9 @@ describe("the ffmpeg steps, up to the spawn", () => {
941
792
  // `stepSpeak` runs — the synthesis is not what is broken here — so this also
942
793
  // pins the ORDER: a step that mastered before speaking would fail without ever
943
794
  // calling the voice service.
944
- const speech = stubSpeech();
945
- restores.push(speech.restore, stubReporter().restore, stubUploads({}, { writable: true }));
795
+ const speech = installStubSpeech();
796
+ installStubReporter();
797
+ installStubUploads({}, { writable: true });
946
798
 
947
799
  await expect(narrate("Read this back.", "jane")).rejects.toBeInstanceOf(FatalError);
948
800
  expect(speech.calls.length).toBe(1);
@@ -953,8 +805,9 @@ describe("the ffmpeg steps, up to the spawn", () => {
953
805
  // The `finally` in `withTempDir`, on the path that matters: a guest's disk is
954
806
  // small, and a step that leaked a directory per failed run would fill it.
955
807
  const before = await readdir(tmpdir());
956
- const speech = stubSpeech();
957
- restores.push(speech.restore, stubReporter().restore, stubUploads({}, { writable: true }));
808
+ installStubSpeech();
809
+ installStubReporter();
810
+ installStubUploads({}, { writable: true });
958
811
 
959
812
  await expect(narrate("Read this back.")).rejects.toBeInstanceOf(FatalError);
960
813
  const after = await readdir(tmpdir());
@@ -86,8 +86,9 @@
86
86
 
87
87
  import { workflow, workflowApp } from "@alexkroman1/aai";
88
88
  import { ASSEMBLYAI_TTS_DEFAULT_VOICE, ASSEMBLYAI_TTS_VOICES } from "@alexkroman1/aai/tts";
89
+ import type { WorkflowDef } from "@alexkroman1/aai/workflow-api";
89
90
  import { z } from "zod";
90
- import { auditFlow } from "./workflows/audit.ts";
91
+ import { auditFlow, type CallAudit } from "./workflows/audit.ts";
91
92
 
92
93
  /**
93
94
  * The voices the form offers.
@@ -112,31 +113,43 @@ const VOICES = Object.entries(ASSEMBLYAI_TTS_VOICES)
112
113
  */
113
114
  const [FIRST_VOICE = ASSEMBLYAI_TTS_DEFAULT_VOICE, ...OTHER_VOICES] = VOICES;
114
115
 
116
+ /**
117
+ * The run input, as its own const.
118
+ *
119
+ * Named rather than inline because {@link audit} carries an explicit type, and
120
+ * that annotation is what lets `workflows/audit.ts` name
121
+ * `WorkflowInputOf<typeof audit>` for its body's parameter: the body's own
122
+ * signature would otherwise be part of what infers this declaration's type, and
123
+ * TypeScript refuses the cycle (`TS7022`).
124
+ */
125
+ const auditInput = z.object({
126
+ // A plain string, because an upload id is what the run really receives. What
127
+ // makes it a file picker rather than a text box is the `uploads` line below.
128
+ //
129
+ // "Anything" is not marketing: the first step hands the file to ffmpeg, so the
130
+ // accepted set is ffmpeg's rather than this template's — a video's audio track
131
+ // included, since the conversion drops the video.
132
+ recording: z.string().describe("Any recording — WAV, MP3, M4A, or a video's audio track"),
133
+ // An enum, so the form renders a SELECT rather than a text box — which is the
134
+ // whole reason the list is derived above rather than left free-form. Optional,
135
+ // so the SDK's own default voice applies when nobody chooses.
136
+ voice: z
137
+ .enum([FIRST_VOICE, ...OTHER_VOICES])
138
+ .optional()
139
+ .describe("Voice to read the audit in"),
140
+ });
141
+
115
142
  /**
116
143
  * The declaration: schema, description, and the directive body.
117
144
  *
118
145
  * Exported so `WorkflowOutputOf<typeof audit>` names the output type in one place —
119
146
  * including from `client.tsx`, where `import type` is erased and so bundles nothing
120
- * server-side.
147
+ * server-side — and so `workflows/audit.ts` can name `WorkflowInputOf<typeof audit>`
148
+ * for the body's parameter.
121
149
  */
122
- export const audit = workflow({
150
+ export const audit: WorkflowDef<typeof auditInput, CallAudit> = workflow({
123
151
  description: "Level a call recording, transcribe it at its pauses, and audit what was said",
124
- input: z.object({
125
- // A plain string, because an upload id is what the run really receives. What
126
- // makes it a file picker rather than a text box is the `uploads` line below.
127
- //
128
- // "Anything" is not marketing: the first step hands the file to ffmpeg, so the
129
- // accepted set is ffmpeg's rather than this template's — a video's audio track
130
- // included, since the conversion drops the video.
131
- recording: z.string().describe("Any recording — WAV, MP3, M4A, or a video's audio track"),
132
- // An enum, so the form renders a SELECT rather than a text box — which is the
133
- // whole reason the list is derived above rather than left free-form. Optional,
134
- // so the SDK's own default voice applies when nobody chooses.
135
- voice: z
136
- .enum([FIRST_VOICE, ...OTHER_VOICES])
137
- .optional()
138
- .describe("Voice to read the audit in"),
139
- }),
152
+ input: auditInput,
140
153
  // The one line that makes the form take a file: `<WorkflowFields>` renders a
141
154
  // picker for this property, `useWorkflowSubmit` stores the chosen file, and the
142
155
  // ingest step reads it back with `readUpload`.
@@ -13,30 +13,34 @@
13
13
  * cut it in the pauses or fell back to cutting by arithmetic, and that difference
14
14
  * is exactly what explains a mangled word at a seam. So `blindCuts` is rendered
15
15
  * rather than hidden, alongside what the recording measured before levelling.
16
- * - **`api.download`, not a URL.** The run's output carries an upload id. The
16
+ * - **`useDownloadUrl`, not a URL.** The run's output carries an upload id. The
17
17
  * obvious `<audio src={`/workflows/uploads/${id}`}>` is wrong in a way that only
18
18
  * shows up after a deploy: the byte route takes the same `Authorization` header
19
19
  * every other route does, and neither `<audio src>` nor `<a href>` can send one.
20
20
  * So a page built on a URL works against `aai dev`, where there is no token, and
21
- * 401s the moment the agent has one.
21
+ * 401s the moment the agent has one. The hook is `aai-ui`'s rather than four
22
+ * lines here, because the two lines that matter are the ones around them: the
23
+ * `URL.revokeObjectURL` on cleanup, and the guard that stops a slow first
24
+ * download landing under a second run's output.
22
25
  */
23
26
 
24
27
  import "@alexkroman1/aai-ui/styles.css";
25
28
  // ERASED at build time, so naming the agent's own type costs the browser bundle
26
29
  // nothing — and it is what stops this file restating a shape `workflows/audit.ts`
27
30
  // already declares.
28
- import type { WorkflowOutputOf } from "@alexkroman1/aai";
31
+ import { formatBytes, formatDuration } from "@alexkroman1/aai/utils";
32
+ import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
29
33
  import {
30
34
  createWorkflowApi,
31
35
  Form,
32
36
  page,
33
37
  SubmitButton,
34
38
  UploadProgressBar,
39
+ useDownloadUrl,
35
40
  useWorkflowSubmit,
36
41
  WorkflowFields,
37
42
  WorkflowProgress,
38
43
  } from "@alexkroman1/aai-ui";
39
- import { useEffect, useState } from "react";
40
44
  import type { audit } from "./agent.ts";
41
45
 
42
46
  /** What a completed run reports, derived from the workflow rather than restated. */
@@ -59,51 +63,6 @@ const WORKFLOW = "audit";
59
63
  */
60
64
  const api = createWorkflowApi();
61
65
 
62
- /** `4:09`, from the milliseconds a run reports. */
63
- function duration(ms: number): string {
64
- const total = Math.round(ms / 1000);
65
- const minutes = Math.floor(total / 60);
66
- return `${minutes}:${String(total % 60).padStart(2, "0")}`;
67
- }
68
-
69
- /**
70
- * The finished run's audio, as something the browser will play.
71
- *
72
- * A hook rather than four lines in the component because the CLEANUP is the part
73
- * worth keeping in one place: an object URL pins its blob for the life of the
74
- * document, so it is revoked when the id changes and when the page goes away. The
75
- * `cancelled` flag covers the other half — a second run settling while the first
76
- * download is still in flight would otherwise set state from the stale one.
77
- */
78
- function useAudioUrl(uploadId: string | undefined): { url?: string; error?: string } {
79
- const [state, setState] = useState<{ url?: string; error?: string }>({});
80
-
81
- useEffect(() => {
82
- if (uploadId === undefined) {
83
- setState({});
84
- return;
85
- }
86
- let cancelled = false;
87
- let objectUrl: string | undefined;
88
- api
89
- .download(uploadId)
90
- .then((blob) => {
91
- if (cancelled) return;
92
- objectUrl = URL.createObjectURL(blob);
93
- setState({ url: objectUrl });
94
- })
95
- .catch((err: unknown) => {
96
- if (!cancelled) setState({ error: err instanceof Error ? err.message : String(err) });
97
- });
98
- return () => {
99
- cancelled = true;
100
- if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl);
101
- };
102
- }, [uploadId]);
103
-
104
- return state;
105
- }
106
-
107
66
  /** One labelled number in the pipeline panel. */
108
67
  function Stat({ label, value }: { label: string; value: string }) {
109
68
  return (
@@ -135,7 +94,10 @@ export function App() {
135
94
  const { submit, run, pending, upload, pauseUpload, resumeUpload, error } =
136
95
  useWorkflowSubmit<Audit>(WORKFLOW, { api });
137
96
  const output = run?.status === "completed" ? run.output : undefined;
138
- const audio = useAudioUrl(output?.audio);
97
+ // `useDownloadUrl` is the SDK's: the byte route takes the agent's bearer, so the
98
+ // bytes have to be FETCHED and handed to the element as an object URL — and the
99
+ // object URL has to be revoked, which is the half a page written by hand forgets.
100
+ const audio = useDownloadUrl(output?.audio, { api });
139
101
 
140
102
  return (
141
103
  <main className="mx-auto flex max-w-3xl flex-col gap-6 p-8">
@@ -172,7 +134,7 @@ export function App() {
172
134
  <div className="flex flex-col gap-1">
173
135
  <h2 className="text-xl">{output.headline}</h2>
174
136
  <p className="text-sm opacity-70">
175
- {output.source} · {duration(output.durationMs)} · {output.words} words
137
+ {output.source} · {formatDuration(output.durationMs)} · {output.words} words
176
138
  </p>
177
139
  </div>
178
140
 
@@ -188,7 +150,7 @@ export function App() {
188
150
  label="Cut in speech"
189
151
  value={output.blindCuts === 0 ? "none" : String(output.blindCuts)}
190
152
  />
191
- <Stat label="Run time" value={duration(output.elapsedMs)} />
153
+ <Stat label="Run time" value={formatDuration(output.elapsedMs)} />
192
154
  </dl>
193
155
 
194
156
  <Findings title="Risks" items={output.risks} />
@@ -196,9 +158,10 @@ export function App() {
196
158
 
197
159
  <section className="flex flex-col gap-2">
198
160
  <h3 className="text-sm font-medium opacity-70">
199
- Read aloud · {duration(output.audioDurationMs)} ·{" "}
200
- {Math.round(output.audioBytes / 1024)} KB
161
+ Read aloud · {formatDuration(output.audioDurationMs)} ·{" "}
162
+ {formatBytes(output.audioBytes)}
201
163
  </h3>
164
+ {audio.pending && <p className="text-sm opacity-70">Fetching the audio…</p>}
202
165
  {audio.error !== undefined && (
203
166
  <p className="text-red-600">Could not load the audio: {audio.error}</p>
204
167
  )}
@@ -45,11 +45,16 @@
45
45
  * ingest result, once as the plan) and buy nothing.
46
46
  */
47
47
 
48
- import { encodeWav, mapConcurrent, readUpload, report } from "@alexkroman1/aai/utils";
48
+ import { encodeWav, mapConcurrent, readUpload, report } from "@alexkroman1/aai/step";
49
+ import { countWords, formatDuration } from "@alexkroman1/aai/utils";
50
+ // ERASED at build time, so the body can name the schema's own output type without
51
+ // a runtime cycle back through `agent.ts` — the same mechanism `client.tsx` uses
52
+ // for `WorkflowOutputOf`.
53
+ import type { WorkflowInputOf } from "@alexkroman1/aai/workflow-api";
54
+ import type { audit } from "../agent.ts";
49
55
  import { ingestRecording } from "./ingest.ts";
50
56
  import {
51
57
  ANALYSIS_FORMAT,
52
- clock,
53
58
  durationSeconds,
54
59
  planSegments,
55
60
  type Segment,
@@ -136,13 +141,7 @@ export type CallAudit = {
136
141
  * The input is what `POST /workflows/runs` carries — see `agent.ts` for the schema
137
142
  * it is validated against before a run exists.
138
143
  */
139
- export async function auditFlow(input: {
140
- recording: string;
141
- // `| undefined` explicitly, not merely optional: `exactOptionalPropertyTypes` is
142
- // on repo-wide, and what a zod `.optional()` infers is a property that may be
143
- // PRESENT and undefined.
144
- voice?: string | undefined;
145
- }): Promise<CallAudit> {
144
+ export async function auditFlow(input: WorkflowInputOf<typeof audit>): Promise<CallAudit> {
146
145
  "use workflow";
147
146
 
148
147
  // Both at once: neither needs the other, and issued together they are one round
@@ -207,7 +206,7 @@ export async function auditFlow(input: {
207
206
  * **`encodeWav` is what makes a byte range decodable.** The stored audio is
208
207
  * headerless PCM, and the endpoint decodes each request independently, so a slice
209
208
  * of it is meaningless bytes until a header says what they are. That header is the
210
- * SDK's (`@alexkroman1/aai/utils`) rather than this template's: the equivalent
209
+ * SDK's (`@alexkroman1/aai/step`) rather than this template's: the equivalent
211
210
  * function in `transcription-workflow` is 25 lines of `DataView` writes with a
212
211
  * comment about which of the two declared lengths a decoder trusts, and there is
213
212
  * no reason for a second copy of it to exist.
@@ -222,7 +221,7 @@ export async function transcribeSegment(audioId: string, segment: Segment): Prom
222
221
  // ORDER is not guaranteed here and does not need to be — the calls go out
223
222
  // together, so their lines interleave by completion, and `segment.index` is what
224
223
  // puts the TRANSCRIPT back in order.
225
- await report(`Transcribing ${clock(segment.startMs)}–${clock(segment.endMs)}.`);
224
+ await report(`Transcribing ${formatDuration(segment.startMs)}–${formatDuration(segment.endMs)}.`);
226
225
 
227
226
  // `[start, end)`, the same half-open pair `planSegments` produced — the store
228
227
  // owns the conversion to HTTP's inclusive range, so there is no `- 1` here to get
@@ -231,7 +230,7 @@ export async function transcribeSegment(audioId: string, segment: Segment): Prom
231
230
  const text = await transcribeSpan(
232
231
  encodeWav(audio.bytes, ANALYSIS_FORMAT),
233
232
  `segment-${segment.index}.wav`,
234
- `Segment ${segment.index} (${clock(segment.startMs)})`,
233
+ `Segment ${segment.index} (${formatDuration(segment.startMs)})`,
235
234
  );
236
235
 
237
236
  return { index: segment.index, text };
@@ -297,9 +296,3 @@ export function joinSegments(segments: readonly Segment[], parts: readonly Segme
297
296
  }
298
297
  return joined;
299
298
  }
300
-
301
- /** Words in a transcript, for the counts a page shows. */
302
- export function countWords(text: string): number {
303
- const trimmed = text.trim();
304
- return trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
305
- }