@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,201 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The two steps after the transcript: a model reads the call, and a voice reads
4
+ * the model back — through ffmpeg on the way out.
5
+ *
6
+ * ```text
7
+ * summarize one step, LLM Gateway → headline, risks, actions, script
8
+ * narrate one step, TTS + ffmpeg → an MP3, stored, and its id
9
+ * ```
10
+ *
11
+ * `spoken-summary` owns the audio ROUND TRIP and is the template to read for it:
12
+ * why `stepSpeak` exists at all (a `TtsSession` is an event stream wired into a
13
+ * live pipeline's playback, and a step has no turn to be part of and has to return
14
+ * a VALUE), why `writeUpload` is its other half, and why speaking and storing must
15
+ * be one step. None of that is restated here.
16
+ *
17
+ * **What this file adds is the pass AFTER the synthesis**, and it is the second
18
+ * half of what having a decoder in the pipeline buys. `stepSpeak` answers with a
19
+ * 24 kHz WAV, which is correct and is not a deliverable:
20
+ *
21
+ * - **It is uncompressed.** A 90-second summary is 4.3 MB, and the page downloads
22
+ * the whole thing through `api.download` before it can play a note of it. The
23
+ * same summary as VBR MP3 is ~110 KB, which is a fortieth.
24
+ * - **Its level is whatever the voice service chose.** Played straight after a
25
+ * recording this desk levelled to −16 LUFS, a summary at −24 sounds broken. The
26
+ * mastering pass puts both on the same scale, which is the whole reason to have
27
+ * one number for the desk rather than one per stage.
28
+ *
29
+ * So the audio the page plays has been through ffmpeg twice — once on the way in
30
+ * to make it analysable, once on the way out to make it shippable. `media.ts`'s
31
+ * `masterArgs` is the second, and it explains why that one is a SINGLE `loudnorm`
32
+ * pass where the ingest is two.
33
+ */
34
+
35
+ import { stat, writeFile } from "node:fs/promises";
36
+ import { join } from "node:path";
37
+ import { runFfmpeg } from "@alexkroman1/aai/ffmpeg";
38
+ import { report, stepSpeak } from "@alexkroman1/aai/step";
39
+ import { stepGenerateJsonClassified, throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
40
+ import { withTempDir, writeUploadFromFile } from "@alexkroman1/aai/step-files";
41
+ import { formatBytes, formatDuration, omitUndefined, plural } from "@alexkroman1/aai/utils";
42
+ import { z } from "zod";
43
+ import { masterArgs } from "./media.ts";
44
+
45
+ /** Risks the summary is reduced to. Enough to be useful, few enough to act on. */
46
+ const MAX_RISKS = 4;
47
+
48
+ /** Actions the summary is reduced to. */
49
+ const MAX_ACTIONS = 4;
50
+
51
+ /**
52
+ * Characters of transcript handed to the model.
53
+ *
54
+ * The pass-an-id-not-a-payload rule meeting a case where the payload IS the work:
55
+ * the text has to cross the queue between two steps, so it is bounded rather than
56
+ * trusted. 40k characters is roughly four hours of speech — past where another
57
+ * paragraph changes a four-point summary.
58
+ */
59
+ const MAX_TRANSCRIPT_CHARS = 40_000;
60
+
61
+ /** How long the mastering pass may run. Seconds of work; the bound is for a pathological input. */
62
+ const MASTER_TIMEOUT_MS = 5 * 60_000;
63
+
64
+ /**
65
+ * The shape the model must answer in, as something that CHECKS.
66
+ *
67
+ * `stepGenerateJson` validates against this and throws plainly when the reply
68
+ * misses, which is what a retry is for: a model that answered with prose may well
69
+ * obey on the next attempt.
70
+ */
71
+ const AuditReply = z.object({
72
+ headline: z.string().trim().min(1),
73
+ // Allowed to be EMPTY, unlike `spoken` below, and the asymmetry is deliberate: a
74
+ // call with nothing worrying in it is a real call, and a schema that demanded a
75
+ // risk would get an invented one. An empty array is an answer.
76
+ risks: z.array(z.string().trim().min(1)),
77
+ actions: z.array(z.string().trim().min(1)),
78
+ // NOT `.default("")` — the whole second half of this workflow has nothing to say
79
+ // without it, and a default would turn a missing field into a silent half-second
80
+ // of audio rather than a retry.
81
+ spoken: z.string().trim().min(1),
82
+ });
83
+
84
+ /** What the model made of the call. */
85
+ export type CallSummary = {
86
+ headline: string;
87
+ risks: string[];
88
+ actions: string[];
89
+ /** The same summary, written to be READ ALOUD — see {@link summarize}. */
90
+ spoken: string;
91
+ };
92
+
93
+ /**
94
+ * Reduce the transcript to a headline, the risks, the actions, and a script.
95
+ *
96
+ * **The model is asked for TWO summaries, and the difference is the point.**
97
+ * `risks`/`actions` are for reading and `spoken` is for hearing; a template that
98
+ * synthesized its own bullet list produces a voice reading "one. two. three." with
99
+ * no connective tissue. So the schema asks for a script as well, in sentences, and
100
+ * that is what {@link narrate} is handed. It is a decision a prompt alone does not
101
+ * hold, which is why the field is required rather than defaulted.
102
+ */
103
+ export async function summarize(
104
+ transcript: string,
105
+ source: string,
106
+ durationMs: number,
107
+ ): Promise<CallSummary> {
108
+ "use step";
109
+
110
+ await report("Reading the transcript.");
111
+ const reply = await stepGenerateJsonClassified(
112
+ `Audit this transcript of a recorded call (${source}, ${formatDuration(durationMs)}).\n\n` +
113
+ "Answer with JSON only, in this shape:\n" +
114
+ `{"headline": "...", "risks": ["..."], "actions": ["..."], "spoken": "..."}\n\n` +
115
+ "- headline: one line naming what the call was about.\n" +
116
+ `- risks: at most ${MAX_RISKS} things a reader should worry about — a ` +
117
+ "commitment nobody owns, a number that was guessed at, a disagreement left " +
118
+ "unresolved. Quote or name the specifics. An EMPTY array if the call really " +
119
+ "had none; never invent one.\n" +
120
+ `- actions: at most ${MAX_ACTIONS} things somebody has to do next, each ` +
121
+ "naming who if the call named them.\n" +
122
+ "- spoken: the same audit written to be READ ALOUD. Full sentences that " +
123
+ "flow, under 150 words, no bullet markers, no headings, no markdown. " +
124
+ "Someone will hear this without seeing the lists.\n\n" +
125
+ `Transcript:\n${transcript.slice(0, MAX_TRANSCRIPT_CHARS)}`,
126
+ {
127
+ system: "You audit recorded calls. You answer with JSON and nothing else.",
128
+ schema: AuditReply,
129
+ },
130
+ // The `Classified` caller is `stepGenerateJson` plus `throwStepError`, which is
131
+ // what reads the gateway's own status: a 429 is worth another attempt and a 400
132
+ // is not, and that is what tells the DevKit which.
133
+ );
134
+
135
+ await report(
136
+ `Found ${reply.risks.length} ${plural(reply.risks.length, "risk")} and ` +
137
+ `${reply.actions.length} ${plural(reply.actions.length, "action")}.`,
138
+ );
139
+ return {
140
+ headline: reply.headline,
141
+ risks: reply.risks.slice(0, MAX_RISKS),
142
+ actions: reply.actions.slice(0, MAX_ACTIONS),
143
+ spoken: reply.spoken,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Read the audit aloud, master it, store it, and answer with its id.
149
+ *
150
+ * **All four halves belong in ONE step**, and the reason is what a journal
151
+ * records: a step is replayed by its RETURN VALUE, so an id is replayed and bytes
152
+ * are not. Split in two, the audio would have to cross the queue between them —
153
+ * megabytes of it, on every resume — and the temp file the mastering pass needs
154
+ * cannot cross a step boundary at all (see `@alexkroman1/aai/step-files`). Together, a resumed
155
+ * run replays the id and re-reads a file that is already there.
156
+ *
157
+ * The cost is that a retried step writes a second upload and abandons the first.
158
+ * Cheap next to a step that cannot retry.
159
+ */
160
+ export async function narrate(
161
+ script: string,
162
+ voice?: string,
163
+ ): Promise<{ audio: string; durationMs: number; bytes: number }> {
164
+ "use step";
165
+
166
+ const spoken = await stepSpeak(script, omitUndefined({ voice }));
167
+
168
+ return await withTempDir(
169
+ async (dir) => {
170
+ const wav = join(dir, "spoken.wav");
171
+ const mp3 = join(dir, "summary.mp3");
172
+
173
+ // `writeFile` rather than a stream, and this is the one place in the template
174
+ // where holding the whole thing in memory is right: `stepSpeak` already
175
+ // returned it as a single `Uint8Array`, so streaming it to disk would be
176
+ // copying from the heap to the heap on the way. It is bounded by the script,
177
+ // which the schema keeps under 150 words.
178
+ await writeFile(wav, spoken.audio);
179
+
180
+ await runFfmpeg(masterArgs(wav, mp3), { timeoutMs: MASTER_TIMEOUT_MS }).catch(
181
+ throwFfmpegStepError,
182
+ );
183
+ const bytes = (await stat(mp3)).size;
184
+
185
+ const stored = await writeUploadFromFile(mp3, {
186
+ // Named, because this is what a person sees on the download link rather than
187
+ // an opaque id — and typed, because the byte route serves the type it was
188
+ // given and a browser will not play a file it was handed as bytes.
189
+ name: "audit.mp3",
190
+ type: "audio/mpeg",
191
+ });
192
+
193
+ await report(
194
+ `Recorded a ${Math.round(spoken.durationMs / 1000)}s audit in ${spoken.voice}'s voice — ` +
195
+ `${formatBytes(bytes)} of MP3, from ${formatBytes(spoken.audio.byteLength)} of WAV.`,
196
+ );
197
+ return { audio: stored.id, durationMs: spoken.durationMs, bytes };
198
+ },
199
+ { prefix: "aai-call-audit-" },
200
+ );
201
+ }
@@ -0,0 +1,44 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The one transcription request, and its classification.
4
+ *
5
+ * No directive, so it sits under `workflows/` untransformed and is called FROM a
6
+ * step, inheriting its environment. It is its own module for the same reason
7
+ * `transcription-workflow` has one: `stepTranscribeSync` is the SDK's — the URL,
8
+ * the raw-key auth (no `Bearer`, which is a 401 that reads like a wrong key), the
9
+ * multipart shape, the deadline and the three-way failure verdict all live there —
10
+ * so what is left at the call site is the classification that hands the verdict to
11
+ * the DevKit, and that belongs somewhere a spec can reach it.
12
+ */
13
+
14
+ import { stepTranscribeSyncClassified } from "@alexkroman1/aai/step-errors";
15
+
16
+ /**
17
+ * Transcribe one complete WAV.
18
+ *
19
+ * `bytes` must be a whole file, header included — the endpoint decodes each
20
+ * request independently, so a headerless span is bytes it will refuse. This desk
21
+ * stores headerless PCM on purpose (see `media.ts`) and puts a header back with
22
+ * `encodeWav` for exactly this call.
23
+ *
24
+ * `stepTranscribeSyncClassified` — the SDK's own `stepTranscribeSync` plus
25
+ * `throwStepError`, and nothing else — is the whole of what this adds, and it is
26
+ * where the three-way call is made: a `FatalError` stops the DevKit retrying something that
27
+ * will answer the same way, a bare `RetryableError` retries in ONE SECOND (that
28
+ * class's own default), and a `RetryableError` carrying `retryAfter` waits exactly
29
+ * as long as the far side asked. The last matters here because a whole fan-out
30
+ * hits a rate limit together — a second later all of them ask again, where on the
31
+ * server's number they drain.
32
+ *
33
+ * @param label - How this piece is named in a failure. The CALLER's vocabulary (a
34
+ * segment's timestamp), because it is what a reader of the log has in front of
35
+ * them.
36
+ */
37
+ export async function transcribeSpan(
38
+ bytes: Uint8Array,
39
+ filename: string,
40
+ label: string,
41
+ ): Promise<string> {
42
+ const { text } = await stepTranscribeSyncClassified(bytes, { filename, label });
43
+ return text;
44
+ }
@@ -1,11 +1,20 @@
1
- import type { ToolContext } from "@alexkroman1/aai";
2
- import { createToolContext } from "@alexkroman1/aai/testing";
1
+ import type {
2
+ DialogPosition,
3
+ InferToolOutput,
4
+ ToolContext,
5
+ ToolDef,
6
+ ToolInputSchema,
7
+ } from "@alexkroman1/aai";
8
+ import { isToolFailure } from "@alexkroman1/aai";
9
+ import { createToolContext, ok } from "@alexkroman1/aai/testing";
3
10
  import { describe, expect, test } from "vitest";
4
- import { dispatchSlot } from "./shared.ts";
11
+ import { callFlow, dispatchSlot } from "./shared.ts";
12
+ import incidentAddNote from "./tools/incident_add_note.ts";
5
13
  import incidentCreate from "./tools/incident_create.ts";
6
14
  import incidentEscalate from "./tools/incident_escalate.ts";
7
15
  import incidentTriage from "./tools/incident_triage.ts";
8
16
  import incidentUpdateStatus from "./tools/incident_update_status.ts";
17
+ import opsRunScenario from "./tools/ops_run_scenario.ts";
9
18
  import resourcesDispatch from "./tools/resources_dispatch.ts";
10
19
  import resourcesUpdateStatus from "./tools/resources_update_status.ts";
11
20
 
@@ -14,14 +23,31 @@ import resourcesUpdateStatus from "./tools/resources_update_status.ts";
14
23
  * construction. */
15
24
  const makeCtx = (): ToolContext => createToolContext();
16
25
 
26
+ /**
27
+ * What a gated tool's own `execute` returned, read off the tool itself.
28
+ *
29
+ * `callFlow.tool` threads its result type out now, so `InferToolOutput` answers
30
+ * `DialogToolResult<R> | ToolFailure` — this is that minus the envelope and the
31
+ * refusal arm. It replaces the inline `{ dispatched: { callsign: string }[] }`
32
+ * shapes the assertions below used to restate, which were a second copy of each
33
+ * tool's return type that could not go stale loudly.
34
+ *
35
+ * The unwrap itself is `ok` from `@alexkroman1/aai/testing`; the hand-rolled
36
+ * copy that used to sit here was byte-identical to three other templates'.
37
+ */
38
+ type Result<T extends ToolDef<ToolInputSchema>> = Extract<
39
+ InferToolOutput<T>,
40
+ { result: unknown }
41
+ >["result"];
42
+
43
+ /** Where the call is, without going through a tool. */
44
+ const at = (ctx: ToolContext): DialogPosition => callFlow.position(ctx);
45
+
17
46
  async function createIncidentFor(
18
47
  ctx: ToolContext,
19
48
  description = "structure fire with heavy smoke",
20
49
  ): Promise<string> {
21
- const result = (await incidentCreate.execute(
22
- { location: "400 Oak Street", description },
23
- ctx,
24
- )) as { incidentId: string };
50
+ const result = await incidentCreate.execute({ location: "400 Oak Street", description }, ctx);
25
51
  return result.incidentId;
26
52
  }
27
53
 
@@ -52,10 +78,9 @@ describe("dispatch-center template", () => {
52
78
  const ctx = makeCtx();
53
79
  const incidentId = await createIncidentFor(ctx, "cardiac arrest, patient not breathing");
54
80
 
55
- const result = (await resourcesDispatch.execute({ incidentId, callsigns: ["auto"] }, ctx)) as {
56
- dispatched: { callsign: string }[];
57
- failed?: { callsign: string; reason: string }[];
58
- };
81
+ const result = ok<Result<typeof resourcesDispatch>>(
82
+ await resourcesDispatch.execute({ incidentId, callsigns: ["auto"] }, ctx),
83
+ );
59
84
 
60
85
  expect(result.failed).toBeUndefined();
61
86
  expect(result.dispatched.length).toBeGreaterThan(0);
@@ -68,10 +93,10 @@ describe("dispatch-center template", () => {
68
93
  // mutex in updateState makes each one run against the previous one's
69
94
  // finished state, so neither incident's changes are half-applied when
70
95
  // the other's mutator runs.
71
- const [a, b] = (await Promise.all([
96
+ const [a, b] = await Promise.all([
72
97
  incidentCreate.execute({ location: "1 First St", description: "gas leak" }, ctx),
73
98
  incidentCreate.execute({ location: "2 Second St", description: "vehicle crash" }, ctx),
74
- ])) as { incidentId: string }[];
99
+ ]);
75
100
 
76
101
  const state = dispatchSlot.get(ctx);
77
102
  expect(a?.incidentId).not.toBe(b?.incidentId);
@@ -124,21 +149,120 @@ describe("dispatch-center template", () => {
124
149
  const incidentId = await createIncidentFor(ctx);
125
150
  await incidentUpdateStatus.execute({ incidentId, status: "resolved" }, ctx);
126
151
 
127
- const escalated = (await incidentEscalate.execute({ incidentId, reason: "flare-up" }, ctx)) as {
128
- error?: string;
129
- };
130
- expect(escalated.error).toMatch(/resolved/);
152
+ // Each is a refusal the BODY answered, so it arrives unwrapped as a
153
+ // `ToolFailure` rather than under the position envelope — which is what the
154
+ // narrowing says, where the old cast to `{ error?: string }` said nothing
155
+ // and would have read `undefined` off a success just as quietly.
156
+ const escalated = await incidentEscalate.execute({ incidentId, reason: "flare-up" }, ctx);
157
+ expect(isToolFailure(escalated) && escalated.error).toMatch(/resolved/);
131
158
 
132
- const reResolved = (await incidentUpdateStatus.execute(
133
- { incidentId, status: "resolved" },
134
- ctx,
135
- )) as { error?: string };
136
- expect(reResolved.error).toMatch(/resolved/);
159
+ const reResolved = await incidentUpdateStatus.execute({ incidentId, status: "resolved" }, ctx);
160
+ expect(isToolFailure(reResolved) && reResolved.error).toMatch(/resolved/);
137
161
 
138
- const dispatchedTo = (await resourcesDispatch.execute(
162
+ const dispatchedTo = await resourcesDispatch.execute(
139
163
  { incidentId, callsigns: ["Medic-1"] },
140
164
  ctx,
141
- )) as { error?: string };
142
- expect(dispatchedTo.error).toMatch(/resolved/);
165
+ );
166
+ expect(isToolFailure(dispatchedTo) && dispatchedTo.error).toMatch(/resolved/);
167
+ });
168
+ });
169
+
170
+ describe("the call flow", () => {
171
+ test("a fresh shift is in standby, and every incident tool refuses there", async () => {
172
+ const ctx = makeCtx();
173
+ expect(at(ctx).state).toBe("standby");
174
+
175
+ // Each of these used to run and answer `Incident INC-0001 not found` — a
176
+ // data answer to a positional question. The refusal now names where the
177
+ // shift is and quotes what to do about it.
178
+ for (const call of [
179
+ incidentTriage.execute({ incidentId: "INC-0001" }, ctx),
180
+ resourcesDispatch.execute({ incidentId: "INC-0001", callsigns: ["Medic-1"] }, ctx),
181
+ incidentUpdateStatus.execute({ incidentId: "INC-0001", status: "on_scene" }, ctx),
182
+ incidentEscalate.execute({ incidentId: "INC-0001", reason: "spreading" }, ctx),
183
+ incidentAddNote.execute({ incidentId: "INC-0001", note: "caller hung up" }, ctx),
184
+ resourcesUpdateStatus.execute({ callsign: "Medic-1", status: "en_route" }, ctx),
185
+ ]) {
186
+ const refusal = await call;
187
+ expect(isToolFailure(refusal)).toBe(true);
188
+ expect(isToolFailure(refusal) && refusal.error).toMatch(/standby/);
189
+ }
190
+
191
+ // And nothing ran: a refusal must not have touched the board.
192
+ expect(dispatchSlot.get(ctx).incidentCounter).toBe(0);
193
+ });
194
+
195
+ test("logging, triaging and dispatching walk the call through its three steps", async () => {
196
+ const ctx = makeCtx();
197
+
198
+ const created = await incidentCreate.execute(
199
+ { location: "400 Oak Street", description: "structure fire with heavy smoke" },
200
+ ctx,
201
+ );
202
+ // `state`/`instruction`, not `at`/`next`: this ungated tool spreads the
203
+ // `DialogPosition` verbatim now, so it reports its position under the same
204
+ // keys every gated tool's result carries.
205
+ expect(created.state).toBe("working.triaging");
206
+ expect(created.instruction).toMatch(/incident_triage/);
207
+
208
+ const triaged = ok<Result<typeof incidentTriage>>(
209
+ await incidentTriage.execute({ incidentId: created.incidentId, severity: "critical" }, ctx),
210
+ );
211
+ expect(triaged.triageScore).toBeGreaterThan(0);
212
+ expect(at(ctx).state).toBe("working.dispatching");
213
+
214
+ ok(
215
+ await resourcesDispatch.execute({ incidentId: created.incidentId, autoDispatch: true }, ctx),
216
+ );
217
+ expect(at(ctx).state).toBe("working.monitoring");
218
+ expect(at(ctx).instruction).toMatch(/radio in/);
219
+ });
220
+
221
+ test("a dispatch that rolled nothing leaves the call where it was", async () => {
222
+ const ctx = makeCtx();
223
+ const incidentId = await createIncidentFor(ctx);
224
+ await incidentTriage.execute({ incidentId }, ctx);
225
+ expect(at(ctx).state).toBe("working.dispatching");
226
+
227
+ // Every requested callsign is unknown, so no unit moved — and the call has
228
+ // not moved on either.
229
+ const result = ok<Result<typeof resourcesDispatch>>(
230
+ await resourcesDispatch.execute({ incidentId, callsigns: ["Ghost-1"] }, ctx),
231
+ );
232
+ expect(result.dispatched).toHaveLength(0);
233
+ expect(at(ctx).state).toBe("working.dispatching");
234
+ });
235
+
236
+ test("a new call is legal mid-incident and puts the flow back on triage", async () => {
237
+ const ctx = makeCtx();
238
+ const first = await createIncidentFor(ctx);
239
+ await incidentTriage.execute({ incidentId: first }, ctx);
240
+ await resourcesDispatch.execute({ incidentId: first, autoDispatch: true }, ctx);
241
+ expect(at(ctx).state).toBe("working.monitoring");
242
+
243
+ await createIncidentFor(ctx, "chemical spill spreading toward a storm drain");
244
+ expect(at(ctx).state).toBe("working.triaging");
245
+
246
+ // The first incident is still workable — the position tracks the call in
247
+ // hand, and the tools are addressed by id.
248
+ ok(await incidentAddNote.execute({ incidentId: first, note: "crews on scene" }, ctx));
249
+ });
250
+
251
+ test("a failed tool does not advance the flow", async () => {
252
+ const ctx = makeCtx();
253
+ const incidentId = await createIncidentFor(ctx);
254
+ await incidentUpdateStatus.execute({ incidentId, status: "resolved" }, ctx);
255
+ const before = at(ctx).state;
256
+
257
+ const refused = await incidentTriage.execute({ incidentId, severity: "critical" }, ctx);
258
+ expect(isToolFailure(refused)).toBe(true);
259
+ expect(at(ctx).state).toBe(before);
260
+ });
261
+
262
+ test("a training scenario logs incidents like a real call does", async () => {
263
+ const ctx = makeCtx();
264
+ const result = await opsRunScenario.execute({ scenario: "mass_casualty" }, ctx);
265
+ expect(result.incidentsCreated.length).toBeGreaterThan(1);
266
+ expect(result.state).toBe("working.triaging");
143
267
  });
144
268
  });