@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
@@ -7,15 +7,21 @@
7
7
  * every directive body obeys — replayed from the top, so no live handles and no
8
8
  * undurable decisions; step arguments and return values are serialized, so pass
9
9
  * an id and not a payload — and both hold here unchanged. What this template
10
- * adds is the shape a real provider limit forces on a workflow, and it is three
10
+ * adds is the shape a real provider limit forces on a workflow, and it is four
11
11
  * steps in a straight line:
12
12
  *
13
13
  * ```text
14
+ * normalizeRecording one step → an upload id in a format that can be cut
14
15
  * splitRecording one step → the format + a byte range per segment
15
16
  * transcribeSegment N steps → one sync API request each, bounded
16
17
  * mergeTranscript one step → the stitched transcript
17
18
  * ```
18
19
  *
20
+ * The first is the newest and the one a reader is least likely to expect, since
21
+ * everything below it is arithmetic over a WAV and real recordings are not WAVs.
22
+ * `normalize.ts` is where ffmpeg enters, and its module doc carries why the
23
+ * conversion is file-to-file and why a temp file may not outlive its step.
24
+ *
19
25
  * ## Why the SYNC endpoint, and why that forces a fan-out
20
26
  *
21
27
  * AssemblyAI has two pre-recorded APIs. The BATCH one takes a job and a webhook
@@ -35,7 +41,7 @@
35
41
  * ## Three properties this leans on
36
42
  *
37
43
  * - **A step can read the agent's env now.** `stepEnv`/`requireStepEnv`
38
- * (`@alexkroman1/aai/utils`) is what makes any of this real: a step is
44
+ * (`@alexkroman1/aai/step`) is what makes any of this real: a step is
39
45
  * dispatched separately from the agent bundle and is handed no `ToolContext`,
40
46
  * so before that seam existed no step anywhere could authenticate an outbound
41
47
  * call, and every workflow template's I/O was a fixture saying so.
@@ -57,18 +63,22 @@
57
63
  * `output` exists only when the last segment does.
58
64
  */
59
65
 
60
- import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
61
- import { emit, mapConcurrent, readUpload, report, uploadInfo } from "@alexkroman1/aai/utils";
62
66
  import {
63
- clock,
64
- countWords,
65
- stitchTranscript,
66
- TRANSCRIPT_STREAM,
67
- type TranscriptChunk,
68
- } from "./stitch.ts";
67
+ emit,
68
+ encodeWav,
69
+ mapConcurrent,
70
+ readUpload,
71
+ report,
72
+ uploadInfo,
73
+ } from "@alexkroman1/aai/step";
74
+ import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
75
+ import { countWords, formatDuration, plural } from "@alexkroman1/aai/utils";
76
+ import { normalizeRecording } from "./normalize.ts";
77
+ import { stitchTranscript, TRANSCRIPT_STREAM, type TranscriptChunk } from "./stitch.ts";
69
78
  import { elapsed, timed, transcribeWav } from "./sync-api.ts";
70
79
  import {
71
80
  bytesPerSecond,
81
+ HEADER_PROBE_BYTES,
72
82
  parseWav,
73
83
  planSegments,
74
84
  SEGMENT_OVERLAP_SECONDS,
@@ -76,7 +86,6 @@ import {
76
86
  type Segment,
77
87
  UnsupportedRecordingError,
78
88
  type WavFormat,
79
- wavWithHeader,
80
89
  } from "./wav.ts";
81
90
 
82
91
  /**
@@ -186,15 +195,6 @@ export function segmentConcurrency(format: WavFormat): number {
186
195
  return Math.max(1, Math.min(MAX_SEGMENT_CONCURRENCY, Math.floor(BYTES_IN_FLIGHT / perSegment)));
187
196
  }
188
197
 
189
- /**
190
- * Bytes probed for the WAV header.
191
- *
192
- * The canonical header is 44 bytes; a recorder that writes a `LIST` or `bext`
193
- * chunk in front of the samples pushes the `data` chunk further out, and 64 KB
194
- * covers every such file anyone has produced by accident.
195
- */
196
- const HEADER_PROBE_BYTES = 64 * 1024;
197
-
198
198
  /**
199
199
  * What a finished run reports, whichever flow produced it.
200
200
  *
@@ -234,7 +234,16 @@ export async function transcribeFlow(input: { recording: string }) {
234
234
  // round trip instead of two before any audio is read. The ORDER is still a
235
235
  // pure function of this line — the two calls go out synchronously, left to
236
236
  // right — which is what a replay reproduces.
237
- const [startedAt, plan] = await Promise.all([startClock(), splitRecording(input.recording)]);
237
+ //
238
+ // The clock starts before the conversion rather than after it, because a
239
+ // reader comparing the three flows over one file is comparing what the desk
240
+ // COST them, and re-encoding an m4a is part of that.
241
+ const [startedAt, ready] = await Promise.all([startClock(), normalizeRecording(input.recording)]);
242
+
243
+ // `ready.recording` from here on, not `input.recording`: a converted file is a
244
+ // DIFFERENT upload, and cutting the original by offsets planned against the
245
+ // converted one is a fan-out of garbage that still reports success.
246
+ const plan = await splitRecording(ready.recording);
238
247
 
239
248
  // One step per segment, bounded, in an order a replay reproduces exactly.
240
249
  // A failed segment fails the RUN, deliberately: every sibling that finished is
@@ -242,11 +251,12 @@ export async function transcribeFlow(input: { recording: string }) {
242
251
  // what is missing, where catching here to salvage a partial transcript would
243
252
  // return a recording with a silent hole in it and report success.
244
253
  const parts = await mapConcurrent(plan.segments, segmentConcurrency(plan.format), (segment) =>
245
- transcribeSegment(input.recording, plan.format, segment),
254
+ transcribeSegment(ready.recording, plan.format, segment),
246
255
  );
247
256
 
248
- // Whatever this returns is what a caller reads as `output` on a completed run
249
- // so it is what the page renders, typed through `WorkflowOutputOf`.
257
+ // The ORIGINAL id, and only here: `mergeTranscript` uses it for the filename a
258
+ // reader sees, and `standup.m4a` is the recording they uploaded where the
259
+ // converted copy is an artifact of how the desk works.
250
260
  return await mergeTranscript(input.recording, plan.durationMs, parts, startedAt);
251
261
  }
252
262
 
@@ -272,7 +282,7 @@ export async function splitRecording(uploadId: string): Promise<{
272
282
  const durationMs = segments.at(-1)?.endMs ?? 0;
273
283
 
274
284
  await report(
275
- `Split ${clock(durationMs)} of audio into ${segments.length} segment${segments.length === 1 ? "" : "s"}.`,
285
+ `Split ${formatDuration(durationMs)} of audio into ${segments.length} ${plural(segments.length, "segment")}.`,
276
286
  );
277
287
  return { format, segments, durationMs };
278
288
  }
@@ -299,7 +309,7 @@ export async function transcribeSegment(
299
309
  // calls together, so their lines interleave by completion — the page renders a
300
310
  // log, not a sequence, and `segment.index` is what puts the TRANSCRIPT back in
301
311
  // order.
302
- await report(`Transcribing ${clock(segment.startMs)}–${clock(segment.endMs)}.`);
312
+ await report(`Transcribing ${formatDuration(segment.startMs)}–${formatDuration(segment.endMs)}.`);
303
313
 
304
314
  // `[start, end)`, the same half-open pair `planSegments` produced — the store
305
315
  // owns the conversion to HTTP's inclusive range, so there is no `- 1` here to
@@ -312,20 +322,25 @@ export async function transcribeSegment(
312
322
  // answers better — and getting it wrong is a whole transcript in the wrong
313
323
  // language. Add one back only for a desk that really knows.
314
324
  //
315
- // `wavWithHeader` is what makes a WINDOW decodable: the endpoint decodes each
325
+ // `encodeWav` is what makes a WINDOW decodable: the endpoint decodes each
316
326
  // request independently, so a slice of the middle of a recording is a headerless
317
327
  // tail until one is put back on it. The streaming flow needs no equivalent — its
318
- // parts were cut with a header each.
328
+ // parts were cut with a header each. The header is the SDK's rather than this
329
+ // template's: a `WavFormat` is structurally a `PcmFormat`, and 22 lines of
330
+ // `DataView` writes with a comment about which of the two declared lengths a
331
+ // decoder trusts is not a thing worth a second copy of.
319
332
  const { value: text, ms } = await timed(() =>
320
333
  transcribeWav(
321
- wavWithHeader(format, audio.bytes),
334
+ encodeWav(audio.bytes, format),
322
335
  `segment-${segment.index}.wav`,
323
- `Segment ${segment.index} (${clock(segment.startMs)})`,
336
+ `Segment ${segment.index} (${formatDuration(segment.startMs)})`,
324
337
  ),
325
338
  );
326
339
  // The LATENCY, which is what says whether the concurrency bound or the endpoint
327
340
  // is the thing limiting the run — see `timed`'s doc.
328
- await report(`Transcribed ${clock(segment.startMs)}–${clock(segment.endMs)} in ${elapsed(ms)}.`);
341
+ await report(
342
+ `Transcribed ${formatDuration(segment.startMs)}–${formatDuration(segment.endMs)} in ${elapsed(ms)}.`,
343
+ );
329
344
  // And the WORDS, into their own stream, which is what makes this run's answer
330
345
  // streamable rather than only its narration: the page stitches whatever has
331
346
  // arrived and renders the transcript growing, minutes before `output` exists.
@@ -363,7 +378,7 @@ export async function mergeTranscript(
363
378
  ): Promise<Transcript> {
364
379
  "use step";
365
380
 
366
- await report(`Stitching ${parts.length} segment${parts.length === 1 ? "" : "s"} together.`);
381
+ await report(`Stitching ${parts.length} ${plural(parts.length, "segment")} together.`);
367
382
 
368
383
  // `mapConcurrent` resolves in ITEM order however the calls settled, so this is
369
384
  // already ordered — sorted anyway, because the merge is where an ordering
@@ -408,12 +423,13 @@ export async function startClock(): Promise<number> {
408
423
  return Date.now();
409
424
  }
410
425
 
411
- // `clock` and `countWords` are re-exported rather than re-declared: `stream.ts`
412
- // and `batch.ts` already import them from this module, and the split that let the
413
- // PAGE stitch a partial transcript should not ripple through every flow.
426
+ // Re-exported rather than re-declared: `stream.ts` and `batch.ts` already import
427
+ // these from this module, and the split that let the PAGE stitch a partial
428
+ // transcript should not ripple through every flow. `clock` and `countWords` used
429
+ // to be in this list and are `formatDuration`/`countWords` on
430
+ // `@alexkroman1/aai/utils` now — a run narrates itself and the page renders the
431
+ // same run, so those two were a private copy of a formatter the SDK ships.
414
432
  export {
415
- clock,
416
- countWords,
417
433
  stitchChunks,
418
434
  stitchTranscript,
419
435
  TRANSCRIPT_STREAM,
@@ -425,9 +441,8 @@ export {
425
441
  /**
426
442
  * Run a `wav.ts` helper, turning its "cannot cut this" into a terminal failure.
427
443
  *
428
- * Exported for the same reason `countWords` is: `stream.ts` plans with the same
429
- * `wav.ts` helpers and owes the same classification, and it had this byte for
430
- * byte.
444
+ * Exported because `stream.ts` plans with the same `wav.ts` helpers and owes the
445
+ * same classification, and it had this byte for byte.
431
446
  */
432
447
  export function fatalOnUnsupported<T>(read: () => T): T {
433
448
  try {
@@ -26,6 +26,21 @@
26
26
  * anything else by name rather than trying to be clever — an MP3 or an M4A frame
27
27
  * boundary cannot be found by arithmetic, and finding it means shipping a
28
28
  * decoder into a step.
29
+ *
30
+ * ## Who READS the refusals below
31
+ *
32
+ * {@link UnsupportedRecordingError} is raised from here and handled in two
33
+ * completely different ways, which is worth knowing before editing a message:
34
+ *
35
+ * - The plain `transcribe` flow CONVERTS rather than refusing. `normalize.ts`
36
+ * calls {@link parseWav} as a QUESTION — a throw is its signal to hand the file
37
+ * to ffmpeg — so on that path no message here reaches a person, and a file this
38
+ * module rejects for a huge {@link MAX_BYTES_PER_SECOND} is one that
39
+ * normalization fixes by downsampling.
40
+ * - `transcribeStream` still refuses, and has to: it cuts a recording while the
41
+ * bytes are still arriving, and a partial file is not something ffmpeg can
42
+ * transcode. There the sentences below are the whole of what a person is told,
43
+ * so they keep naming the `ffmpeg` line that fixes the file.
29
44
  */
30
45
 
31
46
  /** What the sync endpoint will accept in one request. */
@@ -57,6 +72,22 @@ export const MAX_SEGMENT_BYTES = 40 * 1024 * 1024;
57
72
  /** The sync endpoint refuses audio shorter than this. */
58
73
  export const MIN_SEGMENT_MS = 80;
59
74
 
75
+ /**
76
+ * Bytes probed for the WAV header.
77
+ *
78
+ * The canonical header is 44 bytes; a recorder that writes a `LIST` or `bext`
79
+ * chunk in front of the samples pushes the `data` chunk further out, and 64 KB
80
+ * covers every such file anyone has produced by accident.
81
+ *
82
+ * Declared HERE because three callers need the same window, and the third one is
83
+ * what made a shared constant the rule rather than a preference: `splitRecording`
84
+ * cuts on it, `planFromHead` in `stream.ts` cuts on it, and `normalize.ts` decides
85
+ * whether to CONVERT on it. Two of those probing a different amount than the third
86
+ * is a file the desk converts and then cannot cut, or refuses and then converts —
87
+ * both silent, and neither reproducible from any one module.
88
+ */
89
+ export const HEADER_PROBE_BYTES = 64 * 1024;
90
+
60
91
  /**
61
92
  * The largest `bytesPerSecond` this desk will cut, and the reason it is checked
62
93
  * at PARSE time rather than at cut time.
@@ -281,34 +312,3 @@ export function planSegments(format: WavFormat): Segment[] {
281
312
  }
282
313
  return segments;
283
314
  }
284
-
285
- /**
286
- * Put a canonical 44-byte WAV header in front of a slice of samples.
287
- *
288
- * A byte range of a WAV is raw PCM with no header, and the sync endpoint reads
289
- * the rate and channel count out of one. Sending `audio/pcm` with a `config`
290
- * part instead would work equally well; a header keeps the two parts of the
291
- * request from being able to disagree.
292
- */
293
- export function wavWithHeader(format: WavFormat, samples: Uint8Array): Uint8Array<ArrayBuffer> {
294
- const out = new Uint8Array(44 + samples.length);
295
- const view = new DataView(out.buffer);
296
- const write = (at: number, text: string) => {
297
- for (let i = 0; i < text.length; i++) view.setUint8(at + i, text.charCodeAt(i));
298
- };
299
-
300
- write(0, "RIFF");
301
- view.setUint32(4, 36 + samples.length, true);
302
- write(8, "WAVEfmt ");
303
- view.setUint32(16, 16, true); // `fmt ` payload length
304
- view.setUint16(20, 1, true); // linear PCM
305
- view.setUint16(22, format.channels, true);
306
- view.setUint32(24, format.sampleRate, true);
307
- view.setUint32(28, bytesPerSecond(format), true);
308
- view.setUint16(32, blockAlign(format), true);
309
- view.setUint16(34, format.bitsPerSample, true);
310
- write(36, "data");
311
- view.setUint32(40, samples.length, true);
312
- out.set(samples, 44);
313
- return out;
314
- }
@@ -1,7 +1,13 @@
1
1
  /// <reference types="vite/client" />
2
2
 
3
3
  import type { ToolContext } from "@alexkroman1/aai";
4
- import { createToolContext, runTool, withDiscoveredTools } from "@alexkroman1/aai/testing";
4
+ import {
5
+ createToolContext,
6
+ ok,
7
+ okPosition,
8
+ toolRunner,
9
+ withDiscoveredTools,
10
+ } from "@alexkroman1/aai/testing";
5
11
  import { describe, expect, test } from "vitest";
6
12
  import authoredAgent from "./agent.ts";
7
13
 
@@ -18,7 +24,14 @@ const agentDef = withDiscoveredTools(
18
24
  import.meta.glob("./tools/*.ts", { eager: true }),
19
25
  );
20
26
 
21
- import { activeAssistant, FLIGHTS, tripProjection, tripSlot, tripView } from "./shared.ts";
27
+ import {
28
+ activeAssistant,
29
+ FLIGHTS,
30
+ gateFlow,
31
+ tripProjection,
32
+ tripSlot,
33
+ tripView,
34
+ } from "./shared.ts";
22
35
 
23
36
  // ─── Harness ─────────────────────────────────────────────────────────────────
24
37
 
@@ -26,11 +39,11 @@ import { activeAssistant, FLIGHTS, tripProjection, tripSlot, tripView } from "./
26
39
  * below rests on. */
27
40
  const makeCtx = (): ToolContext => createToolContext();
28
41
 
29
- /** A tool by the name the model calls it by, bound to this agent. The lookup
30
- * and its "no such tool" message are `runTool`'s (`@alexkroman1/aai/testing`);
31
- * what is local is only which agent they run against. */
32
- const run = (name: string, args: Record<string, unknown>, ctx: ToolContext) =>
33
- runTool(agentDef, name, args, ctx);
42
+ /** A tool by the name the model calls it by, bound to this agent. The lookup,
43
+ * its "no such tool" message and the args-or-context shape are all
44
+ * `toolRunner`'s (`@alexkroman1/aai/testing`); what is local is only which
45
+ * agent it runs against. */
46
+ const run = toolRunner(agentDef);
34
47
 
35
48
  /** The state a tool just mutated, read back the way `syncState` reads it. */
36
49
  function stateOf(ctx: ToolContext) {
@@ -116,36 +129,47 @@ describe("sensitive tools stage rather than act", () => {
116
129
  const ctx = makeCtx();
117
130
  await run("book_hotel", { hotelId: "H1", nights: 3 }, ctx);
118
131
 
119
- const applied = (await run("confirm_action", {}, ctx)) as {
120
- applied: string;
121
- reference: string;
122
- };
123
- expect(applied.applied).toContain("Harborview Suites");
132
+ // Staging moved the gate, in the same window it wrote `pending`.
133
+ expect(gateFlow.position(ctx).state).toBe("awaitingConfirmation");
134
+
135
+ // A gated tool answers the flow's POSITION wrapped around the body's own
136
+ // return value, so the applied sentence is under `result`. `okPosition`
137
+ // keeps the position and THROWS on a refusal, quoting it — where the cast
138
+ // it replaces read `undefined` off the failure and died three assertions
139
+ // later on a property of undefined.
140
+ const confirmed = okPosition<{ applied: string; reference: string }>(
141
+ await run("confirm_action", ctx),
142
+ );
143
+ expect(confirmed.result.applied).toContain("Harborview Suites");
124
144
  // 3 nights at $265.
125
- expect(applied.applied).toContain("$795");
145
+ expect(confirmed.result.applied).toContain("$795");
146
+ // SETTLED: the gate re-armed as part of the same call.
147
+ expect(confirmed.state).toBe("browsing");
126
148
 
127
149
  const state = stateOf(ctx);
128
150
  expect(state.pending).toBeNull();
129
151
  expect(state.bookings).toHaveLength(1);
130
- expect(state.bookings[0]?.reference).toBe(applied.reference);
152
+ expect(state.bookings[0]?.reference).toBe(confirmed.result.reference);
131
153
 
132
- // The gate re-arms: a second confirm has nothing to apply.
133
- expect(await run("confirm_action", {}, ctx)).toEqual({
134
- error: "Nothing is waiting for confirmation. Use the booking tool first.",
154
+ // The gate re-arms, and now REFUSES rather than reporting an empty apply
155
+ // `when: "awaitingConfirmation"` is what a second confirm meets.
156
+ expect(await run("confirm_action", ctx)).toMatchObject({
157
+ error: expect.stringContaining('this conversation is at "browsing"'),
135
158
  });
136
159
  });
137
160
 
138
161
  test("cancel_action drops the staged change and leaves the booking alone", async () => {
139
162
  const ctx = makeCtx();
140
163
  await run("update_ticket", { flightId: "LX54" }, ctx);
141
- const dropped = (await run("cancel_action", {}, ctx)) as { discarded: string };
142
- expect(dropped.discarded).toContain("LX54");
164
+ const dropped = okPosition<{ discarded: string }>(await run("cancel_action", ctx));
165
+ expect(dropped.result.discarded).toContain("LX54");
166
+ expect(dropped.state).toBe("browsing");
143
167
 
144
168
  const state = stateOf(ctx);
145
169
  expect(state.pending).toBeNull();
146
170
  expect(state.ticket?.flightId).toBe("LX40");
147
- expect(await run("cancel_action", {}, ctx)).toEqual({
148
- message: "Nothing was waiting for confirmation.",
171
+ expect(await run("cancel_action", ctx)).toMatchObject({
172
+ error: expect.stringContaining('this conversation is at "browsing"'),
149
173
  });
150
174
  });
151
175
 
@@ -161,14 +185,14 @@ describe("sensitive tools stage rather than act", () => {
161
185
 
162
186
  test("cancelling the ticket makes a later ticket change impossible", async () => {
163
187
  const ctx = makeCtx();
164
- await run("cancel_ticket", {}, ctx);
165
- await run("confirm_action", {}, ctx);
188
+ await run("cancel_ticket", ctx);
189
+ await run("confirm_action", ctx);
166
190
  expect(stateOf(ctx).ticket).toBeNull();
167
191
 
168
192
  expect(await run("update_ticket", { flightId: "LX52" }, ctx)).toEqual({
169
193
  error: "This caller has no ticket to move — it was cancelled on this call.",
170
194
  });
171
- expect(await run("cancel_ticket", {}, ctx)).toEqual({ error: "There is no ticket to cancel." });
195
+ expect(await run("cancel_ticket", ctx)).toEqual({ error: "There is no ticket to cancel." });
172
196
  });
173
197
 
174
198
  test("a second staging is REFUSED rather than overwriting the first", async () => {
@@ -193,7 +217,12 @@ describe("sensitive tools stage rather than act", () => {
193
217
 
194
218
  // The first staging is untouched, and it is what a yes applies.
195
219
  expect(stateOf(ctx).pending).toEqual({ kind: "update_ticket", flightId: "LX52" });
196
- const applied = (await run("confirm_action", {}, ctx)) as { applied: string };
220
+ // A refused SECOND staging must not have moved the gate either — it was
221
+ // already `awaitingConfirmation` and the refusal changed nothing.
222
+ expect(gateFlow.position(ctx).state).toBe("awaitingConfirmation");
223
+ // `ok` is `okPosition` with `.result` taken off: this assertion is about
224
+ // what the apply DID, not about where the gate landed.
225
+ const applied = ok<{ applied: string }>(await run("confirm_action", ctx));
197
226
  expect(applied.applied).toContain("LX52");
198
227
  expect(stateOf(ctx).bookings).toEqual([]);
199
228
 
@@ -207,7 +236,7 @@ describe("sensitive tools stage rather than act", () => {
207
236
  test("cancel_action clears the block, so a declined change does not wedge the desk", async () => {
208
237
  const ctx = makeCtx();
209
238
  await run("book_car_rental", { carId: "C2", days: 3 }, ctx);
210
- await run("cancel_action", {}, ctx);
239
+ await run("cancel_action", ctx);
211
240
  const staged = (await run("book_excursion", { excursionId: "E2" }, ctx)) as {
212
241
  awaitingConfirmation: boolean;
213
242
  };
@@ -225,12 +254,14 @@ describe("sensitive tools stage rather than act", () => {
225
254
 
226
255
  await run("to_hotel_assistant", { request: "a room" }, first);
227
256
  await run("book_hotel", { hotelId: "H3", nights: 1 }, first);
228
- await run("confirm_action", {}, first);
257
+ await run("confirm_action", first);
229
258
 
230
259
  expect(activeAssistant(stateOf(second))).toBe("primary");
231
260
  expect(stateOf(second).bookings).toEqual([]);
232
- expect(await run("confirm_action", {}, second)).toEqual({
233
- error: "Nothing is waiting for confirmation. Use the booking tool first.",
261
+ // The GATE is per-session too, not just the trip.
262
+ expect(gateFlow.position(second).state).toBe("browsing");
263
+ expect(await run("confirm_action", second)).toMatchObject({
264
+ error: expect.stringContaining('this conversation is at "browsing"'),
234
265
  });
235
266
  expect(stateOf(first).bookings).toHaveLength(1);
236
267
  });
@@ -283,8 +314,8 @@ describe("search tools", () => {
283
314
  test("lookup_booking reports the ticket the caller is actually holding", async () => {
284
315
  const ctx = makeCtx();
285
316
  await run("update_ticket", { flightId: "LX52" }, ctx);
286
- await run("confirm_action", {}, ctx);
287
- const booking = (await run("lookup_booking", {}, ctx)) as {
317
+ await run("confirm_action", ctx);
318
+ const booking = (await run("lookup_booking", ctx)) as {
288
319
  passenger: string;
289
320
  ticket: { flight: string; departs: string } | null;
290
321
  };
@@ -327,9 +358,9 @@ describe("tripView projection", () => {
327
358
  test("totals every confirmed booking", async () => {
328
359
  const ctx = makeCtx();
329
360
  await run("book_hotel", { hotelId: "H3", nights: 2 }, ctx); // 2 × 180
330
- await run("confirm_action", {}, ctx);
361
+ await run("confirm_action", ctx);
331
362
  await run("book_excursion", { excursionId: "E1" }, ctx); // 35
332
- await run("confirm_action", {}, ctx);
363
+ await run("confirm_action", ctx);
333
364
 
334
365
  const view = tripView(stateOf(ctx));
335
366
  expect(view.total).toBe(395);
@@ -1,5 +1,5 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
- import { AutoScroll, client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
2
+ import { AutoScroll, client, useAgentState } from "@alexkroman1/aai-ui";
3
3
  import type { TripView } from "./shared.ts";
4
4
  import { SPECIALIST_IDS, SPECIALISTS, tripProjection } from "./shared.ts";
5
5
 
@@ -15,7 +15,6 @@ function deskLabel(id: (typeof DESKS)[number]): string {
15
15
  * the transcript — the whole delegation mechanism is otherwise invisible.
16
16
  */
17
17
  function DeskStrip({ active }: { active: TripView["assistant"] }) {
18
- const theme = useTheme();
19
18
  return (
20
19
  <div className="flex flex-wrap gap-1.5">
21
20
  {DESKS.map((id) => {
@@ -23,12 +22,9 @@ function DeskStrip({ active }: { active: TripView["assistant"] }) {
23
22
  return (
24
23
  <span
25
24
  key={id}
26
- className="rounded-full px-2.5 py-1 text-[11px] font-medium capitalize"
27
- style={{
28
- background: on ? theme.primary : theme.surface,
29
- color: on ? theme.bg : theme.text,
30
- opacity: on ? 1 : 0.55,
31
- }}
25
+ className={`rounded-full px-2.5 py-1 text-[11px] font-medium capitalize ${
26
+ on ? "bg-aai-primary text-aai-bg" : "bg-aai-surface text-aai-text opacity-55"
27
+ }`}
32
28
  >
33
29
  {deskLabel(id)}
34
30
  </span>
@@ -39,11 +35,10 @@ function DeskStrip({ active }: { active: TripView["assistant"] }) {
39
35
  }
40
36
 
41
37
  function ItinerarySidebar() {
42
- const theme = useTheme();
43
38
  const trip = useAgentState(tripProjection);
44
39
 
45
40
  return (
46
- <div className="flex flex-col gap-4 p-4 h-full min-h-0" style={{ color: theme.text }}>
41
+ <div className="flex flex-col gap-4 p-4 h-full min-h-0 text-aai-text">
47
42
  <div className="flex flex-col gap-2">
48
43
  <h3 className="text-sm font-bold opacity-60 uppercase tracking-wide">{trip.passenger}</h3>
49
44
  <DeskStrip active={trip.assistant} />
@@ -52,10 +47,7 @@ function ItinerarySidebar() {
52
47
  {/* The staged action — the browser half of the confirmation gate. What is
53
48
  on screen is exactly what the concierge just asked out loud. */}
54
49
  {trip.pending && (
55
- <div
56
- className="rounded-lg p-3 text-sm"
57
- style={{ background: theme.surface, border: `1px solid ${theme.primary}` }}
58
- >
50
+ <div className="rounded-lg p-3 text-sm bg-aai-surface border border-aai-primary">
59
51
  <p className="text-[11px] font-bold uppercase tracking-wide opacity-60">
60
52
  Waiting on your yes
61
53
  </p>
@@ -66,7 +58,7 @@ function ItinerarySidebar() {
66
58
  <div className="flex flex-col gap-2">
67
59
  <p className="text-[11px] font-bold uppercase tracking-wide opacity-60">Ticket</p>
68
60
  {trip.ticket ? (
69
- <div className="rounded-lg p-3" style={{ background: theme.surface }}>
61
+ <div className="rounded-lg p-3 bg-aai-surface">
70
62
  <p className="text-sm font-medium">
71
63
  {trip.ticket.flightId} · {trip.ticket.route}
72
64
  </p>
@@ -85,24 +77,20 @@ function ItinerarySidebar() {
85
77
  {trip.bookings.map((booking) => (
86
78
  <div
87
79
  key={booking.reference}
88
- className="flex items-center justify-between gap-3 rounded-lg p-3"
89
- style={{ background: theme.surface }}
80
+ className="flex items-center justify-between gap-3 rounded-lg p-3 bg-aai-surface"
90
81
  >
91
82
  <div className="min-w-0">
92
83
  <p className="truncate text-sm">{booking.summary}</p>
93
84
  <p className="text-xs opacity-60">{booking.reference}</p>
94
85
  </div>
95
- <span className="text-sm font-bold" style={{ color: theme.primary }}>
86
+ <span className="text-sm font-bold text-aai-primary">
96
87
  ${booking.price.toLocaleString("en-US")}
97
88
  </span>
98
89
  </div>
99
90
  ))}
100
- <div
101
- className="flex justify-between border-t pt-2 text-sm font-bold"
102
- style={{ borderColor: theme.border }}
103
- >
91
+ <div className="flex justify-between border-t border-aai-border pt-2 text-sm font-bold">
104
92
  <span>Total</span>
105
- <span style={{ color: theme.primary }}>${trip.total.toLocaleString("en-US")}</span>
93
+ <span className="text-aai-primary">${trip.total.toLocaleString("en-US")}</span>
106
94
  </div>
107
95
  </div>
108
96
  )}
@@ -24,6 +24,7 @@ import {
24
24
  activeAssistant,
25
25
  applyPending,
26
26
  describeAction,
27
+ gateFlow,
27
28
  note,
28
29
  SPECIALISTS,
29
30
  type SpecialistId,
@@ -111,28 +112,46 @@ export const completeOrEscalate = tripSlot.updateTool({
111
112
  * halts before a sensitive tool and resumes on approval; the halt here is that
112
113
  * every sensitive tool stages instead of acting, so the approval has somewhere
113
114
  * to arrive.
115
+ *
116
+ * **`when: "awaitingConfirmation"` is the other side of that halt.** It used to
117
+ * be a null check inside {@link applyPending} — "Nothing is waiting for
118
+ * confirmation. Use the booking tool first." — which is the question "where is
119
+ * this conversation", asked of the payload. The gate asks the machine instead,
120
+ * and its refusal carries the state's own instruction. `SETTLED` fires only when
121
+ * the body did NOT answer with a {@link ToolFailure}, so an application that
122
+ * failed leaves the change staged and the caller still being asked.
114
123
  */
115
- export const confirmAction = tripSlot.updateTool({
124
+ export const confirmAction = gateFlow.tool({
116
125
  description:
117
126
  "Apply the change the caller has just confirmed out loud. Only call this " +
118
127
  "after you have read the change back and heard a clear yes.",
119
- execute(_args, trip) {
120
- return applyPending(trip);
121
- },
128
+ when: "awaitingConfirmation",
129
+ send: { type: "SETTLED" },
130
+ execute: (_args, ctx) => tripSlot.update(ctx, (trip) => applyPending(trip)),
122
131
  });
123
132
 
124
- /** `cancel_action` — the caller said no. Drops the staged action, changes nothing. */
125
- export const cancelAction = tripSlot.updateTool({
133
+ /**
134
+ * `cancel_action` the caller said no. Drops the staged action, changes nothing.
135
+ *
136
+ * Gated for the same reason as `confirm_action`, and its own "nothing was
137
+ * waiting" arm is gone with the same argument.
138
+ */
139
+ export const cancelAction = gateFlow.tool({
126
140
  description:
127
141
  "Discard the change the caller just declined. Call this when they say no, " +
128
142
  "or when they want to change the details before confirming.",
129
- execute(_args, trip) {
130
- const action = trip.pending;
131
- if (!action) return { message: "Nothing was waiting for confirmation." };
132
- trip.pending = null;
133
- const described = describeAction(action);
134
- const summary = typeof described === "string" ? described : action.kind;
135
- note(trip, `Declined: ${summary}`);
136
- return { discarded: summary, message: "Nothing was changed." };
137
- },
143
+ when: "awaitingConfirmation",
144
+ send: { type: "SETTLED" },
145
+ execute: (_args, ctx) =>
146
+ tripSlot.update(ctx, (trip) => {
147
+ const action = trip.pending;
148
+ // Reachable only if the position and the payload disagree; see
149
+ // `applyPending`. Reported rather than thrown, mid-call.
150
+ if (!action) return { discarded: null, message: "Nothing was staged after all." };
151
+ trip.pending = null;
152
+ const described = describeAction(action);
153
+ const summary = typeof described === "string" ? described : action.kind;
154
+ note(trip, `Declined: ${summary}`);
155
+ return { discarded: summary, message: "Nothing was changed." };
156
+ }),
138
157
  });