@alexkroman1/aai-cli 6.10.1 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  2. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
  3. package/dist/_dev-env.d.ts +22 -2
  4. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
  5. package/dist/_dev-server.d.ts +0 -17
  6. package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
  7. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  8. package/dist/_preflight.d.ts +1 -1
  9. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
  10. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  11. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
  12. package/dist/_workflow-scan.d.ts +108 -0
  13. package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
  14. package/dist/cli.mjs +30 -25
  15. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  16. package/dist/client-bundler.mjs +1 -1
  17. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  18. package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
  19. package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
  20. package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
  21. package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
  22. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  23. package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
  24. package/dist/project-config.mjs +1 -1
  25. package/dist/scaffold/CLAUDE.md +93 -14
  26. package/dist/scaffold/package.json +5 -3
  27. package/dist/scaffold/server.mjs +13 -4
  28. package/dist/scaffold/vite.config.ts +1 -1
  29. package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
  30. package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
  31. package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
  32. package/dist/templates/call-audit/agent.test.ts +818 -0
  33. package/dist/templates/call-audit/agent.ts +171 -0
  34. package/dist/templates/call-audit/client.tsx +198 -0
  35. package/dist/templates/call-audit/workflows/audit.ts +298 -0
  36. package/dist/templates/call-audit/workflows/ingest.ts +251 -0
  37. package/dist/templates/call-audit/workflows/media.ts +637 -0
  38. package/dist/templates/call-audit/workflows/summarize.ts +201 -0
  39. package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
  40. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  41. package/dist/templates/dispatch-center/client.tsx +239 -129
  42. package/dist/templates/dispatch-center/shared.ts +99 -1
  43. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  44. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  45. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  46. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  47. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  48. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  49. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  50. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  51. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  52. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  53. package/dist/templates/health-assistant/agent.test.ts +22 -4
  54. package/dist/templates/infocom-adventure/agent.test.ts +4 -6
  55. package/dist/templates/infocom-adventure/client.tsx +246 -164
  56. package/dist/templates/link-digest/agent.test.ts +24 -19
  57. package/dist/templates/link-digest/client.tsx +47 -61
  58. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  59. package/dist/templates/night-owl/agent.test.ts +70 -19
  60. package/dist/templates/night-owl/agent.ts +5 -0
  61. package/dist/templates/night-owl/client.tsx +56 -56
  62. package/dist/templates/night-owl/shared.ts +24 -0
  63. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  64. package/dist/templates/pizza-ordering/agent.test.ts +27 -27
  65. package/dist/templates/pizza-ordering/client.tsx +9 -26
  66. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  67. package/dist/templates/plan-and-execute/agent.ts +1 -1
  68. package/dist/templates/plan-and-execute/client.tsx +12 -15
  69. package/dist/templates/plan-and-execute/shared.ts +71 -2
  70. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  71. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  72. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  73. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  74. package/dist/templates/podcast-digest/agent.test.ts +746 -0
  75. package/dist/templates/podcast-digest/agent.ts +139 -0
  76. package/dist/templates/podcast-digest/client.tsx +154 -0
  77. package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
  78. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  79. package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
  80. package/dist/templates/recap-workflow/agent.test.ts +107 -96
  81. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  82. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  83. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  84. package/dist/templates/recap-workflow/workflows/recap.ts +48 -41
  85. package/dist/templates/redline/agent.test.ts +17 -15
  86. package/dist/templates/redline/client.tsx +12 -12
  87. package/dist/templates/redline/workflows/redline.ts +19 -31
  88. package/dist/templates/research-workflow/agent.test.ts +60 -59
  89. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  90. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  91. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  92. package/dist/templates/retail/agent.test.ts +26 -23
  93. package/dist/templates/retail/client.tsx +226 -117
  94. package/dist/templates/retail/registry.test.ts +38 -6
  95. package/dist/templates/retail/store.test.ts +82 -15
  96. package/dist/templates/retail/store.ts +174 -47
  97. package/dist/templates/retail/system-prompt.md +11 -2
  98. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
  99. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
  100. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  101. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  102. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  103. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  104. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  105. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  106. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  107. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
  108. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
  109. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
  110. package/dist/templates/retail/tools/modify_user_address.ts +2 -2
  111. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
  112. package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
  113. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  114. package/dist/templates/solo-rpg/client.tsx +38 -37
  115. package/dist/templates/solo-rpg/shared.ts +145 -19
  116. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  117. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  118. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  119. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  120. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  121. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  122. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  123. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  124. package/dist/templates/spoken-summary/agent.test.ts +314 -0
  125. package/dist/templates/spoken-summary/agent.ts +155 -0
  126. package/dist/templates/spoken-summary/client.tsx +185 -0
  127. package/dist/templates/spoken-summary/workflows/summarize.ts +237 -0
  128. package/dist/templates/spoken-summary/workflows/transcribe.ts +138 -0
  129. package/dist/templates/support-line/agent.test.ts +11 -16
  130. package/dist/templates/support-line/agent.ts +1 -1
  131. package/dist/templates/support-line/client.tsx +9 -9
  132. package/dist/templates/support-line/nodes.ts +100 -0
  133. package/dist/templates/support-line/procedure.ts +407 -0
  134. package/dist/templates/support-line/tools/answer_question.ts +17 -4
  135. package/dist/templates/transcription-workflow/agent.test.ts +246 -123
  136. package/dist/templates/transcription-workflow/agent.ts +21 -7
  137. package/dist/templates/transcription-workflow/client.tsx +17 -42
  138. package/dist/templates/transcription-workflow/workflows/batch.ts +79 -180
  139. package/dist/templates/transcription-workflow/workflows/normalize.ts +247 -0
  140. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  141. package/dist/templates/transcription-workflow/workflows/stream.ts +10 -9
  142. package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
  143. package/dist/templates/transcription-workflow/workflows/transcribe.ts +55 -40
  144. package/dist/templates/transcription-workflow/workflows/wav.ts +31 -31
  145. package/dist/templates/travel-concierge/agent.test.ts +64 -33
  146. package/dist/templates/travel-concierge/client.tsx +11 -23
  147. package/dist/templates/travel-concierge/routing.ts +34 -15
  148. package/dist/templates/travel-concierge/shared.ts +70 -3
  149. package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
  150. package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
  151. package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
  152. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
  153. package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
  154. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
  155. package/dist/worker-bundler.mjs +1 -1
  156. package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
  157. package/dist/workflow-bundler.d.ts +6 -1
  158. package/dist/workflow.d.ts +1 -1
  159. package/package.json +5 -4
  160. package/dist/templates/support-line/graph.ts +0 -224
  161. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  162. /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
@@ -0,0 +1,171 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * A WORKFLOW APP that audits a recorded call, with **ffmpeg in the pipeline on
4
+ * both sides of the model.**
5
+ *
6
+ * ```text
7
+ * any recording → levelled PCM → cut at the pauses → transcript
8
+ * ffmpeg ffmpeg sync STT
9
+ *
10
+ * → headline, risks, actions → a script → an MP3
11
+ * LLM Gateway schema TTS + ffmpeg
12
+ * ```
13
+ *
14
+ * ## Which template to read first
15
+ *
16
+ * This one is the deepest of the workflow apps, so it is the wrong place to start.
17
+ *
18
+ * - `link-digest` owns the SHAPE — `workflowApp()`, no session, no tools, a form
19
+ * that starts a run and a page that watches it.
20
+ * - `transcription-workflow` owns the FAN-OUT — why the sync transcription
21
+ * endpoint's 120-second cap forces one, and how a run survives dying on segment
22
+ * 27 of 60.
23
+ * - `spoken-summary` owns the audio ROUND TRIP — `stepSpeak`, `writeUpload`, and
24
+ * why a page needs `api.download` rather than a URL.
25
+ *
26
+ * **What this template adds is a decoder**, and everything downstream changes
27
+ * because of it. `workflows/audit.ts` carries the table comparing it against
28
+ * `transcription-workflow` row by row; the summary is that normalizing FIRST lets
29
+ * the desk cut a recording *in its pauses* instead of every 90 seconds, which
30
+ * deletes the segment overlap, the seam-matching stitcher, the WAV header parser
31
+ * and one of the two provider caps it had to plan against.
32
+ *
33
+ * ## Five ffmpeg jobs, and each one is a decision
34
+ *
35
+ * `workflows/media.ts` is where they are built, and it is the file to read: every
36
+ * argv is a pure function, so the argv is a value a spec asserts on rather than a
37
+ * string embedded in a step that spawns.
38
+ *
39
+ * | | what it does | why it is not obvious |
40
+ * | --- | --- | --- |
41
+ * | `ffprobe` | what the file WAS | on a temp file, because a pipe cannot seek an m4a's trailing index |
42
+ * | `loudnorm` pass 1 | measure five numbers | `-f null -`: decode everything, write nothing |
43
+ * | `loudnorm` pass 2 | apply them | one linear gain, so speech does not pump |
44
+ * | `silencedetect` | find every pause | same pass as above — a filter chain, not a second decode |
45
+ * | `libmp3lame` | master the summary | 4.3 MB of WAV becomes ~110 KB |
46
+ *
47
+ * The two analyses read their answers back by **different routes**, and that is the
48
+ * single most surprising thing in the template: loudness arrives on stderr (one
49
+ * block, printed last, so the SDK's capped stderr TAIL holds it) and the pauses
50
+ * arrive in a FILE (one event per pause, so a tail would silently drop the
51
+ * earliest ones and the desk would mis-cut only long recordings). `media.ts`'s
52
+ * module doc carries it.
53
+ *
54
+ * ## What it needs
55
+ *
56
+ * - **`ASSEMBLYAI_API_KEY` in the agent env** — `.env` under `aai dev`,
57
+ * `aai secret put ASSEMBLYAI_API_KEY` once deployed. `requiredEnv` below is what
58
+ * makes a deploy check for it rather than letting the first run find out. One key
59
+ * covers transcription, the model and the voice alike.
60
+ * - **Storage** (`aai storage enable`, Settings → Database in the studio, or
61
+ * `DATABASE_URL` under `aai dev`). REQUIRED, unlike most workflow apps: an
62
+ * upload's record is a row, and this desk both reads an upload and writes two.
63
+ * - **ffmpeg** — every deployed guest's image installs it (and `ffprobe` with it).
64
+ * Under `aai dev` it is whatever is on `PATH`, or what `AAI_FFMPEG_PATH` /
65
+ * `AAI_FFPROBE_PATH` name. That is the one place dev/prod parity is partial, so
66
+ * a missing binary is reported as an instruction rather than as
67
+ * `spawn ffmpeg ENOENT`.
68
+ *
69
+ * ## It is scriptable, which is the other half of having an API
70
+ *
71
+ * The page is one caller. Two requests do the same thing from a shell — upload,
72
+ * then start a run naming the id, with `wait` holding the request open:
73
+ *
74
+ * ```sh
75
+ * ID=$(curl -s -X POST "https://<your-agent>/workflows/uploads?name=call.m4a" \
76
+ * -H 'content-type: audio/mp4' --data-binary @call.m4a | jq -r .id)
77
+ *
78
+ * curl -X POST https://<your-agent>/workflows/runs \
79
+ * -H 'content-type: application/json' \
80
+ * -d "{\"workflow\":\"audit\",\"wait\":60000,\"input\":{\"recording\":\"$ID\"}}"
81
+ * ```
82
+ *
83
+ * The spoken audit comes back as an upload id in the run's output; the byte route
84
+ * (`GET /workflows/uploads/<id>`) takes the same bearer every other route does.
85
+ */
86
+
87
+ import { workflow, workflowApp } from "@alexkroman1/aai";
88
+ import { ASSEMBLYAI_TTS_DEFAULT_VOICE, ASSEMBLYAI_TTS_VOICES } from "@alexkroman1/aai/tts";
89
+ import type { WorkflowDef } from "@alexkroman1/aai/workflow-api";
90
+ import { z } from "zod";
91
+ import { auditFlow, type CallAudit } from "./workflows/audit.ts";
92
+
93
+ /**
94
+ * The voices the form offers.
95
+ *
96
+ * READ from the SDK's catalog rather than listed, because a wrong voice id is a
97
+ * SILENT failure — it is a free-form string the service rejects in band after the
98
+ * socket is open, so the synthesis simply produces nothing. Narrowed to the English
99
+ * ones because the audit is written in the transcript's language and the prompt does
100
+ * not translate; every voice in the catalog speaks exactly one.
101
+ */
102
+ const VOICES = Object.entries(ASSEMBLYAI_TTS_VOICES)
103
+ .filter(([, spec]) => spec.language === "en")
104
+ .map(([id]) => id);
105
+
106
+ /**
107
+ * The same list as a TUPLE, which is what `z.enum` takes.
108
+ *
109
+ * Destructured rather than cast: a `.map` produces an array, and
110
+ * `as [string, ...string[]]` would be a template teaching a cast. The default covers
111
+ * the empty case honestly — a catalog with no English voice falls back to the SDK's
112
+ * own default rather than rendering a picker with no options.
113
+ */
114
+ const [FIRST_VOICE = ASSEMBLYAI_TTS_DEFAULT_VOICE, ...OTHER_VOICES] = VOICES;
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
+
142
+ /**
143
+ * The declaration: schema, description, and the directive body.
144
+ *
145
+ * Exported so `WorkflowOutputOf<typeof audit>` names the output type in one place —
146
+ * including from `client.tsx`, where `import type` is erased and so bundles nothing
147
+ * server-side — and so `workflows/audit.ts` can name `WorkflowInputOf<typeof audit>`
148
+ * for the body's parameter.
149
+ */
150
+ export const audit: WorkflowDef<typeof auditInput, CallAudit> = workflow({
151
+ description: "Level a call recording, transcribe it at its pauses, and audit what was said",
152
+ input: auditInput,
153
+ // The one line that makes the form take a file: `<WorkflowFields>` renders a
154
+ // picker for this property, `useWorkflowSubmit` stores the chosen file, and the
155
+ // ingest step reads it back with `readUpload`.
156
+ uploads: ["recording"],
157
+ run: auditFlow,
158
+ });
159
+
160
+ export default workflowApp({
161
+ name: "Call Audit",
162
+ workflows: { audit },
163
+ // Checked at deploy time, so a missing key is a warning naming it rather than a run
164
+ // that fails on its third step. A workflow app declares no providers, so this is the
165
+ // only thing that can name the credential its steps read.
166
+ //
167
+ // ffmpeg is NOT here and cannot be: `requiredEnv` checks the agent's environment,
168
+ // and a binary on `PATH` is not an environment variable. The deployed guest always
169
+ // has one; under `aai dev` the failure names its own remedy.
170
+ requiredEnv: ["ASSEMBLYAI_API_KEY"],
171
+ });
@@ -0,0 +1,198 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The page: a form, a progress log, the audit, and a player.
4
+ *
5
+ * `link-digest` shows these primitives raw, `transcription-workflow` shows the form
6
+ * layer in full, and `spoken-summary` shows how a page plays a file the RUN
7
+ * produced. None of that is restated here — this page is deliberately the least
8
+ * novel file in the template, because the subject is the pipeline.
9
+ *
10
+ * Two things it does add, and both are about being honest about the pipeline:
11
+ *
12
+ * - **The PIPELINE panel.** A reader cannot tell from a transcript whether the desk
13
+ * cut it in the pauses or fell back to cutting by arithmetic, and that difference
14
+ * is exactly what explains a mangled word at a seam. So `blindCuts` is rendered
15
+ * rather than hidden, alongside what the recording measured before levelling.
16
+ * - **`useDownloadUrl`, not a URL.** The run's output carries an upload id. The
17
+ * obvious `<audio src={`/workflows/uploads/${id}`}>` is wrong in a way that only
18
+ * shows up after a deploy: the byte route takes the same `Authorization` header
19
+ * every other route does, and neither `<audio src>` nor `<a href>` can send one.
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. 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.
25
+ */
26
+
27
+ import "@alexkroman1/aai-ui/styles.css";
28
+ // ERASED at build time, so naming the agent's own type costs the browser bundle
29
+ // nothing — and it is what stops this file restating a shape `workflows/audit.ts`
30
+ // already declares.
31
+ import { formatBytes, formatDuration } from "@alexkroman1/aai/utils";
32
+ import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
33
+ import {
34
+ createWorkflowApi,
35
+ Form,
36
+ page,
37
+ SubmitButton,
38
+ UploadProgressBar,
39
+ useDownloadUrl,
40
+ useWorkflowSubmit,
41
+ WorkflowFields,
42
+ WorkflowProgress,
43
+ } from "@alexkroman1/aai-ui";
44
+ import type { audit } from "./agent.ts";
45
+
46
+ /** What a completed run reports, derived from the workflow rather than restated. */
47
+ type Audit = WorkflowOutputOf<typeof audit>;
48
+
49
+ /**
50
+ * The workflow's name, as a page starts a run by one.
51
+ *
52
+ * A rename in `agent.ts` is a runtime 400 rather than a compile error, which is why
53
+ * `agent.test.ts` pins this string.
54
+ */
55
+ const WORKFLOW = "audit";
56
+
57
+ /**
58
+ * Hoisted out of the component deliberately.
59
+ *
60
+ * The hooks hold the client in a ref precisely so a fresh object per render cannot
61
+ * restart their watch, but building one in render is still a new `fetch` closure
62
+ * every time and reads as though it were free.
63
+ */
64
+ const api = createWorkflowApi();
65
+
66
+ /** One labelled number in the pipeline panel. */
67
+ function Stat({ label, value }: { label: string; value: string }) {
68
+ return (
69
+ <div className="flex flex-col">
70
+ <dt className="text-xs uppercase tracking-wide opacity-60">{label}</dt>
71
+ <dd className="text-sm">{value}</dd>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ /** A list that renders nothing rather than an empty box — see `risks` in the schema. */
77
+ function Findings({ title, items }: { title: string; items: string[] }) {
78
+ if (items.length === 0) return null;
79
+ return (
80
+ <section className="flex flex-col gap-1">
81
+ <h3 className="text-sm font-medium opacity-70">{title}</h3>
82
+ <ul className="flex list-disc flex-col gap-1 pl-5">
83
+ {items.map((item) => (
84
+ <li key={item}>{item}</li>
85
+ ))}
86
+ </ul>
87
+ </section>
88
+ );
89
+ }
90
+
91
+ export function App() {
92
+ // The generic is what makes `run.status === "completed"` narrow to a TYPED
93
+ // `run.output` instead of `unknown`.
94
+ const { submit, run, pending, upload, pauseUpload, resumeUpload, error } =
95
+ useWorkflowSubmit<Audit>(WORKFLOW, { api });
96
+ const output = run?.status === "completed" ? run.output : undefined;
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 });
101
+
102
+ return (
103
+ <main className="mx-auto flex max-w-3xl flex-col gap-6 p-8">
104
+ <header className="flex flex-col gap-1">
105
+ <h1 className="text-2xl font-medium">Call Audit</h1>
106
+ <p className="text-sm opacity-70">
107
+ Upload a call recording — any format. It comes back levelled, transcribed at its pauses,
108
+ and audited for risks and actions.
109
+ </p>
110
+ </header>
111
+
112
+ <Form onSubmit={submit} error={error} className="flex flex-col gap-4">
113
+ {/* Every control, from the workflow's own input schema. See the module doc. */}
114
+ <WorkflowFields workflow={WORKFLOW} />
115
+ <SubmitButton pending={pending} pendingLabel="Auditing…">
116
+ Audit the call
117
+ </SubmitButton>
118
+ </Form>
119
+
120
+ {/* The upload is its own wait, and the one nothing else can describe: the run
121
+ does not EXIST until the bytes are in, so there is no run id and nothing
122
+ for `<WorkflowProgress>` to read. */}
123
+ <UploadProgressBar upload={upload} onPause={pauseUpload} onResume={resumeUpload} />
124
+
125
+ {/* What the run itself says, from `report()` in the workflow's steps — which
126
+ for this template is the ffmpeg narration: what the file was, what it
127
+ measured, how many pauses were found. */}
128
+ <WorkflowProgress runId={run?.runId} api={api} />
129
+
130
+ {run?.status === "failed" && <p className="text-red-600">That one failed: {run.error}</p>}
131
+
132
+ {output !== undefined && (
133
+ <article className="flex flex-col gap-6">
134
+ <div className="flex flex-col gap-1">
135
+ <h2 className="text-xl">{output.headline}</h2>
136
+ <p className="text-sm opacity-70">
137
+ {output.source} · {formatDuration(output.durationMs)} · {output.words} words
138
+ </p>
139
+ </div>
140
+
141
+ {/* What the pipeline did, which is this template's subject. Rendered rather
142
+ than logged because `blindCuts` is the one number that explains a bad
143
+ seam, and a reader has no other way to know. */}
144
+ <dl className="grid grid-cols-2 gap-3 rounded border border-current/10 p-4 sm:grid-cols-3">
145
+ <Stat label="Source codec" value={output.codec} />
146
+ <Stat label="Loudness in" value={`${output.loudnessBefore} LUFS`} />
147
+ <Stat label="Speech" value={`${output.speechPercent}%`} />
148
+ <Stat label="Segments" value={String(output.segments)} />
149
+ <Stat
150
+ label="Cut in speech"
151
+ value={output.blindCuts === 0 ? "none" : String(output.blindCuts)}
152
+ />
153
+ <Stat label="Run time" value={formatDuration(output.elapsedMs)} />
154
+ </dl>
155
+
156
+ <Findings title="Risks" items={output.risks} />
157
+ <Findings title="Actions" items={output.actions} />
158
+
159
+ <section className="flex flex-col gap-2">
160
+ <h3 className="text-sm font-medium opacity-70">
161
+ Read aloud · {formatDuration(output.audioDurationMs)} ·{" "}
162
+ {formatBytes(output.audioBytes)}
163
+ </h3>
164
+ {audio.pending && <p className="text-sm opacity-70">Fetching the audio…</p>}
165
+ {audio.error !== undefined && (
166
+ <p className="text-red-600">Could not load the audio: {audio.error}</p>
167
+ )}
168
+ {audio.url !== undefined && (
169
+ <>
170
+ {/* No `<track>`, and that is a judgement rather than an
171
+ oversight: the spoken text is rendered in full immediately
172
+ below this player, which is the same information a caption
173
+ track would carry. `spoken-summary` serves a one-cue WebVTT
174
+ data URL instead — worth reading for how, if a real track is
175
+ what a page needs. */}
176
+ <audio controls src={audio.url} className="w-full" />
177
+ {/* `download` works on an object URL because the bytes are already in
178
+ the tab; it is the href that could not carry the agent's bearer,
179
+ not the attribute. */}
180
+ <a href={audio.url} download="audit.mp3" className="text-sm underline">
181
+ Download audit.mp3
182
+ </a>
183
+ </>
184
+ )}
185
+ <p className="text-sm opacity-70">{output.spoken}</p>
186
+ </section>
187
+
188
+ <details className="text-sm">
189
+ <summary className="cursor-pointer opacity-70">Transcript</summary>
190
+ <p className="mt-2 whitespace-pre-wrap">{output.transcript}</p>
191
+ </details>
192
+ </article>
193
+ )}
194
+ </main>
195
+ );
196
+ }
197
+
198
+ page({ name: "Call Audit", component: App });
@@ -0,0 +1,298 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The workflow body, and the fan-out it plans.
4
+ *
5
+ * ```text
6
+ * now one step → when the run began
7
+ * ingestRecording one step → levelled PCM + every pause (ingest.ts)
8
+ * planSegments the BODY → where to cut (media.ts, pure)
9
+ * transcribeSegment N steps → one sync API request each, bounded
10
+ * summarize one step → headline, risks, actions (summarize.ts)
11
+ * narrate one step → an MP3 of the summary (summarize.ts)
12
+ * now one step → when it finished
13
+ * ```
14
+ *
15
+ * Read `transcription-workflow` first: it owns the fan-out — why the sync
16
+ * endpoint forces one, how `mapConcurrent` keeps a replay's call order stable,
17
+ * why a segment is addressed by byte range and never carried — and none of that
18
+ * is restated here. **What this template adds is what changes when a DECODER is
19
+ * in the pipeline**, and it is worth reading the two side by side, because the
20
+ * difference is subtraction:
21
+ *
22
+ * | | `transcription-workflow` | here |
23
+ * | --- | --- | --- |
24
+ * | accepts | any audio, converts to WAV | any audio, converts to raw PCM |
25
+ * | header | parsed (`parseWav`, ~180 lines) | **none — byte 0 is second 0** |
26
+ * | cut at | every 90s, wherever that lands | **the middle of a pause** |
27
+ * | overlap | 2s per segment, transcribed twice | **none** |
28
+ * | stitching | seam matching, drops repeated words | **ordered concatenation** |
29
+ * | caps to plan against | 120s AND 40 MB, whichever binds | **120s** |
30
+ * | levelling | none | `loudnorm`, two-pass |
31
+ *
32
+ * Every row on the right is a consequence of one decision: normalize FIRST, to a
33
+ * format this desk chose. `media.ts` carries the argument for each.
34
+ *
35
+ * ## The plan is made in the BODY, and that is legal
36
+ *
37
+ * `planSegments` runs in the directive body rather than in a step, which looks
38
+ * like a rule violation and is not: it is a pure function of `ingested.silences`
39
+ * and `ingested.durationMs`, both of which came out of a journaled step result.
40
+ * So a replay re-derives the identical list in the identical order, which is
41
+ * exactly what `mapConcurrent` needs — the DevKit correlates a journal entry to a
42
+ * step call by the ORDER the call was issued in.
43
+ *
44
+ * Putting it in a step would journal the same list twice (once as part of the
45
+ * ingest result, once as the plan) and buy nothing.
46
+ */
47
+
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";
55
+ import { ingestRecording } from "./ingest.ts";
56
+ import {
57
+ ANALYSIS_FORMAT,
58
+ durationSeconds,
59
+ planSegments,
60
+ type Segment,
61
+ speechFraction,
62
+ } from "./media.ts";
63
+ import { narrate, summarize } from "./summarize.ts";
64
+ import { transcribeSpan } from "./sync-api.ts";
65
+
66
+ /**
67
+ * How many segments to keep in flight.
68
+ *
69
+ * A CONSTANT here, where `transcription-workflow` derives one per recording — and
70
+ * the difference is the payoff of normalizing. That template cuts whatever format
71
+ * it was handed, so the byte cost of a segment is a property of the file: the same
72
+ * 32 segments are 94 MB of 16 kHz mono or 1.28 GB of a format at the endpoint's
73
+ * ceiling, and only one of those is safe to have in flight. It has to divide a
74
+ * measured byte budget to find a width.
75
+ *
76
+ * Here the format is {@link ANALYSIS_FORMAT} for every recording, so a segment is
77
+ * at most 3.5 MB and 32 of them are 113 MB — comfortably inside the ~640 MB that
78
+ * template measured as the point where the endpoint starts returning `503`s. So
79
+ * the byte bound never binds and what is left is the endpoint's own knee, which it
80
+ * measured at 32. Its `BYTES_IN_FLIGHT` and `MAX_SEGMENT_CONCURRENCY` docs carry
81
+ * both measurements; this is the one number that survives them.
82
+ */
83
+ export const SEGMENT_CONCURRENCY = 32;
84
+
85
+ /** What one segment's request came back with — the STEP's result, journaled. */
86
+ export type SegmentText = {
87
+ index: number;
88
+ text: string;
89
+ };
90
+
91
+ /** What a finished run reports. Small and JSON-shaped, like every step result. */
92
+ export type CallAudit = {
93
+ /** The uploaded file's own name. */
94
+ source: string;
95
+ /** What ffprobe made of it before the conversion — `aac`, `mp3`, `pcm_s16le`. */
96
+ codec: string;
97
+ /** Length of the audio. */
98
+ durationMs: number;
99
+ /** How long the RUN took, wall clock. */
100
+ elapsedMs: number;
101
+ /** How many requests the transcript was assembled from. */
102
+ segments: number;
103
+ /**
104
+ * Segments whose end landed in speech because no pause was in range.
105
+ *
106
+ * `0` on an ordinary recording. Surfaced because it is the one thing that can
107
+ * make this desk's transcript as seam-damaged as a blind cut's, and a reader
108
+ * looking at a mangled word deserves to know which case they are in.
109
+ */
110
+ blindCuts: number;
111
+ /** Share of the recording that is speech rather than pause, 0-100. */
112
+ speechPercent: number;
113
+ /** Integrated loudness BEFORE levelling, LUFS — what the recording arrived at. */
114
+ loudnessBefore: number;
115
+ words: number;
116
+ transcript: string;
117
+ /** One line naming what the call was about. */
118
+ headline: string;
119
+ /** What a reader should worry about. */
120
+ risks: string[];
121
+ /** What somebody has to do next. */
122
+ actions: string[];
123
+ /** The summary, written to be heard. */
124
+ spoken: string;
125
+ /**
126
+ * Upload id of the spoken summary — an MP3, in this app's own store.
127
+ *
128
+ * An ID rather than the bytes, and that is the rule rather than a preference: a
129
+ * run's output is read back as JSON. `api.download(id)` is the browser half.
130
+ */
131
+ audio: string;
132
+ /** How long the spoken summary lasts. */
133
+ audioDurationMs: number;
134
+ /** Size of the MP3, which is the number that makes the mastering pass worth it. */
135
+ audioBytes: number;
136
+ };
137
+
138
+ /**
139
+ * Audit a call recording: level it, transcribe it, summarize it, read it back.
140
+ *
141
+ * The input is what `POST /workflows/runs` carries — see `agent.ts` for the schema
142
+ * it is validated against before a run exists.
143
+ */
144
+ export async function auditFlow(input: WorkflowInputOf<typeof audit>): Promise<CallAudit> {
145
+ "use workflow";
146
+
147
+ // Both at once: neither needs the other, and issued together they are one round
148
+ // trip instead of two before any audio moves. The ORDER is still a pure function
149
+ // of this expression — the two calls go out synchronously, left to right — which
150
+ // is what a replay reproduces.
151
+ const [startedAt, ingested] = await Promise.all([now(), ingestRecording(input.recording)]);
152
+
153
+ // Pure, in the body, from journaled values. See the module doc. Planned against
154
+ // the stored BYTE COUNT rather than the reported duration — `durationSeconds`
155
+ // carries why those are not interchangeable.
156
+ const segments = planSegments(ingested.silences, ingested.bytes);
157
+
158
+ // One step per segment, bounded, in an order a replay reproduces exactly. A
159
+ // failed segment fails the RUN deliberately: every sibling that finished is
160
+ // already journaled, so a resume replays those for free and re-issues only what
161
+ // is missing — where catching here to salvage a partial transcript would return
162
+ // a recording with a silent hole in it and report success.
163
+ const parts = await mapConcurrent(segments, SEGMENT_CONCURRENCY, (segment) =>
164
+ transcribeSegment(ingested.audio, segment),
165
+ );
166
+
167
+ const transcript = joinSegments(segments, parts);
168
+ const summary = await summarize(transcript, ingested.source, ingested.durationMs);
169
+ const spoken = await narrate(summary.spoken, input.voice);
170
+ const finishedAt = await now();
171
+
172
+ // Whatever this returns is what a caller reads as `output` on a completed run —
173
+ // so it is what the page renders, typed through `WorkflowOutputOf`. Assembled in
174
+ // the body rather than in a step because every field is already journaled: a
175
+ // step here would re-record values it was handed.
176
+ return {
177
+ source: ingested.source,
178
+ codec: ingested.codec,
179
+ durationMs: ingested.durationMs,
180
+ elapsedMs: finishedAt - startedAt,
181
+ segments: segments.length,
182
+ blindCuts: segments.filter((segment) => segment.cutInSpeech).length,
183
+ speechPercent: Math.round(
184
+ speechFraction(ingested.silences, durationSeconds(ingested.bytes)) * 100,
185
+ ),
186
+ loudnessBefore: ingested.loudness.inputLufs,
187
+ words: countWords(transcript),
188
+ transcript,
189
+ headline: summary.headline,
190
+ risks: summary.risks,
191
+ actions: summary.actions,
192
+ spoken: summary.spoken,
193
+ audio: spoken.audio,
194
+ audioDurationMs: spoken.durationMs,
195
+ audioBytes: spoken.bytes,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Transcribe one segment through the sync API.
201
+ *
202
+ * One step each, so a run that dies part-way resumes having replayed the finished
203
+ * ones from the journal — no re-reading, no re-billing — and issues exactly the
204
+ * calls that are missing.
205
+ *
206
+ * **`encodeWav` is what makes a byte range decodable.** The stored audio is
207
+ * headerless PCM, and the endpoint decodes each request independently, so a slice
208
+ * of it is meaningless bytes until a header says what they are. That header is the
209
+ * SDK's (`@alexkroman1/aai/step`) rather than this template's: the equivalent
210
+ * function in `transcription-workflow` is 25 lines of `DataView` writes with a
211
+ * comment about which of the two declared lengths a decoder trusts, and there is
212
+ * no reason for a second copy of it to exist.
213
+ */
214
+ export async function transcribeSegment(audioId: string, segment: Segment): Promise<SegmentText> {
215
+ "use step";
216
+
217
+ // One line per segment, which is what makes the fan-out legible to a page: the
218
+ // status is `running` for the whole thing, so without this a sixty-segment
219
+ // recording and a one-segment recording look identical while they run.
220
+ //
221
+ // ORDER is not guaranteed here and does not need to be — the calls go out
222
+ // together, so their lines interleave by completion, and `segment.index` is what
223
+ // puts the TRANSCRIPT back in order.
224
+ await report(`Transcribing ${formatDuration(segment.startMs)}–${formatDuration(segment.endMs)}.`);
225
+
226
+ // `[start, end)`, the same half-open pair `planSegments` produced — the store
227
+ // owns the conversion to HTTP's inclusive range, so there is no `- 1` here to get
228
+ // wrong.
229
+ const audio = await readUpload(audioId, { start: segment.startByte, end: segment.endByte });
230
+ const text = await transcribeSpan(
231
+ encodeWav(audio.bytes, ANALYSIS_FORMAT),
232
+ `segment-${segment.index}.wav`,
233
+ `Segment ${segment.index} (${formatDuration(segment.startMs)})`,
234
+ );
235
+
236
+ return { index: segment.index, text };
237
+ }
238
+
239
+ /**
240
+ * Retries beyond the default 3, because a rate limit is the expected failure and a
241
+ * segment that 429s is not a segment that is wrong.
242
+ */
243
+ transcribeSegment.maxRetries = 5;
244
+
245
+ /**
246
+ * When it is now, as epoch ms.
247
+ *
248
+ * A STEP, and that is the whole reason it exists rather than a `Date.now()` in the
249
+ * body: a body replays from the top on every resume, so a clock read there returns
250
+ * a different value each time and every duration derived from it would be a
251
+ * different duration. A step's result is journaled, so this is the moment the run
252
+ * really reached this line however many times it is replayed.
253
+ *
254
+ * Called twice — once at each end — rather than a `startClock`/`elapsed` pair,
255
+ * because the alternative is a step taking every field of the output so it can
256
+ * subtract inside itself. Two journal entries and a subtraction in the body is the
257
+ * smaller thing.
258
+ */
259
+ export async function now(): Promise<number> {
260
+ "use step";
261
+
262
+ return Date.now();
263
+ }
264
+
265
+ /**
266
+ * Join the segment transcripts into one.
267
+ *
268
+ * Ordered concatenation, and the absence of anything cleverer is the point:
269
+ * segments do not overlap, so there is nothing to de-duplicate. The equivalent in
270
+ * `transcription-workflow` is a seam matcher that looks back up to 40 words for a
271
+ * repeated run and drops it — necessary there, because a blind cut forces a
272
+ * two-second overlap to avoid splitting a word, and heuristic by nature.
273
+ *
274
+ * The one judgement left is the SEPARATOR, and the plan already knows the answer.
275
+ * A cut placed in a pause is a turn or sentence boundary, so a paragraph break
276
+ * reads correctly; a blind cut lands mid-sentence, so it gets a space. That is the
277
+ * one place `cutInSpeech` changes an output rather than a report.
278
+ */
279
+ export function joinSegments(segments: readonly Segment[], parts: readonly SegmentText[]): string {
280
+ // `mapConcurrent` resolves in ITEM order however the calls settled, so this is
281
+ // already ordered — sorted anyway, because a merge is where an ordering mistake
282
+ // would be invisible rather than loud.
283
+ const byIndex = new Map(parts.map((part) => [part.index, part.text]));
284
+ let joined = "";
285
+ for (const segment of segments) {
286
+ const text = (byIndex.get(segment.index) ?? "").trim();
287
+ if (text === "") continue;
288
+ if (joined === "") {
289
+ joined = text;
290
+ continue;
291
+ }
292
+ // The separator belongs to the boundary BEFORE this segment, which is the
293
+ // previous segment's end — so the flag read here is the earlier one's.
294
+ const previous = segments[segment.index - 1];
295
+ joined += previous?.cutInSpeech === true ? ` ${text}` : `\n\n${text}`;
296
+ }
297
+ return joined;
298
+ }