@alexkroman1/aai-cli 13.3.0 → 14.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 (134) hide show
  1. package/dist/{_agent-Clbq3a4h.mjs → _agent-BzUeqOdj.mjs} +32 -9
  2. package/dist/_agent.d.ts +23 -0
  3. package/dist/{_bundler-DEkjO1d8.mjs → _bundler-DolUCMxu.mjs} +8 -7
  4. package/dist/_cli-common.d.ts +45 -6
  5. package/dist/{_config-ZY2euhdh.mjs → _config-DYzC6WMD.mjs} +2 -2
  6. package/dist/_dev-env.d.ts +1 -1
  7. package/dist/{_dev-server-C13xuGB1.mjs → _dev-server-CSMqF8PN.mjs} +20 -22
  8. package/dist/_dev-vite-config.d.ts +3 -3
  9. package/dist/{_init-Ci4QGjp8.mjs → _init-CQ8idAwo.mjs} +9 -23
  10. package/dist/{_output-DbJt9gsl.mjs → _output-DBP9Op_d.mjs} +22 -13
  11. package/dist/_output.d.ts +10 -9
  12. package/dist/{_preflight-B2laWPdU.mjs → _preflight-CcAR577V.mjs} +2 -2
  13. package/dist/_resource-commands.d.ts +9 -9
  14. package/dist/{_server-common-BV4_CrP3.mjs → _server-common-vILJp3it.mjs} +1 -1
  15. package/dist/{_slug-api-DlKpj91X.mjs → _slug-api-CGvc2YSQ.mjs} +18 -9
  16. package/dist/_slug-api.d.ts +19 -0
  17. package/dist/{_studio-DTi3EdXQ.mjs → _studio-DnR_BqFp.mjs} +7 -3
  18. package/dist/_studio-commands.d.ts +13 -13
  19. package/dist/{_templates-V4l5sZJy.mjs → _templates-CK4oKoeX.mjs} +3 -3
  20. package/dist/{_typecheck-gate-sV4N5F93.mjs → _typecheck-gate-U3jcil8L.mjs} +3 -2
  21. package/dist/_typecheck-gate.d.ts +3 -1
  22. package/dist/{_utils-Dhd058D9.mjs → _utils-D5JGcjiW.mjs} +43 -1
  23. package/dist/_utils.d.ts +35 -0
  24. package/dist/{_workflow-determinism-C9l8uC2R.mjs → _workflow-determinism-CvpqMpdh.mjs} +18 -26
  25. package/dist/_workflow-determinism.d.ts +3 -3
  26. package/dist/{build-CWyFGe-W.mjs → build-Mxk8gWvX.mjs} +46 -7
  27. package/dist/build.d.ts +5 -0
  28. package/dist/cli.d.ts +1 -2
  29. package/dist/cli.mjs +117 -135
  30. package/dist/{client-bundler-CT-_26c5.mjs → client-bundler-BJgREAh6.mjs} +1 -1
  31. package/dist/client-bundler.mjs +1 -1
  32. package/dist/{delete-RqlQZ6Y_.mjs → delete-BdFnbnVR.mjs} +4 -4
  33. package/dist/{deploy-B7f3CK7v.mjs → deploy-uAJ4NukN.mjs} +11 -11
  34. package/dist/{dev-DamXieW_.mjs → dev-DApPSaE_.mjs} +3 -3
  35. package/dist/{eval-h3DTVfRw.mjs → eval-BK47A_K5.mjs} +3 -3
  36. package/dist/{init-D-vFrV8h.mjs → init-DukDxECd.mjs} +11 -19
  37. package/dist/{login-CDiemNmC.mjs → login-DaLrPOFg.mjs} +4 -6
  38. package/dist/{logs-DGwPNCzB.mjs → logs-7BRwq0cG.mjs} +21 -14
  39. package/dist/project-config.mjs +1 -1
  40. package/dist/scaffold/CLAUDE.md +84 -56
  41. package/dist/scaffold/package.json +4 -4
  42. package/dist/scaffold/server.mjs +129 -25
  43. package/dist/{secret-V1w7y9-j.mjs → secret-Cjp912yF.mjs} +5 -5
  44. package/dist/secret.d.ts +8 -6
  45. package/dist/{studio-BpMCfUNx.mjs → studio-CpHlNHUZ.mjs} +13 -15
  46. package/dist/templates/call-audit/agent.test.ts +11 -6
  47. package/dist/templates/call-audit/agent.ts +2 -2
  48. package/dist/templates/call-audit/client.tsx +27 -42
  49. package/dist/templates/call-audit/workflows/audit.ts +7 -5
  50. package/dist/templates/call-audit/workflows/ingest.ts +7 -7
  51. package/dist/templates/call-audit/workflows/media.ts +12 -37
  52. package/dist/templates/call-audit/workflows/summarize.ts +8 -8
  53. package/dist/templates/call-audit/workflows/sync-api.ts +3 -3
  54. package/dist/templates/dispatch-center/agent.test.ts +7 -7
  55. package/dist/templates/dispatch-center/client.tsx +53 -61
  56. package/dist/templates/dispatch-center/shared.ts +0 -8
  57. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +4 -1
  58. package/dist/templates/embedded-assets/agent.eval.test.ts +4 -2
  59. package/dist/templates/health-assistant/agent.eval.test.ts +4 -2
  60. package/dist/templates/infocom-adventure/client.tsx +40 -36
  61. package/dist/templates/link-digest/agent.test.ts +3 -3
  62. package/dist/templates/link-digest/agent.ts +2 -2
  63. package/dist/templates/link-digest/client.tsx +27 -17
  64. package/dist/templates/link-digest/workflows/digest.ts +13 -16
  65. package/dist/templates/night-owl/agent.eval.test.ts +4 -2
  66. package/dist/templates/night-owl/client.tsx +8 -2
  67. package/dist/templates/pipeline-simple/agent.test.ts +2 -2
  68. package/dist/templates/pizza-ordering/client.tsx +5 -4
  69. package/dist/templates/pizza-ordering/shared.ts +3 -8
  70. package/dist/templates/pizza-ordering/tools/add_pizza.ts +3 -2
  71. package/dist/templates/pizza-ordering/tools/place_order.ts +3 -2
  72. package/dist/templates/pizza-ordering/tools/remove_pizza.ts +3 -2
  73. package/dist/templates/pizza-ordering/tools/update_pizza.ts +3 -2
  74. package/dist/templates/pizza-ordering/tools/view_order.ts +3 -2
  75. package/dist/templates/plan-and-execute/agent.test.ts +10 -5
  76. package/dist/templates/plan-and-execute/client.tsx +2 -2
  77. package/dist/templates/podcast-digest/agent.test.ts +20 -11
  78. package/dist/templates/podcast-digest/client.tsx +16 -22
  79. package/dist/templates/podcast-digest/workflows/digest.ts +21 -19
  80. package/dist/templates/podcast-digest/workflows/feeds.ts +61 -33
  81. package/dist/templates/podcast-digest/workflows/slack.ts +7 -7
  82. package/dist/templates/recap-workflow/agent.eval.test.ts +21 -13
  83. package/dist/templates/recap-workflow/agent.test.ts +27 -23
  84. package/dist/templates/recap-workflow/workflows/recap.ts +24 -28
  85. package/dist/templates/redline/agent.eval.test.ts +5 -8
  86. package/dist/templates/redline/agent.test.ts +1 -1
  87. package/dist/templates/redline/client.tsx +36 -34
  88. package/dist/templates/redline/workflows/redline.ts +17 -17
  89. package/dist/templates/research-workflow/agent.eval.test.ts +5 -5
  90. package/dist/templates/research-workflow/agent.test.ts +10 -7
  91. package/dist/templates/research-workflow/workflows/research.ts +28 -30
  92. package/dist/templates/retail/agent.test.ts +17 -15
  93. package/dist/templates/retail/cancel.ts +7 -6
  94. package/dist/templates/retail/client.tsx +39 -58
  95. package/dist/templates/retail/payment.ts +4 -3
  96. package/dist/templates/retail/pending.test.ts +19 -19
  97. package/dist/templates/retail/store.test.ts +5 -5
  98. package/dist/templates/retail/swap.ts +8 -11
  99. package/dist/templates/solo-rpg/agent.eval.test.ts +2 -16
  100. package/dist/templates/solo-rpg/agent.test.ts +12 -12
  101. package/dist/templates/solo-rpg/client.tsx +3 -3
  102. package/dist/templates/spoken-summary/agent.test.ts +9 -9
  103. package/dist/templates/spoken-summary/agent.ts +3 -3
  104. package/dist/templates/spoken-summary/client.tsx +23 -30
  105. package/dist/templates/spoken-summary/workflows/summarize.ts +11 -11
  106. package/dist/templates/spoken-summary/workflows/transcribe.ts +14 -14
  107. package/dist/templates/support-line/client.tsx +4 -3
  108. package/dist/templates/support-line/prompts.ts +0 -2
  109. package/dist/templates/transcription-workflow/agent.test.ts +20 -12
  110. package/dist/templates/transcription-workflow/agent.ts +2 -2
  111. package/dist/templates/transcription-workflow/client.tsx +15 -14
  112. package/dist/templates/transcription-workflow/recover.ts +1 -1
  113. package/dist/templates/transcription-workflow/run-panel.tsx +34 -14
  114. package/dist/templates/transcription-workflow/total-latency.tsx +46 -20
  115. package/dist/templates/transcription-workflow/workflows/batch.ts +15 -15
  116. package/dist/templates/transcription-workflow/workflows/downsample.ts +1 -1
  117. package/dist/templates/transcription-workflow/workflows/normalize.ts +15 -19
  118. package/dist/templates/transcription-workflow/workflows/stream.ts +12 -12
  119. package/dist/templates/transcription-workflow/workflows/sync-api.ts +3 -3
  120. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -34
  121. package/dist/templates/travel-concierge/agent.eval.test.ts +2 -2
  122. package/dist/templates/travel-concierge/agent.test.ts +15 -7
  123. package/dist/templates/travel-concierge/client.tsx +2 -2
  124. package/dist/templates/travel-concierge/shared.ts +10 -14
  125. package/dist/templates/travel-concierge/tools/lookup_booking.ts +4 -3
  126. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +3 -2
  127. package/dist/templates/travel-concierge/tools/search_excursions.ts +3 -2
  128. package/dist/templates/travel-concierge/tools/search_flights.ts +3 -2
  129. package/dist/templates/travel-concierge/tools/search_hotels.ts +3 -2
  130. package/dist/{test-guenJUxi.mjs → test-D032V2cA.mjs} +4 -17
  131. package/dist/typecheck.mjs +1 -1
  132. package/dist/worker-bundler.mjs +1 -1
  133. package/dist/{workflow-BZyKLkhV.mjs → workflow-CVioyO3V.mjs} +6 -5
  134. package/package.json +4 -4
@@ -1,10 +1,11 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
- import type { AgentState, Session } from "@alexkroman1/aai-ui";
2
+ import type { AgentState } from "@alexkroman1/aai-ui";
3
3
  import {
4
4
  AutoScroll,
5
- client,
5
+ mountClient,
6
6
  useConversation,
7
- useSession,
7
+ useSessionActions,
8
+ useSessionError,
8
9
  useSessionSelector,
9
10
  useTheme,
10
11
  } from "@alexkroman1/aai-ui";
@@ -98,7 +99,7 @@ function Transcript() {
98
99
  // The one theme read left in this file, and the case `useTheme()` is still
99
100
  // for: `scrollbar-color` takes TWO values and has no utility class, so it has
100
101
  // to be a style — and reading it off the theme beats re-pinning the two hex
101
- // codes the `client({ theme })` block below already declares.
102
+ // codes the `mountClient({ theme })` block below already declares.
102
103
  const theme = useTheme();
103
104
 
104
105
  return (
@@ -146,8 +147,16 @@ function Transcript() {
146
147
  {transcript.text}
147
148
  </div>
148
149
  )}
150
+ {/* Same contract as the shipped `MessageList`'s indicator: the blinking
151
+ block is the only sign the parser is working, and to a screen reader it
152
+ is one unpronounceable glyph. */}
149
153
  {thinking && (
150
- <div className="animate-pulse" style={{ color: GREEN_DIM }}>
154
+ <div
155
+ role="status"
156
+ aria-label="The parser is thinking"
157
+ className="animate-pulse"
158
+ style={{ color: GREEN_DIM }}
159
+ >
151
160
  &#9612;
152
161
  </div>
153
162
  )}
@@ -155,12 +164,18 @@ function Transcript() {
155
164
  );
156
165
  }
157
166
 
167
+ /** Module scope, so the selector has a STABLE identity: an inline arrow makes
168
+ * `useSyncExternalStoreWithSelector` rebuild its selection memo every render,
169
+ * and this runs on every snapshot push — each STT partial and streaming
170
+ * delta. Counting with `reduce` rather than `filter().length` for the same
171
+ * reason: the array copy is thrown away, and it grows for the whole adventure. */
172
+ const userTurns = (snapshot: { messages: { role: string }[] }): number =>
173
+ snapshot.messages.reduce((n, message) => (message.role === "user" ? n + 1 : n), 0);
174
+
158
175
  /** The turn counter — a NUMBER out of the selector, so a new message array with
159
176
  * the same user-message count re-renders nothing. */
160
177
  function TurnCount() {
161
- const turns = useSessionSelector(
162
- (snapshot) => snapshot.messages.filter((message) => message.role === "user").length,
163
- );
178
+ const turns = useSessionSelector(userTurns);
164
179
  return <span>Turns: {turns}</span>;
165
180
  }
166
181
 
@@ -207,7 +222,7 @@ function StatusDot() {
207
222
  * never told an unannounced one appeared.
208
223
  */
209
224
  function ErrorBanner() {
210
- const error = useSessionSelector((snapshot) => snapshot.error);
225
+ const error = useSessionError();
211
226
  if (!error) return null;
212
227
  return (
213
228
  <div
@@ -215,33 +230,22 @@ function ErrorBanner() {
215
230
  className="px-5 py-2 text-xs"
216
231
  style={{ background: "#3a0000", color: "#ff4141" }}
217
232
  >
218
- ERROR: {error.message}
233
+ ERROR: {error.message} ({error.code})
219
234
  </div>
220
235
  );
221
236
  }
222
237
 
223
- /**
224
- * Start a fresh conversation without leaving the game screen.
238
+ /*
239
+ * Pause/resume, new game and hang-up.
225
240
  *
226
- * Written out here rather than reached for on the session, because there is no
227
- * one method that does it: `reset()` clears the CONVERSATION and keeps the
228
- * session, which is wrong for any agent that also keeps session-scoped state —
229
- * this game's world would come back with the next tool call. `end()` drops the
230
- * resume identity, so the redial is a brand-new session (fresh world, opening
231
- * scene included), and `start()` puts the player straight into it rather than
232
- * back at the title screen.
241
+ * `useSessionActions()` is the narrow way `<Controls>` reaches the methods, and
242
+ * it is published now so this row takes the three it presses and one
243
+ * selector for the flag it reads, instead of a whole-snapshot `useSession()`
244
+ * that re-rendered the footer on every partial transcript.
233
245
  */
234
- function newConversation(session: Session): void {
235
- session.end();
236
- session.start();
237
- }
238
-
239
- /** Pause/resume, new game and hang-up. The only place a whole-session
240
- * subscription is still needed: the ACTIONS live on `useSession()`, and
241
- * `useSessionCore` — the narrow way `<Controls>` reaches them — is not on the
242
- * public surface. */
243
246
  function Footer() {
244
- const session = useSession();
247
+ const { toggle, restart, end } = useSessionActions();
248
+ const running = useSessionSelector((snapshot) => snapshot.running);
245
249
  return (
246
250
  <div
247
251
  className="flex items-center justify-between px-5 py-2 shrink-0 gap-3"
@@ -253,9 +257,9 @@ function Footer() {
253
257
  type="button"
254
258
  className="px-4 py-1 bg-transparent cursor-pointer uppercase tracking-wider font-mono text-[11px]"
255
259
  style={{ color: GREEN_DIM, border: `1px solid ${GREEN_DARK}` }}
256
- onClick={session.toggle}
260
+ onClick={toggle}
257
261
  >
258
- {session.running ? "[P]ause" : "[R]esume"}
262
+ {running ? "[P]ause" : "[R]esume"}
259
263
  </button>
260
264
  {/* The one-click new conversation the default shell's `<Controls>`
261
265
  gives every other template — a custom `component:` renders no
@@ -266,7 +270,7 @@ function Footer() {
266
270
  type="button"
267
271
  className="px-4 py-1 bg-transparent cursor-pointer uppercase tracking-wider font-mono text-[11px]"
268
272
  style={{ color: GREEN_DIM, border: `1px solid ${GREEN_DARK}` }}
269
- onClick={() => newConversation(session)}
273
+ onClick={restart}
270
274
  >
271
275
  [N]ew Game
272
276
  </button>
@@ -276,7 +280,7 @@ function Footer() {
276
280
  type="button"
277
281
  className="px-4 py-1 bg-transparent cursor-pointer uppercase tracking-wider font-mono text-[11px]"
278
282
  style={{ color: GREEN_DIM, border: `1px solid ${GREEN_DARK}` }}
279
- onClick={() => session.end()}
283
+ onClick={() => end()}
280
284
  >
281
285
  [Q]uit
282
286
  </button>
@@ -315,7 +319,7 @@ function Crt({ children }: { children: ReactNode }) {
315
319
  }
316
320
 
317
321
  function TitleScreen() {
318
- const session = useSession();
322
+ const { start } = useSessionActions();
319
323
  return (
320
324
  <Crt>
321
325
  <div
@@ -345,7 +349,7 @@ function TitleScreen() {
345
349
  border: `1px solid ${GREEN}`,
346
350
  animation: "ic-pulse 2s ease-in-out infinite",
347
351
  }}
348
- onClick={session.start}
352
+ onClick={start}
349
353
  >
350
354
  Begin Adventure
351
355
  </button>
@@ -381,7 +385,7 @@ function InfocomAdventure() {
381
385
  );
382
386
  }
383
387
 
384
- client({
388
+ mountClient({
385
389
  component: InfocomAdventure,
386
390
  theme: {
387
391
  bg: CRT_BG,
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import {
29
- createWorkflowCtx,
29
+ createWorkflowContext,
30
30
  schemaInputIssues,
31
31
  stubGatewayRoute,
32
32
  stubStepInfo,
@@ -170,7 +170,7 @@ describe("fetchArticle", () => {
170
170
  });
171
171
 
172
172
  test("fails FATALLY on a 404 and plainly on a 503", async () => {
173
- // `stepFetchOk` builds the message, so it is the SDK's one spelling for a
173
+ // `stepFetchOrFail` builds the message, so it is the SDK's one spelling for a
174
174
  // bad response — the REQUEST, the status, and a preview of any body.
175
175
  stubPage("", 404);
176
176
  await expect(fetchArticle("https://example.com/gone")).rejects.toThrow(
@@ -264,7 +264,7 @@ describe("summarize", () => {
264
264
  // which is the only place the policy is observable at all. `runSteps: false`
265
265
  // because the subject is the declared policy rather than the work: the steps
266
266
  // would otherwise need a page and a model.
267
- const ctx = createWorkflowCtx({ runSteps: false });
267
+ const ctx = createWorkflowContext({ runSteps: false });
268
268
  await digestFlow({ url: "https://example.com/a" }, ctx);
269
269
 
270
270
  const summarizeStep = ctx.steps.find((step) => step.name === "summarize");
@@ -13,8 +13,8 @@
13
13
  * the entire product, and the declaration says so:
14
14
  *
15
15
  * - `workflowApp()` is `agent({ …, page: "static" })` with the discriminant
16
- * already set. The declaration is not decoration: `createServer` declines
17
- * `/websocket` with a reason (so a page mounted with `client()` by mistake
16
+ * already set. The declaration is not decoration: `createRuntimeServer` declines
17
+ * `/websocket` with a reason (so a page mounted with `mountClient()` by mistake
18
18
  * fails the same way in `aai dev` and in production rather than only after a
19
19
  * deploy) and telephony defaults off.
20
20
  * - There is no `stt`/`llm`/`tts`, no `tools` and no `systemPrompt` — and they
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * The browser half of a workflow app.
4
4
  *
5
- * Mounted with `page()` rather than `client()`: there is no session to build, so
5
+ * Mounted with `mountPage()` rather than `mountClient()`: there is no session to build, so
6
6
  * there is no socket, no audio graph, and no microphone request. Everything else
7
7
  * is the same — the same `client.tsx` filename, React, Tailwind, and the same
8
8
  * theme tokens the voice components read.
@@ -49,7 +49,7 @@
49
49
  *
50
50
  * `useWorkflowSubmit` answers "where has this got to" from the world's own
51
51
  * record — pending, running, completed. `<WorkflowProgress>` answers "what is it
52
- * doing" from what the run wrote itself (`report()` in `workflows/digest.ts`). A
52
+ * doing" from what the run wrote itself (`stepReport()` in `workflows/digest.ts`). A
53
53
  * page with only the first shows "Working…" for the length of the run; a page
54
54
  * with only the second cannot tell a finished run from a quiet one. Both are
55
55
  * cheap: one stream each, ended by the agent when there is nothing left to say.
@@ -64,7 +64,7 @@
64
64
  * where a fan-out makes the history worth seeing.
65
65
  */
66
66
 
67
- import { page, useWorkflowSubmit, WorkflowProgress } from "@alexkroman1/aai-ui";
67
+ import { BulletList, mountPage, useWorkflowSubmit, WorkflowProgress } from "@alexkroman1/aai-ui";
68
68
  import "@alexkroman1/aai-ui/styles.css";
69
69
  // ERASED at build time, so naming the agent's own type costs the browser bundle
70
70
  // nothing — and it is what stops this file restating a shape `workflows/
@@ -92,10 +92,6 @@ function pendingNote(startedHere: boolean, found: boolean): string {
92
92
 
93
93
  export function App() {
94
94
  const [url, setUrl] = useState("");
95
- // Did THIS load start the run? A reload cannot have, and that is the only way
96
- // the page can tell "working on what you just submitted" from "picking up
97
- // where you left off" — the hook reports the run, not who asked for it.
98
- const [startedHere, setStartedHere] = useState(false);
99
95
  // The generic is what makes `run.status === "completed"` narrow to a TYPED
100
96
  // `run.output` instead of `unknown`. `error` is the agent's own sentence for a
101
97
  // rejected input, which is better copy than anything this page could write, and
@@ -104,14 +100,14 @@ export function App() {
104
100
  // No `key` and no `recover`: this tab's handle on its own runs is minted and
105
101
  // remembered by the hook, and read back as it mounts. See the module doc for
106
102
  // what a page says when it wants a different one.
107
- const { submit, run, pending, error, wake } = useWorkflowSubmit<typeof digest>("digest");
103
+ const { submit, run, pending, error, wake, startedHere } =
104
+ useWorkflowSubmit<typeof digest>("digest");
108
105
 
109
106
  // `submit()` resolves as soon as the run exists — deliberately not when it
110
107
  // finishes. That is the whole mechanism: the digest sleeps for a while, and
111
108
  // this page is free to be closed in the meantime.
112
109
  const onSubmit = (event: React.FormEvent) => {
113
110
  event.preventDefault();
114
- setStartedHere(true);
115
111
  void submit({ url });
116
112
  };
117
113
 
@@ -125,6 +121,11 @@ export function App() {
125
121
  required
126
122
  value={url}
127
123
  onChange={(e) => setUrl(e.target.value)}
124
+ // A placeholder is not a name: it disappears the moment anything is
125
+ // typed, and a screen reader reaches an unlabelled box. The declared
126
+ // fields in `@alexkroman1/aai-ui` say the same thing — a `label`, or
127
+ // an `aria-label` where the row has no room for one.
128
+ aria-label="Article URL"
128
129
  // A REAL article, because a placeholder is a suggestion and this one
129
130
  // gets typed. `https://example.com/article` 404s, and the bare
130
131
  // `example.com` a reader trims it to has no prose on it — so the first
@@ -139,7 +140,13 @@ export function App() {
139
140
  </button>
140
141
  </form>
141
142
 
142
- {error !== undefined && <p className="text-red-600">{error}</p>}
143
+ {/* `<Form>` carries `role="alert"` for the templates that declare their
144
+ fields; a hand-written form has to say it itself. */}
145
+ {error !== undefined && (
146
+ <p role="alert" className="text-red-600">
147
+ {error}
148
+ </p>
149
+ )}
143
150
 
144
151
  {/* A run that has not settled says so. `pending` is not derivable from the
145
152
  snapshot alone — an id the agent never knew leaves `run` undefined,
@@ -168,16 +175,19 @@ export function App() {
168
175
  </button>
169
176
  )}
170
177
 
171
- {run?.status === "failed" && <p className="text-red-600">That one failed: {run.error}</p>}
178
+ {/* `role="alert"`, like the submit error above: this is the outcome the
179
+ reader has been waiting for, and it can arrive long after they looked
180
+ away. */}
181
+ {run?.status === "failed" && (
182
+ <p role="alert" className="text-red-600">
183
+ That one failed: {run.error}
184
+ </p>
185
+ )}
172
186
 
173
187
  {run?.status === "completed" && (
174
188
  <article className="flex flex-col gap-3">
175
189
  <h2 className="text-xl">{run.output.headline}</h2>
176
- <ul className="flex list-disc flex-col gap-1 pl-5">
177
- {run.output.points.map((point) => (
178
- <li key={point}>{point}</li>
179
- ))}
180
- </ul>
190
+ <BulletList items={run.output.points} />
181
191
  <p className="text-sm opacity-70">Filed {run.output.filedAt}</p>
182
192
  </article>
183
193
  )}
@@ -185,4 +195,4 @@ export function App() {
185
195
  );
186
196
  }
187
197
 
188
- page({ name: "Link Digest", component: App });
198
+ mountPage({ name: "Link Digest", component: App });
@@ -24,10 +24,10 @@
24
24
  * fetched text crosses a queue between them, which is what the cap on it is for.
25
25
  */
26
26
 
27
- import type { WorkflowCtx } from "@alexkroman1/aai";
27
+ import type { WorkflowContext } from "@alexkroman1/aai";
28
28
  import { htmlToText, pageMetadata } from "@alexkroman1/aai/html";
29
- import { report, stepInfo } from "@alexkroman1/aai/step";
30
- import { FatalError, stepFetchOk, stepGenerateJsonClassified } from "@alexkroman1/aai/step-errors";
29
+ import { stepInfo, stepReport } from "@alexkroman1/aai/step";
30
+ import { FatalError, stepFetchOrFail, stepGenerateJsonOrFail } from "@alexkroman1/aai/step-errors";
31
31
  import { omitUndefined } from "@alexkroman1/aai/utils";
32
32
  import { z } from "zod";
33
33
 
@@ -107,7 +107,7 @@ export type Article = {
107
107
  * literally the page's render model, and `WorkflowOutputOf<typeof digest>` in
108
108
  * `client.tsx` is that type, derived rather than restated.
109
109
  */
110
- export async function digestFlow(input: { url: string }, ctx: WorkflowCtx) {
110
+ export async function digestFlow(input: { url: string }, ctx: WorkflowContext) {
111
111
  const article = await ctx.step("fetchArticle", () => fetchArticle(input.url));
112
112
  // `maxAttempts: 6` was `summarize.maxRetries = 5` — five retries AFTER the
113
113
  // first attempt, so six in all. The retry policy moved from a property on the
@@ -138,7 +138,7 @@ export async function digestFlow(input: { url: string }, ctx: WorkflowCtx) {
138
138
  */
139
139
  export async function fetchArticle(url: string): Promise<Article> {
140
140
  const { hostname } = new URL(url);
141
- await report(`Reading ${hostname}…`);
141
+ await stepReport(`Reading ${hostname}…`);
142
142
 
143
143
  // `stepFetch`, not `fetch`, and the rule has no exception for a step that
144
144
  // makes only one request: the global pins nothing, so it offers h2 in ALPN
@@ -147,12 +147,12 @@ export async function fetchArticle(url: string): Promise<Article> {
147
147
  // reset with no HTTP status, which `toStepError` below has nothing to read.
148
148
  // It also reports a connection failure with its whole `cause` chain instead
149
149
  // of a bare `TypeError: fetch failed`. Redirects are followed by default.
150
- // `stepFetchOk` rather than `stepFetch` + an `ok` check: it makes the
150
+ // `stepFetchOrFail` rather than `stepFetch` + an `ok` check: it makes the
151
151
  // retryable/terminal split for us — a 404 or a 403 answers the same way on
152
152
  // the fourth attempt, while a rate limit is exactly what retries are for, and
153
153
  // its `Retry-After` reaches the engine's schedule instead of the default
154
154
  // backoff. It also puts the server's own error text in the message.
155
- const response = await stepFetchOk(url, {
155
+ const response = await stepFetchOrFail(url, {
156
156
  // Some sites answer a bare request with a challenge page; asking for HTML
157
157
  // at least says what we want. Nothing here defeats a real bot wall, and a
158
158
  // template pretending otherwise would be the dishonest version.
@@ -191,19 +191,19 @@ export async function summarize(article: Article): Promise<Digest> {
191
191
  // built with `omitUndefined` rather than a conditional spread — the guard is
192
192
  // then the value, which is the case that primitive is for.
193
193
  const model = lastChance ? FALLBACK_MODEL : undefined;
194
- await report(
194
+ await stepReport(
195
195
  lastChance
196
196
  ? `Last attempt (${step?.attempt} of ${step?.maxAttempts}): asking for something simpler.`
197
197
  : "Pulling out the claims worth keeping.",
198
198
  );
199
199
 
200
- // `stepGenerateJsonClassified` unwraps the fence a model puts around JSON,
200
+ // `stepGenerateJsonOrFail` unwraps the fence a model puts around JSON,
201
201
  // parses it, and validates it against `DigestReply` — and throws PLAINLY when
202
202
  // any of those misses, which is the whole retry policy in one distinction: a
203
203
  // model that answered with prose may answer correctly on the next attempt,
204
- // where a 401 will not. The `Classified` suffix is what makes the 401 half
204
+ // where a 401 will not. The `OrFail` suffix is what makes the 401 half
205
205
  // terminal: it is `stepGenerateJson` with `throwStepError` already applied.
206
- const parsed = await stepGenerateJsonClassified(
206
+ const parsed = await stepGenerateJsonOrFail(
207
207
  `Title: ${article.title}\nURL: ${article.url}\n\n${article.text}`,
208
208
  {
209
209
  schema: DigestReply,
@@ -246,7 +246,7 @@ export async function summarize(article: Article): Promise<Digest> {
246
246
  * file(digest))`. Anything at BODY level is the bug, not an exception.
247
247
  */
248
248
  export async function file(_digest: Digest): Promise<string> {
249
- await report("Filing the digest.");
249
+ await stepReport("Filing the digest.");
250
250
  // A real desk would write the digest to its database here. The stub writes
251
251
  // nothing, which is what the `_` says — and it is a stub because `ctx.db` is
252
252
  // the half of a tool context a step still cannot reach.
@@ -282,8 +282,5 @@ export function extractTitle(html: string): string | undefined {
282
282
  * structure are bytes the summary does not read.
283
283
  */
284
284
  export function extractText(html: string): string {
285
- return htmlToText(html, { maxChars: MAX_ARTICLE_CHARS })
286
- .replace(/\s+/g, " ")
287
- .trim()
288
- .slice(0, MAX_ARTICLE_CHARS);
285
+ return htmlToText(html, { maxChars: MAX_ARTICLE_CHARS }).replace(/\s+/g, " ").trim();
289
286
  }
@@ -13,8 +13,10 @@
13
13
  /**
14
14
  * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
15
15
  *
16
- * The glob is written HERE rather than reached for from a shared helper because
17
- * this file SHIPS a scaffolded project has no repo helper to import. Without
16
+ * Taken from `virtual:aai/agent` rather than a hand-written glob: the plugin
17
+ * expands it against THIS file's own directory, so the spec needs no glob and
18
+ * no shared helper — which matters because this file SHIPS, and a scaffolded
19
+ * project has no repo helper to import. Without
18
20
  * it the eval would drive an agent with no `recommend` at all, which is the one
19
21
  * failure a green eval must never be able to hide.
20
22
  *
@@ -14,7 +14,13 @@ import "@alexkroman1/aai-ui/styles.css";
14
14
  * reconnect is nagging. `useToolCallStart` and `useEvent` are for exactly this —
15
15
  * they fire once, carry no history, and drive throwaway `useState`.
16
16
  */
17
- import { Button, client, useAgentState, useEvent, useToolCallStart } from "@alexkroman1/aai-ui";
17
+ import {
18
+ Button,
19
+ mountClient,
20
+ useAgentState,
21
+ useEvent,
22
+ useToolCallStart,
23
+ } from "@alexkroman1/aai-ui";
18
24
  import { useEffect, useState } from "react";
19
25
  import { MOODS, nightProjection } from "./shared.ts";
20
26
 
@@ -111,7 +117,7 @@ function RecSidebar() {
111
117
  );
112
118
  }
113
119
 
114
- client({
120
+ mountClient({
115
121
  name: "Night Owl",
116
122
  icon: "\u{1F989}",
117
123
  subtitle: "A cozy companion for the small hours",
@@ -1,6 +1,6 @@
1
1
  import { agent } from "@alexkroman1/aai";
2
2
  import { toAgentConfig } from "@alexkroman1/aai/manifest";
3
- import { assemblyAIS2s, openaiS2s } from "@alexkroman1/aai/s2s";
3
+ import { assemblyAIS2s, openAIS2s } from "@alexkroman1/aai/s2s";
4
4
  import { assemblyAIStt, deepgramStt, elevenLabsStt, sonioxStt } from "@alexkroman1/aai/stt";
5
5
  import { ASSEMBLYAI_TTS_VOICES, assemblyAITts, cartesiaTts, rimeTts } from "@alexkroman1/aai/tts";
6
6
  import { describe, expect, test } from "vitest";
@@ -99,7 +99,7 @@ describe("swapping any other stage", () => {
99
99
  test("S2S: an explicit opt-in, and it REPLACES the pipeline rather than joining it", () => {
100
100
  for (const [s2s, kind] of [
101
101
  [assemblyAIS2s(), "assemblyai"],
102
- [openaiS2s({ voice: "alloy" }), "openai-realtime"],
102
+ [openAIS2s({ voice: "alloy" }), "openai-realtime"],
103
103
  ] as const) {
104
104
  const config = toAgentConfig(agent({ name: "Line", s2s }));
105
105
  expect(config.s2s?.kind).toBe(kind);
@@ -1,6 +1,7 @@
1
+ import { formatMoney } from "@alexkroman1/aai/utils";
1
2
  import "@alexkroman1/aai-ui/styles.css";
2
- import { client, useAgentState } from "@alexkroman1/aai-ui";
3
- import { formatPrice, orderProjection, pizzaPrice } from "./shared.ts";
3
+ import { mountClient, useAgentState } from "@alexkroman1/aai-ui";
4
+ import { orderProjection, pizzaPrice } from "./shared.ts";
4
5
 
5
6
  function PizzaIcon({ size }: { size: string }) {
6
7
  const dim = size === "small" ? 36 : size === "large" ? 52 : 44;
@@ -67,7 +68,7 @@ function OrderSidebar() {
67
68
  </p>
68
69
  </div>
69
70
  <p className="text-sm font-bold whitespace-nowrap text-aai-primary">
70
- {formatPrice(pizzaPrice(p))}
71
+ {formatMoney(pizzaPrice(p))}
71
72
  </p>
72
73
  </div>
73
74
  ))}
@@ -79,7 +80,7 @@ function OrderSidebar() {
79
80
  );
80
81
  }
81
82
 
82
- client({
83
+ mountClient({
83
84
  name: "Pizza Palace",
84
85
  sidebar: OrderSidebar,
85
86
  theme: {
@@ -1,4 +1,5 @@
1
1
  import { type DeepReadonly, sessionSlot } from "@alexkroman1/aai";
2
+ import { formatMoney } from "@alexkroman1/aai/utils";
2
3
 
3
4
  export const SIZES = ["small", "medium", "large"] as const;
4
5
  export const CRUSTS = ["thin", "regular", "thick", "stuffed"] as const;
@@ -37,19 +38,13 @@ export function calculateTotal(pizzas: readonly ReadonlyPizza[]): number {
37
38
  return pizzas.reduce((total, pizza) => total + pizzaPrice(pizza), 0);
38
39
  }
39
40
 
40
- /** The one money format. Tool results, the projection, and the sidebar all
41
- * show prices through this, so they can never disagree on rounding. */
42
- export function formatPrice(amount: number): string {
43
- return `$${amount.toFixed(2)}`;
44
- }
45
-
46
41
  /**
47
42
  * The menu as prompt prose, generated from `MENU` so the agent can never
48
43
  * quote a price the pricing code doesn't charge.
49
44
  */
50
45
  export function menuText(): string {
51
46
  const price = (amount: number, upcharge = false) =>
52
- amount === 0 ? "free" : `${upcharge ? "+" : ""}${formatPrice(amount)}`;
47
+ amount === 0 ? "free" : `${upcharge ? "+" : ""}${formatMoney(amount)}`;
53
48
  const list = (items: Record<string, number>, upcharge = false) =>
54
49
  Object.entries(items)
55
50
  .map(([name, amount]) => `${name.replaceAll("_", " ")} (${price(amount, upcharge)})`)
@@ -169,7 +164,7 @@ export function orderView(order: FrozenOrderState): OrderView {
169
164
  const placed = order.placed;
170
165
  return {
171
166
  pizzas: order.pizzas,
172
- total: placed?.total ?? formatPrice(calculateTotal(order.pizzas)),
167
+ total: placed?.total ?? formatMoney(calculateTotal(order.pizzas)),
173
168
  orderPlaced: Boolean(placed),
174
169
  ...(placed
175
170
  ? { orderNumber: placed.orderNumber, estimatedMinutes: placed.estimatedMinutes }
@@ -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
  });