akanjs 3.0.0-alpha.51 → 3.0.0-alpha.53

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 (71) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  3. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  4. package/package.json +1 -1
  5. package/server/akanApp.ts +15 -11
  6. package/server/routeTreeBuilder.ts +10 -8
  7. package/service/predefinedAdaptor/llm.adaptor.ts +0 -1
  8. package/store/agent/StoreSurfaceSource.ts +1 -5
  9. package/store/agentic/AgentValue.ts +112 -0
  10. package/store/agentic/StExposeBuilder.ts +47 -0
  11. package/store/agentic/StExposeDraft.ts +24 -0
  12. package/store/agentic/StStateBuilder.ts +85 -0
  13. package/store/agentic/StStateDraft.ts +23 -0
  14. package/store/agentic/StToolBuilder.ts +43 -25
  15. package/store/agentic/StToolDraft.ts +22 -0
  16. package/store/agentic/attachAgentic.ts +20 -14
  17. package/store/agentic/index.ts +6 -3
  18. package/store/agentic/useFieldTool.ts +0 -4
  19. package/store/agentic/useFormTools.ts +4 -6
  20. package/store/agentic/useRelationFieldTool.ts +1 -2
  21. package/types/service/predefinedAdaptor/llm.adaptor.d.ts +0 -1
  22. package/types/store/agentic/AgentValue.d.ts +35 -0
  23. package/types/store/agentic/StExposeBuilder.d.ts +21 -0
  24. package/types/store/agentic/StExposeDraft.d.ts +13 -0
  25. package/types/store/agentic/StStateBuilder.d.ts +21 -0
  26. package/types/store/agentic/StStateDraft.d.ts +12 -0
  27. package/types/store/agentic/StToolBuilder.d.ts +15 -16
  28. package/types/store/agentic/StToolDraft.d.ts +13 -0
  29. package/types/store/agentic/attachAgentic.d.ts +17 -11
  30. package/types/store/agentic/index.d.ts +6 -3
  31. package/types/store/agentic/useFormTools.d.ts +4 -4
  32. package/types/vendor/use-agentic/Agentic.d.ts +3 -3
  33. package/types/vendor/use-agentic/AgenticSurface.d.ts +5 -4
  34. package/types/vendor/use-agentic/types.d.ts +5 -5
  35. package/types/vendor/use-agentic/useAgentTool.d.ts +2 -2
  36. package/ui/Agent/Tool.tsx +1 -15
  37. package/ui/Data/ListContainer.tsx +20 -42
  38. package/ui/Dialog/Provider.tsx +7 -6
  39. package/ui/Dropdown.tsx +7 -9
  40. package/ui/Layout/Sider.tsx +12 -6
  41. package/ui/Model/EditModal.tsx +4 -6
  42. package/ui/Model/EditWrapper.tsx +2 -5
  43. package/ui/Model/NewWrapper_Client.tsx +1 -2
  44. package/ui/Model/Remove.tsx +2 -1
  45. package/ui/Model/RemoveWrapper.tsx +2 -1
  46. package/ui/Model/SureToRemove.tsx +2 -5
  47. package/ui/Model/ViewEditModal.tsx +6 -9
  48. package/ui/Model/ViewModal.tsx +3 -4
  49. package/ui/Model/ViewWrapper.tsx +2 -1
  50. package/ui/Popconfirm.tsx +7 -2
  51. package/ui/ScreenNavigator.tsx +5 -5
  52. package/ui/Select.tsx +8 -2
  53. package/ui/System/SelectLanguage.tsx +2 -1
  54. package/ui/System/ThemeToggle.tsx +2 -1
  55. package/ui/Tab/Provider.tsx +6 -7
  56. package/ui/index.ts +1 -0
  57. package/vendor/use-agentic/AgentSession.ts +1 -2
  58. package/vendor/use-agentic/Agentic.tsx +4 -4
  59. package/vendor/use-agentic/AgenticSurface.ts +16 -10
  60. package/vendor/use-agentic/WIRE.md +0 -1
  61. package/vendor/use-agentic/types.ts +5 -6
  62. package/vendor/use-agentic/useAgentState.ts +0 -1
  63. package/vendor/use-agentic/useAgentTool.ts +3 -3
  64. package/webkit/bootCsr.tsx +10 -8
  65. package/webkit/usePageTool.tsx +5 -7
  66. package/store/agentic/readableValue.ts +0 -17
  67. package/store/agentic/useStExpose.ts +0 -18
  68. package/store/agentic/useStState.ts +0 -49
  69. package/types/store/agentic/readableValue.d.ts +0 -3
  70. package/types/store/agentic/useStExpose.d.ts +0 -8
  71. package/types/store/agentic/useStState.d.ts +0 -12
package/CHANGELOG.md CHANGED
@@ -1,5 +1,108 @@
1
1
  # akanjs
2
2
 
3
+ ## 2.4.2
4
+
5
+ ### Minor Changes
6
+
7
+ - 11aa655: feat(constant): remove a relation's target with its owner via `cascade: "remove"`
8
+
9
+ A relation field can now take its target down with it: `image: field(File, { cascade: "remove" })`, arrays
10
+ included. The removal runs through the **target's service**, so the target's own `_postRemove` runs with it —
11
+ that is how removing a model also deletes the file's stored blob or object, with no extra wiring in the owning
12
+ module.
13
+
14
+ Only a relation accepts the option. A `String`, an `ID`, a scalar, and a nested array each fail while the class
15
+ is being built, naming the field: none of them points at a document the framework could remove.
16
+
17
+ Target services resolve lazily, at removal time, so a cascade adds no boot-order edge between two services and a
18
+ cascade cycle cannot fail the boot. They resolve _before_ the parent is touched, so a model cascading into a
19
+ module the app never mounted fails with nothing half-removed.
20
+
21
+ Two limits worth knowing. Nothing checks whether another document still references the same target, so declaring
22
+ `cascade` asserts that the field owns its target exclusively. And query-level removal (`deleteManyByQuery` /
23
+ `updateManyByQuery`) stamps `removedAt` in one atomic update that fires no hooks and therefore no cascade —
24
+ remove documents one at a time when they cascade.
25
+
26
+ - 11aa655: feat(store): invalidate sibling slices on create and refetch them in `Load.Units`
27
+
28
+ A model with more than one parent is listed by more than one slice, and a create could only ever be spliced
29
+ into the slice it was issued from. Creating a `bizDoc` from `bizDocInOrg` left `bizDocListInProject` without
30
+ it, and an RSC navigation back to that page replayed a cached payload whose server-stamped
31
+ `bizDocInitAtInProject` still satisfied `Load.Units`' cache check — so the new document stayed invisible until
32
+ a full reload.
33
+
34
+ `create<Model>` and `create<Model>InForm` now stamp a new per-slice `<model>StaleAt<Suffix>` on every slice
35
+ _except_ the one named by `sliceName`. Whether the new document belongs to a sibling slice is a server-side
36
+ filter decision, so the siblings are marked for revalidation rather than patched optimistically. `Load.Units`
37
+ refetches through `refresh<Model><Suffix>` when its slice's `StaleAt` is newer than its `InitAt`, and a
38
+ completed refresh restamps `InitAt` past `StaleAt` to end the stale state. The refetch is skipped while the
39
+ list is already loading, which also dedups several `Load.Units` mounted on one slice.
40
+
41
+ `Load.Units` also takes a `staleTime` prop, in milliseconds, for age-based revalidation independent of any
42
+ create: `staleTime={0}` always refetches on mount, `staleTime={30_000}` refetches only when the cached data is
43
+ older than 30s. Omitting it leaves the component purely invalidation-driven.
44
+
45
+ Update and remove are unchanged — they already walk every slice, because a document they touch is one the
46
+ cached lists can be searched for by id.
47
+
48
+ - 11aa655: feat(server): add subRoute hosts at runtime with `AKAN_SUB_ROUTE_HOSTS`
49
+
50
+ An app resolved a request Host to a basePath through a map fixed at build time — the domains written into
51
+ `akan.config.ts` plus the generated `<basePath>-<branch>.<serveDomain>`. A platform that mints its hostnames when
52
+ a project is created cannot write either one into the repo, so its subRoute hosts fell through to the root app.
53
+
54
+ `AKAN_SUB_ROUTE_HOSTS` adds to that map at boot, in the same spirit as `AKAN_PUBLIC_BASE_PATHS`:
55
+
56
+ ```
57
+ AKAN_SUB_ROUTE_HOSTS="soft=soft-abc.try.akanjs.com,soft.acme.com;office=office-abc.try.akanjs.com"
58
+ ```
59
+
60
+ Hosts are matched lowercased and without a port, exactly as the built-in ones are. The env mapping is a union with
61
+ the built one, never a replacement, so a tenant's own domains keep working and removing the env restores the
62
+ previous behaviour byte for byte.
63
+
64
+ A basePath the build does not serve is dropped with a warning rather than honoured — the route tree is a build
65
+ output, so accepting one would answer every request under it with a 404 and nothing to explain why. A malformed
66
+ entry is skipped the same way: the value is rendered by a deployment platform, and one bad character must not turn
67
+ into a boot loop across every pod.
68
+
69
+ `x-base-path` is now checked against the basePaths the build serves before it is trusted, matching the check
70
+ `getBasePathFromPathname` already applied to it. An unrecognised value falls through to host matching instead of
71
+ selecting a basePath that resolves to nothing.
72
+
73
+ - 25d5b15: feat(devkit): sync lib `page` trees into apps and honor `pageConfig.devOnly`
74
+
75
+ Apps can opt in with `syncPageLibs` (`true` / lib list) so `akan sync` links each lib's routes under
76
+ `page/(libs)/(<lib>)` — once per basePath when the app has subRoutes. The link is generated and
77
+ gitignored; the lib source stays the edit target. Collision on the resolved route pattern is a
78
+ sync-time error.
79
+
80
+ `pageConfig.devOnly: true` (literal only) keeps a route out of `akan build` while `akan start` still
81
+ serves it. On a `_layout` it excludes the whole subtree. Symlink-aware file ops avoid wiping a synced
82
+ lib when cleaning an app link, and dangling lib links no longer break `getApps`.
83
+
84
+ - 25d5b15: feat(signal): refresh websocket credentials in-session and re-check pubsub rooms
85
+
86
+ Handshake credentials now live on `AppWsData` (headers/cookies only). Clients that hold the token in
87
+ memory send it with `fetch.setJwt(...)`, which forwards an `__auth` frame; the server swaps the
88
+ snapshot synchronously and re-runs each subscribed room's guards, unsubscribing the ones that fail.
89
+
90
+ Guards should read the caller with `context.get("account")` instead of branching on HTTP vs websocket
91
+ transport. Slice-level guards still only wrap generated query/mutation endpoints — a pubsub endpoint
92
+ needs its own `guards` if the room itself must be protected.
93
+
94
+ ### Patch Changes
95
+
96
+ - 6d58c7e: add search feature for sqlit database with fts5
97
+ - 25d5b15: fix(ui): refresh EditModal when the hydrated edit payload is stale
98
+
99
+ RSC navigation can replay a cached page tree, and an edit shell hydrated from that payload can show
100
+ arbitrarily old form data. EditModal now treats a cache replay (or a `modelViewAt` older than 60s) as
101
+ stale and refetches via `edit<Model>` while keeping the modal open on the last known id.
102
+
103
+ - 42cf7a2: Normalize unsendable relayed WebSocket close codes to 1001 so Bun's global client WebSocket.close no longer throws InvalidAccessError at the gateway's client-to-upstream relay when a peer disappears without a close frame (code 1006). The upstream-to-client relay applies the same normalization defensively.
104
+ - 04cb46d: Allow the documented wsConnect export during source validation for root layouts, including base-path and grouped root boundaries, while continuing to reject it on nested layouts.
105
+
3
106
  ## 2.4.1
4
107
 
5
108
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.51",
3
+ "version": "3.0.0-alpha.53",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -45,6 +45,19 @@ type GatewayUpstream = {
45
45
  ws?: Extract<AkanUpstream, { type: "tcp" }>;
46
46
  };
47
47
 
48
+ /**
49
+ * Received-only close codes cannot be sent in a close frame. The gateway deliberately normalizes
50
+ * every unsendable code, including semantically distinct 1005 and 1006 events, to 1001 in both
51
+ * relay directions. In particular, Bun's global client `WebSocket.close()` throws an
52
+ * InvalidAccessError for these codes at the client-to-upstream relay; normalization at the
53
+ * upstream-to-client `Bun.ServerWebSocket.close()` relay is defensive and keeps behavior symmetric.
54
+ */
55
+ const relayableCloseCode = (code: number): number => {
56
+ if (code >= 3000 && code <= 4999) return code;
57
+ if (code >= 1000 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) return code;
58
+ return 1001;
59
+ };
60
+
48
61
  /** Options for the Akan gateway that launches child server replicas and listens for traffic. */
49
62
  export interface AkanAppOptions {
50
63
  replica?: number | string;
@@ -602,9 +615,7 @@ export class AkanApp {
602
615
  if (result === 0) upstream.close();
603
616
  });
604
617
  upstream.addEventListener("close", (event) => {
605
- const code = AkanApp.#sendableCloseCode(event.code);
606
- if (code === undefined) ws.close();
607
- else ws.close(code, event.reason);
618
+ ws.close(relayableCloseCode(event.code), event.reason);
608
619
  });
609
620
  upstream.addEventListener("error", () => ws.close(1011, "upstream websocket error"));
610
621
  Object.assign(ws.data, { pending });
@@ -625,15 +636,8 @@ export class AkanApp {
625
636
  else ws.data.pending?.push(payload as string | ArrayBuffer);
626
637
  }
627
638
 
628
- static #sendableCloseCode(code: number): number | undefined {
629
- const sendable = (code >= 1000 && code <= 1014 && (code < 1004 || code > 1006)) || (code >= 3000 && code <= 4999);
630
- return sendable ? code : undefined;
631
- }
632
-
633
639
  #handleWsClose(ws: Bun.ServerWebSocket<GatewayWsData>, code: number, reason: string) {
634
- const upstreamCode = AkanApp.#sendableCloseCode(code);
635
- if (upstreamCode === undefined) ws.data.upstream.close();
636
- else ws.data.upstream.close(upstreamCode, reason);
640
+ ws.data.upstream.close(relayableCloseCode(code), reason);
637
641
  const child = this.#children.get(ws.data.childIdx);
638
642
  if (child) child.metrics.activeWebSockets = Math.max(0, (child.metrics.activeWebSockets ?? 1) - 1);
639
643
  }
@@ -191,6 +191,7 @@ export class RouteTreeBuilder {
191
191
  parentLayouts: RouteRender[] = [],
192
192
  parentPaths: string[] = [],
193
193
  parentHead?: ResolveHead,
194
+ parentOverrides: RouteRender[] = [],
194
195
  ): PathRoute[] {
195
196
  const parentPath = parentPaths.filter((p) => p !== "/").join("");
196
197
  const currentPathSegment = /^\/\(.*\)$/.test(route.path) ? "" : route.path;
@@ -201,8 +202,10 @@ export class RouteTreeBuilder {
201
202
  const currentLayout = !isRoot && route.renderLayout ? route.renderLayout : null;
202
203
 
203
204
  const currentOverrideRenders = route.renderOverrides ? [route.renderOverrides] : [];
204
- const renderRootLayouts = [...parentRootLayouts, ...(currentRootLayout ? [currentRootLayout] : [])];
205
- const renderLayouts = [...parentLayouts, ...currentOverrideRenders, ...(currentLayout ? [currentLayout] : [])];
205
+ const overrideRenders = [...parentOverrides, ...currentOverrideRenders];
206
+ const rootLayoutStack = [...parentRootLayouts, ...(currentRootLayout ? [currentRootLayout] : [])];
207
+ const renderRootLayouts = [...overrideRenders, ...rootLayoutStack];
208
+ const renderLayouts = [...parentLayouts, ...(currentLayout ? [currentLayout] : [])];
206
209
  if (route.renderLayout) {
207
210
  this.#fallbackRoutes.push({
208
211
  path: routePath,
@@ -213,11 +216,10 @@ export class RouteTreeBuilder {
213
216
  }
214
217
  const routeHead = RouteTreeBuilder.#composeHeadResolvers(route.renderLayout?.resolveHead, parentHead);
215
218
  const pageRenderRootLayouts =
216
- route.pageIncludesOwnLayout === false && currentRootLayout ? parentRootLayouts : renderRootLayouts;
217
- const pageRenderLayouts =
218
- route.pageIncludesOwnLayout === false && currentLayout
219
- ? [...parentLayouts, ...currentOverrideRenders]
220
- : renderLayouts;
219
+ route.pageIncludesOwnLayout === false && currentRootLayout
220
+ ? [...overrideRenders, ...parentRootLayouts]
221
+ : renderRootLayouts;
222
+ const pageRenderLayouts = route.pageIncludesOwnLayout === false && currentLayout ? parentLayouts : renderLayouts;
221
223
  const pageHead = route.pageIncludesOwnLayout === false ? parentHead : routeHead;
222
224
  return [
223
225
  ...(route.renderPage
@@ -236,7 +238,7 @@ export class RouteTreeBuilder {
236
238
  : []),
237
239
  ...(route.children.size
238
240
  ? [...route.children.values()].flatMap((child) =>
239
- this.#getPathRoutes(child, renderRootLayouts, renderLayouts, pathSegments, routeHead),
241
+ this.#getPathRoutes(child, rootLayoutStack, renderLayouts, pathSegments, routeHead, overrideRenders),
240
242
  )
241
243
  : []),
242
244
  ];
@@ -48,7 +48,6 @@ export interface AgentWireTool {
48
48
  name: string;
49
49
  description?: string;
50
50
  parameters?: Record<string, unknown>;
51
- effect?: string;
52
51
  needsConfirm?: boolean;
53
52
  }
54
53
 
@@ -56,7 +56,6 @@ export class StoreSurfaceSource implements SurfaceSource {
56
56
  required: ["path"],
57
57
  additionalProperties: false,
58
58
  },
59
- effect: "state",
60
59
 
61
60
  guard: (args) =>
62
61
  typeof args.path === "string" && args.path.startsWith("/") && !args.path.startsWith("//")
@@ -83,7 +82,6 @@ export class StoreSurfaceSource implements SurfaceSource {
83
82
  description:
84
83
  "Go back to the previous page in this session's history. Use it to undo a navigation; use navigate for a path.",
85
84
  parameters: { type: "object", properties: {}, additionalProperties: false },
86
- effect: "state",
87
85
 
88
86
  guard: () => (router.canGoBack() ? true : "There is no previous page in this session's history."),
89
87
  run: async () => {
@@ -112,7 +110,7 @@ export class StoreSurfaceSource implements SurfaceSource {
112
110
  },
113
111
  additionalProperties: false,
114
112
  },
115
- effect: "query",
113
+ settle: false,
116
114
  run: (args) => {
117
115
  const root = StoreSurfaceSource.#zoneRoot(viewKey);
118
116
  const section = typeof args.section === "string" ? args.section.trim() : "";
@@ -149,7 +147,6 @@ export class StoreSurfaceSource implements SurfaceSource {
149
147
  required: ["target"],
150
148
  additionalProperties: false,
151
149
  },
152
- effect: "state",
153
150
  run: (args) => {
154
151
  const name = typeof args.target === "string" ? args.target.trim() : "";
155
152
  if (!name) throw new Error("highlight needs a target.");
@@ -182,7 +179,6 @@ export class StoreSurfaceSource implements SurfaceSource {
182
179
  required: ["key"],
183
180
  additionalProperties: false,
184
181
  },
185
- effect: "state",
186
182
  run: (args: Record<string, unknown>) => {
187
183
  this.#bridge ??= AgentBridge.of();
188
184
  return this.#bridge.read(String(args.key), viewKey);
@@ -0,0 +1,112 @@
1
+ import {
2
+ type CLIENT_VALUE,
3
+ type Cls,
4
+ type Dayjs,
5
+ dayjs,
6
+ type EnumInstance,
7
+ type FIELD_META,
8
+ type GetStateObject,
9
+ isEnum,
10
+ PrimitiveRegistry,
11
+ type PrimitiveScalar,
12
+ type UnCls,
13
+ } from "akanjs/base";
14
+ import { type ConstantModelRef, type MaskModel, mask, maskFieldsOf } from "akanjs/constant";
15
+
16
+ type AgentSingleType = typeof PrimitiveScalar | EnumInstance<string, any> | ConstantModelRef;
17
+
18
+ /** What a readable declaration names its value as. One level of array, because a published value is one JSON shape. */
19
+ export type AgentFieldType = AgentSingleType | AgentSingleType[];
20
+
21
+ /**
22
+ * A model class resolves to its state object rather than its instance type, so a component may hand over either
23
+ * the hydrated document or the plain data copied out of one — masking reads the model that was named, not the
24
+ * class the value still carries.
25
+ */
26
+ export type AgentValueOf<T> = T extends readonly (infer F)[]
27
+ ? AgentValueOf<F>[]
28
+ : T extends { refName: "Any" }
29
+ ? unknown
30
+ : T extends EnumInstance<string, infer V>
31
+ ? V
32
+ : T extends DateConstructor
33
+ ? Dayjs | Date | string
34
+ : T extends { [FIELD_META]: unknown }
35
+ ? GetStateObject<UnCls<T>>
36
+ : T extends { [CLIENT_VALUE]: infer V }
37
+ ? V
38
+ : unknown;
39
+
40
+ type ValueKind = "any" | "date" | "scalar" | "enum" | "model";
41
+
42
+ /**
43
+ * Turns a declared type into what an agent may read of a value of that type.
44
+ *
45
+ * The type is the whole declaration: it typechecks what the component hands over, and it decides how the value is
46
+ * rendered — a model class masks by that model, a `Date` leaves as an ISO string, a scalar passes. `Any` is the
47
+ * escape hatch and passes the value untouched, so a payload nobody modeled stays publishable and the caller owns
48
+ * whether it is JSON and whether it is worth its tokens.
49
+ */
50
+ export class AgentValue {
51
+ static serialize(type: AgentFieldType, value: unknown): unknown {
52
+ const single = Array.isArray(type) ? type[0] : type;
53
+ if (Array.isArray(type) && Array.isArray(value)) return value.map((item) => AgentValue.#one(single, item));
54
+ return AgentValue.#one(single, value);
55
+ }
56
+
57
+ /**
58
+ * Reports an unreadable type the way `st.tool` reports an undescribable argument — on the console, and the
59
+ * declaration goes unpublished. Throwing would cost the route its server render over an agent-tooling mistake.
60
+ */
61
+ static publishable(owner: string, type: AgentFieldType): boolean {
62
+ try {
63
+ AgentValue.#kindOf(Array.isArray(type) ? type[0] : type);
64
+ return true;
65
+ } catch (error) {
66
+ console.error(`${owner} is not published: its type is ${error instanceof Error ? error.message : String(error)}`);
67
+ return false;
68
+ }
69
+ }
70
+
71
+ static #one(type: AgentSingleType, value: unknown): unknown {
72
+ if (value === null || value === undefined) return value;
73
+ switch (AgentValue.#kindOf(type)) {
74
+ case "date": {
75
+ const parsed = dayjs(value as string | number | Date);
76
+ return parsed.isValid() ? parsed.toISOString() : null;
77
+ }
78
+ case "model":
79
+ return mask(type as MaskModel, value);
80
+ default:
81
+ return value;
82
+ }
83
+ }
84
+
85
+ static #kindOf(type: AgentSingleType): ValueKind {
86
+ if (isEnum(type as Cls)) return "enum";
87
+ if (PrimitiveRegistry.has(type as unknown as Cls)) {
88
+ const refName = PrimitiveRegistry.getName(type as typeof PrimitiveScalar);
89
+ switch (refName) {
90
+ case "Any":
91
+ return "any";
92
+ case "Date":
93
+ return "date";
94
+ case "ID":
95
+ case "String":
96
+ case "Int":
97
+ case "Float":
98
+ case "Boolean":
99
+ return "scalar";
100
+ default:
101
+ throw new Error(`the scalar ${refName}, which an agent cannot read.`);
102
+ }
103
+ }
104
+ if (maskFieldsOf(type as MaskModel)) return "model";
105
+ throw new Error(`${AgentValue.#typeName(type)}, and a readable value is a scalar, an enum, a model, or Any.`);
106
+ }
107
+
108
+ static #typeName(type: AgentSingleType): string {
109
+ const named = type as { name?: string } | null | undefined;
110
+ return named?.name ? `the type ${named.name}` : `${String(type)}`;
111
+ }
112
+ }
@@ -0,0 +1,47 @@
1
+ import { useAgentResource } from "../../vendor/use-agentic";
2
+
3
+ import { useRef } from "../hooks";
4
+ import { type AgentFieldType, AgentValue, type AgentValueOf } from "./AgentValue";
5
+
6
+ export interface StExposeMeta {
7
+ /** `false` keeps the key out of post-call diff reports — for values that change on their own every second. */
8
+ report?: boolean;
9
+ }
10
+
11
+ /**
12
+ * A read-only value past its description, waiting for the value itself. `.value()` is the one hook.
13
+ *
14
+ * The declared type is what makes the read safe: it typechecks what the component hands over and it decides how
15
+ * the value is rendered, so a model's `hidden` and `secret` fields are stripped by the model that was named
16
+ * rather than by whatever class the value still happens to carry.
17
+ */
18
+ export class StExposeBuilder<T extends AgentFieldType> {
19
+ readonly #name: string | null;
20
+ readonly #type: T;
21
+ readonly #desc: string;
22
+ readonly #meta: StExposeMeta;
23
+
24
+ constructor(name: string | null, type: T, desc: string, meta: StExposeMeta = {}) {
25
+ this.#name = name;
26
+ this.#type = type;
27
+ this.#desc = desc;
28
+ this.#meta = meta;
29
+ }
30
+
31
+ /**
32
+ * A thunk is read when the agent reads, which is the difference that matters for a value assembled out of a ref
33
+ * the children fill in after this render — computing it here would publish whatever was there before they ran.
34
+ */
35
+ value(value: AgentValueOf<T> | (() => AgentValueOf<T>) | null | undefined): void {
36
+ const declared = useRef<{ name: string | null } | null>(null);
37
+ declared.current ??= {
38
+ name: this.#name && AgentValue.publishable(`st.expose("${this.#name}")`, this.#type) ? this.#name : null,
39
+ };
40
+ useAgentResource(declared.current.name, value, {
41
+ description: this.#desc,
42
+ report: this.#meta.report,
43
+ serialize: (current) =>
44
+ AgentValue.serialize(this.#type, typeof current === "function" ? (current as () => unknown)() : current),
45
+ });
46
+ }
47
+ }
@@ -0,0 +1,24 @@
1
+ import type { AgentFieldType } from "./AgentValue";
2
+ import { StExposeBuilder, type StExposeMeta } from "./StExposeBuilder";
3
+
4
+ /**
5
+ * A readable value before it has said what it is: `st.expose("x", ID).desc("…")`.
6
+ *
7
+ * A falsy name declares nothing and publishes nothing, so a component whose publication is conditional keeps a
8
+ * constant hook count instead of branching around the chain.
9
+ */
10
+ export class StExposeDraft<T extends AgentFieldType> {
11
+ readonly #name: string | null;
12
+ readonly #type: T;
13
+ readonly #meta: StExposeMeta;
14
+
15
+ constructor(name: string | null, type: T, meta: StExposeMeta = {}) {
16
+ this.#name = name;
17
+ this.#type = type;
18
+ this.#meta = meta;
19
+ }
20
+
21
+ desc(text: string): StExposeBuilder<T> {
22
+ return new StExposeBuilder(this.#name, this.#type, text, this.#meta);
23
+ }
24
+ }
@@ -0,0 +1,85 @@
1
+ import { capitalize } from "akanjs/common";
2
+ import type { ParamFieldType } from "akanjs/constant";
3
+ import type { Dispatch, SetStateAction } from "react";
4
+ import { type JsonSchema, useAgentState } from "../../vendor/use-agentic";
5
+
6
+ import { useRef } from "../hooks";
7
+ import { type AgentFieldType, AgentValue, type AgentValueOf } from "./AgentValue";
8
+ import { StToolBuilder } from "./StToolBuilder";
9
+
10
+ export interface StStateMeta {
11
+ /** `false` keeps the key out of post-call diff reports — for values that change on their own every second. */
12
+ report?: boolean;
13
+ /** Publishes a `set<Name>` tool writing the type this state declares. Read-only without it. */
14
+ set?: boolean;
15
+ }
16
+
17
+ interface StStateDeclaration {
18
+ name: string | null;
19
+ set: { schema: JsonSchema; type: ParamFieldType } | null;
20
+ }
21
+
22
+ /**
23
+ * Local state past its description, waiting for its initial value. `.init()` is the one hook and returns what
24
+ * `useState` returns.
25
+ *
26
+ * The declared type does both halves: it renders the read, and — with `set` — it is the schema of the setter tool
27
+ * an agent writes through. A type nothing can describe costs the write, not the read and not the render.
28
+ */
29
+ export class StStateBuilder<T extends AgentFieldType> {
30
+ readonly #name: string | null;
31
+ readonly #type: T;
32
+ readonly #desc: string;
33
+ readonly #meta: StStateMeta;
34
+
35
+ constructor(name: string | null, type: T, desc: string, meta: StStateMeta = {}) {
36
+ this.#name = name;
37
+ this.#type = type;
38
+ this.#desc = desc;
39
+ this.#meta = meta;
40
+ }
41
+
42
+ init(
43
+ initial: AgentValueOf<T> | (() => AgentValueOf<T>),
44
+ ): [AgentValueOf<T>, Dispatch<SetStateAction<AgentValueOf<T>>>];
45
+ init(
46
+ initial: AgentValueOf<T> | null | (() => AgentValueOf<T> | null),
47
+ ): [AgentValueOf<T> | null, Dispatch<SetStateAction<AgentValueOf<T> | null>>];
48
+
49
+ init(initial: AgentValueOf<T> | null | (() => AgentValueOf<T> | null)): unknown {
50
+ type Value = AgentValueOf<T> | null;
51
+ const name = this.#name;
52
+ const type = this.#type;
53
+ const declared = useRef<StStateDeclaration | null>(null);
54
+ declared.current ??= {
55
+ name: name && AgentValue.publishable(`st.useState("${name}")`, type) ? name : null,
56
+ set: name && this.#meta.set ? StStateBuilder.#writable(name, type) : null,
57
+ };
58
+ const { set } = declared.current;
59
+ return useAgentState<Value>(declared.current.name, initial, {
60
+ description: this.#desc,
61
+ report: this.#meta.report,
62
+ serialize: (value) => AgentValue.serialize(type, value),
63
+ ...(set
64
+ ? {
65
+ set: set.schema,
66
+ parse: (value) =>
67
+ StToolBuilder.checkedValue(`set${capitalize(name ?? "")}`, "value", set.type, value) as Value,
68
+ }
69
+ : {}),
70
+ });
71
+ }
72
+
73
+ /** A `set` an agent could only call wrong leaves the key readable — the same trade `st.tool`'s `.arg` makes. */
74
+ static #writable(name: string, type: AgentFieldType): StStateDeclaration["set"] {
75
+ const scalar = type as unknown as ParamFieldType;
76
+ try {
77
+ return { schema: StToolBuilder.schemaOf(scalar), type: scalar };
78
+ } catch (error) {
79
+ console.error(
80
+ `st.useState("${name}") stays read-only: writing ${error instanceof Error ? error.message : String(error)}`,
81
+ );
82
+ return null;
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,23 @@
1
+ import type { AgentFieldType } from "./AgentValue";
2
+ import { StStateBuilder, type StStateMeta } from "./StStateBuilder";
3
+
4
+ /**
5
+ * Local state before it has said what it is: `st.useState("tab", String, { set: true }).desc("…")`.
6
+ *
7
+ * A falsy name keeps the state and publishes nothing, so a conditional surface never changes the hook count.
8
+ */
9
+ export class StStateDraft<T extends AgentFieldType> {
10
+ readonly #name: string | null;
11
+ readonly #type: T;
12
+ readonly #meta: StStateMeta;
13
+
14
+ constructor(name: string | null, type: T, meta: StStateMeta = {}) {
15
+ this.#name = name;
16
+ this.#type = type;
17
+ this.#meta = meta;
18
+ }
19
+
20
+ desc(text: string): StStateBuilder<T> {
21
+ return new StStateBuilder(this.#name, this.#type, text, this.#meta);
22
+ }
23
+ }