@alexkroman1/aai-cli 5.13.2 → 5.14.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 (64) hide show
  1. package/dist/scaffold/package.json +3 -3
  2. package/dist/scaffold/pnpm-workspace.yaml +24 -0
  3. package/dist/templates/dispatch-center/agent.test.ts +10 -17
  4. package/dist/templates/dispatch-center/agent.ts +2 -2
  5. package/dist/templates/dispatch-center/client.tsx +9 -13
  6. package/dist/templates/dispatch-center/shared.ts +36 -63
  7. package/dist/templates/dispatch-center/tools/incident_add_note.ts +4 -4
  8. package/dist/templates/dispatch-center/tools/incident_create.ts +2 -2
  9. package/dist/templates/dispatch-center/tools/incident_escalate.ts +4 -4
  10. package/dist/templates/dispatch-center/tools/incident_get.ts +9 -4
  11. package/dist/templates/dispatch-center/tools/incident_triage.ts +4 -4
  12. package/dist/templates/dispatch-center/tools/incident_update_status.ts +4 -4
  13. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +2 -2
  14. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +2 -2
  15. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +4 -4
  16. package/dist/templates/dispatch-center/tools/resources_get_available.ts +2 -2
  17. package/dist/templates/dispatch-center/tools/resources_update_status.ts +2 -2
  18. package/dist/templates/infocom-adventure/agent.ts +10 -10
  19. package/dist/templates/infocom-adventure/client.tsx +5 -17
  20. package/dist/templates/infocom-adventure/shared.ts +17 -14
  21. package/dist/templates/pizza-ordering/agent.test.ts +27 -31
  22. package/dist/templates/pizza-ordering/agent.ts +8 -8
  23. package/dist/templates/pizza-ordering/client.tsx +4 -4
  24. package/dist/templates/pizza-ordering/shared.ts +14 -20
  25. package/dist/templates/retail/agent.test.ts +137 -138
  26. package/dist/templates/retail/agent.ts +7 -10
  27. package/dist/templates/retail/authenticate.ts +2 -2
  28. package/dist/templates/retail/client.tsx +13 -14
  29. package/dist/templates/retail/registry.test.ts +9 -9
  30. package/dist/templates/retail/resolve.test.ts +17 -16
  31. package/dist/templates/retail/resolve.ts +5 -4
  32. package/dist/templates/retail/shared.test.ts +17 -16
  33. package/dist/templates/retail/shared.ts +39 -18
  34. package/dist/templates/retail/store.test.ts +40 -47
  35. package/dist/templates/retail/store.ts +39 -65
  36. package/dist/templates/retail/swap.test.ts +20 -17
  37. package/dist/templates/retail/swap.ts +7 -14
  38. package/dist/templates/retail/tools/cancel_pending_order.ts +5 -4
  39. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +6 -5
  40. package/dist/templates/retail/tools/find_user_id_by_email.ts +2 -2
  41. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +2 -2
  42. package/dist/templates/retail/tools/get_item_details.ts +4 -3
  43. package/dist/templates/retail/tools/get_order_details.ts +4 -3
  44. package/dist/templates/retail/tools/get_product_details.ts +4 -3
  45. package/dist/templates/retail/tools/get_user_details.ts +4 -3
  46. package/dist/templates/retail/tools/list_all_product_types.ts +2 -2
  47. package/dist/templates/retail/tools/modify_pending_order_address.ts +4 -3
  48. package/dist/templates/retail/tools/modify_pending_order_items.ts +6 -6
  49. package/dist/templates/retail/tools/modify_pending_order_payment.ts +6 -6
  50. package/dist/templates/retail/tools/modify_user_address.ts +4 -3
  51. package/dist/templates/retail/tools/return_delivered_order_items.ts +6 -6
  52. package/dist/templates/solo-rpg/agent.test.ts +26 -34
  53. package/dist/templates/solo-rpg/agent.ts +2 -2
  54. package/dist/templates/solo-rpg/client.tsx +1 -1
  55. package/dist/templates/solo-rpg/shared.ts +6 -14
  56. package/dist/templates/solo-rpg/tools/action_roll.ts +2 -2
  57. package/dist/templates/solo-rpg/tools/burn_momentum.ts +2 -2
  58. package/dist/templates/solo-rpg/tools/check_state.ts +2 -2
  59. package/dist/templates/solo-rpg/tools/load_game.ts +2 -2
  60. package/dist/templates/solo-rpg/tools/oracle.ts +3 -3
  61. package/dist/templates/solo-rpg/tools/save_game.ts +2 -2
  62. package/dist/templates/solo-rpg/tools/setup_character.ts +2 -2
  63. package/dist/templates/solo-rpg/tools/update_state.ts +12 -11
  64. package/package.json +3 -3
@@ -1,6 +1,7 @@
1
+ import { isToolFailure } from "@alexkroman1/aai";
1
2
  import { z } from "zod";
2
3
  import { resolveOrder } from "../resolve.ts";
3
- import { authenticatedUser, getState, isError, retailTool, setFocus } from "../store.ts";
4
+ import { authenticatedUser, retailSlot, retailTool, setFocus } from "../store.ts";
4
5
  import { assertCanCoverDiff, planItemSwap } from "../swap.ts";
5
6
 
6
7
  export const exchangeDeliveredOrderItems = retailTool({
@@ -34,12 +35,12 @@ export const exchangeDeliveredOrderItems = retailTool({
34
35
  // source order — with `summary` first, `result` in its signature can't be
35
36
  // inferred and silently falls back to `unknown`.
36
37
  execute: (args, ctx) => {
37
- const state = getState(ctx);
38
+ const state = retailSlot.get(ctx);
38
39
  const user = authenticatedUser(state);
39
- if (isError(user)) return user;
40
+ if (isToolFailure(user)) return user;
40
41
 
41
42
  const order = resolveOrder(state, args.order_id);
42
- if (isError(order)) return order;
43
+ if (isToolFailure(order)) return order;
43
44
  setFocus(state, { orderId: order.order_id });
44
45
 
45
46
  if (order.status !== "delivered") {
@@ -53,7 +54,7 @@ export const exchangeDeliveredOrderItems = retailTool({
53
54
  const plan = planItemSwap(state, order, args.item_ids, args.new_item_ids, {
54
55
  requireDifferent: false,
55
56
  });
56
- if (isError(plan)) return plan;
57
+ if (isToolFailure(plan)) return plan;
57
58
 
58
59
  const blocked = assertCanCoverDiff(user, args.payment_method_id, plan.diff);
59
60
  if (blocked) return blocked;
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { authenticateAs } from "../authenticate.ts";
3
- import { getState, retailTool } from "../store.ts";
3
+ import { retailSlot, retailTool } from "../store.ts";
4
4
 
5
5
  export const findUserIdByEmail = retailTool({
6
6
  name: "find_user_id_by_email",
@@ -17,7 +17,7 @@ export const findUserIdByEmail = retailTool({
17
17
  // literal properties in source order — with `summary` first, `result` in its
18
18
  // signature can't be inferred and silently falls back to `unknown`.
19
19
  execute: (args, ctx) => {
20
- const state = getState(ctx);
20
+ const state = retailSlot.get(ctx);
21
21
  const target = args.email.trim().toLowerCase();
22
22
  const match = Object.values(state.store.users).find(
23
23
  (user) => user.email.toLowerCase() === target,
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { authenticateAs } from "../authenticate.ts";
3
- import { getState, retailTool } from "../store.ts";
3
+ import { retailSlot, retailTool } from "../store.ts";
4
4
 
5
5
  export const findUserIdByNameZip = retailTool({
6
6
  name: "find_user_id_by_name_zip",
@@ -16,7 +16,7 @@ export const findUserIdByNameZip = retailTool({
16
16
  // `execute` before `summary`: see find_user_id_by_email.ts for why the order
17
17
  // is load-bearing for the generic `result` type in `summary`.
18
18
  execute: (args, ctx) => {
19
- const state = getState(ctx);
19
+ const state = retailSlot.get(ctx);
20
20
  const first = args.first_name.trim().toLowerCase();
21
21
  const last = args.last_name.trim().toLowerCase();
22
22
  const zip = args.zip.replace(/\D/g, "");
@@ -1,5 +1,6 @@
1
+ import { isToolFailure } from "@alexkroman1/aai";
1
2
  import { z } from "zod";
2
- import { findItem, getState, isError, retailTool } from "../store.ts";
3
+ import { findItem, retailSlot, retailTool } from "../store.ts";
3
4
 
4
5
  export const getItemDetails = retailTool({
5
6
  name: "get_item_details",
@@ -13,8 +14,8 @@ export const getItemDetails = retailTool({
13
14
  // `execute` before `summary`: see find_user_id_by_email.ts for why the order
14
15
  // is load-bearing for the generic `result` type in `summary`.
15
16
  execute: (args, ctx) => {
16
- const found = findItem(getState(ctx), args.item_id);
17
- if (isError(found)) return found;
17
+ const found = findItem(retailSlot.get(ctx), args.item_id);
18
+ if (isToolFailure(found)) return found;
18
19
  return {
19
20
  item_id: found.variant.item_id,
20
21
  product_id: found.product.product_id,
@@ -1,6 +1,7 @@
1
+ import { isToolFailure } from "@alexkroman1/aai";
1
2
  import { z } from "zod";
2
3
  import { resolveOrder } from "../resolve.ts";
3
- import { getState, isError, retailTool, setFocus } from "../store.ts";
4
+ import { retailSlot, retailTool, setFocus } from "../store.ts";
4
5
 
5
6
  export const getOrderDetails = retailTool({
6
7
  name: "get_order_details",
@@ -17,9 +18,9 @@ export const getOrderDetails = retailTool({
17
18
  // `execute` before `summary`: see find_user_id_by_email.ts for why the order
18
19
  // is load-bearing for the generic `result` type in `summary`.
19
20
  execute: (args, ctx) => {
20
- const state = getState(ctx);
21
+ const state = retailSlot.get(ctx);
21
22
  const order = resolveOrder(state, args.order_id);
22
- if (isError(order)) return order;
23
+ if (isToolFailure(order)) return order;
23
24
  setFocus(state, { orderId: order.order_id });
24
25
  return {
25
26
  order_id: order.order_id,
@@ -1,5 +1,6 @@
1
+ import { isToolFailure } from "@alexkroman1/aai";
1
2
  import { z } from "zod";
2
- import { findProduct, getState, isError, retailTool, setFocus } from "../store.ts";
3
+ import { findProduct, retailSlot, retailTool, setFocus } from "../store.ts";
3
4
 
4
5
  export const getProductDetails = retailTool({
5
6
  name: "get_product_details",
@@ -13,9 +14,9 @@ export const getProductDetails = retailTool({
13
14
  // `execute` before `summary`: see find_user_id_by_email.ts for why the order
14
15
  // is load-bearing for the generic `result` type in `summary`.
15
16
  execute: (args, ctx) => {
16
- const state = getState(ctx);
17
+ const state = retailSlot.get(ctx);
17
18
  const product = findProduct(state, args.product_id);
18
- if (isError(product)) return product;
19
+ if (isToolFailure(product)) return product;
19
20
  setFocus(state, { productId: product.product_id });
20
21
  return {
21
22
  name: product.name,
@@ -1,5 +1,6 @@
1
+ import { isToolFailure } from "@alexkroman1/aai";
1
2
  import { z } from "zod";
2
- import { authenticatedUser, getState, isError, retailTool } from "../store.ts";
3
+ import { authenticatedUser, retailSlot, retailTool } from "../store.ts";
3
4
 
4
5
  export const getUserDetails = retailTool({
5
6
  name: "get_user_details",
@@ -12,9 +13,9 @@ export const getUserDetails = retailTool({
12
13
  // `execute` before `summary`: see find_user_id_by_email.ts for why the order
13
14
  // is load-bearing for the generic `result` type in `summary`.
14
15
  execute: (args, ctx) => {
15
- const state = getState(ctx);
16
+ const state = retailSlot.get(ctx);
16
17
  const user = authenticatedUser(state);
17
- if (isError(user)) return user;
18
+ if (isToolFailure(user)) return user;
18
19
  if (user.user_id !== args.user_id) {
19
20
  return {
20
21
  error: `${args.user_id} is not the customer on this call. You can help only one customer per conversation.`,
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { getState, retailTool } from "../store.ts";
2
+ import { retailSlot, retailTool } from "../store.ts";
3
3
 
4
4
  export const listAllProductTypes = retailTool({
5
5
  name: "list_all_product_types",
@@ -13,7 +13,7 @@ export const listAllProductTypes = retailTool({
13
13
  // `execute` before `summary`: see find_user_id_by_email.ts for why the order
14
14
  // is load-bearing for the generic `result` type in `summary`.
15
15
  execute: (_args, ctx) => {
16
- const state = getState(ctx);
16
+ const state = retailSlot.get(ctx);
17
17
  const entries = Object.values(state.store.products)
18
18
  .map((product) => [product.name, product.product_id] as const)
19
19
  .sort(([a], [b]) => a.localeCompare(b));
@@ -1,7 +1,8 @@
1
+ import { isToolFailure } from "@alexkroman1/aai";
1
2
  import { z } from "zod";
2
3
  import { AddressFields, formatAddress, toAddress } from "../address.ts";
3
4
  import { resolveOrder } from "../resolve.ts";
4
- import { getState, isError, retailTool, setFocus } from "../store.ts";
5
+ import { retailSlot, retailTool, setFocus } from "../store.ts";
5
6
 
6
7
  export const modifyPendingOrderAddress = retailTool({
7
8
  name: "modify_pending_order_address",
@@ -20,9 +21,9 @@ export const modifyPendingOrderAddress = retailTool({
20
21
  // source order — with `summary` first, `result` in its signature can't be
21
22
  // inferred and silently falls back to `unknown`.
22
23
  execute: (args, ctx) => {
23
- const state = getState(ctx);
24
+ const state = retailSlot.get(ctx);
24
25
  const order = resolveOrder(state, args.order_id);
25
- if (isError(order)) return order;
26
+ if (isToolFailure(order)) return order;
26
27
  setFocus(state, { orderId: order.order_id });
27
28
 
28
29
  // Any pending variant is fine here — unlike cancel and modify-items, which
@@ -1,11 +1,11 @@
1
+ import { isToolFailure } from "@alexkroman1/aai";
1
2
  import { z } from "zod";
2
3
  import { resolveOrder } from "../resolve.ts";
3
4
  import {
4
5
  authenticatedUser,
5
- getState,
6
- isError,
7
6
  isGiftCard,
8
7
  money,
8
+ retailSlot,
9
9
  retailTool,
10
10
  setFocus,
11
11
  } from "../store.ts";
@@ -42,12 +42,12 @@ export const modifyPendingOrderItems = retailTool({
42
42
  // source order — with `summary` first, `result` in its signature can't be
43
43
  // inferred and silently falls back to `unknown`.
44
44
  execute: (args, ctx) => {
45
- const state = getState(ctx);
45
+ const state = retailSlot.get(ctx);
46
46
  const user = authenticatedUser(state);
47
- if (isError(user)) return user;
47
+ if (isToolFailure(user)) return user;
48
48
 
49
49
  const order = resolveOrder(state, args.order_id);
50
- if (isError(order)) return order;
50
+ if (isToolFailure(order)) return order;
51
51
  setFocus(state, { orderId: order.order_id });
52
52
 
53
53
  // Exactly 'pending'. A 'pending (item modified)' order has already used its
@@ -61,7 +61,7 @@ export const modifyPendingOrderItems = retailTool({
61
61
  const plan = planItemSwap(state, order, args.item_ids, args.new_item_ids, {
62
62
  requireDifferent: true,
63
63
  });
64
- if (isError(plan)) return plan;
64
+ if (isToolFailure(plan)) return plan;
65
65
 
66
66
  const blocked = assertCanCoverDiff(user, args.payment_method_id, plan.diff);
67
67
  if (blocked) return blocked;
@@ -1,12 +1,12 @@
1
+ import { isToolFailure } from "@alexkroman1/aai";
1
2
  import { z } from "zod";
2
3
  import { resolveOrder } from "../resolve.ts";
3
4
  import {
4
5
  authenticatedUser,
5
6
  findPaymentMethod,
6
- getState,
7
- isError,
8
7
  isGiftCard,
9
8
  money,
9
+ retailSlot,
10
10
  retailTool,
11
11
  setFocus,
12
12
  } from "../store.ts";
@@ -32,12 +32,12 @@ export const modifyPendingOrderPayment = retailTool({
32
32
  // source order — with `summary` first, `result` in its signature can't be
33
33
  // inferred and silently falls back to `unknown`.
34
34
  execute: (args, ctx) => {
35
- const state = getState(ctx);
35
+ const state = retailSlot.get(ctx);
36
36
  const user = authenticatedUser(state);
37
- if (isError(user)) return user;
37
+ if (isToolFailure(user)) return user;
38
38
 
39
39
  const order = resolveOrder(state, args.order_id);
40
- if (isError(order)) return order;
40
+ if (isToolFailure(order)) return order;
41
41
  setFocus(state, { orderId: order.order_id });
42
42
 
43
43
  if (!order.status.startsWith("pending")) {
@@ -47,7 +47,7 @@ export const modifyPendingOrderPayment = retailTool({
47
47
  }
48
48
 
49
49
  const newMethod = findPaymentMethod(user, args.payment_method_id);
50
- if (isError(newMethod)) return newMethod;
50
+ if (isToolFailure(newMethod)) return newMethod;
51
51
 
52
52
  const original = order.payment_history[0];
53
53
  if (order.payment_history.length !== 1 || original?.transaction_type !== "payment") {
@@ -1,6 +1,7 @@
1
+ import { isToolFailure } from "@alexkroman1/aai";
1
2
  import { z } from "zod";
2
3
  import { AddressFields, formatAddress, toAddress } from "../address.ts";
3
- import { authenticatedUser, getState, isError, retailTool } from "../store.ts";
4
+ import { authenticatedUser, retailSlot, retailTool } from "../store.ts";
4
5
 
5
6
  export const modifyUserAddress = retailTool({
6
7
  name: "modify_user_address",
@@ -17,9 +18,9 @@ export const modifyUserAddress = retailTool({
17
18
  // source order — with `summary` first, `result` in its signature can't be
18
19
  // inferred and silently falls back to `unknown`.
19
20
  execute: (args, ctx) => {
20
- const state = getState(ctx);
21
+ const state = retailSlot.get(ctx);
21
22
  const user = authenticatedUser(state);
22
- if (isError(user)) return user;
23
+ if (isToolFailure(user)) return user;
23
24
  if (user.user_id !== args.user_id) {
24
25
  return {
25
26
  error: `${args.user_id} is not the customer on this call. You can help only one customer per conversation.`,
@@ -1,11 +1,11 @@
1
+ import { isToolFailure } from "@alexkroman1/aai";
1
2
  import { z } from "zod";
2
3
  import { resolveOrder } from "../resolve.ts";
3
4
  import {
4
5
  authenticatedUser,
5
6
  findPaymentMethod,
6
- getState,
7
- isError,
8
7
  isGiftCard,
8
+ retailSlot,
9
9
  retailTool,
10
10
  setFocus,
11
11
  } from "../store.ts";
@@ -37,12 +37,12 @@ export const returnDeliveredOrderItems = retailTool({
37
37
  // source order — with `summary` first, `result` in its signature can't be
38
38
  // inferred and silently falls back to `unknown`.
39
39
  execute: (args, ctx) => {
40
- const state = getState(ctx);
40
+ const state = retailSlot.get(ctx);
41
41
  const user = authenticatedUser(state);
42
- if (isError(user)) return user;
42
+ if (isToolFailure(user)) return user;
43
43
 
44
44
  const order = resolveOrder(state, args.order_id);
45
- if (isError(order)) return order;
45
+ if (isToolFailure(order)) return order;
46
46
  setFocus(state, { orderId: order.order_id });
47
47
 
48
48
  if (order.status !== "delivered") {
@@ -52,7 +52,7 @@ export const returnDeliveredOrderItems = retailTool({
52
52
  }
53
53
 
54
54
  const method = findPaymentMethod(user, args.payment_method_id);
55
- if (isError(method)) return method;
55
+ if (isToolFailure(method)) return method;
56
56
 
57
57
  const originalMethodId = order.payment_history[0]?.payment_method_id;
58
58
  if (!isGiftCard(method) && args.payment_method_id !== originalMethodId) {
@@ -1,15 +1,16 @@
1
1
  import type { Db, ToolContext } from "@alexkroman1/aai";
2
+ import { createToolContext } from "@alexkroman1/aai/testing";
2
3
  import { describe, expect, test, vi } from "vitest";
3
4
  import {
4
5
  applyConsequences,
5
6
  DEFAULT_STATE,
6
7
  type GameState,
7
- getGameState,
8
+ gameSlot,
8
9
  MAX_NPCS,
9
10
  MIN_MOMENTUM,
10
11
  makeNpc,
11
12
  rollAction,
12
- saveGameState,
13
+ type StateSlot,
13
14
  } from "./shared.ts";
14
15
  import { actionRoll } from "./tools/action_roll.ts";
15
16
  import { burnMomentum } from "./tools/burn_momentum.ts";
@@ -46,19 +47,10 @@ function makeDb(): { db: Db; rows: Map<string, unknown> } {
46
47
  return { db, rows };
47
48
  }
48
49
 
49
- function makeCtx(sessionId = "session-a", db: Db = makeDb().db): ToolContext {
50
- return {
51
- env: {},
52
- state: {},
53
- db,
54
- generate: () => Promise.reject(new Error("generate not available in tests")),
55
- messages: [],
56
- sessionId,
57
- // Never aborts — a test has no turn to cancel. `ctx.signal` is always
58
- // present at runtime, so a stub supplies one rather than omitting it.
59
- signal: new AbortController().signal,
60
- send: vi.fn(),
61
- };
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() });
62
54
  }
63
55
 
64
56
  const SETUP_ARGS = {
@@ -106,19 +98,19 @@ describe("setup_character", () => {
106
98
  await setupCharacter.execute(SETUP_ARGS, ctx);
107
99
 
108
100
  // Simulate a played, damaged game between setups.
109
- const played = getGameState(ctx);
101
+ const played = gameSlot.get(ctx);
110
102
  played.health = 1;
111
103
  played.momentum = -4;
112
104
  played.chaosFactor = 8;
113
105
  played.sceneCount = 42;
114
- saveGameState(ctx, played);
106
+ gameSlot.set(ctx, played);
115
107
 
116
108
  const result = (await setupCharacter.execute(
117
109
  { ...SETUP_ARGS, playerName: "Luna" },
118
110
  ctx,
119
111
  )) as Record<string, unknown>;
120
112
 
121
- const state = getGameState(ctx);
113
+ const state = gameSlot.get(ctx);
122
114
  expect(state.npcs).toHaveLength(1);
123
115
  expect(state.npcs[0]?.id).toBe("npc_1");
124
116
  expect(state.clocks).toHaveLength(1);
@@ -140,7 +132,7 @@ describe("setup_character", () => {
140
132
  test("stats are a permutation of [3,2,2,1,1] with the archetype's stat at 3", async () => {
141
133
  const ctx = makeCtx();
142
134
  await setupCharacter.execute(SETUP_ARGS, ctx);
143
- const state = getGameState(ctx);
135
+ const state = gameSlot.get(ctx);
144
136
  const stats = [state.edge, state.heart, state.iron, state.shadow, state.wits];
145
137
  expect([...stats].sort()).toEqual([1, 1, 2, 2, 3]);
146
138
  // investigator biases wits (index 4) to the high stat
@@ -267,7 +259,7 @@ describe("burn_momentum", () => {
267
259
  clockTicks: 1,
268
260
  },
269
261
  };
270
- saveGameState(ctx, state);
262
+ gameSlot.set(ctx, state);
271
263
  }
272
264
 
273
265
  test("a legal burn reverts the miss's consequences, upgrades, and resets momentum", async () => {
@@ -278,7 +270,7 @@ describe("burn_momentum", () => {
278
270
  expect(result.burned).toBe(true);
279
271
  expect(result.newResultCode).toBe("STRONG_HIT");
280
272
 
281
- const state = getGameState(ctx);
273
+ const state = gameSlot.get(ctx);
282
274
  expect(state.health).toBe(5); // -2 reverted
283
275
  expect(state.clocks[0]?.filled).toBe(0); // tick reverted
284
276
  expect(state.momentum).toBe(2); // reset, overriding the strong hit's gain
@@ -306,21 +298,21 @@ describe("burn_momentum", () => {
306
298
 
307
299
  // Strong hits cannot be upgraded
308
300
  seedRolledState(8, ctx);
309
- const state = getGameState(ctx);
301
+ const state = gameSlot.get(ctx);
310
302
  state.lastRoll!.result = "STRONG_HIT";
311
- saveGameState(ctx, state);
303
+ gameSlot.set(ctx, state);
312
304
  result = (await burnMomentum.execute({} as never, ctx)) as Record<string, unknown>;
313
305
  expect(result.error).toMatch(/already a Strong Hit/);
314
306
  });
315
307
 
316
308
  test("action_roll persists the roll so burn needs no dice arguments", async () => {
317
309
  const ctx = makeCtx();
318
- saveGameState(ctx, playingState());
310
+ gameSlot.set(ctx, playingState());
319
311
  await actionRoll.execute(
320
312
  { move: "clash", stat: "iron", position: "risky", effect: "standard", purpose: "attack" },
321
313
  ctx,
322
314
  );
323
- const state = getGameState(ctx);
315
+ const state = gameSlot.get(ctx);
324
316
  expect(state.lastRoll).not.toBeNull();
325
317
  expect(state.lastRoll?.move).toBe("clash");
326
318
  expect(state.lastRoll?.deltas).toBeDefined();
@@ -372,13 +364,13 @@ describe("rollAction", () => {
372
364
  describe("update_state", () => {
373
365
  test("clock ids never collide after a removal (max-scan, not length+1)", async () => {
374
366
  const ctx = makeCtx();
375
- saveGameState(ctx, playingState()); // has clock_1
367
+ gameSlot.set(ctx, playingState()); // has clock_1
376
368
 
377
369
  await updateState.execute({ addClockName: "Second" }, ctx); // clock_2
378
370
  await updateState.execute({ removeClockName: "Doom" }, ctx); // removes clock_1
379
371
  await updateState.execute({ addClockName: "Third" }, ctx);
380
372
 
381
- const state = getGameState(ctx);
373
+ const state = gameSlot.get(ctx);
382
374
  const ids = state.clocks.map((c) => c.id);
383
375
  expect(new Set(ids).size).toBe(ids.length);
384
376
  expect(ids).toEqual(["clock_2", "clock_3"]);
@@ -388,7 +380,7 @@ describe("update_state", () => {
388
380
  const ctx = makeCtx();
389
381
  const state = playingState();
390
382
  state.clocks[0]!.filled = 3; // 3 of 4
391
- saveGameState(ctx, state);
383
+ gameSlot.set(ctx, state);
392
384
 
393
385
  const result = (await updateState.execute({ advanceClockName: "Doom" }, ctx)) as {
394
386
  clockEvents: { clock: string; trigger: string }[];
@@ -402,13 +394,13 @@ describe("update_state", () => {
402
394
  while (state.npcs.length < MAX_NPCS) {
403
395
  state.npcs.push(makeNpc({ id: `npc_${state.npcs.length + 1}`, name: "Extra" }));
404
396
  }
405
- saveGameState(ctx, state);
397
+ gameSlot.set(ctx, state);
406
398
 
407
399
  const result = (await updateState.execute({ addNpcName: "One Too Many" }, ctx)) as {
408
400
  warnings?: string[];
409
401
  };
410
402
  expect(result.warnings?.[0]).toMatch(/NPC limit/);
411
- const after = getGameState(ctx);
403
+ const after = gameSlot.get(ctx);
412
404
  expect(after.npcs).toHaveLength(MAX_NPCS);
413
405
  });
414
406
 
@@ -442,7 +434,7 @@ describe("save_game / load_game", () => {
442
434
  const played = playingState();
443
435
  played.playerName = "Kael";
444
436
  played.sceneCount = 7;
445
- saveGameState(sessionA, played);
437
+ gameSlot.set(sessionA, played);
446
438
  const saved = (await saveGame.execute({ slot: "chapter-2" }, sessionA)) as Record<
447
439
  string,
448
440
  unknown
@@ -460,7 +452,7 @@ describe("save_game / load_game", () => {
460
452
  expect(loaded.loaded).toBe(true);
461
453
  expect(loaded.playerName).toBe("Kael");
462
454
  expect(loaded.sceneCount).toBe(7);
463
- expect(getGameState(sessionB).playerName).toBe("Kael");
455
+ expect(gameSlot.get(sessionB).playerName).toBe("Kael");
464
456
  });
465
457
 
466
458
  test("loading a missing slot reports an error instead of resetting the game", async () => {
@@ -472,9 +464,9 @@ describe("save_game / load_game", () => {
472
464
  test("saving twice to one slot upserts — the newer save wins", async () => {
473
465
  const { db, rows } = makeDb();
474
466
  const ctx = makeCtx("session-a", db);
475
- saveGameState(ctx, playingState());
467
+ gameSlot.set(ctx, playingState());
476
468
  await saveGame.execute({}, ctx); // autosave
477
- getGameState(ctx).sceneCount = 9;
469
+ gameSlot.get(ctx).sceneCount = 9;
478
470
  await saveGame.execute({}, ctx);
479
471
  expect(rows.size).toBe(1);
480
472
  expect(rows.get("save:autosave")).toMatchObject({ sceneCount: 9 });
@@ -1,5 +1,5 @@
1
1
  import { agent } from "@alexkroman1/aai";
2
- import { DEFAULT_STATE, type StateSlot } from "./shared.ts";
2
+ import { gameSlot } from "./shared.ts";
3
3
  import systemPrompt from "./system-prompt.md?raw";
4
4
  import { actionRoll } from "./tools/action_roll.ts";
5
5
  import { burnMomentum } from "./tools/burn_momentum.ts";
@@ -22,7 +22,7 @@ export default agent({
22
22
  // One declaration replaces a `ctx.send("game_state", state)` in every
23
23
  // state-mutating tool — six of them, and adding a seventh meant
24
24
  // remembering to push or watching the UI quietly fall out of sync.
25
- syncState: (s: StateSlot) => s.game ?? DEFAULT_STATE,
25
+ syncState: gameSlot.read,
26
26
  tools: {
27
27
  action_roll: actionRoll,
28
28
  burn_momentum: burnMomentum,
@@ -771,7 +771,7 @@ function SoloRPGApp() {
771
771
  // The agent's own session state, projected by `syncState` and pushed after
772
772
  // every tool call — no per-tool `ctx.send`, and nothing to keep in step
773
773
  // when a new tool starts mutating the game.
774
- const game = useAgentState<GameState>() ?? DEFAULT_STATE;
774
+ const game = useAgentState<GameState>(DEFAULT_STATE);
775
775
 
776
776
  return (
777
777
  <StartScreen
@@ -1,4 +1,4 @@
1
- import type { ToolContext } from "@alexkroman1/aai";
1
+ import { type SlotStateOf, sessionSlot, type ToolContext } from "@alexkroman1/aai";
2
2
  import { z } from "zod";
3
3
 
4
4
  // ── Tuning Constants ─────────────────────────────────────────────────────────
@@ -324,20 +324,12 @@ export const DEFAULT_STATE: GameState = {
324
324
  // The in-play game lives in `ctx.state`, the agent's per-session mutable
325
325
  // state — concurrent players get independent games by construction, and the
326
326
  // live game needs no persistence of its own (that's what save slots are for).
327
- export type StateSlot = { game?: GameState };
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.
330
+ export const gameSlot = sessionSlot("game", () => structuredClone(DEFAULT_STATE));
328
331
 
329
- /** The session's live game. Mutations to the returned object stick — it is
330
- * the object stored in `ctx.state`. */
331
- export function getGameState(ctx: ToolContext): GameState {
332
- const slot = ctx.state as StateSlot;
333
- slot.game ??= structuredClone(DEFAULT_STATE);
334
- return slot.game;
335
- }
336
-
337
- /** Replace the session's live game wholesale (setup, load). */
338
- export function saveGameState(ctx: ToolContext, state: GameState): void {
339
- (ctx.state as StateSlot).game = state;
340
- }
332
+ export type StateSlot = SlotStateOf<typeof gameSlot>;
341
333
 
342
334
  // ── Persistent save slots (ctx.db) ───────────────────────────────────────────
343
335
  // save_game / load_game are genuine cross-session persistence, so they use
@@ -4,7 +4,7 @@ import {
4
4
  applyConsequences,
5
5
  canBurnMomentum,
6
6
  checkChaosInterrupt,
7
- getGameState,
7
+ gameSlot,
8
8
  MOVE_LABELS,
9
9
  MOVES,
10
10
  RESULT_LABELS,
@@ -26,7 +26,7 @@ export const actionRoll = tool({
26
26
  targetNpcId: z.string().max(32).describe("Target NPC id for social moves").optional(),
27
27
  }),
28
28
  async execute(args, ctx) {
29
- const state = getGameState(ctx);
29
+ const state = gameSlot.get(ctx);
30
30
  const statValue = state[args.stat];
31
31
  const roll = rollAction(args.stat, statValue, args.move);
32
32
 
@@ -2,7 +2,7 @@ import { tool } from "@alexkroman1/aai";
2
2
  import {
3
3
  applyConsequences,
4
4
  canBurnMomentum,
5
- getGameState,
5
+ gameSlot,
6
6
  MOMENTUM_RESET,
7
7
  RESULT_LABELS,
8
8
  revertConsequences,
@@ -12,7 +12,7 @@ export const burnMomentum = tool({
12
12
  description:
13
13
  "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
14
  async execute(_args, ctx) {
15
- const state = getGameState(ctx);
15
+ const state = gameSlot.get(ctx);
16
16
  const last = state.lastRoll;
17
17
  if (!last) return { error: "No recent action roll to upgrade. Roll first." };
18
18
  if (last.result === "STRONG_HIT") {
@@ -1,11 +1,11 @@
1
1
  import { tool } from "@alexkroman1/aai";
2
- import { getGameState, stateSummary } from "../shared.ts";
2
+ import { gameSlot, stateSummary } from "../shared.ts";
3
3
 
4
4
  export const checkState = tool({
5
5
  description:
6
6
  "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
7
  async execute(_args, ctx) {
8
- const state = getGameState(ctx);
8
+ const state = gameSlot.get(ctx);
9
9
  return stateSummary(state);
10
10
  },
11
11
  });
@@ -1,7 +1,7 @@
1
1
  import { tool } from "@alexkroman1/aai";
2
2
  import { z } from "zod";
3
3
  import type { GameState } from "../shared.ts";
4
- import { loadState, saveGameState, saveSlotKey, saveSlotParam } from "../shared.ts";
4
+ 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.
@@ -11,7 +11,7 @@ export const loadGame = tool({
11
11
  async execute(args, ctx) {
12
12
  const saved = await loadState<GameState>(ctx, saveSlotKey(args.slot));
13
13
  if (!saved) return { error: "No save found." };
14
- saveGameState(ctx, saved);
14
+ gameSlot.set(ctx, saved);
15
15
  return {
16
16
  loaded: true,
17
17
  playerName: saved.playerName,