@alexkroman1/aai-cli 5.14.0 → 6.1.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 (231) hide show
  1. package/dist/{_agent-C8Jk-ZcP.mjs → _agent-vGW5SBnX.mjs} +23 -5
  2. package/dist/_agent.d.ts +18 -0
  3. package/dist/{_api-client-BX8Qv51n.mjs → _api-client-LgLksMhN.mjs} +45 -8
  4. package/dist/_api-client.d.ts +39 -0
  5. package/dist/{_bundler-BuIyDdvs.mjs → _bundler-BapCUUJN.mjs} +42 -14
  6. package/dist/_bundler.d.ts +43 -5
  7. package/dist/_cli-common.d.ts +67 -3
  8. package/dist/{_config-CzwAWOWr.mjs → _config-D_s09e7g.mjs} +36 -18
  9. package/dist/_deploy.d.ts +2 -5
  10. package/dist/_dev-restart.d.ts +6 -1
  11. package/dist/{_dev-server-Ch7K8e8i.mjs → _dev-server-DQ1t9sbw.mjs} +94 -38
  12. package/dist/_dev-server.d.ts +30 -2
  13. package/dist/_e2e-test-utils.d.ts +80 -0
  14. package/dist/_fault-mode.d.ts +175 -0
  15. package/dist/{_init-CbkrE2wq.mjs → _init-CpISqoR8.mjs} +3 -3
  16. package/dist/{_output-CC300DzW.mjs → _output-CKkmWs7i.mjs} +8 -1
  17. package/dist/_output.d.ts +9 -2
  18. package/dist/{_server-common-61fL__39.mjs → _server-common-6e0QI4mq.mjs} +1 -1
  19. package/dist/{_slug-api-D30zPmsn.mjs → _slug-api-D2heILuC.mjs} +2 -2
  20. package/dist/{_studio-D2ciIhbi.mjs → _studio-zAJycZ8j.mjs} +3 -3
  21. package/dist/{_templates-B1kt3BGt.mjs → _templates-iDGjGiPf.mjs} +4 -6
  22. package/dist/{_typecheck-gate-4v9UBZHh.mjs → _typecheck-gate-BXvUNnfs.mjs} +1 -1
  23. package/dist/{_utils-0DHbcqUb.mjs → _utils-B8QmtFhK.mjs} +21 -7
  24. package/dist/_utils.d.ts +18 -2
  25. package/dist/{_vite-env-Dg_QlVv0.mjs → _vite-env-BNveawd1.mjs} +31 -1
  26. package/dist/_vite-env.d.ts +30 -0
  27. package/dist/build-BO1Ni6oJ.mjs +62 -0
  28. package/dist/build.d.ts +19 -0
  29. package/dist/cli-workflow.d.ts +10 -0
  30. package/dist/cli.d.ts +1 -2
  31. package/dist/cli.mjs +406 -224
  32. package/dist/{client-bundler-C9qXL8A2.mjs → client-bundler-B8Q9F8gJ.mjs} +3 -22
  33. package/dist/client-bundler.mjs +1 -1
  34. package/dist/{delete-DCRX3d96.mjs → delete-4I0uNR4a.mjs} +6 -7
  35. package/dist/delete.d.ts +2 -8
  36. package/dist/{deploy-DjsEV-40.mjs → deploy-DJ3HRcia.mjs} +13 -14
  37. package/dist/{dev-CvPH5dbF.mjs → dev-C6SWaAZP.mjs} +3 -3
  38. package/dist/{eject-LgBRfnbH.mjs → eject-Zrzk5KV3.mjs} +41 -15
  39. package/dist/eject.d.ts +30 -3
  40. package/dist/{init-BppxMR8r.mjs → init-BTRGiPQr.mjs} +61 -26
  41. package/dist/init.d.ts +11 -0
  42. package/dist/{login-BQQaxJhL.mjs → login-k0Z3VQu9.mjs} +5 -5
  43. package/dist/project-config.mjs +1 -1
  44. package/dist/scaffold/CLAUDE.md +498 -91
  45. package/dist/scaffold/package.json +11 -8
  46. package/dist/scaffold/pnpm-workspace.yaml +8 -0
  47. package/dist/scaffold/server.mjs +53 -57
  48. package/dist/{secret-Ds9dvqAj.mjs → secret-DzSQx1Ds.mjs} +6 -3
  49. package/dist/{storage-DbrQSHFk.mjs → storage-C-Do0mIA.mjs} +18 -6
  50. package/dist/{studio-GSFd4s5O.mjs → studio-BGVmCA2G.mjs} +10 -10
  51. package/dist/templates/code-interpreter/agent.ts +0 -2
  52. package/dist/templates/dispatch-center/agent.test.ts +17 -19
  53. package/dist/templates/dispatch-center/agent.ts +2 -29
  54. package/dist/templates/dispatch-center/client.tsx +33 -16
  55. package/dist/templates/dispatch-center/shared.ts +53 -19
  56. package/dist/templates/dispatch-center/tools/incident_add_note.ts +12 -14
  57. package/dist/templates/dispatch-center/tools/incident_create.ts +41 -44
  58. package/dist/templates/dispatch-center/tools/incident_escalate.ts +59 -61
  59. package/dist/templates/dispatch-center/tools/incident_get.ts +3 -4
  60. package/dist/templates/dispatch-center/tools/incident_triage.ts +43 -45
  61. package/dist/templates/dispatch-center/tools/incident_update_status.ts +39 -41
  62. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +2 -5
  63. package/dist/templates/dispatch-center/tools/ops_protocols.ts +1 -1
  64. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +20 -23
  65. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +52 -54
  66. package/dist/templates/dispatch-center/tools/resources_get_available.ts +2 -4
  67. package/dist/templates/dispatch-center/tools/resources_update_status.ts +39 -42
  68. package/dist/templates/embedded-assets/agent.test.ts +83 -0
  69. package/dist/templates/embedded-assets/agent.ts +1 -48
  70. package/dist/templates/embedded-assets/shared.ts +17 -0
  71. package/dist/templates/embedded-assets/system-prompt.md +13 -0
  72. package/dist/templates/embedded-assets/tools/list_topics.ts +9 -0
  73. package/dist/templates/embedded-assets/tools/search_knowledge.ts +26 -0
  74. package/dist/templates/health-assistant/agent.test.ts +169 -0
  75. package/dist/templates/health-assistant/agent.ts +1 -166
  76. package/dist/templates/health-assistant/fda.ts +100 -0
  77. package/dist/templates/health-assistant/tools/check_drug_interaction.ts +59 -0
  78. package/dist/templates/health-assistant/tools/medication_lookup.ts +31 -0
  79. package/dist/templates/infocom-adventure/agent.test.ts +205 -0
  80. package/dist/templates/infocom-adventure/agent.ts +3 -106
  81. package/dist/templates/infocom-adventure/client.tsx +28 -12
  82. package/dist/templates/infocom-adventure/shared.ts +28 -11
  83. package/dist/templates/infocom-adventure/tools/game_state_drop.ts +13 -0
  84. package/dist/templates/infocom-adventure/tools/game_state_flag.ts +13 -0
  85. package/dist/templates/infocom-adventure/tools/game_state_get.ts +16 -0
  86. package/dist/templates/infocom-adventure/tools/game_state_history.ts +14 -0
  87. package/dist/templates/infocom-adventure/tools/game_state_move.ts +14 -0
  88. package/dist/templates/infocom-adventure/tools/game_state_restart.ts +13 -0
  89. package/dist/templates/infocom-adventure/tools/game_state_score.ts +13 -0
  90. package/dist/templates/infocom-adventure/tools/game_state_take.ts +13 -0
  91. package/dist/templates/link-digest/agent.test.ts +223 -0
  92. package/dist/templates/link-digest/agent.ts +79 -0
  93. package/dist/templates/link-digest/client.tsx +146 -0
  94. package/dist/templates/link-digest/workflows/digest.ts +248 -0
  95. package/dist/templates/math-buddy/agent.ts +0 -2
  96. package/dist/templates/night-owl/agent.test.ts +80 -0
  97. package/dist/templates/night-owl/agent.ts +1 -68
  98. package/dist/templates/night-owl/system-prompt.md +10 -0
  99. package/dist/templates/night-owl/tools/recommend.ts +64 -0
  100. package/dist/templates/personal-finance/agent.ts +0 -2
  101. package/dist/templates/pizza-ordering/agent.test.ts +52 -47
  102. package/dist/templates/pizza-ordering/agent.ts +9 -158
  103. package/dist/templates/pizza-ordering/shared.ts +35 -12
  104. package/dist/templates/pizza-ordering/tools/add_pizza.ts +31 -0
  105. package/dist/templates/pizza-ordering/tools/place_order.ts +22 -0
  106. package/dist/templates/pizza-ordering/tools/remove_pizza.ts +22 -0
  107. package/dist/templates/pizza-ordering/tools/set_customer_name.ts +13 -0
  108. package/dist/templates/pizza-ordering/tools/update_pizza.ts +28 -0
  109. package/dist/templates/pizza-ordering/tools/view_order.ts +21 -0
  110. package/dist/templates/plan-and-execute/agent.test.ts +411 -0
  111. package/dist/templates/plan-and-execute/agent.ts +23 -0
  112. package/dist/templates/plan-and-execute/client.tsx +115 -0
  113. package/dist/templates/plan-and-execute/graph.ts +178 -0
  114. package/dist/templates/plan-and-execute/prompts.ts +110 -0
  115. package/dist/templates/plan-and-execute/shared.ts +161 -0
  116. package/dist/templates/plan-and-execute/system-prompt.md +18 -0
  117. package/dist/templates/plan-and-execute/tools/plan_status.ts +20 -0
  118. package/dist/templates/plan-and-execute/tools/revise_plan.ts +63 -0
  119. package/dist/templates/plan-and-execute/tools/start_plan.ts +51 -0
  120. package/dist/templates/plan-and-execute/tools/work_next_step.ts +101 -0
  121. package/dist/templates/recap-workflow/agent.test.ts +762 -0
  122. package/dist/templates/recap-workflow/agent.ts +95 -0
  123. package/dist/templates/recap-workflow/shared.ts +43 -0
  124. package/dist/templates/recap-workflow/tools/cancel_recap.ts +23 -0
  125. package/dist/templates/recap-workflow/tools/keep_transcript.ts +31 -0
  126. package/dist/templates/recap-workflow/tools/recap_progress.ts +27 -0
  127. package/dist/templates/recap-workflow/tools/recap_status.ts +46 -0
  128. package/dist/templates/recap-workflow/tools/request_recap.ts +43 -0
  129. package/dist/templates/recap-workflow/workflows/recap.ts +544 -0
  130. package/dist/templates/recap-workflow/workflows/tokens.ts +26 -0
  131. package/dist/templates/redline/agent.test.ts +222 -0
  132. package/dist/templates/redline/agent.ts +89 -0
  133. package/dist/templates/redline/client.tsx +184 -0
  134. package/dist/templates/redline/workflows/prompts.ts +68 -0
  135. package/dist/templates/redline/workflows/redline.ts +258 -0
  136. package/dist/templates/research-workflow/agent.test.ts +525 -0
  137. package/dist/templates/research-workflow/agent.ts +94 -0
  138. package/dist/templates/research-workflow/shared.ts +35 -0
  139. package/dist/templates/research-workflow/tools/file_it_now.ts +22 -0
  140. package/dist/templates/research-workflow/tools/request_research.ts +33 -0
  141. package/dist/templates/research-workflow/tools/research_progress.ts +26 -0
  142. package/dist/templates/research-workflow/tools/research_status.ts +40 -0
  143. package/dist/templates/research-workflow/workflows/prompts.ts +143 -0
  144. package/dist/templates/research-workflow/workflows/research.ts +558 -0
  145. package/dist/templates/retail/agent.test.ts +236 -172
  146. package/dist/templates/retail/agent.ts +5 -42
  147. package/dist/templates/retail/client.tsx +33 -11
  148. package/dist/templates/retail/registry.test.ts +31 -16
  149. package/dist/templates/retail/resolve.ts +28 -64
  150. package/dist/templates/retail/shared.test.ts +4 -3
  151. package/dist/templates/retail/shared.ts +24 -7
  152. package/dist/templates/retail/store.test.ts +12 -10
  153. package/dist/templates/retail/store.ts +69 -30
  154. package/dist/templates/retail/tools/cancel_pending_order.ts +4 -9
  155. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -11
  156. package/dist/templates/retail/tools/find_user_id_by_email.ts +5 -9
  157. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +5 -7
  158. package/dist/templates/retail/tools/get_item_details.ts +5 -7
  159. package/dist/templates/retail/tools/get_order_details.ts +5 -7
  160. package/dist/templates/retail/tools/get_product_details.ts +4 -7
  161. package/dist/templates/retail/tools/get_user_details.ts +4 -7
  162. package/dist/templates/retail/tools/list_all_product_types.ts +5 -7
  163. package/dist/templates/retail/tools/modify_pending_order_address.ts +4 -9
  164. package/dist/templates/retail/tools/modify_pending_order_items.ts +4 -16
  165. package/dist/templates/retail/tools/modify_pending_order_payment.ts +3 -9
  166. package/dist/templates/retail/tools/modify_user_address.ts +4 -9
  167. package/dist/templates/retail/tools/return_delivered_order_items.ts +3 -9
  168. package/dist/templates/retail/tools/transfer_to_human_agents.ts +1 -1
  169. package/dist/templates/solo-rpg/agent.test.ts +152 -29
  170. package/dist/templates/solo-rpg/agent.ts +3 -21
  171. package/dist/templates/solo-rpg/client.tsx +19 -6
  172. package/dist/templates/solo-rpg/shared.ts +19 -11
  173. package/dist/templates/solo-rpg/tools/action_roll.ts +2 -4
  174. package/dist/templates/solo-rpg/tools/burn_momentum.ts +2 -4
  175. package/dist/templates/solo-rpg/tools/check_state.ts +6 -6
  176. package/dist/templates/solo-rpg/tools/load_game.ts +1 -1
  177. package/dist/templates/solo-rpg/tools/oracle.ts +18 -11
  178. package/dist/templates/solo-rpg/tools/save_game.ts +10 -7
  179. package/dist/templates/solo-rpg/tools/setup_character.ts +1 -1
  180. package/dist/templates/solo-rpg/tools/update_state.ts +3 -4
  181. package/dist/templates/support-line/agent.test.ts +343 -0
  182. package/dist/templates/support-line/agent.ts +27 -0
  183. package/dist/templates/support-line/client.tsx +122 -0
  184. package/dist/templates/support-line/graph.ts +224 -0
  185. package/dist/templates/support-line/knowledge.json +65 -0
  186. package/dist/templates/support-line/prompts.ts +95 -0
  187. package/dist/templates/support-line/shared.ts +240 -0
  188. package/dist/templates/support-line/system-prompt.md +17 -0
  189. package/dist/templates/support-line/tools/answer_question.ts +72 -0
  190. package/dist/templates/support-line/tools/list_topics.ts +22 -0
  191. package/dist/templates/support-line/tools/log_ticket.ts +30 -0
  192. package/dist/templates/transcription-workflow/agent.test.ts +563 -0
  193. package/dist/templates/transcription-workflow/agent.ts +92 -0
  194. package/dist/templates/transcription-workflow/client.tsx +234 -0
  195. package/dist/templates/transcription-workflow/workflows/transcribe.ts +416 -0
  196. package/dist/templates/transcription-workflow/workflows/wav.ts +314 -0
  197. package/dist/templates/travel-concierge/agent.test.ts +338 -0
  198. package/dist/templates/travel-concierge/agent.ts +29 -0
  199. package/dist/templates/travel-concierge/client.tsx +161 -0
  200. package/dist/templates/travel-concierge/routing.ts +138 -0
  201. package/dist/templates/travel-concierge/shared.ts +547 -0
  202. package/dist/templates/travel-concierge/system-prompt.md +24 -0
  203. package/dist/templates/travel-concierge/tools/book_car_rental.ts +20 -0
  204. package/dist/templates/travel-concierge/tools/book_excursion.ts +18 -0
  205. package/dist/templates/travel-concierge/tools/book_hotel.ts +20 -0
  206. package/dist/templates/travel-concierge/tools/cancel_action.ts +7 -0
  207. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +16 -0
  208. package/dist/templates/travel-concierge/tools/complete_or_escalate.ts +7 -0
  209. package/dist/templates/travel-concierge/tools/confirm_action.ts +7 -0
  210. package/dist/templates/travel-concierge/tools/lookup_booking.ts +35 -0
  211. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +33 -0
  212. package/dist/templates/travel-concierge/tools/search_excursions.ts +37 -0
  213. package/dist/templates/travel-concierge/tools/search_flights.ts +43 -0
  214. package/dist/templates/travel-concierge/tools/search_hotels.ts +36 -0
  215. package/dist/templates/travel-concierge/tools/to_car_rental_assistant.ts +11 -0
  216. package/dist/templates/travel-concierge/tools/to_excursion_assistant.ts +11 -0
  217. package/dist/templates/travel-concierge/tools/to_flight_assistant.ts +11 -0
  218. package/dist/templates/travel-concierge/tools/to_hotel_assistant.ts +11 -0
  219. package/dist/templates/travel-concierge/tools/update_ticket.ts +22 -0
  220. package/dist/templates/web-researcher/agent.ts +0 -2
  221. package/dist/templates/web-researcher/system-prompt.md +10 -0
  222. package/dist/{test-2LWBcXvY.mjs → test-B6ZBxSpk.mjs} +2 -2
  223. package/dist/typecheck.mjs +17 -15
  224. package/dist/worker-bundler-CIdDhZ0c.mjs +398 -0
  225. package/dist/worker-bundler.d.ts +14 -0
  226. package/dist/worker-bundler.mjs +1 -108
  227. package/dist/workflow-B-O97AqI.mjs +125 -0
  228. package/dist/workflow-bundler.d.ts +133 -0
  229. package/dist/workflow.d.ts +67 -0
  230. package/package.json +13 -9
  231. package/dist/build-DQJ1ZSm9.mjs +0 -39
@@ -10,15 +10,15 @@ import {
10
10
  MIN_MOMENTUM,
11
11
  makeNpc,
12
12
  rollAction,
13
- type StateSlot,
14
13
  } from "./shared.ts";
15
- import { actionRoll } from "./tools/action_roll.ts";
16
- import { burnMomentum } from "./tools/burn_momentum.ts";
17
- import { checkState } from "./tools/check_state.ts";
18
- import { loadGame } from "./tools/load_game.ts";
19
- import { saveGame } from "./tools/save_game.ts";
20
- import { setupCharacter } from "./tools/setup_character.ts";
21
- import { updateState } from "./tools/update_state.ts";
14
+ import actionRoll from "./tools/action_roll.ts";
15
+ import burnMomentum from "./tools/burn_momentum.ts";
16
+ import checkState from "./tools/check_state.ts";
17
+ import loadGame from "./tools/load_game.ts";
18
+ import oracle from "./tools/oracle.ts";
19
+ import saveGame from "./tools/save_game.ts";
20
+ import setupCharacter from "./tools/setup_character.ts";
21
+ import updateState from "./tools/update_state.ts";
22
22
 
23
23
  // ── Test doubles ─────────────────────────────────────────────────────────────
24
24
 
@@ -48,9 +48,10 @@ function makeDb(): { db: Db; rows: Map<string, unknown> } {
48
48
  }
49
49
 
50
50
  /** `send` is a spy rather than the recorder `createToolContext` installs,
51
- * because this suite asserts call counts on it. */
52
- function makeCtx(sessionId = "session-a", db: Db = makeDb().db): ToolContext<StateSlot> {
53
- return createToolContext<StateSlot>({ sessionId, db, send: vi.fn() });
51
+ * because this suite asserts call counts on it. Each call gets its own slot
52
+ * store, which is what makes two contexts two games. */
53
+ function makeCtx(db: Db = makeDb().db): ToolContext {
54
+ return createToolContext({ db, send: vi.fn() });
54
55
  }
55
56
 
56
57
  const SETUP_ARGS = {
@@ -98,12 +99,12 @@ describe("setup_character", () => {
98
99
  await setupCharacter.execute(SETUP_ARGS, ctx);
99
100
 
100
101
  // Simulate a played, damaged game between setups.
101
- const played = gameSlot.get(ctx);
102
- played.health = 1;
103
- played.momentum = -4;
104
- played.chaosFactor = 8;
105
- played.sceneCount = 42;
106
- gameSlot.set(ctx, played);
102
+ gameSlot.update(ctx, (played) => {
103
+ played.health = 1;
104
+ played.momentum = -4;
105
+ played.chaosFactor = 8;
106
+ played.sceneCount = 42;
107
+ });
107
108
 
108
109
  const result = (await setupCharacter.execute(
109
110
  { ...SETUP_ARGS, playerName: "Luna" },
@@ -139,10 +140,14 @@ describe("setup_character", () => {
139
140
  expect(state.wits).toBe(3);
140
141
  });
141
142
 
142
- test("game state is scoped per session — a second session sees a fresh game", async () => {
143
- await setupCharacter.execute(SETUP_ARGS, makeCtx("session-a"));
144
- // ctx.state is per-session by construction a new session, a new game.
145
- const other = (await checkState.execute({} as never, makeCtx("session-b"))) as {
143
+ test("a second, independent context sees a fresh game", async () => {
144
+ // What this really checks: the state lives in the SLOT and not in a
145
+ // module-level variable. `createToolContext()` hands each call its own
146
+ // detached slot store, so the isolation is per CONTEXT — two distinct
147
+ // session ids would prove nothing extra, and `sessionSlot` could stop
148
+ // keying by session with this still passing.
149
+ await setupCharacter.execute(SETUP_ARGS, makeCtx());
150
+ const other = (await checkState.execute({} as never, makeCtx())) as {
146
151
  initialized: boolean;
147
152
  };
148
153
  expect(other.initialized).toBe(false);
@@ -298,9 +303,9 @@ describe("burn_momentum", () => {
298
303
 
299
304
  // Strong hits cannot be upgraded
300
305
  seedRolledState(8, ctx);
301
- const state = gameSlot.get(ctx);
302
- state.lastRoll!.result = "STRONG_HIT";
303
- gameSlot.set(ctx, state);
306
+ gameSlot.update(ctx, (state) => {
307
+ state.lastRoll!.result = "STRONG_HIT";
308
+ });
304
309
  result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
305
310
  expect(result.error).toMatch(/already a Strong Hit/);
306
311
  });
@@ -359,6 +364,122 @@ describe("rollAction", () => {
359
364
  });
360
365
  });
361
366
 
367
+ // ── oracle ───────────────────────────────────────────────────────────────────
368
+ //
369
+ // `chaos_check` is the one oracle branch that WRITES: `checkChaosInterrupt`
370
+ // lowers the chaos factor when the roll lands. It used to read the slot with
371
+ // `gameSlot.get` and assign to what came back, under a comment claiming the
372
+ // value was live — which described the removed `ctx.state` bag, not a slot. The
373
+ // stored value is deep-frozen, so ~1 call in 5 at the default chaos factor (and
374
+ // ~6 in 10 as it climbs) threw a `TypeError` instead of answering. These
375
+ // assertions are on the STORED value for that reason: a body writing to a
376
+ // private copy would pass every check on its own return value.
377
+
378
+ describe("oracle", () => {
379
+ /** Force `d(sides)` to roll `value` on the next call. */
380
+ function mockRoll(value: number, sides: number) {
381
+ return vi.spyOn(Math, "random").mockReturnValue((value - 0.5) / sides);
382
+ }
383
+
384
+ test("a chaos interrupt that LANDS lowers the stored chaos factor", async () => {
385
+ const ctx = makeCtx();
386
+ const state = playingState();
387
+ state.chaosFactor = 9; // threshold 6 — a roll of 1 lands
388
+ gameSlot.set(ctx, state);
389
+ mockRoll(1, 10);
390
+
391
+ const result = (await oracle.execute({ type: "chaos_check" }, ctx)) as {
392
+ interrupted: boolean;
393
+ interruptType: string | null;
394
+ chaosFactor: number;
395
+ };
396
+
397
+ expect(result.interrupted).toBe(true);
398
+ expect(result.interruptType).toBeTruthy();
399
+ expect(result.chaosFactor).toBe(8);
400
+ // The half the old code could not do: the write reached the slot.
401
+ expect(gameSlot.get(ctx).chaosFactor).toBe(8);
402
+ });
403
+
404
+ test("the chaos factor floors at 3, where no roll is taken at all", async () => {
405
+ const ctx = makeCtx();
406
+ const state = playingState();
407
+ state.chaosFactor = 3; // threshold 0 — `checkChaosInterrupt` returns early
408
+ gameSlot.set(ctx, state);
409
+
410
+ const result = (await oracle.execute({ type: "chaos_check" }, ctx)) as {
411
+ interrupted: boolean;
412
+ chaosFactor: number;
413
+ };
414
+ expect(result.interrupted).toBe(false);
415
+ expect(gameSlot.get(ctx).chaosFactor).toBe(3);
416
+ });
417
+
418
+ test("a chaos check that MISSES changes nothing", async () => {
419
+ const ctx = makeCtx();
420
+ const state = playingState();
421
+ state.chaosFactor = 5; // threshold 2
422
+ gameSlot.set(ctx, state);
423
+ mockRoll(10, 10); // past the threshold
424
+
425
+ const result = (await oracle.execute({ type: "chaos_check" }, ctx)) as {
426
+ interrupted: boolean;
427
+ chaosFactor: number;
428
+ };
429
+ expect(result.interrupted).toBe(false);
430
+ expect(result.chaosFactor).toBe(5);
431
+ expect(gameSlot.get(ctx).chaosFactor).toBe(5);
432
+ });
433
+
434
+ test("a chaos check on an untouched session starts from the default factor", async () => {
435
+ const ctx = makeCtx();
436
+ mockRoll(1, 10); // DEFAULT_STATE.chaosFactor is 5, so threshold 2 — lands
437
+ const result = (await oracle.execute({ type: "chaos_check" }, ctx)) as { chaosFactor: number };
438
+ expect(result.chaosFactor).toBe(DEFAULT_STATE.chaosFactor - 1);
439
+ expect(gameSlot.get(ctx).chaosFactor).toBe(DEFAULT_STATE.chaosFactor - 1);
440
+ });
441
+
442
+ test("yes_no maps the d6 onto its three answers", async () => {
443
+ const ctx = makeCtx();
444
+ for (const [roll, answer] of [
445
+ [1, "No"],
446
+ [2, "No"],
447
+ [3, "Yes, but with a complication"],
448
+ [4, "Yes, but with a complication"],
449
+ [5, "Yes"],
450
+ [6, "Yes"],
451
+ ] as const) {
452
+ mockRoll(roll, 6);
453
+ const result = (await oracle.execute({ type: "yes_no" }, ctx)) as {
454
+ roll: number;
455
+ answer: string;
456
+ };
457
+ expect.soft(result, `roll ${roll}`).toEqual({ type: "yes_no", roll, answer });
458
+ }
459
+ });
460
+
461
+ test("the four inspiration branches answer without touching the game", async () => {
462
+ const ctx = makeCtx();
463
+ gameSlot.set(ctx, playingState());
464
+ const before = structuredClone(gameSlot.get(ctx));
465
+
466
+ const reaction = (await oracle.execute({ type: "npc_reaction" }, ctx)) as { reaction: string };
467
+ const twist = (await oracle.execute({ type: "scene_twist" }, ctx)) as { twist: string };
468
+ const theme = (await oracle.execute({ type: "action_theme" }, ctx)) as {
469
+ action: string;
470
+ theme: string;
471
+ seed: string;
472
+ };
473
+
474
+ expect(reaction.reaction).toBeTruthy();
475
+ expect(twist.twist).toBeTruthy();
476
+ expect(theme.action).toBeTruthy();
477
+ expect(theme.theme).toBeTruthy();
478
+ expect(theme.seed.split(" ")).toHaveLength(3);
479
+ expect(gameSlot.get(ctx)).toEqual(before);
480
+ });
481
+ });
482
+
362
483
  // ── update_state: clocks, caps, validation ───────────────────────────────────
363
484
 
364
485
  describe("update_state", () => {
@@ -430,7 +551,7 @@ describe("save_game / load_game", () => {
430
551
  const { db, rows } = makeDb();
431
552
 
432
553
  // Session A plays and saves.
433
- const sessionA = makeCtx("session-a", db);
554
+ const sessionA = makeCtx(db);
434
555
  const played = playingState();
435
556
  played.playerName = "Kael";
436
557
  played.sceneCount = 7;
@@ -443,8 +564,8 @@ describe("save_game / load_game", () => {
443
564
  expect(saved.slot).toBe("chapter-2");
444
565
  expect(rows.get("save:chapter-2")).toMatchObject({ playerName: "Kael", sceneCount: 7 });
445
566
 
446
- // Session B (fresh ctx.state, same app db) resumes it.
447
- const sessionB = makeCtx("session-b", db);
567
+ // Session B (a fresh game slot, the same app db) resumes it.
568
+ const sessionB = makeCtx(db);
448
569
  const loaded = (await loadGame.execute({ slot: "chapter-2" }, sessionB)) as Record<
449
570
  string,
450
571
  unknown
@@ -463,10 +584,12 @@ describe("save_game / load_game", () => {
463
584
 
464
585
  test("saving twice to one slot upserts — the newer save wins", async () => {
465
586
  const { db, rows } = makeDb();
466
- const ctx = makeCtx("session-a", db);
587
+ const ctx = makeCtx(db);
467
588
  gameSlot.set(ctx, playingState());
468
589
  await saveGame.execute({}, ctx); // autosave
469
- gameSlot.get(ctx).sceneCount = 9;
590
+ gameSlot.update(ctx, (game) => {
591
+ game.sceneCount = 9;
592
+ });
470
593
  await saveGame.execute({}, ctx);
471
594
  expect(rows.size).toBe(1);
472
595
  expect(rows.get("save:autosave")).toMatchObject({ sceneCount: 9 });
@@ -1,18 +1,8 @@
1
1
  import { agent } from "@alexkroman1/aai";
2
2
  import { gameSlot } from "./shared.ts";
3
- import systemPrompt from "./system-prompt.md?raw";
4
- import { actionRoll } from "./tools/action_roll.ts";
5
- import { burnMomentum } from "./tools/burn_momentum.ts";
6
- import { checkState } from "./tools/check_state.ts";
7
- import { loadGame } from "./tools/load_game.ts";
8
- import { oracle } from "./tools/oracle.ts";
9
- import { saveGame } from "./tools/save_game.ts";
10
- import { setupCharacter } from "./tools/setup_character.ts";
11
- import { updateState } from "./tools/update_state.ts";
12
3
 
13
4
  export default agent({
14
5
  name: "Solo RPG",
15
- systemPrompt,
16
6
  greeting:
17
7
  "Welcome. Tell me your name, or describe the kind of story you want, and we will begin. You can say something like, dark fantasy warrior named Kael, or just give me a name and I will build a world around you.",
18
8
  sttPrompt:
@@ -22,15 +12,7 @@ export default agent({
22
12
  // One declaration replaces a `ctx.send("game_state", state)` in every
23
13
  // state-mutating tool — six of them, and adding a seventh meant
24
14
  // remembering to push or watching the UI quietly fall out of sync.
25
- syncState: gameSlot.read,
26
- tools: {
27
- action_roll: actionRoll,
28
- burn_momentum: burnMomentum,
29
- check_state: checkState,
30
- load_game: loadGame,
31
- oracle,
32
- save_game: saveGame,
33
- setup_character: setupCharacter,
34
- update_state: updateState,
35
- },
15
+ // The identity projection: this campaign IS what the client renders, and the
16
+ // slot's own default is what a session that has run no tool projects.
17
+ syncState: gameSlot.projection((game) => game),
36
18
  });
@@ -1,12 +1,13 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
+ import type { DeepReadonly } from "@alexkroman1/aai";
2
3
  import { ChatView, client, SidebarLayout, StartScreen, useAgentState } from "@alexkroman1/aai-ui";
3
4
  import type { ReactNode } from "react";
4
5
  import {
5
6
  type Clock,
6
- DEFAULT_STATE,
7
7
  type Disposition,
8
8
  type GameState,
9
9
  GENRES,
10
+ gameSlot,
10
11
  MAX_RESOURCE,
11
12
  MIN_MOMENTUM,
12
13
  type NPC,
@@ -14,6 +15,18 @@ import {
14
15
  type TIME_PHASES,
15
16
  } from "./shared.ts";
16
17
 
18
+ // The campaign before the first tool call, derived from the SAME projection the
19
+ // server pushes rather than from the module default beside it — so a session
20
+ // that has run no tool renders exactly what its first push will replace. Every
21
+ // sibling template derives its fallback this way; this one read `DEFAULT_STATE`
22
+ // directly, which is a second copy of "what an untouched session looks like".
23
+ //
24
+ // It is typed READONLY because that is what the projection produces, and a
25
+ // client only ever renders what the server pushed — so the four components
26
+ // below take a readonly campaign too, which is the deep-readonly slot
27
+ // propagating exactly as far as it should.
28
+ const EMPTY_GAME = gameSlot.projection((game) => game)(undefined);
29
+
17
30
  // ── Color Palette ────────────────────────────────────────────────────────────
18
31
  const C = {
19
32
  bg: "#0a0a0c",
@@ -290,7 +303,7 @@ function StatPip({ label, value }: { label: string; value: number }) {
290
303
  );
291
304
  }
292
305
 
293
- function ClockDisplay({ clock }: { clock: Clock }) {
306
+ function ClockDisplay({ clock }: { clock: DeepReadonly<Clock> }) {
294
307
  const typeColor =
295
308
  clock.clockType === "threat"
296
309
  ? C.threat
@@ -355,7 +368,7 @@ function ClockDisplay({ clock }: { clock: Clock }) {
355
368
  );
356
369
  }
357
370
 
358
- function NpcCard({ npc }: { npc: NPC }) {
371
+ function NpcCard({ npc }: { npc: DeepReadonly<NPC> }) {
359
372
  const dispColor = C.disposition[npc.disposition] || C.textMuted;
360
373
  const icon = DISP_ICON[npc.disposition] || "\u25CB";
361
374
  return (
@@ -390,7 +403,7 @@ function NpcCard({ npc }: { npc: NPC }) {
390
403
  );
391
404
  }
392
405
 
393
- function StoryArc({ story }: { story: StoryBlueprint }) {
406
+ function StoryArc({ story }: { story: DeepReadonly<StoryBlueprint> }) {
394
407
  const totalActs = story.acts.length;
395
408
  const currentPhase = story.acts[story.currentAct - 1]?.phase ?? "";
396
409
  const pct = totalActs > 0 ? ((story.currentAct - 1) / totalActs) * 100 : 0;
@@ -443,7 +456,7 @@ function StoryArc({ story }: { story: StoryBlueprint }) {
443
456
 
444
457
  // ── Sidebar ──────────────────────────────────────────────────────────────────
445
458
 
446
- function Sidebar({ game }: { game: GameState }) {
459
+ function Sidebar({ game }: { game: DeepReadonly<GameState> }) {
447
460
  return (
448
461
  <div
449
462
  style={{
@@ -771,7 +784,7 @@ function SoloRPGApp() {
771
784
  // The agent's own session state, projected by `syncState` and pushed after
772
785
  // every tool call — no per-tool `ctx.send`, and nothing to keep in step
773
786
  // when a new tool starts mutating the game.
774
- const game = useAgentState<GameState>(DEFAULT_STATE);
787
+ const game = useAgentState<DeepReadonly<GameState>>(EMPTY_GAME);
775
788
 
776
789
  return (
777
790
  <StartScreen
@@ -1,4 +1,4 @@
1
- import { type SlotStateOf, sessionSlot, type ToolContext } from "@alexkroman1/aai";
1
+ import { type DeepReadonly, sessionSlot, type ToolContext } from "@alexkroman1/aai";
2
2
  import { z } from "zod";
3
3
 
4
4
  // ── Tuning Constants ─────────────────────────────────────────────────────────
@@ -320,16 +320,24 @@ export const DEFAULT_STATE: GameState = {
320
320
  kidMode: false,
321
321
  };
322
322
 
323
- // ── Live game state (ctx.state) ─────────────────────────────────────────────
324
- // The in-play game lives in `ctx.state`, the agent's per-session mutable
325
- // state — concurrent players get independent games by construction, and the
326
- // live game needs no persistence of its own (that's what save slots are for).
327
- // The clone is load-bearing: `DEFAULT_STATE` is one module-level object shared
328
- // by every session in the process, so a mutation without it would let one
329
- // player's game show up in another's.
323
+ // ── Live game state (one session slot) ───────────────────────────────────────
324
+ // The in-play game lives in one `sessionSlot`, keyed per session — concurrent
325
+ // players get independent games by construction. The clone is load-bearing:
326
+ // `DEFAULT_STATE` is one module-level object shared by every session in the
327
+ // process, so a factory without it would let one player's game show up in
328
+ // another's.
330
329
  export const gameSlot = sessionSlot("game", () => structuredClone(DEFAULT_STATE));
331
330
 
332
- export type StateSlot = SlotStateOf<typeof gameSlot>;
331
+ /**
332
+ * The game as a READ hands it out: deep-frozen, and typed to say so.
333
+ *
334
+ * Every pure helper below takes this rather than {@link GameState}, which is
335
+ * the widening a deep-readonly slot forces and the reason it is worth doing:
336
+ * a mutable `GameState` still satisfies it, so a helper called with an
337
+ * `updateTool` draft is unaffected, while a helper that WOULD have mutated
338
+ * stops compiling instead of throwing at its first call in production.
339
+ */
340
+ export type FrozenGameState = DeepReadonly<GameState>;
333
341
 
334
342
  // ── Persistent save slots (ctx.db) ───────────────────────────────────────────
335
343
  // save_game / load_game are genuine cross-session persistence, so they use
@@ -433,7 +441,7 @@ export function makeNpc(opts: {
433
441
  };
434
442
  }
435
443
 
436
- export function clockSummary(c: Clock) {
444
+ export function clockSummary(c: DeepReadonly<Clock>) {
437
445
  return {
438
446
  id: c.id,
439
447
  name: c.name,
@@ -450,7 +458,7 @@ export function clockSummary(c: Clock) {
450
458
  * setup_character, update_state, and check_state. Includes the player's
451
459
  * content boundaries (contentLines) so they survive past the setup turn.
452
460
  */
453
- export function stateSummary(state: GameState) {
461
+ export function stateSummary(state: FrozenGameState) {
454
462
  return {
455
463
  initialized: state.initialized,
456
464
  phase: state.phase,
@@ -1,4 +1,3 @@
1
- import { tool } from "@alexkroman1/aai";
2
1
  import { z } from "zod";
3
2
  import {
4
3
  applyConsequences,
@@ -12,7 +11,7 @@ import {
12
11
  updateChaosFactor,
13
12
  } from "../shared.ts";
14
13
 
15
- export const actionRoll = tool({
14
+ export default gameSlot.updateTool({
16
15
  description:
17
16
  "Core mechanic. Roll 2d6 + stat (capped at 10) vs 2d10 challenge dice. Also applies consequences (health/spirit/supply/momentum changes, clock advancement) based on move type, position, and result. Call for ANY risky action. Pure conversation needs no roll.",
18
17
  inputSchema: z.object({
@@ -25,8 +24,7 @@ export const actionRoll = tool({
25
24
  purpose: z.string().max(300).describe("What the character is attempting"),
26
25
  targetNpcId: z.string().max(32).describe("Target NPC id for social moves").optional(),
27
26
  }),
28
- async execute(args, ctx) {
29
- const state = gameSlot.get(ctx);
27
+ execute(args, state) {
30
28
  const statValue = state[args.stat];
31
29
  const roll = rollAction(args.stat, statValue, args.move);
32
30
 
@@ -1,4 +1,3 @@
1
- import { tool } from "@alexkroman1/aai";
2
1
  import {
3
2
  applyConsequences,
4
3
  canBurnMomentum,
@@ -8,11 +7,10 @@ import {
8
7
  revertConsequences,
9
8
  } from "../shared.ts";
10
9
 
11
- export const burnMomentum = tool({
10
+ export default gameSlot.updateTool({
12
11
  description:
13
12
  "Burn momentum to upgrade the most recent action roll. Only valid when current momentum beats the roll's challenge dice (both dice for a full upgrade, one for Miss to Weak Hit). Reverts the original result's consequences, applies the upgraded result, and resets momentum to +2.",
14
- async execute(_args, ctx) {
15
- const state = gameSlot.get(ctx);
13
+ execute(_args, state) {
16
14
  const last = state.lastRoll;
17
15
  if (!last) return { error: "No recent action roll to upgrade. Roll first." };
18
16
  if (last.result === "STRONG_HIT") {
@@ -1,11 +1,11 @@
1
- import { tool } from "@alexkroman1/aai";
2
1
  import { gameSlot, stateSummary } from "../shared.ts";
3
2
 
4
- export const checkState = tool({
3
+ // `gameSlot.tool`, the READING half: the body is handed the value, so it needs
4
+ // neither a context annotation nor an opening `gameSlot.get(ctx)` — and what it
5
+ // is handed is deep-frozen, which is what makes "this tool only reads" a
6
+ // property of the declaration rather than a claim in a comment.
7
+ export default gameSlot.tool({
5
8
  description:
6
9
  "Returns the full current game state. Call this at the start of every turn, before narrating or rolling, and treat the returned values as ground truth — never guess or remember stats from previous turns.",
7
- async execute(_args, ctx) {
8
- const state = gameSlot.get(ctx);
9
- return stateSummary(state);
10
- },
10
+ execute: (_args, game) => stateSummary(game),
11
11
  });
@@ -5,7 +5,7 @@ import { gameSlot, loadState, saveSlotKey, saveSlotParam } from "../shared.ts";
5
5
 
6
6
  // Requires storage — `aai storage enable` (or DATABASE_URL in .env under
7
7
  // `aai dev`); the rest of the game works without it.
8
- export const loadGame = tool({
8
+ export default tool({
9
9
  description: "Load a previously saved game.",
10
10
  inputSchema: z.object({ slot: saveSlotParam }),
11
11
  async execute(args, ctx) {
@@ -202,7 +202,7 @@ const THEMES = [
202
202
  "Wound",
203
203
  ];
204
204
 
205
- export const oracle = tool({
205
+ export default tool({
206
206
  description:
207
207
  "Consult the oracle for narrative inspiration. Generates random prompts from thematic tables.",
208
208
  inputSchema: z.object({
@@ -210,7 +210,7 @@ export const oracle = tool({
210
210
  .enum(["action_theme", "npc_reaction", "scene_twist", "yes_no", "chaos_check"])
211
211
  .describe("Type of oracle consultation"),
212
212
  }),
213
- async execute(args, ctx) {
213
+ execute(args, ctx) {
214
214
  if (args.type === "yes_no") {
215
215
  const roll = d(6);
216
216
  const answer = roll <= 2 ? "No" : roll <= 4 ? "Yes, but with a complication" : "Yes";
@@ -218,15 +218,22 @@ export const oracle = tool({
218
218
  }
219
219
 
220
220
  if (args.type === "chaos_check") {
221
- // gameSlot.get returns the live state object mutations stick.
222
- const state = gameSlot.get(ctx);
223
- const interrupt = checkChaosInterrupt(state);
224
- return {
225
- type: "chaos_check",
226
- chaosFactor: state.chaosFactor,
227
- interrupted: Boolean(interrupt),
228
- interruptType: interrupt,
229
- };
221
+ // `checkChaosInterrupt` LOWERS the chaos factor when the roll lands, so
222
+ // this branch is a write and has to go through the slot's mutation
223
+ // window: `gameSlot.get` hands out the deep-frozen stored value, and
224
+ // assigning to it is a compile error and a `TypeError`. It is one branch
225
+ // of five, which is why this stays an ordinary `tool()` with a scoped
226
+ // `update` rather than becoming `gameSlot.updateTool` — the other four
227
+ // read nothing and store nothing.
228
+ return gameSlot.update(ctx, (game) => {
229
+ const interrupt = checkChaosInterrupt(game);
230
+ return {
231
+ type: "chaos_check",
232
+ chaosFactor: game.chaosFactor,
233
+ interrupted: Boolean(interrupt),
234
+ interruptType: interrupt,
235
+ };
236
+ });
230
237
  }
231
238
 
232
239
  if (args.type === "npc_reaction") {
@@ -1,20 +1,23 @@
1
- import { tool } from "@alexkroman1/aai";
2
1
  import { z } from "zod";
3
2
  import { gameSlot, saveSlotKey, saveSlotParam, saveState } from "../shared.ts";
4
3
 
5
4
  // Requires storage — `aai storage enable` (or DATABASE_URL in .env under
6
5
  // `aai dev`); the rest of the game works without it.
7
- export const saveGame = tool({
6
+ //
7
+ // `gameSlot.tool` even though the body AWAITS: the reading half places no
8
+ // constraint on the body (only `updateTool` must be synchronous, because its
9
+ // draft is stored when it returns). What it does place is the frozen value, and
10
+ // a save is the purest read there is.
11
+ export default gameSlot.tool({
8
12
  description: "Save current game to persistent storage.",
9
13
  inputSchema: z.object({ slot: saveSlotParam }),
10
- async execute(args, ctx) {
11
- const state = gameSlot.get(ctx);
12
- await saveState(ctx, saveSlotKey(args.slot), state);
14
+ async execute(args, game, ctx) {
15
+ await saveState(ctx, saveSlotKey(args.slot), game);
13
16
  return {
14
17
  saved: true,
15
18
  slot: args.slot ?? "autosave",
16
- name: state.playerName,
17
- scene: state.sceneCount,
19
+ name: game.playerName,
20
+ scene: game.sceneCount,
18
21
  };
19
22
  },
20
23
  });
@@ -18,7 +18,7 @@ import {
18
18
  TONES,
19
19
  } from "../shared.ts";
20
20
 
21
- export const setupCharacter = tool({
21
+ export default tool({
22
22
  description:
23
23
  "Set up the entire game in one call. Starts a completely fresh game (any previous unsaved game is replaced), generates stats, initializes state, and marks the game as ready. After this returns, just narrate the opening scene. No need to call update_state — everything is already done.",
24
24
  inputSchema: z.object({
@@ -1,4 +1,4 @@
1
- import { pushCapped, tool } from "@alexkroman1/aai";
1
+ import { pushCapped } from "@alexkroman1/aai";
2
2
  import { z } from "zod";
3
3
  import {
4
4
  DEFAULT_CLOCK_SEGMENTS,
@@ -19,7 +19,7 @@ import {
19
19
  updateCrisisFlags,
20
20
  } from "../shared.ts";
21
21
 
22
- export const updateState = tool({
22
+ export default gameSlot.updateTool({
23
23
  description:
24
24
  "Lightweight state sync for during gameplay. Handles location changes, NPC additions, clock additions, time changes, and session log entries. Resource changes (health/spirit/supply/momentum) are auto-applied by action_roll — only use those fields here for manual adjustments like resting or trading. Pass only what changed.",
25
25
  inputSchema: z.object({
@@ -54,8 +54,7 @@ export const updateState = tool({
54
54
  storyComplete: z.boolean().describe("Mark story as complete").optional(),
55
55
  logEntry: z.string().max(500).describe("Short log entry for this scene").optional(),
56
56
  }),
57
- async execute(args, ctx) {
58
- const state = gameSlot.get(ctx);
57
+ execute(args, state) {
59
58
  const warnings: string[] = [];
60
59
  const clockEvents: { clock: string; trigger: string }[] = [];
61
60