@alexkroman1/aai-cli 6.11.0 → 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 +40 -19
  26. package/dist/scaffold/package.json +5 -3
  27. package/dist/scaffold/server.mjs +1 -1
  28. package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
  29. package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
  30. package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
  31. package/dist/templates/call-audit/agent.test.ts +57 -204
  32. package/dist/templates/call-audit/agent.ts +32 -19
  33. package/dist/templates/call-audit/client.tsx +17 -54
  34. package/dist/templates/call-audit/workflows/audit.ts +11 -18
  35. package/dist/templates/call-audit/workflows/ingest.ts +106 -114
  36. package/dist/templates/call-audit/workflows/media.ts +2 -12
  37. package/dist/templates/call-audit/workflows/summarize.ts +47 -52
  38. package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
  39. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  40. package/dist/templates/dispatch-center/client.tsx +239 -129
  41. package/dist/templates/dispatch-center/shared.ts +99 -1
  42. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  43. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  44. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  45. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  46. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  47. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  48. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  49. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  50. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  51. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  52. package/dist/templates/health-assistant/agent.test.ts +22 -4
  53. package/dist/templates/infocom-adventure/agent.test.ts +4 -6
  54. package/dist/templates/infocom-adventure/client.tsx +246 -164
  55. package/dist/templates/link-digest/agent.test.ts +24 -19
  56. package/dist/templates/link-digest/client.tsx +47 -61
  57. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  58. package/dist/templates/night-owl/agent.test.ts +70 -19
  59. package/dist/templates/night-owl/agent.ts +5 -0
  60. package/dist/templates/night-owl/client.tsx +56 -56
  61. package/dist/templates/night-owl/shared.ts +24 -0
  62. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  63. package/dist/templates/pizza-ordering/agent.test.ts +27 -27
  64. package/dist/templates/pizza-ordering/client.tsx +9 -26
  65. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  66. package/dist/templates/plan-and-execute/agent.ts +1 -1
  67. package/dist/templates/plan-and-execute/client.tsx +12 -15
  68. package/dist/templates/plan-and-execute/shared.ts +71 -2
  69. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  70. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  71. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  72. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  73. package/dist/templates/podcast-digest/agent.test.ts +746 -0
  74. package/dist/templates/podcast-digest/agent.ts +139 -0
  75. package/dist/templates/podcast-digest/client.tsx +154 -0
  76. package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
  77. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  78. package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
  79. package/dist/templates/recap-workflow/agent.test.ts +97 -94
  80. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  81. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  82. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  83. package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
  84. package/dist/templates/redline/agent.test.ts +17 -15
  85. package/dist/templates/redline/client.tsx +12 -12
  86. package/dist/templates/redline/workflows/redline.ts +19 -31
  87. package/dist/templates/research-workflow/agent.test.ts +60 -59
  88. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  89. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  90. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  91. package/dist/templates/retail/agent.test.ts +26 -23
  92. package/dist/templates/retail/client.tsx +226 -117
  93. package/dist/templates/retail/registry.test.ts +38 -6
  94. package/dist/templates/retail/store.test.ts +82 -15
  95. package/dist/templates/retail/store.ts +174 -47
  96. package/dist/templates/retail/system-prompt.md +11 -2
  97. package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
  98. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
  99. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  100. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  101. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  102. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  103. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  104. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  105. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  106. package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
  107. package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
  108. package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
  109. package/dist/templates/retail/tools/modify_user_address.ts +2 -2
  110. package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
  111. package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
  112. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  113. package/dist/templates/solo-rpg/client.tsx +38 -37
  114. package/dist/templates/solo-rpg/shared.ts +145 -19
  115. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  116. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  117. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  118. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  119. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  120. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  121. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  122. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  123. package/dist/templates/spoken-summary/agent.test.ts +68 -97
  124. package/dist/templates/spoken-summary/agent.ts +30 -17
  125. package/dist/templates/spoken-summary/client.tsx +10 -50
  126. package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
  127. package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
  128. package/dist/templates/support-line/agent.test.ts +11 -16
  129. package/dist/templates/support-line/agent.ts +1 -1
  130. package/dist/templates/support-line/client.tsx +9 -9
  131. package/dist/templates/support-line/nodes.ts +100 -0
  132. package/dist/templates/support-line/procedure.ts +407 -0
  133. package/dist/templates/support-line/tools/answer_question.ts +17 -4
  134. package/dist/templates/transcription-workflow/agent.test.ts +94 -194
  135. package/dist/templates/transcription-workflow/agent.ts +1 -1
  136. package/dist/templates/transcription-workflow/client.tsx +17 -42
  137. package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
  138. package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
  139. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  140. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
  141. package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
  142. package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
  143. package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
  144. package/dist/templates/travel-concierge/agent.test.ts +64 -33
  145. package/dist/templates/travel-concierge/client.tsx +11 -23
  146. package/dist/templates/travel-concierge/routing.ts +34 -15
  147. package/dist/templates/travel-concierge/shared.ts +70 -3
  148. package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
  149. package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
  150. package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
  151. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
  152. package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
  153. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
  154. package/dist/worker-bundler.mjs +1 -1
  155. package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
  156. package/dist/workflow-bundler.d.ts +6 -1
  157. package/dist/workflow.d.ts +1 -1
  158. package/package.json +5 -4
  159. package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
  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,83 +7,70 @@
7
7
  * is the same — the same `client.tsx` filename, React, Tailwind, and the same
8
8
  * theme tokens the voice components read.
9
9
  *
10
- * What replaces `useSession()` is three things: `createWorkflowApi()` to start a
11
- * run, `useWorkflowRun()` to watch its STATUS, and `useWorkflowProgress()` to
12
- * read what it has WRITTEN. The API is durable, so the `runId` is the whole
13
- * state — it survives a reload, a different device, or `curl`.
10
+ * What replaces `useSession()` is `useWorkflowSubmit()`: it starts the run,
11
+ * follows its STATUS, and hands back the controls bound to it — `wake`, `cancel`
12
+ * and `reset`. The API is durable, so the `runId` is the whole state — it
13
+ * survives a reload, a different device, or `curl`.
14
+ *
15
+ * ## The FORM here is still written by hand, deliberately
16
+ *
17
+ * This is the template that shows the primitives raw. `redline` and
18
+ * `transcription-workflow` declare their forms — `<Form>` + `<WorkflowFields>`
19
+ * renders one control per scalar the schema declares — and that is what most
20
+ * pages should do. This one writes its single `<input>` itself, so a reader can
21
+ * see what the declared layer is standing on: an ordinary `onSubmit` handing an
22
+ * object to `submit()`.
14
23
  *
15
24
  * ## Status and progress are different questions
16
25
  *
17
- * `useWorkflowRun` answers "where has this got to" from the world's own record —
18
- * pending, running, completed. `useWorkflowProgress` answers "what is it doing"
19
- * from what the run wrote itself (`report()` in `workflows/digest.ts`). A page
20
- * with only the first shows "Working…" for the length of the run; a page with
21
- * only the second cannot tell a finished run from a quiet one. Both are cheap:
22
- * one stream each, ended by the agent when there is nothing left to say.
26
+ * `useWorkflowSubmit` answers "where has this got to" from the world's own
27
+ * record — pending, running, completed. `<WorkflowProgress>` answers "what is it
28
+ * doing" from what the run wrote itself (`report()` in `workflows/digest.ts`). A
29
+ * page with only the first shows "Working…" for the length of the run; a page
30
+ * with only the second cannot tell a finished run from a quiet one. Both are
31
+ * cheap: one stream each, ended by the agent when there is nothing left to say.
23
32
  *
24
33
  * Progress also REPLAYS — chunks are retained with the run — so a reload mid-run
25
- * catches up rather than starting from whatever arrives next. This page renders
26
- * only the newest line, because on a page this small that is the whole of what a
27
- * status wants; `transcription-workflow` renders the full log, where a fan-out makes
28
- * the history worth seeing.
34
+ * catches up rather than starting from whatever arrives next. `lines={1}` is
35
+ * what narrows it to the newest line, because on a page this small that is the
36
+ * whole of what a status wants; `transcription-workflow` renders the full log,
37
+ * where a fan-out makes the history worth seeing.
29
38
  */
30
39
 
31
- import { createWorkflowApi, page, useWorkflowProgress, useWorkflowRun } from "@alexkroman1/aai-ui";
40
+ import { page, useWorkflowSubmit, WorkflowProgress } from "@alexkroman1/aai-ui";
32
41
  import "@alexkroman1/aai-ui/styles.css";
33
42
  // ERASED at build time, so naming the agent's own type costs the browser bundle
34
43
  // nothing — and it is what stops this file restating a shape `workflows/
35
44
  // digest.ts` already declares.
36
- import type { WorkflowOutputOf } from "@alexkroman1/aai";
37
- // The one runtime import from the SDK a browser bundle wants: `/utils` is the
38
- // zod-free subpath, so it costs a few hundred bytes rather than the root
39
- // barrel's module graph.
40
- import { errorMessage } from "@alexkroman1/aai/utils";
45
+ import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
41
46
  import { useState } from "react";
42
47
  import type { digest } from "./agent.ts";
43
48
 
44
49
  /** What a completed run reports, derived from the workflow rather than restated. */
45
50
  type Digest = WorkflowOutputOf<typeof digest>;
46
51
 
47
- /**
48
- * Hoisted out of the component deliberately.
49
- *
50
- * `useWorkflowRun` holds the client in a ref precisely so a fresh object per
51
- * render cannot restart its watch, but building one in render is still a new
52
- * `fetch` closure every time and reads as though it were free.
53
- */
54
- const api = createWorkflowApi();
55
-
56
52
  export function App() {
57
53
  const [url, setUrl] = useState("");
58
- const [runId, setRunId] = useState<string>();
59
- const [error, setError] = useState<string>();
60
54
  // The generic is what makes `run.status === "completed"` narrow to a TYPED
61
- // `run.output` instead of `unknown`.
62
- const { run, polling } = useWorkflowRun<Digest>(runId, { api });
63
- // What the run has SAID, as against where it has got to. Defaults to `string`,
64
- // which is what `report()` writes.
65
- const { latest, supported } = useWorkflowProgress(runId, { api });
55
+ // `run.output` instead of `unknown`. `error` is the agent's own sentence for a
56
+ // rejected input, which is better copy than anything this page could write, and
57
+ // `wake` is bound to whatever run the hook is following the whole reason this
58
+ // page no longer holds a `createWorkflowApi()` of its own.
59
+ const { submit, run, pending, error, wake } = useWorkflowSubmit<Digest>("digest");
66
60
 
67
- const submit = async (event: React.FormEvent) => {
61
+ // `submit()` resolves as soon as the run exists — deliberately not when it
62
+ // finishes. That is the whole mechanism: the digest sleeps for a while, and
63
+ // this page is free to be closed in the meantime.
64
+ const onSubmit = (event: React.FormEvent) => {
68
65
  event.preventDefault();
69
- setError(undefined);
70
- try {
71
- // Resolves as soon as the run exists — deliberately not when it finishes.
72
- // That is the whole mechanism: the digest sleeps for a while, and this
73
- // page is free to be closed in the meantime.
74
- setRunId(await api.start("digest", { url }));
75
- } catch (err) {
76
- // The agent's own sentence: an input failing the workflow's schema names
77
- // the issue, which is better copy than anything this page could write.
78
- setError(errorMessage(err));
79
- }
66
+ void submit({ url });
80
67
  };
81
68
 
82
69
  return (
83
70
  <main className="mx-auto flex max-w-2xl flex-col gap-6 p-8">
84
71
  <h1 className="text-2xl font-medium">Link Digest</h1>
85
72
 
86
- <form onSubmit={submit} className="flex gap-2">
73
+ <form onSubmit={onSubmit} className="flex gap-2">
87
74
  <input
88
75
  type="url"
89
76
  required
@@ -92,34 +79,33 @@ export function App() {
92
79
  placeholder="https://example.com/article"
93
80
  className="flex-1 rounded-md border px-3 py-2"
94
81
  />
95
- <button type="submit" disabled={polling} className="rounded-md border px-4 py-2">
96
- {polling ? "Working…" : "Digest"}
82
+ <button type="submit" disabled={pending} className="rounded-md border px-4 py-2">
83
+ {pending ? "Working…" : "Digest"}
97
84
  </button>
98
85
  </form>
99
86
 
100
87
  {error !== undefined && <p className="text-red-600">{error}</p>}
101
88
 
102
- {/* A run that has not settled says so. `polling` is not derivable from the
89
+ {/* A run that has not settled says so. `pending` is not derivable from the
103
90
  snapshot alone — an id the agent never knew leaves `run` undefined,
104
91
  which would otherwise read as "still waiting" forever. */}
105
- {polling && <p>You can close this tab — the run continues without it.</p>}
106
-
107
- {/* The run's own narration, newest line only — see the module doc.
92
+ {pending && <p>You can close this tab — the run continues without it.</p>}
108
93
 
109
- `supported` is what keeps this from being blank forever on an agent
110
- deployed before progress streams existed: "wrote nothing yet" and
111
- "serves no stream" are indistinguishable from `progress` alone. */}
112
- {supported && latest !== undefined && <p className="text-sm opacity-70">{latest}</p>}
94
+ {/* The run's own narration, newest line only. `lines={1}` is the window;
95
+ everything else the replay, and the "serves no stream" case that is
96
+ otherwise indistinguishable from "wrote nothing yet" belongs to the
97
+ component. */}
98
+ <WorkflowProgress runId={run?.runId} lines={1} className="text-sm opacity-70" />
113
99
 
114
100
  {/* The counterpart of the `sleep` in `workflows/digest.ts`. Without it the
115
101
  only handle on a sleeping run is `cancel`, so "file it now" and "throw
116
102
  it away" would be the same button. `wake` answering 0 means the run had
117
103
  already moved past its wait, which is why nothing here treats that as a
118
104
  failure. */}
119
- {runId !== undefined && polling && (
105
+ {pending && (
120
106
  <button
121
107
  type="button"
122
- onClick={() => void api.wake(runId)}
108
+ onClick={() => void wake()}
123
109
  className="self-start rounded-md border px-3 py-1 text-sm"
124
110
  >
125
111
  File it now
@@ -24,8 +24,9 @@
24
24
  * fetched text crosses a queue between them, which is what the cap on it is for.
25
25
  */
26
26
 
27
- import { throwStepError, toStepError } from "@alexkroman1/aai/step-errors";
28
- import { report, stepFetch, stepGenerateJson } from "@alexkroman1/aai/utils";
27
+ import { report } from "@alexkroman1/aai/step";
28
+ import { stepFetchOk, stepGenerateJsonClassified } from "@alexkroman1/aai/step-errors";
29
+ import { decodeHtmlEntities } from "@alexkroman1/aai/utils";
29
30
  import { FatalError, sleep } from "workflow";
30
31
  import { z } from "zod";
31
32
 
@@ -120,18 +121,18 @@ export async function fetchArticle(url: string): Promise<Article> {
120
121
  // reset with no HTTP status, which `toStepError` below has nothing to read.
121
122
  // It also reports a connection failure with its whole `cause` chain instead
122
123
  // of a bare `TypeError: fetch failed`. Redirects are followed by default.
123
- const response = await stepFetch(url, {
124
+ // `stepFetchOk` rather than `stepFetch` + an `ok` check: it makes the
125
+ // retryable/terminal split for us — a 404 or a 403 answers the same way on
126
+ // the fourth attempt, while a rate limit is exactly what retries are for, and
127
+ // its `Retry-After` reaches the DevKit's schedule instead of the default
128
+ // backoff. It also puts the server's own error text in the message.
129
+ const response = await stepFetchOk(url, {
124
130
  // Some sites answer a bare request with a challenge page; asking for HTML
125
131
  // at least says what we want. Nothing here defeats a real bot wall, and a
126
132
  // template pretending otherwise would be the dishonest version.
127
133
  headers: { Accept: "text/html,application/xhtml+xml" },
128
134
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
129
135
  });
130
- // The retryable/terminal split, for the page we were pointed at: a 404 or a
131
- // 403 answers the same way on the fourth attempt, while a rate limit is
132
- // exactly what retries are for — and `toStepError` is what carries its
133
- // `Retry-After` into the DevKit's schedule instead of the default backoff.
134
- if (!response.ok) throw toStepError(response, `GET ${url} failed: HTTP ${response.status}`);
135
136
 
136
137
  const html = await response.text();
137
138
  const text = extractText(html);
@@ -159,12 +160,13 @@ export async function summarize(article: Article): Promise<Digest> {
159
160
 
160
161
  await report("Pulling out the claims worth keeping.");
161
162
 
162
- // `stepGenerateJson` unwraps the fence a model puts around JSON, parses it,
163
- // and validates it against `DigestReply` — and throws PLAINLY when any of
164
- // those misses, which is the whole retry policy in one distinction: a model
165
- // that answered with prose may answer correctly on the next attempt, where a
166
- // 401 will not. `throwStepError` is what makes the 401 half terminal.
167
- const parsed = await stepGenerateJson(
163
+ // `stepGenerateJsonClassified` unwraps the fence a model puts around JSON,
164
+ // parses it, and validates it against `DigestReply` — and throws PLAINLY when
165
+ // any of those misses, which is the whole retry policy in one distinction: a
166
+ // model that answered with prose may answer correctly on the next attempt,
167
+ // where a 401 will not. The `Classified` suffix is what makes the 401 half
168
+ // terminal: it is `stepGenerateJson` with `throwStepError` already applied.
169
+ const parsed = await stepGenerateJsonClassified(
168
170
  `Title: ${article.title}\nURL: ${article.url}\n\n${article.text}`,
169
171
  {
170
172
  schema: DigestReply,
@@ -172,7 +174,7 @@ export async function summarize(article: Article): Promise<Digest> {
172
174
  `You digest articles. Reply with JSON only: {"headline": string, "points": string[]}. ` +
173
175
  `Give exactly ${POINTS} points. No markdown fence, no preamble.`,
174
176
  },
175
- ).catch(throwStepError);
177
+ );
176
178
 
177
179
  return {
178
180
  url: article.url,
@@ -208,7 +210,7 @@ export async function file(_digest: Digest): Promise<string> {
208
210
  /** The document's `<title>`, when it has one. */
209
211
  export function extractTitle(html: string): string | undefined {
210
212
  const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1];
211
- return title ? decodeEntities(title).replace(/\s+/g, " ").trim() || undefined : undefined;
213
+ return title ? decodeHtmlEntities(title).replace(/\s+/g, " ").trim() || undefined : undefined;
212
214
  }
213
215
 
214
216
  /**
@@ -222,7 +224,7 @@ export function extractTitle(html: string): string | undefined {
222
224
  * reader.
223
225
  */
224
226
  export function extractText(html: string): string {
225
- return decodeEntities(
227
+ return decodeHtmlEntities(
226
228
  html
227
229
  .replace(/<(script|style|noscript|template)[^>]*>[\s\S]*?<\/\1>/gi, " ")
228
230
  .replace(/<!--[\s\S]*?-->/g, " ")
@@ -232,17 +234,3 @@ export function extractText(html: string): string {
232
234
  .trim()
233
235
  .slice(0, MAX_ARTICLE_CHARS);
234
236
  }
235
-
236
- /** The five entities that survive tag-stripping often enough to matter. */
237
- function decodeEntities(text: string): string {
238
- return (
239
- text
240
- .replace(/&lt;/g, "<")
241
- .replace(/&gt;/g, ">")
242
- .replace(/&quot;/g, '"')
243
- .replace(/&#0?39;|&apos;/g, "'")
244
- .replace(/&nbsp;/g, " ")
245
- // `&amp;` LAST, or `&amp;lt;` decodes twice into a `<` the page never had.
246
- .replace(/&amp;/g, "&")
247
- );
248
- }
@@ -1,10 +1,16 @@
1
1
  /// <reference types="vite/client" />
2
2
 
3
3
  import { toAgentConfig } from "@alexkroman1/aai/manifest";
4
- import { createToolContext, runTool, withDiscoveredTools } from "@alexkroman1/aai/testing";
4
+ import {
5
+ createToolContext,
6
+ parseToolInput,
7
+ toolInputIssues,
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
- import { CATEGORIES, MOODS } from "./shared.ts";
13
+ import { CATEGORIES, MOODS, nightProjection, nightSlot } from "./shared.ts";
8
14
 
9
15
  /**
10
16
  * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
@@ -17,6 +23,14 @@ const agentDef = withDiscoveredTools(
17
23
  import.meta.glob("./tools/*.ts", { eager: true }),
18
24
  );
19
25
 
26
+ /**
27
+ * `runTool` takes the context in the ARGUMENTS' place when a tool needs none,
28
+ * so `toolRunner`'s second parameter takes either — which is why it is one
29
+ * signature rather than an overload pair. An omitted context is a fresh one,
30
+ * i.e. a distinct session with an empty slot.
31
+ */
32
+ const run = toolRunner(agentDef);
33
+
20
34
  describe("night-owl template", () => {
21
35
  test("config passes manifest validation", () => {
22
36
  // Same conversion `aai build`/`aai deploy` run.
@@ -33,22 +47,53 @@ describe("night-owl template", () => {
33
47
  test("recommend is discovered from tools/", () => {
34
48
  expect(Object.keys(agentDef.tools ?? {})).toEqual(["recommend"]);
35
49
  });
50
+
51
+ test("the projection an untouched session pushes is an empty log", () => {
52
+ // What `useAgentState(nightProjection)` reads before the first tool call —
53
+ // derived from the slot's own default rather than guessed at in the page.
54
+ expect(nightProjection()).toEqual({ recs: [] });
55
+ });
36
56
  });
37
57
 
38
58
  describe("recommend", () => {
39
59
  test("answers with picks for the category and mood asked for", async () => {
40
- const ctx = createToolContext();
41
- const result = await runTool(agentDef, "recommend", { category: "movie", mood: "cozy" }, ctx);
60
+ const result = await run("recommend", { category: "movie", mood: "cozy" });
42
61
  expect(result).toMatchObject({ category: "movie", mood: "cozy" });
43
62
  expect((result as { picks: string[] }).picks.length).toBeGreaterThan(0);
44
63
  });
45
64
 
46
- test("pushes the same picks to the client, which is what the page renders", async () => {
47
- // `ctx.send` is the only reason this tool takes a context at all: the
48
- // client renders the picks rather than waiting to hear them read aloud.
65
+ test("the picks land in the session's own log, newest first", async () => {
66
+ // The log is STATE, not an event stream: `syncState` pushes this projection
67
+ // after every tool call, so a page that reloads mid-session resumes with it
68
+ // rather than starting empty.
69
+ const ctx = createToolContext();
70
+ const first = await run("recommend", { category: "book", mood: "spooky" }, ctx);
71
+ const second = await run("recommend", { category: "music", mood: "chill" }, ctx);
72
+ expect(nightProjection(nightSlot.get(ctx))).toEqual({ recs: [second, first] });
73
+ });
74
+
75
+ test("two calls with no shared context are two sessions", async () => {
76
+ // The other half of the same rule, and the one that bites: an omitted
77
+ // context is a FRESH session, so nothing accumulates across these calls.
49
78
  const ctx = createToolContext();
50
- const result = await runTool(agentDef, "recommend", { category: "book", mood: "spooky" }, ctx);
51
- expect(ctx.sent).toEqual([{ event: "recommendations", data: result }]);
79
+ await run("recommend", { category: "book", mood: "cozy" });
80
+ expect(nightSlot.get(ctx).recs).toHaveLength(0);
81
+ });
82
+
83
+ test("the wind-down nudge is sent once, on the third pick", async () => {
84
+ // A moment rather than state, which is why it is a `ctx.send` the page
85
+ // consumes with `useEvent` and not a field on the projection: re-delivering
86
+ // it on every reconnect would be nagging.
87
+ const ctx = createToolContext();
88
+ await run("recommend", { category: "movie", mood: "cozy" }, ctx);
89
+ await run("recommend", { category: "music", mood: "cozy" }, ctx);
90
+ expect(ctx.sent).toEqual([]);
91
+ await run("recommend", { category: "book", mood: "cozy" }, ctx);
92
+ expect(ctx.sent).toEqual([
93
+ { event: "wind_down", data: "Three picks in. Want me to work out your bedtime?" },
94
+ ]);
95
+ await run("recommend", { category: "movie", mood: "chill" }, ctx);
96
+ expect(ctx.sent).toHaveLength(1);
52
97
  });
53
98
 
54
99
  test("every category/mood pair the schema admits has picks behind it", async () => {
@@ -58,23 +103,29 @@ describe("recommend", () => {
58
103
  // package's guide records three shipped tools having.
59
104
  for (const category of CATEGORIES) {
60
105
  for (const mood of MOODS) {
61
- const result = await runTool(
62
- agentDef,
63
- "recommend",
64
- { category, mood },
65
- createToolContext(),
66
- );
106
+ const result = await run("recommend", { category, mood });
67
107
  expect((result as { picks: string[] }).picks, `${category}/${mood}`).not.toHaveLength(0);
68
108
  }
69
109
  }
70
110
  });
71
111
 
112
+ test("the schema accepts a category/mood pair from the enums", async () => {
113
+ const parsed = await parseToolInput<{ category: string; mood: string }>(agentDef, "recommend", {
114
+ category: "movie",
115
+ mood: "cozy",
116
+ });
117
+ expect(parsed).toEqual({ category: "movie", mood: "cozy" });
118
+ });
119
+
72
120
  test("a mood outside the enum is refused by the schema", async () => {
73
121
  // The wire boundary: an LLM tool call is untyped, so the schema is the only
74
122
  // thing between a hallucinated mood and an index into `undefined`.
75
- const schema = agentDef.tools?.recommend?.inputSchema;
76
- if (!schema) throw new Error("recommend has no input schema");
77
- const bad = await schema["~standard"].validate({ category: "movie", mood: "melancholy" });
78
- expect(bad.issues).toBeDefined();
123
+ // `toolInputIssues` is the SDK's own ask — `~standard` is a vendor wire
124
+ // contract, and the detail a hand-rolled version gets wrong first is that
125
+ // `.validate` may be sync or async, so a missing `await` leaves `.issues`
126
+ // undefined and the negative test passes for the wrong reason.
127
+ expect(
128
+ await toolInputIssues(agentDef, "recommend", { category: "movie", mood: "melancholy" }),
129
+ ).toBeDefined();
79
130
  });
80
131
  });
@@ -1,7 +1,12 @@
1
1
  import { agent } from "@alexkroman1/aai";
2
+ import { nightProjection } from "./shared.ts";
2
3
 
3
4
  export default agent({
4
5
  name: "Night Owl",
6
+ // The night's recommendation log, pushed to the client after every tool
7
+ // call. The page renders `useAgentState(nightProjection)` and keeps no copy
8
+ // of its own, so a reload resumes with the list intact.
9
+ syncState: nightProjection,
5
10
  greeting:
6
11
  "Hey there, night owl. Try asking me for a cozy movie recommendation, or tell me what time you need to wake up and I'll calculate the best time to fall asleep.",
7
12
  builtinTools: ["run_code"],
@@ -1,9 +1,22 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
- import { Button, client, useEvent, useTheme, useToolCallStart } from "@alexkroman1/aai-ui";
3
- import { useState } from "react";
4
- import { MOODS } from "./shared.ts";
5
-
6
- type Rec = { category: string; mood: string; picks: string[] };
2
+ /**
3
+ * Two kinds of thing arrive from the agent, and this page keeps them apart.
4
+ *
5
+ * The recommendation LOG is state: the agent owns it in a `sessionSlot`,
6
+ * `syncState` projects it, and `useAgentState(nightProjection)` reads it. The
7
+ * page stores no copy, so a reload resumes with every pick still there. That is
8
+ * the pattern to reach for by default — see `pizza-ordering` for the same shape
9
+ * over a shopping cart.
10
+ *
11
+ * The "recommending…" flash and the wind-down nudge are MOMENTS. Neither is
12
+ * worth storing and neither should replay: a spinner for a call that finished
13
+ * before this component mounted would be a lie, and a nudge re-shown on every
14
+ * reconnect is nagging. `useToolCallStart` and `useEvent` are for exactly this —
15
+ * they fire once, carry no history, and drive throwaway `useState`.
16
+ */
17
+ import { Button, client, useAgentState, useEvent, useToolCallStart } from "@alexkroman1/aai-ui";
18
+ import { useEffect, useState } from "react";
19
+ import { MOODS, nightProjection } from "./shared.ts";
7
20
 
8
21
  const MOOD_EMOJI: Record<string, string> = {
9
22
  chill: "\u{1F60C}",
@@ -19,38 +32,30 @@ const CAT_EMOJI: Record<string, string> = {
19
32
  };
20
33
 
21
34
  function RecSidebar() {
22
- const theme = useTheme();
23
- const [recs, setRecs] = useState<Rec[]>([]);
35
+ // State: the agent's own log. No `useState` mirror, no event diffing.
36
+ const { recs } = useAgentState(nightProjection);
37
+ // Page-local view state — which mood chip is pressed. Nothing to sync.
24
38
  const [activeMood, setActiveMood] = useState<string | null>(null);
39
+ // Moments. Both are cleared by this page, never re-delivered by the session.
40
+ const [pendingMood, setPendingMood] = useState<string | null>(null);
41
+ const [nudge, setNudge] = useState<string | null>(null);
25
42
 
26
- useToolCallStart("recommend", (tc) => {
27
- setActiveMood(tc.args.mood as string);
28
- });
43
+ useToolCallStart("recommend", (tc) => setPendingMood(String(tc.args.mood)));
44
+ useEvent<string>("wind_down", (text) => setNudge(text));
29
45
 
30
- useEvent("recommendations", (result) => {
31
- setRecs((prev) => [result as Rec, ...prev]);
32
- });
46
+ // The flash ends when the picks land, which is the projection changing —
47
+ // the same signal the list itself renders from.
48
+ useEffect(() => setPendingMood(null), [recs.length]);
33
49
 
34
50
  const filtered = activeMood ? recs.filter((r) => r.mood === activeMood) : recs;
35
51
 
36
52
  return (
37
- <div
38
- className="flex flex-col h-full text-sm"
39
- style={{ background: theme.bg, color: theme.text }}
40
- >
41
- <div className="px-4 py-3 border-b shrink-0" style={{ borderColor: theme.border }}>
42
- <h2
43
- className="text-xs font-bold uppercase tracking-wide opacity-60"
44
- style={{ color: theme.text }}
45
- >
46
- Recommendations
47
- </h2>
53
+ <div className="flex flex-col h-full text-sm bg-aai-bg text-aai-text">
54
+ <div className="px-4 py-3 border-b border-aai-border shrink-0">
55
+ <h2 className="text-xs font-bold uppercase tracking-wide opacity-60">Recommendations</h2>
48
56
  </div>
49
57
 
50
- <div
51
- className="flex flex-wrap gap-1.5 px-3 py-2.5 border-b shrink-0"
52
- style={{ borderColor: theme.border }}
53
- >
58
+ <div className="flex flex-wrap gap-1.5 px-3 py-2.5 border-b border-aai-border shrink-0">
54
59
  {MOODS.map((mood) => (
55
60
  <Button
56
61
  key={mood}
@@ -62,60 +67,55 @@ function RecSidebar() {
62
67
  ))}
63
68
  </div>
64
69
 
70
+ {nudge && (
71
+ <div className="flex items-center gap-2 px-3 py-2 border-b border-aai-border shrink-0">
72
+ <p className="flex-1 text-xs text-aai-primary">{nudge}</p>
73
+ <Button variant="ghost" onClick={() => setNudge(null)}>
74
+ Dismiss
75
+ </Button>
76
+ </div>
77
+ )}
78
+
65
79
  <div className="flex-1 overflow-y-auto px-3 py-2">
66
- {filtered.length === 0 && (
67
- <p className="text-xs text-center py-8 opacity-40" style={{ color: theme.text }}>
80
+ {pendingMood && (
81
+ <p className="text-xs py-2 opacity-60 animate-pulse">
82
+ Finding something {pendingMood}&hellip;
83
+ </p>
84
+ )}
85
+ {filtered.length === 0 && !pendingMood && (
86
+ <p className="text-xs text-center py-8 opacity-40">
68
87
  Ask me to recommend a movie, album, or book
69
88
  </p>
70
89
  )}
71
90
  {filtered.map((rec, i) => (
72
91
  <div
73
92
  key={`${rec.category}-${rec.mood}-${i}`}
74
- className="mb-3 p-2.5 rounded-lg border"
75
- style={{ background: theme.surface, borderColor: theme.border }}
93
+ className="mb-3 p-2.5 rounded-lg border border-aai-border bg-aai-surface"
76
94
  >
77
95
  <div className="flex items-center gap-2 mb-1.5">
78
96
  <span className="text-xs">{CAT_EMOJI[rec.category]}</span>
79
- <span className="text-xs font-semibold capitalize" style={{ color: theme.primary }}>
97
+ <span className="text-xs font-semibold capitalize text-aai-primary">
80
98
  {rec.category}s
81
99
  </span>
82
- <span className="text-xs capitalize opacity-50" style={{ color: theme.text }}>
83
- {rec.mood}
84
- </span>
100
+ <span className="text-xs capitalize opacity-50">{rec.mood}</span>
85
101
  </div>
86
102
  {rec.picks.map((pick) => (
87
- <p
88
- key={pick}
89
- className="text-xs pl-5 py-0.5 opacity-80"
90
- style={{ color: theme.text }}
91
- >
103
+ <p key={pick} className="text-xs pl-5 py-0.5 opacity-80">
92
104
  {pick}
93
105
  </p>
94
106
  ))}
95
107
  </div>
96
108
  ))}
97
109
  </div>
98
-
99
- {recs.length > 0 && (
100
- <div className="px-3 py-2 border-t shrink-0" style={{ borderColor: theme.border }}>
101
- <Button
102
- variant="ghost"
103
- className="w-full"
104
- onClick={() => {
105
- setRecs([]);
106
- setActiveMood(null);
107
- }}
108
- >
109
- Clear
110
- </Button>
111
- </div>
112
- )}
113
110
  </div>
114
111
  );
115
112
  }
116
113
 
117
114
  client({
118
115
  name: "Night Owl",
116
+ icon: "\u{1F989}",
117
+ subtitle: "A cozy companion for the small hours",
118
+ buttonText: "Settle in",
119
119
  sidebar: RecSidebar,
120
120
  theme: {
121
121
  bg: "#0c0e1a",
@@ -1,5 +1,29 @@
1
+ import { sessionSlot } from "@alexkroman1/aai";
2
+
1
3
  export const CATEGORIES = ["movie", "music", "book"] as const;
2
4
  export const MOODS = ["chill", "intense", "cozy", "spooky", "funny"] as const;
3
5
 
4
6
  export type Category = (typeof CATEGORIES)[number];
5
7
  export type Mood = (typeof MOODS)[number];
8
+
9
+ /** One answer from `recommend`, as the sidebar renders it. */
10
+ export type Rec = { category: Category; mood: Mood; picks: string[] };
11
+
12
+ /**
13
+ * The night's recommendation log — the agent's own state, not the page's.
14
+ *
15
+ * It used to live in a `useState` in `client.tsx`, rebuilt from a
16
+ * `ctx.send("recommendations", …)` event per call. That made the list a
17
+ * DERIVED thing: a page that mounted late, or reloaded mid-session, started
18
+ * empty while the session it reconnected to still remembered every pick. A
19
+ * slot is the same list stored once, on the side that already survives a
20
+ * reload.
21
+ */
22
+ export const nightSlot = sessionSlot("night", () => ({ recs: [] as Rec[] }));
23
+
24
+ /**
25
+ * What the browser sees. The projection BOTH ends use: `syncState` on the
26
+ * agent, `useAgentState` in the client — so the page's empty state is derived
27
+ * from the same function the server pushes, rather than guessed at.
28
+ */
29
+ export const nightProjection = nightSlot.projection((night) => ({ recs: night.recs }));