@alexkroman1/aai-cli 13.3.0 → 15.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 (158) hide show
  1. package/dist/{_agent-Clbq3a4h.mjs → _agent-BzUeqOdj.mjs} +32 -9
  2. package/dist/_agent.d.ts +23 -0
  3. package/dist/_artifacts-BJOYGQPp.mjs +21 -0
  4. package/dist/_artifacts.d.ts +16 -0
  5. package/dist/_build-target.d.ts +172 -0
  6. package/dist/{_bundler-DEkjO1d8.mjs → _bundler-DM0d0M7m.mjs} +8 -7
  7. package/dist/_cli-common.d.ts +45 -6
  8. package/dist/{_config-ZY2euhdh.mjs → _config-DYzC6WMD.mjs} +2 -2
  9. package/dist/_dev-env.d.ts +1 -1
  10. package/dist/{_dev-server-C13xuGB1.mjs → _dev-server-BzWB6-4y.mjs} +25 -24
  11. package/dist/_dev-vite-config.d.ts +3 -3
  12. package/dist/_e2e-test-utils.d.ts +1 -1
  13. package/dist/{_init-Ci4QGjp8.mjs → _init-Bsi3DZNJ.mjs} +9 -23
  14. package/dist/{_output-DbJt9gsl.mjs → _output-DBP9Op_d.mjs} +22 -13
  15. package/dist/_output.d.ts +10 -9
  16. package/dist/{_preflight-B2laWPdU.mjs → _preflight-CcAR577V.mjs} +2 -2
  17. package/dist/_resource-commands.d.ts +9 -9
  18. package/dist/_server-common-De0haHr9.mjs +70 -0
  19. package/dist/_server-common.d.ts +20 -1
  20. package/dist/{_slug-api-DlKpj91X.mjs → _slug-api-CGvc2YSQ.mjs} +18 -9
  21. package/dist/_slug-api.d.ts +19 -0
  22. package/dist/{_studio-DTi3EdXQ.mjs → _studio-DnR_BqFp.mjs} +7 -3
  23. package/dist/_studio-commands.d.ts +13 -13
  24. package/dist/{_templates-V4l5sZJy.mjs → _templates-CIlJ3Vay.mjs} +4 -4
  25. package/dist/_templates.d.ts +1 -1
  26. package/dist/{_typecheck-gate-sV4N5F93.mjs → _typecheck-gate-U3jcil8L.mjs} +3 -2
  27. package/dist/_typecheck-gate.d.ts +3 -1
  28. package/dist/{_utils-Dhd058D9.mjs → _utils-D5JGcjiW.mjs} +43 -1
  29. package/dist/_utils.d.ts +35 -0
  30. package/dist/_vercel-output.d.ts +63 -0
  31. package/dist/{_workflow-determinism-C9l8uC2R.mjs → _workflow-determinism-CvpqMpdh.mjs} +18 -26
  32. package/dist/_workflow-determinism.d.ts +3 -3
  33. package/dist/build-BhEaxBPu.mjs +481 -0
  34. package/dist/build.d.ts +23 -10
  35. package/dist/cli.d.ts +1 -2
  36. package/dist/cli.mjs +155 -138
  37. package/dist/{client-bundler-CT-_26c5.mjs → client-bundler-6mTLs6ny.mjs} +5 -5
  38. package/dist/client-bundler.d.ts +1 -1
  39. package/dist/client-bundler.mjs +1 -1
  40. package/dist/{delete-RqlQZ6Y_.mjs → delete-BdFnbnVR.mjs} +4 -4
  41. package/dist/{deploy-B7f3CK7v.mjs → deploy-CGqPU5U-.mjs} +11 -11
  42. package/dist/{dev-DamXieW_.mjs → dev-Bx9gYBHM.mjs} +3 -3
  43. package/dist/{eval-h3DTVfRw.mjs → eval-B3I7FqN9.mjs} +3 -3
  44. package/dist/{init-D-vFrV8h.mjs → init-CFyusRbq.mjs} +48 -20
  45. package/dist/init.d.ts +14 -0
  46. package/dist/{login-CDiemNmC.mjs → login-DaLrPOFg.mjs} +4 -6
  47. package/dist/{logs-DGwPNCzB.mjs → logs-7BRwq0cG.mjs} +21 -14
  48. package/dist/project-config.mjs +1 -1
  49. package/dist/scaffold/CLAUDE.md +155 -76
  50. package/dist/scaffold/package.json +6 -6
  51. package/dist/{secret-V1w7y9-j.mjs → secret-Cjp912yF.mjs} +5 -5
  52. package/dist/secret.d.ts +8 -6
  53. package/dist/start.d.ts +112 -0
  54. package/dist/start.mjs +156 -0
  55. package/dist/{studio-BpMCfUNx.mjs → studio-C_zuRC_z.mjs} +13 -15
  56. package/dist/templates/briefing-desk/agent.eval.test.ts +156 -0
  57. package/dist/templates/call-audit/agent.test.ts +11 -6
  58. package/dist/templates/call-audit/agent.ts +2 -2
  59. package/dist/templates/call-audit/client.tsx +27 -42
  60. package/dist/templates/call-audit/workflows/audit.ts +7 -5
  61. package/dist/templates/call-audit/workflows/ingest.ts +7 -7
  62. package/dist/templates/call-audit/workflows/media.ts +12 -37
  63. package/dist/templates/call-audit/workflows/summarize.ts +8 -8
  64. package/dist/templates/call-audit/workflows/sync-api.ts +3 -3
  65. package/dist/templates/code-interpreter/agent.test.ts +103 -0
  66. package/dist/templates/dispatch-center/agent.test.ts +7 -7
  67. package/dist/templates/dispatch-center/client.tsx +53 -61
  68. package/dist/templates/dispatch-center/shared.ts +0 -8
  69. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +4 -1
  70. package/dist/templates/embedded-assets/agent.eval.test.ts +4 -2
  71. package/dist/templates/health-assistant/agent.eval.test.ts +4 -2
  72. package/dist/templates/infocom-adventure/client.tsx +40 -36
  73. package/dist/templates/link-digest/agent.test.ts +3 -3
  74. package/dist/templates/link-digest/agent.ts +2 -2
  75. package/dist/templates/link-digest/client.tsx +81 -19
  76. package/dist/templates/link-digest/workflows/digest.ts +13 -16
  77. package/dist/templates/math-buddy/agent.test.ts +126 -0
  78. package/dist/templates/night-owl/agent.eval.test.ts +4 -2
  79. package/dist/templates/night-owl/client.tsx +8 -2
  80. package/dist/templates/personal-finance/agent.test.ts +127 -0
  81. package/dist/templates/pipeline-simple/agent.test.ts +2 -2
  82. package/dist/templates/pizza-ordering/client.tsx +5 -4
  83. package/dist/templates/pizza-ordering/shared.ts +3 -8
  84. package/dist/templates/pizza-ordering/tools/add_pizza.ts +3 -2
  85. package/dist/templates/pizza-ordering/tools/place_order.ts +3 -2
  86. package/dist/templates/pizza-ordering/tools/remove_pizza.ts +3 -2
  87. package/dist/templates/pizza-ordering/tools/update_pizza.ts +3 -2
  88. package/dist/templates/pizza-ordering/tools/view_order.ts +3 -2
  89. package/dist/templates/plan-and-execute/agent.test.ts +10 -5
  90. package/dist/templates/plan-and-execute/client.tsx +2 -2
  91. package/dist/templates/podcast-digest/agent.test.ts +20 -11
  92. package/dist/templates/podcast-digest/client.tsx +16 -22
  93. package/dist/templates/podcast-digest/workflows/digest.ts +21 -19
  94. package/dist/templates/podcast-digest/workflows/feeds.ts +61 -33
  95. package/dist/templates/podcast-digest/workflows/slack.ts +7 -7
  96. package/dist/templates/recap-workflow/agent.eval.test.ts +21 -13
  97. package/dist/templates/recap-workflow/agent.test.ts +27 -23
  98. package/dist/templates/recap-workflow/workflows/recap.ts +24 -28
  99. package/dist/templates/redline/agent.eval.test.ts +5 -8
  100. package/dist/templates/redline/agent.test.ts +1 -1
  101. package/dist/templates/redline/client.tsx +36 -34
  102. package/dist/templates/redline/workflows/redline.ts +17 -17
  103. package/dist/templates/research-workflow/agent.eval.test.ts +5 -5
  104. package/dist/templates/research-workflow/agent.test.ts +10 -7
  105. package/dist/templates/research-workflow/workflows/research.ts +28 -30
  106. package/dist/templates/retail/agent.test.ts +17 -15
  107. package/dist/templates/retail/cancel.ts +7 -6
  108. package/dist/templates/retail/client.tsx +39 -58
  109. package/dist/templates/retail/payment.ts +4 -3
  110. package/dist/templates/retail/pending.test.ts +19 -19
  111. package/dist/templates/retail/store.test.ts +5 -5
  112. package/dist/templates/retail/swap.ts +8 -11
  113. package/dist/templates/solo-rpg/agent.eval.test.ts +2 -16
  114. package/dist/templates/solo-rpg/agent.test.ts +12 -12
  115. package/dist/templates/solo-rpg/client.tsx +3 -3
  116. package/dist/templates/spoken-summary/agent.test.ts +9 -9
  117. package/dist/templates/spoken-summary/agent.ts +3 -3
  118. package/dist/templates/spoken-summary/client.tsx +23 -30
  119. package/dist/templates/spoken-summary/workflows/summarize.ts +11 -11
  120. package/dist/templates/spoken-summary/workflows/transcribe.ts +14 -14
  121. package/dist/templates/support-line/agent.ts +8 -0
  122. package/dist/templates/support-line/client.tsx +4 -3
  123. package/dist/templates/support-line/prompts.ts +0 -2
  124. package/dist/templates/transcription-workflow/agent.test.ts +20 -12
  125. package/dist/templates/transcription-workflow/agent.ts +2 -2
  126. package/dist/templates/transcription-workflow/client.tsx +15 -14
  127. package/dist/templates/transcription-workflow/recover.ts +1 -1
  128. package/dist/templates/transcription-workflow/run-panel.tsx +34 -14
  129. package/dist/templates/transcription-workflow/total-latency.tsx +46 -20
  130. package/dist/templates/transcription-workflow/workflows/batch.ts +15 -15
  131. package/dist/templates/transcription-workflow/workflows/downsample.ts +1 -1
  132. package/dist/templates/transcription-workflow/workflows/normalize.ts +15 -19
  133. package/dist/templates/transcription-workflow/workflows/stream.ts +12 -12
  134. package/dist/templates/transcription-workflow/workflows/sync-api.ts +3 -3
  135. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -34
  136. package/dist/templates/travel-concierge/agent.eval.test.ts +2 -2
  137. package/dist/templates/travel-concierge/agent.test.ts +15 -7
  138. package/dist/templates/travel-concierge/client.tsx +2 -2
  139. package/dist/templates/travel-concierge/routing.ts +64 -55
  140. package/dist/templates/travel-concierge/shared.ts +10 -14
  141. package/dist/templates/travel-concierge/tools/cancel_action.ts +3 -1
  142. package/dist/templates/travel-concierge/tools/complete_or_escalate.ts +3 -1
  143. package/dist/templates/travel-concierge/tools/confirm_action.ts +3 -1
  144. package/dist/templates/travel-concierge/tools/lookup_booking.ts +4 -3
  145. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +3 -2
  146. package/dist/templates/travel-concierge/tools/search_excursions.ts +3 -2
  147. package/dist/templates/travel-concierge/tools/search_flights.ts +3 -2
  148. package/dist/templates/travel-concierge/tools/search_hotels.ts +3 -2
  149. package/dist/templates/web-researcher/agent.test.ts +130 -0
  150. package/dist/{test-guenJUxi.mjs → test-D032V2cA.mjs} +4 -17
  151. package/dist/typecheck.mjs +1 -1
  152. package/dist/worker-bundler.d.ts +1 -1
  153. package/dist/worker-bundler.mjs +8 -8
  154. package/dist/{workflow-BZyKLkhV.mjs → workflow-CVioyO3V.mjs} +6 -5
  155. package/package.json +9 -4
  156. package/dist/_server-common-BV4_CrP3.mjs +0 -43
  157. package/dist/build-CWyFGe-W.mjs +0 -69
  158. package/dist/scaffold/server.mjs +0 -204
@@ -1,5 +1,6 @@
1
+ import { formatMoney } from "@alexkroman1/aai/utils";
1
2
  import { z } from "zod";
2
- import { CRUSTS, calculateTotal, formatPrice, orderSlot, type Pizza, SIZES } from "../shared.ts";
3
+ import { CRUSTS, calculateTotal, orderSlot, type Pizza, SIZES } from "../shared.ts";
3
4
 
4
5
  export default orderSlot.updateTool({
5
6
  description: "Add a pizza to the order. Use when the customer has decided on a pizza.",
@@ -24,7 +25,7 @@ export default orderSlot.updateTool({
24
25
 
25
26
  return {
26
27
  added: pizza,
27
- orderTotal: formatPrice(calculateTotal(order.pizzas)),
28
+ orderTotal: formatMoney(calculateTotal(order.pizzas)),
28
29
  itemCount: order.pizzas.length,
29
30
  };
30
31
  },
@@ -1,5 +1,6 @@
1
1
  import { toolFailure } from "@alexkroman1/aai";
2
- import { calculateTotal, formatPrice, orderSlot, resetOrder } from "../shared.ts";
2
+ import { formatMoney } from "@alexkroman1/aai/utils";
3
+ import { calculateTotal, orderSlot, resetOrder } from "../shared.ts";
3
4
 
4
5
  export default orderSlot.updateTool({
5
6
  description:
@@ -9,7 +10,7 @@ export default orderSlot.updateTool({
9
10
  if (pizzas.length === 0) return toolFailure("Cannot place an empty order.");
10
11
 
11
12
  const customerName = order.customerName ?? "Guest";
12
- const total = formatPrice(calculateTotal(pizzas));
13
+ const total = formatMoney(calculateTotal(pizzas));
13
14
  const orderNumber = Math.floor(1000 + Math.random() * 9000);
14
15
 
15
16
  const estimatedMinutes = 15 + pizzas.length * 5;
@@ -1,6 +1,7 @@
1
1
  import { toolFailure } from "@alexkroman1/aai";
2
+ import { formatMoney } from "@alexkroman1/aai/utils";
2
3
  import { z } from "zod";
3
- import { calculateTotal, formatPrice, orderSlot } from "../shared.ts";
4
+ import { calculateTotal, orderSlot } from "../shared.ts";
4
5
 
5
6
  export default orderSlot.updateTool({
6
7
  description: "Remove a pizza from the order by its ID.",
@@ -15,7 +16,7 @@ export default orderSlot.updateTool({
15
16
 
16
17
  return {
17
18
  removed,
18
- orderTotal: formatPrice(calculateTotal(order.pizzas)),
19
+ orderTotal: formatMoney(calculateTotal(order.pizzas)),
19
20
  itemCount: order.pizzas.length,
20
21
  };
21
22
  },
@@ -1,6 +1,7 @@
1
1
  import { toolFailure } from "@alexkroman1/aai";
2
+ import { formatMoney } from "@alexkroman1/aai/utils";
2
3
  import { z } from "zod";
3
- import { CRUSTS, calculateTotal, formatPrice, orderSlot, type Pizza, SIZES } from "../shared.ts";
4
+ import { CRUSTS, calculateTotal, orderSlot, type Pizza, SIZES } from "../shared.ts";
4
5
 
5
6
  export default orderSlot.updateTool({
6
7
  description: "Update an existing pizza in the order. Only provided fields are changed.",
@@ -23,6 +24,6 @@ export default orderSlot.updateTool({
23
24
 
24
25
  order.pizzas[idx] = pizza;
25
26
 
26
- return { updated: pizza, orderTotal: formatPrice(calculateTotal(order.pizzas)) };
27
+ return { updated: pizza, orderTotal: formatMoney(calculateTotal(order.pizzas)) };
27
28
  },
28
29
  });
@@ -1,4 +1,5 @@
1
- import { calculateTotal, formatPrice, orderSlot } from "../shared.ts";
1
+ import { formatMoney } from "@alexkroman1/aai/utils";
2
+ import { calculateTotal, orderSlot } from "../shared.ts";
2
3
 
3
4
  export default orderSlot.tool({
4
5
  description: "View the current order summary with all pizzas and total price.",
@@ -15,7 +16,7 @@ export default orderSlot.tool({
15
16
  toppings: p.toppings,
16
17
  quantity: p.quantity,
17
18
  })),
18
- orderTotal: formatPrice(calculateTotal(pizzas)),
19
+ orderTotal: formatMoney(calculateTotal(pizzas)),
19
20
  };
20
21
  },
21
22
  });
@@ -1,7 +1,12 @@
1
1
  /** The def a DEPLOYED agent runs: authored, plus what `tools/` declares. */
2
2
  import agentDef from "virtual:aai/agent";
3
3
  import type { ToolContext } from "@alexkroman1/aai";
4
- import { createToolContext, okPosition, stubGenerate, toolRunner } from "@alexkroman1/aai/testing";
4
+ import {
5
+ createToolContext,
6
+ expectDialogOk,
7
+ stubGenerate,
8
+ toolRunner,
9
+ } from "@alexkroman1/aai/testing";
5
10
  import { describe, expect, test } from "vitest";
6
11
 
7
12
  import { executeStep, MAX_STEP_SEARCHES, normalizeAct, planNode } from "./procedure.ts";
@@ -216,7 +221,7 @@ describe("work_next_step", () => {
216
221
  const ctx = createToolContext({ generate });
217
222
  await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
218
223
 
219
- const first = okPosition<{
224
+ const first = expectDialogOk<{
220
225
  finished: boolean;
221
226
  step: string;
222
227
  result: string;
@@ -243,7 +248,7 @@ describe("work_next_step", () => {
243
248
  });
244
249
  const ctx = createToolContext({ generate });
245
250
  await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
246
- const answered = okPosition<{ finished: boolean; response: string }>(
251
+ const answered = expectDialogOk<{ finished: boolean; response: string }>(
247
252
  await run("work_next_step", ctx),
248
253
  );
249
254
 
@@ -327,7 +332,7 @@ describe("revise_plan", () => {
327
332
  await run("work_next_step", ctx);
328
333
  expect(stateOf(ctx).response).not.toBeNull();
329
334
 
330
- const revised = okPosition<{ finished: boolean; remaining: string[] }>(
335
+ const revised = expectDialogOk<{ finished: boolean; remaining: string[] }>(
331
336
  await run("revise_plan", { instruction: "make it Porto instead" }, ctx),
332
337
  );
333
338
  expect(revised.result.finished).toBe(false);
@@ -362,7 +367,7 @@ describe("revise_plan", () => {
362
367
  });
363
368
  const ctx = createToolContext({ generate });
364
369
  await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
365
- const revised = okPosition<{ finished: boolean }>(
370
+ const revised = expectDialogOk<{ finished: boolean }>(
366
371
  await run("revise_plan", { instruction: "never mind, it is booked" }, ctx),
367
372
  );
368
373
  expect(revised.result.finished).toBe(true);
@@ -1,5 +1,5 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
- import { AutoScroll, client, useAgentState } from "@alexkroman1/aai-ui";
2
+ import { AutoScroll, mountClient, useAgentState } from "@alexkroman1/aai-ui";
3
3
  import { planProjection } from "./shared.ts";
4
4
 
5
5
  /**
@@ -90,7 +90,7 @@ function PlanSidebar() {
90
90
  );
91
91
  }
92
92
 
93
- client({
93
+ mountClient({
94
94
  name: "Planning Desk",
95
95
  sidebar: PlanSidebar,
96
96
  theme: {
@@ -11,7 +11,7 @@
11
11
  * HTTP handling, its partial-failure policy and its `FatalError` guards are
12
12
  * all reachable — `installStubStepFetch` answers the network and
13
13
  * `stubGateway` answers the model.
14
- * - The BODY, twice. `createWorkflowCtx` records what it asked for (the digest
14
+ * - The BODY, twice. `createWorkflowContext` records what it asked for (the digest
15
15
  * loop, the shrinking pending set, the sleep BETWEEN digests and never after
16
16
  * the last), and `runWorkflow` runs it on the real replay engine — which is
17
17
  * what this file used to say a unit test could not do, on the ground that a
@@ -26,8 +26,9 @@
26
26
 
27
27
  import { type FeedItem, parseFeed } from "@alexkroman1/aai/html";
28
28
  import {
29
- createWorkflowCtx,
29
+ createWorkflowContext,
30
30
  parseSchemaInput,
31
+ routeStepFetch,
31
32
  schemaInputIssues,
32
33
  stubGatewayRoute,
33
34
  } from "@alexkroman1/aai/testing";
@@ -422,12 +423,20 @@ const THREE_EPISODES = feedXml(
422
423
  * the thing that forgets, and a step fetch left published reaches the next file.
423
424
  */
424
425
  function stubRoutes(routes: Record<string, { status?: number; body?: unknown }>) {
425
- return installStubStepFetch((request) => {
426
- for (const [fragment, answer] of Object.entries(routes)) {
427
- if (request.url.includes(fragment)) return answer;
428
- }
429
- return { status: 404, body: `no route for ${request.url}` };
430
- });
426
+ return installStubStepFetch(
427
+ routeStepFetch(
428
+ Object.entries(routes).map(
429
+ ([fragment, answer]) =>
430
+ (request) =>
431
+ request.url.includes(fragment) ? answer : undefined,
432
+ ),
433
+ // A 404 rather than the default throw, and this one is deliberate: several
434
+ // cases below are ABOUT a feed the run cannot reach, and they express that
435
+ // by leaving it out of `routes`. Where an unrecognised request would be a
436
+ // finding instead, the default is what says so at the call.
437
+ { unmatched: "notFound" },
438
+ ),
439
+ );
431
440
  }
432
441
 
433
442
  describe("discoverEpisodes", () => {
@@ -837,7 +846,7 @@ describe("posting the digest", () => {
837
846
  });
838
847
 
839
848
  /**
840
- * The 4xx/5xx split is the reason this is not a one-line `stepFetchOk`: a
849
+ * The 4xx/5xx split is the reason this is not a one-line `stepFetchOrFail`: a
841
850
  * revoked webhook answers 4xx identically on every retry, so retrying it
842
851
  * burns the DevKit's attempts and delays the real error by minutes.
843
852
  */
@@ -876,7 +885,7 @@ describe("the body — the run that IS the schedule", () => {
876
885
  * per-step spec can see, and all of which is the template's actual subject.
877
886
  */
878
887
  function driveTwoDigests(pollResults: unknown) {
879
- const ctx = createWorkflowCtx({
888
+ const ctx = createWorkflowContext({
880
889
  runSteps: false,
881
890
  results: {
882
891
  discoverEpisodes: [EPISODE],
@@ -973,7 +982,7 @@ describe("the body — the run that IS the schedule", () => {
973
982
  /**
974
983
  * The schedule, on the real replay engine.
975
984
  *
976
- * The block above drives the same body through `createWorkflowCtx`, which
985
+ * The block above drives the same body through `createWorkflowContext`, which
977
986
  * RECORDS a sleep rather than taking one and replays nothing — right for the
978
987
  * loop's logic, and silent about the property this template is: a run that
979
988
  * sleeps for a day between digests and comes back. `runWorkflow`
@@ -3,7 +3,7 @@
3
3
  * The browser half — a form that starts a run, and a panel that watches one
4
4
  * that may outlive the browser by a week.
5
5
  *
6
- * Mounted with `page()` rather than `client()`: there is no session to build, so
6
+ * Mounted with `mountPage()` rather than `mountClient()`: there is no session to build, so
7
7
  * no socket, no audio graph, no microphone request. `useWorkflowSubmit()` starts
8
8
  * the run, follows its STATUS, and hands back the controls bound to it.
9
9
  *
@@ -86,8 +86,9 @@
86
86
  */
87
87
 
88
88
  import {
89
+ BulletList,
89
90
  Form,
90
- page,
91
+ mountPage,
91
92
  SubmitButton,
92
93
  useRunKey,
93
94
  useWorkflowSubmit,
@@ -98,7 +99,6 @@ import "@alexkroman1/aai-ui/styles.css";
98
99
  // ERASED at build time, so naming the agent's own type costs the browser bundle
99
100
  // nothing — and it is what stops this file restating a shape `workflows/
100
101
  // digest.ts` already declares.
101
- import { useState } from "react";
102
102
  import type { dailyDigest } from "./agent.ts";
103
103
 
104
104
  /** The workflow this page drives. Matches the key in `workflowApp({ workflows })`. */
@@ -131,17 +131,15 @@ export function App() {
131
131
  // Did THIS load start the schedule? A later load cannot have, and that is the
132
132
  // only way the page can tell "scheduled just now" from "still running from
133
133
  // Tuesday" — the hook reports the run, not who asked for it.
134
- const [startedHere, setStartedHere] = useState(false);
135
134
  // The generic is what makes `run.status === "completed"` narrow to a TYPED
136
135
  // `run.output` instead of `unknown`. `error` is the agent's own sentence for a
137
136
  // rejected input — better copy than anything this page could write, and the
138
137
  // reason there is no `try`/`catch` here.
139
138
  // The key REPLACES the tab-scoped one the hook would mint; the lookup that
140
139
  // reads it back on the next load happens either way.
141
- const { submitForm, run, pending, error, wake, cancel } = useWorkflowSubmit<typeof dailyDigest>(
142
- WORKFLOW,
143
- { key },
144
- );
140
+ const { submitForm, run, pending, error, wake, cancel, startedHere } = useWorkflowSubmit<
141
+ typeof dailyDigest
142
+ >(WORKFLOW, { key });
145
143
 
146
144
  return (
147
145
  <main className="mx-auto flex max-w-2xl flex-col gap-6 p-8">
@@ -149,13 +147,7 @@ export function App() {
149
147
 
150
148
  {/* `submit()` resolves as soon as the run EXISTS — deliberately not when it
151
149
  finishes, which here could be a month away. */}
152
- <Form
153
- onSubmit={(values) => {
154
- setStartedHere(true);
155
- return submitForm(values);
156
- }}
157
- error={error}
158
- >
150
+ <Form onSubmit={(values) => submitForm(values)} error={error}>
159
151
  <WorkflowFields workflow={WORKFLOW} />
160
152
  <SubmitButton pending={pending}>
161
153
  {pending ? "Digest scheduled" : "Start digest"}
@@ -195,7 +187,13 @@ export function App() {
195
187
  </div>
196
188
  )}
197
189
 
198
- {run?.status === "failed" && <p className="text-red-600">That run failed: {run.error}</p>}
190
+ {/* `role="alert"`, the same contract `<Form>` gives the submit error
191
+ above: a digest that fails does so days later, with nobody watching. */}
192
+ {run?.status === "failed" && (
193
+ <p role="alert" className="text-red-600">
194
+ That run failed: {run.error}
195
+ </p>
196
+ )}
199
197
  {run?.status === "cancelled" && <p>Cancelled — no further digests will be posted.</p>}
200
198
 
201
199
  {run?.status === "completed" && (
@@ -213,11 +211,7 @@ export function App() {
213
211
  </a>
214
212
  </h2>
215
213
  <p>{episode.summary}</p>
216
- <ul className="flex list-disc flex-col gap-1 pl-5 text-sm">
217
- {episode.keyPoints.map((point) => (
218
- <li key={point}>{point}</li>
219
- ))}
220
- </ul>
214
+ <BulletList items={episode.keyPoints} size="sm" />
221
215
  </section>
222
216
  ))}
223
217
  </article>
@@ -226,4 +220,4 @@ export function App() {
226
220
  );
227
221
  }
228
222
 
229
- page({ name: "Podcast Digest", component: App });
223
+ mountPage({ name: "Podcast Digest", component: App });
@@ -49,13 +49,13 @@
49
49
  * @module digest
50
50
  */
51
51
 
52
- import type { WorkflowCtx } from "@alexkroman1/aai";
53
- import { mapConcurrent, report, TRANSCRIBE_API } from "@alexkroman1/aai/step";
52
+ import type { WorkflowContext } from "@alexkroman1/aai";
53
+ import { mapConcurrent, stepReport, TRANSCRIBE_API } from "@alexkroman1/aai/step";
54
54
  import {
55
55
  FatalError,
56
- stepGenerateJsonClassified,
57
- stepTranscribePollClassified,
58
- stepTranscribeSubmitClassified,
56
+ stepGenerateJsonOrFail,
57
+ stepTranscribePollOrFail,
58
+ stepTranscribeSubmitOrFail,
59
59
  } from "@alexkroman1/aai/step-errors";
60
60
  import { errorMessage } from "@alexkroman1/aai/utils";
61
61
  import type { WorkflowInputOf } from "@alexkroman1/aai/workflow-api";
@@ -160,7 +160,7 @@ export type DailyDigestOutput = {
160
160
  } | null;
161
161
  };
162
162
 
163
- /** What the model must answer with, and what `stepGenerateJsonClassified` enforces. */
163
+ /** What the model must answer with, and what `stepGenerateJsonOrFail` enforces. */
164
164
  const SummaryReply = z.object({
165
165
  summary: z.string().trim().min(1),
166
166
  keyPoints: z.array(z.string().trim().min(1)).min(1).max(5),
@@ -178,7 +178,7 @@ const SummaryReply = z.object({
178
178
  */
179
179
  export async function dailyDigestFlow(
180
180
  input: DigestInput,
181
- ctx: WorkflowCtx,
181
+ ctx: WorkflowContext,
182
182
  ): Promise<DailyDigestOutput> {
183
183
  // No `??` fallbacks: {@link DigestInput} is the schema's OUTPUT, so every
184
184
  // `.default()` has already run by the time a run reaches this line. The
@@ -269,7 +269,7 @@ export async function dailyDigestFlow(
269
269
  */
270
270
  async function waitForTranscripts(
271
271
  jobs: TranscriptJob[],
272
- ctx: WorkflowCtx,
272
+ ctx: WorkflowContext,
273
273
  ): Promise<TranscriptState[]> {
274
274
  let pending = jobs;
275
275
  // Keyed by episode id rather than appended, and that is what keeps the digest
@@ -287,17 +287,19 @@ async function waitForTranscripts(
287
287
  if (pending.length > 0) await ctx.sleep("poll", POLL_DELAY_MS);
288
288
  }
289
289
 
290
- for (const job of pending) settled.set(job.id, gaveUpOn(job));
291
290
  // The list `jobs` arrived in, which `discoverEpisodes` sorted newest first.
291
+ // A job still pending is absent from `settled`, so the `??` IS the
292
+ // give-up path — `pollTranscript` returns the job unchanged while it is
293
+ // submitted, so the value given up on is the one that went in.
292
294
  return jobs.map((job) => settled.get(job.id) ?? gaveUpOn(job));
293
295
  }
294
296
 
295
297
  /**
296
298
  * One episode the run is done waiting for.
297
299
  *
298
- * Its own function because {@link waitForTranscripts} needs it in two places —
299
- * the jobs still pending when the budget ran out, and the unreachable fallback
300
- * the ordered rebuild above needs for a lookup that cannot miss.
300
+ * Its own function because {@link waitForTranscripts} reaches it from the
301
+ * ordered rebuild, for every job whose budget ran out while it was still
302
+ * submitted and which is therefore absent from `settled`.
301
303
  */
302
304
  function gaveUpOn(job: TranscriptJob): TranscriptState {
303
305
  return {
@@ -322,7 +324,7 @@ function gaveUpOn(job: TranscriptJob): TranscriptState {
322
324
  * an `unavailable` VALUE rather than a throw, because one bad episode must not
323
325
  * sink a digest of five.
324
326
  *
325
- * The verdict itself is the SDK's: `stepTranscribeSubmitClassified` reads
327
+ * The verdict itself is the SDK's: `stepTranscribeSubmitOrFail` reads
326
328
  * `TranscribeError`'s own `retryable` AND its `retryAfter`, and throws a
327
329
  * `FatalError` or a `RetryableError` accordingly. The hand-written
328
330
  * `err instanceof TranscribeError && err.retryable` this replaces read only the
@@ -330,9 +332,9 @@ function gaveUpOn(job: TranscriptJob): TranscriptState {
330
332
  * one-second default instead.
331
333
  */
332
334
  export async function submitTranscript(episode: Episode): Promise<TranscriptJob> {
333
- await report(`Submitting ${episode.title} for transcription.`);
335
+ await stepReport(`Submitting ${episode.title} for transcription.`);
334
336
  try {
335
- const { id } = await stepTranscribeSubmitClassified(episode.audioUrl, {
337
+ const { id } = await stepTranscribeSubmitOrFail(episode.audioUrl, {
336
338
  // A digest quotes nobody, so who spoke costs time for nothing.
337
339
  params: { speaker_labels: false },
338
340
  });
@@ -356,12 +358,12 @@ export async function pollTranscript(job: TranscriptJob): Promise<TranscriptStat
356
358
  if (job.transcriptStatus === "unavailable") return job;
357
359
 
358
360
  try {
359
- const progress = await stepTranscribePollClassified(job.transcriptId);
361
+ const progress = await stepTranscribePollOrFail(job.transcriptId);
360
362
  // Branch on `done`, never on a status string: a vocabulary this body does
361
363
  // not own would otherwise read as "not finished yet" forever.
362
364
  if (!progress.done) return job;
363
365
 
364
- await report(`Transcribed ${job.title}.`);
366
+ await stepReport(`Transcribed ${job.title}.`);
365
367
  return {
366
368
  ...job,
367
369
  transcriptStatus: "done",
@@ -391,8 +393,8 @@ export async function summarizeTranscript(state: TranscriptState): Promise<Episo
391
393
  };
392
394
  }
393
395
 
394
- await report(`Summarizing ${state.title}.`);
395
- const parsed = await stepGenerateJsonClassified(
396
+ await stepReport(`Summarizing ${state.title}.`);
397
+ const parsed = await stepGenerateJsonOrFail(
396
398
  [
397
399
  `Podcast: ${state.podcastTitle}`,
398
400
  `Episode: ${state.title}`,
@@ -64,9 +64,9 @@
64
64
  * parsing. It is the section below's subject, not this one's.
65
65
  */
66
66
 
67
- import { type FeedItem, pageMetadata, parseFeed } from "@alexkroman1/aai/html";
68
- import { report } from "@alexkroman1/aai/step";
69
- import { FatalError, stepFetchOk } from "@alexkroman1/aai/step-errors";
67
+ import { type FeedItem, type ParsedFeed, pageMetadata, parseFeed } from "@alexkroman1/aai/html";
68
+ import { mapConcurrent, stepReport } from "@alexkroman1/aai/step";
69
+ import { FatalError, stepFetchOrFail } from "@alexkroman1/aai/step-errors";
70
70
  import { isRecord, omitUndefined, safeJsonParse } from "@alexkroman1/aai/utils";
71
71
  import { z } from "zod";
72
72
 
@@ -74,19 +74,32 @@ import { z } from "zod";
74
74
  const REQUEST_TIMEOUT_MS = 30_000;
75
75
 
76
76
  /**
77
- * A show, reduced to what the rest of the run needs — plus the body, when
78
- * resolving it already had to download one.
77
+ * How many pasted links are resolved at once.
79
78
  *
80
- * `xml` is the difference between one request per feed and two. Both web paths
81
- * fetch the feed to decide whether it IS a feed (`looksLikePodcastFeed`), and
82
- * without somewhere to put that body the reader downloads the identical
83
- * document a second time. The Apple and Spotify paths resolve a URL without
84
- * ever reading the feed, so they leave it unset and the reader fetches once.
79
+ * Small because the far side is Apple and Spotify, who rate-limit, and because
80
+ * a digest is a handful of shows rather than a fan-out the win is turning N
81
+ * sequential round trips into a couple of overlapped ones, not saturating a
82
+ * link.
83
+ */
84
+ const RESOLVE_CONCURRENCY = 4;
85
+
86
+ /**
87
+ * A show, reduced to what the rest of the run needs — plus the PARSE, when
88
+ * resolving it already had to do one.
89
+ *
90
+ * `parsed` is the difference between one request per feed and two, and between
91
+ * one parse and three. Both web paths fetch the feed to decide whether it IS a
92
+ * feed, and without somewhere to put the result the reader downloads and
93
+ * re-parses the identical document. Re-parsing is the more expensive half:
94
+ * `parseFeed` builds a DOM and runs `htmlToText` over every item's title AND
95
+ * description, and a podcast feed routinely carries 300+ entries of HTML show
96
+ * notes. The Apple and Spotify paths resolve a URL without ever reading the
97
+ * feed, so they leave it unset and the reader fetches once.
85
98
  */
86
99
  export type PodcastFeed = {
87
100
  feedUrl: string;
88
101
  title: string;
89
- xml?: string;
102
+ parsed?: ParsedFeed;
90
103
  };
91
104
 
92
105
  /** One episode with audio attached — the unit everything downstream works on. */
@@ -114,12 +127,17 @@ export async function discoverEpisodes(
114
127
  podcastChannels: string,
115
128
  maxEpisodes: number,
116
129
  ): Promise<Episode[]> {
117
- await report("Finding recent podcast episodes.");
130
+ await stepReport("Finding recent podcast episodes.");
118
131
  const links = parsePodcastChannels(podcastChannels);
119
132
  if (links.length === 0) throw new FatalError("Add at least one podcast link.");
120
133
 
121
- const feeds: PodcastFeed[] = [];
122
- for (const url of links) feeds.push(await resolvePodcastFeed(url));
134
+ // Overlapped, not sequential: each resolution is one to three HTTP round
135
+ // trips with a 30s ceiling, and the links are independent. Bounded rather
136
+ // than a bare `Promise.all` because Apple and Spotify both rate-limit — and
137
+ // legal here because this whole function is a STEP BODY (`ctx.step` wraps it
138
+ // in `digest.ts`), so the journal's name+occurrence rule does not apply to
139
+ // what happens inside it.
140
+ const feeds = await mapConcurrent(links, RESOLVE_CONCURRENCY, resolvePodcastFeed);
123
141
 
124
142
  const episodes = (await Promise.all(feeds.map((feed) => readPodcastFeed(feed))))
125
143
  .flat()
@@ -154,7 +172,10 @@ async function resolvePodcastFeed(url: string): Promise<PodcastFeed> {
154
172
  */
155
173
  async function resolveWebPodcastFeed(url: string): Promise<PodcastFeed> {
156
174
  const body = await fetchText(url);
157
- if (looksLikePodcastFeed(body)) return feedFrom(url, body);
175
+ // Parsed ONCE and carried: the "is this a feed" test, the channel title and
176
+ // the item list are three reads of one parse, not three parses.
177
+ const direct = parseFeed(body);
178
+ if (carriesAudio(direct)) return feedFrom(url, direct);
158
179
 
159
180
  const discovered = discoverFeedUrl(body, url);
160
181
  if (!discovered) {
@@ -167,9 +188,9 @@ async function resolveWebPodcastFeed(url: string): Promise<PodcastFeed> {
167
188
  // The advertised URL is verified rather than trusted: plenty of pages point
168
189
  // `application/rss+xml` at a blog feed with no audio in it, and finding that
169
190
  // out here names the page, where finding it out later names an empty digest.
170
- // The body is carried forward rather than re-fetched — see `PodcastFeed.xml`.
171
- const verified = await fetchText(discovered);
172
- if (!looksLikePodcastFeed(verified)) {
191
+ // The parse is carried forward rather than redone — see `PodcastFeed.parsed`.
192
+ const verified = parseFeed(await fetchText(discovered));
193
+ if (!carriesAudio(verified)) {
173
194
  throw new FatalError(`The feed at ${discovered} does not look like a podcast RSS feed.`);
174
195
  }
175
196
  return feedFrom(discovered, verified);
@@ -236,8 +257,9 @@ async function resolveSpotifyPodcastFeed(url: string): Promise<PodcastFeed> {
236
257
 
237
258
  /** Every item in the feed that has audio attached, newest first by the caller. */
238
259
  async function readPodcastFeed(feed: PodcastFeed): Promise<Episode[]> {
239
- const xml = feed.xml ?? (await fetchText(feed.feedUrl));
240
- const parsed = parseFeed(xml);
260
+ // Already parsed on the web paths; the Apple and Spotify paths resolve a URL
261
+ // without ever reading the feed, so this is where those two download it.
262
+ const parsed = feed.parsed ?? parseFeed(await fetchText(feed.feedUrl));
241
263
  // `parsed.title` is the CHANNEL's, which is the whole reason to parse: the
242
264
  // `indexOf` read this replaces took the first `<title>` at any depth.
243
265
  const podcastTitle = parsed?.title ?? feed.title;
@@ -393,17 +415,23 @@ export function titleMatchesSpotify(
393
415
  );
394
416
  }
395
417
 
396
- /** A feed document that actually carries audio — both halves are required. */
397
- export function looksLikePodcastFeed(xml: string): boolean {
398
- const parsed = parseFeed(xml);
399
- // `parseFeed` answering at all is the "is this a feed" half, and it is
400
- // stricter than the `<rss` test it replaces in the direction that matters:
401
- // an HTML page mentioning `<rss` in prose is not a feed. It is also wider
402
- // where being wide is right — an Atom podcast feed has no `<rss` root and
403
- // was refused outright.
418
+ /**
419
+ * A parse that actually carries audio — both halves are required.
420
+ *
421
+ * `parseFeed` answering at all is the "is this a feed" half, and it is stricter
422
+ * than the `<rss` test it replaces in the direction that matters: an HTML page
423
+ * mentioning `<rss` in prose is not a feed. It is also wider where being wide is
424
+ * right — an Atom podcast feed has no `<rss` root and was refused outright.
425
+ */
426
+ function carriesAudio(parsed: ParsedFeed | undefined): parsed is ParsedFeed {
404
427
  return parsed?.items.some((item) => item.enclosureUrl !== undefined) ?? false;
405
428
  }
406
429
 
430
+ /** {@link carriesAudio} over a document that has not been parsed yet. */
431
+ export function looksLikePodcastFeed(xml: string): boolean {
432
+ return carriesAudio(parseFeed(xml));
433
+ }
434
+
407
435
  /** The first feed a page advertises, resolved against the page's own URL. */
408
436
  export function discoverFeedUrl(html: string, pageUrl: string): string | undefined {
409
437
  // `pageMetadata` reads `<link>` off a parse, so attribute ORDER stops
@@ -441,11 +469,11 @@ function publishedAt(episode: Episode): number {
441
469
  return Number.isNaN(parsed) ? 0 : parsed;
442
470
  }
443
471
 
444
- function feedFrom(feedUrl: string, body: string): PodcastFeed {
472
+ function feedFrom(feedUrl: string, parsed: ParsedFeed): PodcastFeed {
445
473
  return {
446
474
  feedUrl,
447
- title: parseFeed(body)?.title ?? hostOf(feedUrl),
448
- xml: body,
475
+ title: parsed.title ?? hostOf(feedUrl),
476
+ parsed,
449
477
  };
450
478
  }
451
479
 
@@ -529,7 +557,7 @@ function appleResults(body: unknown): AppleResult[] {
529
557
  /**
530
558
  * Every outbound call in this file, with one timeout and one failure policy.
531
559
  *
532
- * `stepFetchOk` is `stepFetch` plus the non-2xx branch — see its own doc. Three
560
+ * `stepFetchOrFail` is `stepFetch` plus the non-2xx branch — see its own doc. Three
533
561
  * things come with it that a bare `fetch` here would each have to re-derive:
534
562
  * HTTP/1.1 (so a rate limit arrives as a `503` with `Retry-After` rather than
535
563
  * an h2 stream reset carrying no status), the transient/terminal verdict the
@@ -537,7 +565,7 @@ function appleResults(body: unknown): AppleResult[] {
537
565
  * the iTunes endpoints is the difference between "403" and a sentence.
538
566
  */
539
567
  async function fetchText(url: string): Promise<string> {
540
- const response = await stepFetchOk(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
568
+ const response = await stepFetchOrFail(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
541
569
  return await response.text();
542
570
  }
543
571