akanjs 3.0.0-alpha.11 → 3.0.0-alpha.13

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 (69) hide show
  1. package/base/symbols.ts +4 -0
  2. package/constant/index.ts +1 -0
  3. package/constant/mask.ts +60 -0
  4. package/dictionary/dictInfo.ts +12 -1
  5. package/fetch/client/fetchClient.ts +9 -0
  6. package/package.json +1 -1
  7. package/server/akanApp.ts +3 -1
  8. package/service/predefinedAdaptor/index.ts +1 -0
  9. package/service/predefinedAdaptor/insightQuery.ts +183 -0
  10. package/signal/mcp/Msg.ts +5 -33
  11. package/store/action.ts +9 -20
  12. package/store/actionTag.ts +28 -0
  13. package/store/agent/AgentBridge.ts +281 -0
  14. package/store/agent/StoreCatalogue.ts +296 -0
  15. package/store/agent/index.ts +3 -0
  16. package/store/agent/types.ts +50 -0
  17. package/store/databaseStateNames.ts +31 -0
  18. package/store/formSetterNames.ts +21 -0
  19. package/store/index.ts +7 -0
  20. package/store/rootStore.ts +2 -1
  21. package/store/sliceRole.ts +36 -0
  22. package/store/state.ts +2 -12
  23. package/store/store.ts +5 -0
  24. package/store/storeInstance.ts +54 -16
  25. package/store/storeRegistry.ts +10 -0
  26. package/types/base/symbols.d.ts +4 -0
  27. package/types/constant/index.d.ts +1 -0
  28. package/types/constant/mask.d.ts +34 -0
  29. package/types/dictionary/dictInfo.d.ts +9 -2
  30. package/types/fetch/client/fetchClient.d.ts +9 -0
  31. package/types/service/predefinedAdaptor/index.d.ts +1 -0
  32. package/types/service/predefinedAdaptor/insightQuery.d.ts +50 -0
  33. package/types/signal/mcp/Msg.d.ts +3 -4
  34. package/types/store/actionTag.d.ts +17 -0
  35. package/types/store/agent/AgentBridge.d.ts +70 -0
  36. package/types/store/agent/StoreCatalogue.d.ts +21 -0
  37. package/types/store/agent/index.d.ts +3 -0
  38. package/types/store/agent/types.d.ts +49 -0
  39. package/types/store/agent.d.ts +1 -0
  40. package/types/store/databaseStateNames.d.ts +25 -0
  41. package/types/store/formSetterNames.d.ts +16 -0
  42. package/types/store/index.d.ts +6 -0
  43. package/types/store/rootStore.d.ts +4 -1
  44. package/types/store/sliceRole.d.ts +25 -0
  45. package/types/store/store.d.ts +4 -1
  46. package/types/store/storeInstance.d.ts +16 -0
  47. package/types/store/storeRegistry.d.ts +3 -0
  48. package/types/ui/Agent/Dock.d.ts +16 -0
  49. package/types/ui/Agent/Section.d.ts +10 -0
  50. package/types/ui/Agent/StateKey.d.ts +15 -0
  51. package/types/ui/Agent/Tool.d.ts +15 -0
  52. package/types/ui/Agent/Transcript.d.ts +13 -0
  53. package/types/ui/Agent/index.d.ts +11 -0
  54. package/types/ui/Agent.d.ts +1 -0
  55. package/types/ui/agentAttrs.d.ts +14 -0
  56. package/types/ui/index.d.ts +2 -0
  57. package/ui/Agent/Dock.tsx +61 -0
  58. package/ui/Agent/Section.tsx +24 -0
  59. package/ui/Agent/StateKey.tsx +42 -0
  60. package/ui/Agent/Tool.tsx +66 -0
  61. package/ui/Agent/Transcript.tsx +33 -0
  62. package/ui/Agent/index.ts +7 -0
  63. package/ui/Button.tsx +2 -0
  64. package/ui/Field.tsx +15 -7
  65. package/ui/Input.tsx +7 -0
  66. package/ui/Select.tsx +2 -1
  67. package/ui/Switch.tsx +2 -0
  68. package/ui/agentAttrs.ts +19 -0
  69. package/ui/index.ts +2 -0
@@ -0,0 +1,281 @@
1
+ import { DataList } from "akanjs/base";
2
+ import { Translator } from "akanjs/client";
3
+ import { parseAkanI18nEnv } from "akanjs/common";
4
+ import { ConstantRegistry, type MaskModel, mask } from "akanjs/constant";
5
+ import { FetchClient } from "akanjs/fetch";
6
+ import type { AgentRefusal, AgentUndescribed, JsonSchema, SerializedArg, SerializedSignal } from "akanjs/signal";
7
+
8
+ import { JsonSchemaBuilder } from "../../signal/schema/JsonSchemaBuilder";
9
+ import type { StoreInstance } from "../storeInstance";
10
+ import { StoreRegistry } from "../storeRegistry";
11
+ import { StoreCatalogue } from "./StoreCatalogue";
12
+ import type { SerializedStore, SerializedStoreAction, SerializedStoreState, StoreActionEffect } from "./types";
13
+
14
+ export interface AgentTool {
15
+ name: string;
16
+ title?: string;
17
+ description?: string;
18
+ /** One flat named object, the shape MCP publishes. The bridge maps it onto the action's positional parameters. */
19
+ inputSchema: JsonSchema;
20
+ effect: StoreActionEffect;
21
+ }
22
+
23
+ /** One call the agent made, in the order it made them. */
24
+ export interface AgentCall {
25
+ name: string;
26
+ args: Record<string, unknown>;
27
+ at: Date;
28
+ error?: string;
29
+ }
30
+
31
+ export interface AgentBridgeOptions {
32
+ /** Resolves a dictionary key to its text. Defaults to the seeded `Translator` in the active locale. */
33
+ resolveDescription?: (key: string) => string | undefined;
34
+ }
35
+
36
+ /**
37
+ * What an in-page agent may do to the app the user is looking at.
38
+ *
39
+ * Every call goes through `st.do`, which is the same single dispatch point a click goes through — so the agent
40
+ * cannot reach past what the UI already lets this user do, the app re-renders from the write, and the user watches
41
+ * the result rather than being told about it. That is why the exposure default here is the opposite of the MCP
42
+ * catalogue's: an external agent's `tools/list` is an attack surface built out of names the operator never chose to
43
+ * publish, while this one is the user's own session, under their own credential, with them watching.
44
+ *
45
+ * It is deliberately not an agent. There is no model, no provider, and no key here — an app wires whichever it uses
46
+ * to `tools`, `call`, and `read`. The framework's half is the catalogue, the argument checking, the masking, and the
47
+ * transcript; the conversation is the app's.
48
+ */
49
+ export class AgentBridge {
50
+ readonly tools: AgentTool[];
51
+ readonly refusals: AgentRefusal[];
52
+ /** Published entries with no words an author wrote. What a source scanner cannot see, per `AgentCatalogue`. */
53
+ readonly undescribed: AgentUndescribed[] = [];
54
+
55
+ readonly #instance: StoreInstance;
56
+ readonly #store: SerializedStore;
57
+ readonly #options: AgentBridgeOptions;
58
+ readonly #schema = new JsonSchemaBuilder({ refPrefix: "#/$defs/" });
59
+ readonly #byName = new Map<string, SerializedStoreAction>();
60
+ readonly #calls: AgentCall[] = [];
61
+
62
+ /**
63
+ * The bridge for the app running in this process: the one store every `st.do` goes through, and every signal any
64
+ * client has applied. An app needs no arguments to reach its own agent surface.
65
+ */
66
+ static of(options: AgentBridgeOptions = {}) {
67
+ return new AgentBridge(StoreRegistry.instance, FetchClient.sharedSerializedSignal, options);
68
+ }
69
+
70
+ constructor(
71
+ instance: StoreInstance,
72
+ serializedSignal: Record<string, SerializedSignal>,
73
+ options: AgentBridgeOptions = {},
74
+ ) {
75
+ this.#instance = instance;
76
+ this.#options = options;
77
+ const catalogue = new StoreCatalogue(instance, serializedSignal);
78
+ this.#store = catalogue.store;
79
+ this.refusals = catalogue.refusals;
80
+ for (const [name, action] of Object.entries(this.#store.action)) this.#byName.set(name, action);
81
+ this.tools = Object.entries(this.#store.action).map(([name, action]) => this.#tool(name, action));
82
+ }
83
+
84
+ get state(): { [key: string]: SerializedStoreState } {
85
+ return this.#store.state;
86
+ }
87
+
88
+ get transcript(): readonly AgentCall[] {
89
+ return this.#calls;
90
+ }
91
+
92
+ subscribe(listener: () => void) {
93
+ return this.#instance.subscribe(listener);
94
+ }
95
+
96
+ /**
97
+ * The value behind a state key, stripped of what the model marks `hidden` or `secret`.
98
+ *
99
+ * Masking is not optional here even though the data mostly came from the server already masked: `<model>Form`
100
+ * holds what the *user* typed, credentials included, and an in-page agent ships what it reads to a remote model.
101
+ * The mask is by the declared model rather than by the value's class, because `immerify` copies a form into a
102
+ * plain object and the class is gone by the time anyone can ask.
103
+ */
104
+ read(key: string): unknown {
105
+ const entry = this.#store.state[key];
106
+ if (!entry) throw new Error(`Unknown state key: ${key}`);
107
+ const value = AgentBridge.#unwrap(this.#instance.get()[key]);
108
+ if (entry.refName && entry.modelType) {
109
+ const model = ConstantRegistry.getModelRef(entry.refName, entry.modelType) as MaskModel;
110
+ return mask(model, value);
111
+ }
112
+ if (AgentBridge.#isPlainValue(value)) return value;
113
+ throw new Error(
114
+ `State key "${key}" holds an object that belongs to no model, so there is nothing to mask it by and it is not published. Read the model's own keys instead.`,
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Dispatches through `st.do`, so what happens is what happens when the user clicks.
120
+ *
121
+ * Arguments arrive named and are mapped onto the action's parameters in declared order. An omitted optional one
122
+ * becomes `null`, which is what the slice query builders already expect; an omitted required one is refused,
123
+ * because the alternative is a call that writes `undefined` into state and reports success.
124
+ */
125
+ async call(name: string, args: Record<string, unknown> = {}) {
126
+ const action = this.#byName.get(name);
127
+ if (!action) throw new Error(`Unknown action: ${name}`);
128
+
129
+ const record: AgentCall = { name, args, at: new Date() };
130
+ this.#calls.push(record);
131
+ try {
132
+ const positional = action.args.map((arg) => this.#value(name, arg, args));
133
+ await this.#instance.do[name]?.(...positional);
134
+ } catch (error) {
135
+ record.error = error instanceof Error ? error.message : String(error);
136
+ throw error;
137
+ }
138
+ }
139
+
140
+ #value(name: string, arg: SerializedArg, args: Record<string, unknown>) {
141
+ if (!(arg.name in args) || args[arg.name] === undefined) {
142
+ if (arg.nullable || arg.type === "search") return null;
143
+ throw new Error(`Missing argument "${arg.name}" for ${name}.`);
144
+ }
145
+ return AgentBridge.#checked(name, arg, args[arg.name]);
146
+ }
147
+
148
+ /**
149
+ * Checks a value against what the argument declared, one level of array included.
150
+ *
151
+ * `st.do` accepts anything — it is a rest wrapper — so without this a string where an `Int` belongs is written
152
+ * into state and rendered, and the agent is told the call succeeded. A model argument is checked only for being
153
+ * an object: the published schema describes its fields and the endpoint validates them server-side.
154
+ */
155
+ static #checked(name: string, arg: SerializedArg, value: unknown): unknown {
156
+ const depth = arg.arrDepth ?? 0;
157
+ if (depth) {
158
+ if (!Array.isArray(value)) throw new Error(`Argument "${arg.name}" of ${name} must be an array.`);
159
+ return value.map((item) => AgentBridge.#checked(name, { ...arg, arrDepth: depth - 1 }, item));
160
+ }
161
+ if (value === null) return null;
162
+ if (arg.enum) return AgentBridge.#checkedEnum(name, arg, value);
163
+ switch (arg.refName) {
164
+ case "String":
165
+ case "ID":
166
+ return AgentBridge.#assertType(name, arg, value, "string");
167
+ case "Int":
168
+ if (!Number.isInteger(value)) throw new Error(`Argument "${arg.name}" of ${name} must be a whole number.`);
169
+ return value;
170
+ case "Float":
171
+ if (typeof value !== "number" || !Number.isFinite(value))
172
+ throw new Error(`Argument "${arg.name}" of ${name} must be a finite number.`);
173
+ return value;
174
+ case "Boolean":
175
+ return AgentBridge.#assertType(name, arg, value, "boolean");
176
+ case "Date":
177
+ return AgentBridge.#checkedDate(name, arg, value);
178
+ default:
179
+ if (typeof value !== "object") throw new Error(`Argument "${arg.name}" of ${name} must be an object.`);
180
+ return value;
181
+ }
182
+ }
183
+
184
+ static #assertType(name: string, arg: SerializedArg, value: unknown, type: "string" | "boolean") {
185
+ if (typeof value !== type) throw new Error(`Argument "${arg.name}" of ${name} must be a ${type}.`);
186
+ return value;
187
+ }
188
+
189
+ static #checkedEnum(name: string, arg: SerializedArg, value: unknown) {
190
+ const values = arg.enum ? ConstantRegistry.enum.get(arg.enum)?.values : undefined;
191
+ if (!values) return value;
192
+ if (!values.includes(value as never))
193
+ throw new Error(`Argument "${arg.name}" of ${name} must be one of: ${[...values].join(", ")}.`);
194
+ return value;
195
+ }
196
+
197
+ /** An agent has no `Date`, so an ISO string is what it sends. Anything unparseable is refused rather than `Invalid Date`. */
198
+ static #checkedDate(name: string, arg: SerializedArg, value: unknown) {
199
+ if (value instanceof Date) return value;
200
+ const parsed = typeof value === "string" ? new Date(value) : null;
201
+ if (!parsed || Number.isNaN(parsed.getTime()))
202
+ throw new Error(`Argument "${arg.name}" of ${name} must be an ISO 8601 date string.`);
203
+ return parsed;
204
+ }
205
+
206
+ #tool(name: string, action: SerializedStoreAction): AgentTool {
207
+ const properties = Object.fromEntries(action.args.map((arg) => [arg.name, this.#argSchema(arg)]));
208
+ const required = action.args.filter((arg) => !arg.nullable && arg.type !== "search").map((arg) => arg.name);
209
+ const defs = this.#schema.referencedSchemas(properties);
210
+ return {
211
+ name,
212
+ ...this.#texts(name, action),
213
+ inputSchema: {
214
+ type: "object",
215
+ properties,
216
+ ...(required.length ? { required } : {}),
217
+ additionalProperties: false,
218
+ ...(Object.keys(defs).length ? { $defs: defs } : {}),
219
+ },
220
+ effect: action.effect,
221
+ };
222
+ }
223
+
224
+ #argSchema(arg: SerializedArg) {
225
+ return {
226
+ ...this.#schema.arg(arg),
227
+ ...(arg.example !== undefined ? { examples: [arg.example] } : {}),
228
+ };
229
+ }
230
+
231
+ /**
232
+ * The words an agent picks the action by, from the one channel this codebase has for them.
233
+ *
234
+ * Order matters and follows the same leniency the MCP catalogue uses for a slice: an action's own `.store()`
235
+ * entry, then the endpoint it is named after — which is right rather than merely adequate, because the house
236
+ * rule makes `st.do.X` and `fetch.X` the same verb — then, on a field setter, the field's own label. Only what
237
+ * reaches none of the three is recorded as undescribed.
238
+ */
239
+ #texts(name: string, action: SerializedStoreAction) {
240
+ const { refName, endpoint, field } = action;
241
+ const keys = refName
242
+ ? [
243
+ `${refName}.store.${name}`,
244
+ ...(endpoint ? [`${refName}.signal.${endpoint}`] : []),
245
+ ...(field ? [`${refName}.${field}`] : []),
246
+ ]
247
+ : [];
248
+ for (const key of keys) {
249
+ const title = this.#text(key);
250
+ const description = this.#text(`${key}.desc`);
251
+ if (title || description) return { ...(title ? { title } : {}), ...(description ? { description } : {}) };
252
+ }
253
+ this.undescribed.push({
254
+ key: name,
255
+ reason: refName
256
+ ? `neither \`${refName}.store.${name}\` nor anything it inherits from has text, so an agent has the name and nothing else.`
257
+ : "it belongs to no model, so there is no dictionary node its text could be written in.",
258
+ });
259
+ return {};
260
+ }
261
+
262
+ #text(key: string) {
263
+ if (this.#options.resolveDescription) return this.#options.resolveDescription(key);
264
+ const locale = Translator.getActiveLocale() ?? parseAkanI18nEnv().defaultLocale;
265
+ const text = Translator.translateByLocale(locale, key);
266
+
267
+ return text === key ? undefined : text;
268
+ }
269
+
270
+ static #unwrap(value: unknown) {
271
+ return value instanceof DataList ? value.values : value;
272
+ }
273
+
274
+ /** True when nothing inside could be carrying a model's fields, so there is nothing a mask would have to strip. */
275
+ static #isPlainValue(value: unknown): boolean {
276
+ if (value === null || value === undefined) return true;
277
+ if (Array.isArray(value)) return value.every((item) => AgentBridge.#isPlainValue(item));
278
+ if (value instanceof Date) return true;
279
+ return typeof value !== "object";
280
+ }
281
+ }
@@ -0,0 +1,296 @@
1
+ import { type Cls, FIELD_META, PrimitiveRegistry, type PrimitiveScalar } from "akanjs/base";
2
+ import { capitalize } from "akanjs/common";
3
+ import { ConstantRegistry } from "akanjs/constant";
4
+ import type { AgentRefusal, SerializedArg, SerializedSignal } from "akanjs/signal";
5
+
6
+ import { AgentCatalogue } from "../../signal/agent/AgentCatalogue";
7
+ import { databaseStateModelTypes, databaseStateNames } from "../databaseStateNames";
8
+ import { formSetterNames } from "../formSetterNames";
9
+ import type { SliceActionKey } from "../sliceRole";
10
+ import type { SliceStateKey } from "../state";
11
+ import type { StoreInstance } from "../storeInstance";
12
+ import type { SerializedStore, SerializedStoreAction, SerializedStoreState } from "./types";
13
+
14
+ /** A model field as the catalogue reads it. The parts of `FieldProps` a store setter's argument comes from. */
15
+ interface CatalogueField {
16
+ fieldType?: string;
17
+ isClass?: boolean;
18
+ isMap?: boolean;
19
+ isArray?: boolean;
20
+ arrDepth?: number;
21
+ modelRef?: Cls;
22
+ enum?: { refName: string };
23
+ example?: unknown;
24
+ }
25
+
26
+ /** What each generated slice action takes, in the same terms an endpoint states its arguments. */
27
+ const sliceActionArgs: {
28
+ [key in SliceActionKey]: { effect: "state" | "query"; args: "slice" | SerializedArg[] };
29
+ } = {
30
+ initModel: { effect: "query", args: "slice" },
31
+ refreshModel: { effect: "query", args: [] },
32
+
33
+ selectModel: { effect: "state", args: [] },
34
+ setPageOfModel: { effect: "query", args: [{ type: "param", name: "page", refName: "Int" }] },
35
+ addPageOfModel: { effect: "query", args: [{ type: "param", name: "page", refName: "Int" }] },
36
+ setLimitOfModel: { effect: "query", args: [{ type: "param", name: "limit", refName: "Int" }] },
37
+ setQueryArgsOfModel: { effect: "query", args: "slice" },
38
+ setSortOfModel: { effect: "query", args: [{ type: "param", name: "sort", refName: "String" }] },
39
+ };
40
+
41
+ /** Which of the model's classes each slice state key holds. The rest hold a primitive or a query descriptor. */
42
+ const sliceStateModelTypes: { [key in SliceStateKey]?: SerializedStoreState["modelType"] } = {
43
+ defaultModel: "full",
44
+ modelList: "light",
45
+ modelInitList: "light",
46
+ modelSelection: "light",
47
+ modelInsight: "insight",
48
+ };
49
+
50
+ /**
51
+ * What one built store offers an agent, derived from the store the browser is already running.
52
+ *
53
+ * The store is the audience-neutral half of the client surface the way a signal registry is the server's: a key on
54
+ * `st.do` is the same call the user's own click makes, so an agent that drives it cannot reach past what the UI
55
+ * already permits. That is why the default here is the opposite of the MCP catalogue's — every key is published
56
+ * unless something about it cannot be described, and each of those is recorded as a refusal rather than dropped.
57
+ *
58
+ * Nothing is re-declared. An action's arguments come from the endpoint it is named after, from the field metadata
59
+ * the form setter was generated from, or from the role the store recorded while building the slice; a key that
60
+ * matches none of those three is published only when it takes no arguments at all.
61
+ */
62
+ export class StoreCatalogue {
63
+ readonly store: SerializedStore;
64
+ readonly refusals: AgentRefusal[] = [];
65
+
66
+ readonly #instance: StoreInstance;
67
+ readonly #endpoints = new Map<string, { endpoint: SerializedSignal["endpoint"][string]; refName: string }>();
68
+ readonly #refused = new Set<string>();
69
+
70
+ constructor(instance: StoreInstance, serializedSignal: Record<string, SerializedSignal>) {
71
+ this.#instance = instance;
72
+ for (const { key, endpoint, refName } of AgentCatalogue.candidates(serializedSignal))
73
+ this.#endpoints.set(key, { endpoint, refName });
74
+ this.store = { state: this.#state(), action: this.#actions() };
75
+ }
76
+
77
+ #refuse(key: string, reason: string) {
78
+ if (this.#refused.has(key)) return;
79
+ this.#refused.add(key);
80
+ this.refusals.push({ key, reason });
81
+ }
82
+
83
+ #state(): { [key: string]: SerializedStoreState } {
84
+ const state = this.#instance.get();
85
+ const declared = StoreCatalogue.#declaredStateModels();
86
+ const entries = Object.keys(state)
87
+ .sort()
88
+ .map((key): [string, SerializedStoreState] => {
89
+ const role = this.#instance.sliceStateRoles.get(key);
90
+ const model = role
91
+ ? { refName: role.refName, ...StoreCatalogue.#modelTypeOf(sliceStateModelTypes[role.role]) }
92
+ : (declared.get(key) ?? StoreCatalogue.#refNameOf(state[key]));
93
+ return [
94
+ key,
95
+ {
96
+ type: StoreCatalogue.#typeOf(state[key]),
97
+ ...model,
98
+ ...(role ? { role: role.role } : {}),
99
+ derived: this.#instance.derivedKeys.has(key),
100
+ },
101
+ ];
102
+ });
103
+ return Object.fromEntries(entries);
104
+ }
105
+
106
+ /**
107
+ * The model each generated state key holds, taken from the declaration rather than from the value.
108
+ *
109
+ * A read has to be masked by the model, and the value cannot supply it: `immerify` copies a form into a plain
110
+ * object, so `<model>Form` — an `Input` holding whatever the user typed — arrives with its class already gone.
111
+ */
112
+ static #declaredStateModels() {
113
+ const declared = new Map<string, { refName: string; modelType: SerializedStoreState["modelType"] }>();
114
+ for (const refName of ConstantRegistry.database.keys()) {
115
+ const names = databaseStateNames(refName);
116
+ for (const [role, modelType] of Object.entries(databaseStateModelTypes))
117
+ declared.set(names[role as keyof typeof names], { refName, modelType });
118
+ }
119
+ return declared;
120
+ }
121
+
122
+ static #modelTypeOf(modelType: SerializedStoreState["modelType"]) {
123
+ return modelType ? { modelType } : {};
124
+ }
125
+
126
+ #actions(): { [key: string]: SerializedStoreAction } {
127
+
128
+ const formSetters = this.#formSetters();
129
+ const entries = Object.keys(this.#instance.do)
130
+ .sort()
131
+ .map((key): [string, SerializedStoreAction] | null => {
132
+ if (this.#refused.has(key)) return null;
133
+ const endpoint = this.#endpoints.get(key);
134
+ if (endpoint) return [key, this.#endpointAction(key, endpoint)];
135
+ const formSetter = formSetters.get(key);
136
+ if (formSetter) return [key, formSetter];
137
+ const slice = this.#sliceAction(key);
138
+ if (slice) return [key, slice];
139
+ return this.#plainAction(key);
140
+ })
141
+ .filter((entry): entry is [string, SerializedStoreAction] => !!entry);
142
+ return Object.fromEntries(entries);
143
+ }
144
+
145
+ /**
146
+ * An action named after an endpoint takes the endpoint's arguments. That is not a coincidence to be verified but
147
+ * the house naming rule — "the signal, store, and dictionary re-add the model, so `st.do.X` reads the same as
148
+ * `fetch.X`" — and it is where the store's schemas come from for free.
149
+ */
150
+ #endpointAction(
151
+ key: string,
152
+ { endpoint, refName }: { endpoint: SerializedSignal["endpoint"][string]; refName: string },
153
+ ) {
154
+ const effect = endpoint.type === "mutation" ? "mutation" : "query";
155
+ return { args: endpoint.args, effect, refName, endpoint: key } satisfies SerializedStoreAction;
156
+ }
157
+
158
+ /**
159
+ * The generated field setters, computed forward from the same field metadata `makeFormSetter` generated them from.
160
+ *
161
+ * A `hidden` or `secret` field is refused. Nothing else in the framework lets one of those cross an agent
162
+ * boundary — `resolveReturn` strips them on the way out and `Msg.mask` refuses a payload carrying them — and a
163
+ * setter is the same boundary facing the other way: publishing `setPasswordOnUser` names the field and invites a
164
+ * write to it in one entry.
165
+ */
166
+ #formSetters(): Map<string, SerializedStoreAction> {
167
+ const setters = new Map<string, SerializedStoreAction>();
168
+ for (const [refName, cnst] of ConstantRegistry.database) {
169
+ const className = capitalize(refName);
170
+ const fields = (cnst.full as unknown as { [key: symbol]: Record<string, CatalogueField> })[FIELD_META];
171
+ if (!fields) continue;
172
+ for (const [field, meta] of Object.entries(fields)) {
173
+ const names = formSetterNames(className, field);
174
+ if (!(names.setFieldOnModel in this.#instance.do)) continue;
175
+ if (names.uploadFieldOnModel in this.#instance.do)
176
+ this.#refuse(names.uploadFieldOnModel, "it takes a browser `FileList`, which an agent has no way to hold.");
177
+ const arg = this.#argOfField(names.setFieldOnModel, field, meta);
178
+ if (!arg) continue;
179
+ const base = { effect: "state", refName, field } satisfies Partial<SerializedStoreAction>;
180
+ setters.set(names.setFieldOnModel, { ...base, role: "set", args: [arg] });
181
+ if (!meta.isArray) continue;
182
+ const element = { ...arg, ...(arg.arrDepth && arg.arrDepth > 1 ? { arrDepth: arg.arrDepth - 1 } : {}) };
183
+ if (arg.arrDepth === 1) delete element.arrDepth;
184
+ setters.set(names.addFieldOnModel, { ...base, role: "add", args: [element] });
185
+ setters.set(names.addOrSubFieldOnModel, { ...base, role: "addOrSub", args: [element] });
186
+ setters.set(names.subFieldOnModel, {
187
+ ...base,
188
+ role: "sub",
189
+ args: [{ type: "param", name: "idx", refName: "Int" }],
190
+ });
191
+ }
192
+ }
193
+ return setters;
194
+ }
195
+
196
+ #argOfField(key: string, name: string, field: CatalogueField): SerializedArg | null {
197
+ if (field.fieldType === "hidden" || field.fieldType === "secret") {
198
+ this.#refuse(key, `\`${name}\` is a ${field.fieldType} field, which never crosses an agent boundary.`);
199
+ return null;
200
+ }
201
+ if (field.isMap) {
202
+ this.#refuse(key, `\`${name}\` is a Map, which has no argument schema to publish.`);
203
+ return null;
204
+ }
205
+ const arrDepth = field.arrDepth ?? 0;
206
+ if (field.enum)
207
+ return { type: "body", name, refName: "String", enum: field.enum.refName, ...(arrDepth ? { arrDepth } : {}) };
208
+ if (field.isClass) {
209
+ const model = field.modelRef ? ConstantRegistry.getRefName(field.modelRef, { allowEmpty: true }) : undefined;
210
+ this.#refuse(
211
+ key,
212
+ `\`${name}\` takes ${model ? `a \`${model}\`` : "an object"}, which an agent holding an id cannot build — it is chosen through the UI.`,
213
+ );
214
+ return null;
215
+ }
216
+ if (!field.modelRef || !PrimitiveRegistry.has(field.modelRef)) {
217
+ this.#refuse(key, `\`${name}\` has no primitive to describe it.`);
218
+ return null;
219
+ }
220
+ return {
221
+ type: "body",
222
+ name,
223
+ refName: PrimitiveRegistry.getName(field.modelRef as typeof PrimitiveScalar),
224
+ ...(arrDepth ? { arrDepth } : {}),
225
+ ...(StoreCatalogue.#exampleOf(field.example) ?? {}),
226
+ };
227
+ }
228
+
229
+ #sliceAction(key: string): SerializedStoreAction | null {
230
+ const role = this.#instance.sliceActionRoles.get(key);
231
+ if (!role) return null;
232
+ if (role.role === "selectModel") {
233
+ this.#refuse(key, "it takes the list item itself, and an agent holding an id would store a stub in its place.");
234
+ return null;
235
+ }
236
+ const { effect, args } = sliceActionArgs[role.role];
237
+ return { args: args === "slice" ? role.args : args, effect, refName: role.refName, role: role.role };
238
+ }
239
+
240
+ /**
241
+ * Everything else — a state setter the store generated for a plain key, and the actions a module wrote itself.
242
+ *
243
+ * Published only when it declares no parameters, which is the shape most custom actions have: the data comes from
244
+ * the form state the agent has already filled, in the same order a person fills it. One that does declare
245
+ * parameters and matched none of the three schema sources cannot be called safely, so it is refused by name.
246
+ *
247
+ * `Function.length` is the arity, so a rest parameter reads as none — the one shape that slips through. It slips
248
+ * through as callable with no arguments, which for a rest signature is the empty call.
249
+ */
250
+ #plainAction(key: string): [string, SerializedStoreAction] | null {
251
+ const arity = this.#instance.actionArity.get(key) ?? 0;
252
+ if (arity > 0) {
253
+ this.#refuse(key, `it declares ${arity} argument${arity > 1 ? "s" : ""} that no endpoint or field describes.`);
254
+ return null;
255
+ }
256
+
257
+ const refName = this.#instance.actionOwners.get(key);
258
+ return [key, { args: [], effect: "state", ...(refName ? { refName } : {}) }];
259
+ }
260
+
261
+ static #typeOf(value: unknown): SerializedStoreState["type"] {
262
+ if (value === null || value === undefined) return "unknown";
263
+ if (Array.isArray(value)) return "list";
264
+ if (value instanceof Map) return "map";
265
+ if (value instanceof Date) return "date";
266
+ switch (typeof value) {
267
+ case "string":
268
+ return "string";
269
+ case "number":
270
+ return "number";
271
+ case "boolean":
272
+ return "boolean";
273
+ case "object":
274
+ return StoreCatalogue.#isList(value) ? "list" : "object";
275
+ default:
276
+ return "unknown";
277
+ }
278
+ }
279
+
280
+ /** `DataList` and `dayjs` are the two objects a store holds that are not what `typeof` says they are. */
281
+ static #isList(value: object) {
282
+ return "values" in value && Array.isArray((value as { values: unknown }).values);
283
+ }
284
+
285
+ static #refNameOf(value: unknown) {
286
+ if (!value || typeof value !== "object") return {};
287
+ const refName = ConstantRegistry.getRefName(value.constructor as Cls, { allowEmpty: true });
288
+ return refName ? { refName } : {};
289
+ }
290
+
291
+ static #exampleOf(example: unknown) {
292
+ if (example === null || example === undefined) return null;
293
+ if (["string", "number", "boolean"].includes(typeof example)) return { example: example as string };
294
+ return example instanceof Date ? { example } : null;
295
+ }
296
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./AgentBridge";
2
+ export * from "./StoreCatalogue";
3
+ export * from "./types";
@@ -0,0 +1,50 @@
1
+ import type { SerializedArg } from "akanjs/signal";
2
+ import type { SliceActionKey } from "../sliceRole";
3
+ import type { SliceStateKey } from "../state";
4
+
5
+ /** Which generated form-setter a store key is, when it is one. */
6
+ export type FormSetterRole = "set" | "add" | "sub" | "addOrSub";
7
+
8
+ /** What calling a store action reaches. `state` never leaves the browser. */
9
+ export type StoreActionEffect = "state" | "query" | "mutation";
10
+
11
+ export interface SerializedStoreAction {
12
+ args: SerializedArg[];
13
+ effect: StoreActionEffect;
14
+ /** The model this key belongs to, when the key identifies one. */
15
+ refName?: string;
16
+ /** The endpoint whose arguments it borrows, when it is named after one. */
17
+ endpoint?: string;
18
+ /** The generated role, absent on an action a module wrote itself. */
19
+ role?: SliceActionKey | FormSetterRole;
20
+ /** The model field it writes, on a form setter. */
21
+ field?: string;
22
+ }
23
+
24
+ export interface SerializedStoreState {
25
+ /**
26
+ * What the live value is. A store declares no types — `STATE_META` holds initial values — so this is read off the
27
+ * value the store is holding, and a key initialized to `null` or `[]` says nothing about what may go into it.
28
+ */
29
+ type: "string" | "number" | "boolean" | "date" | "list" | "map" | "object" | "unknown";
30
+ /** The model this key holds, when it holds one. What a read of it is masked by. */
31
+ refName?: string;
32
+ /** Which of the model's five classes, which is what decides the fields a read may carry. */
33
+ modelType?: "input" | "full" | "light" | "insight";
34
+ /** Materialized from a computation, the URL, or storage. Writing one throws, so it is read-only by construction. */
35
+ derived: boolean;
36
+ role?: SliceStateKey;
37
+ }
38
+
39
+ /**
40
+ * One store instance described for an agent: every key it can read and every action it may call.
41
+ *
42
+ * Flat rather than grouped by model, because that is what the store is — `st.use.x` and `st.do.y` are one namespace,
43
+ * so a key means the same thing to every reader and two models cannot both claim one. It is the store's answer to
44
+ * `SerializedSignal`, and it is derived on the client from the built store rather than shipped from the server: the
45
+ * store classes are in the bundle already, and a second copy over the wire is a second thing to keep in step.
46
+ */
47
+ export interface SerializedStore {
48
+ state: { [key: string]: SerializedStoreState };
49
+ action: { [key: string]: SerializedStoreAction };
50
+ }
@@ -0,0 +1,31 @@
1
+ import { capitalize } from "akanjs/common";
2
+
3
+ /**
4
+ * The state keys every model store holds, and which of the model's five classes each one is an instance of.
5
+ *
6
+ * Shared with the agent catalogue, which needs the class rather than the key: a value read out of the store is
7
+ * masked by the model it belongs to, and `immerify` drops the constructor, so the live value cannot say what it is.
8
+ * `modelForm` is the one that matters most — it is an `Input` holding whatever the user has typed, credentials
9
+ * included, and it reaches the catalogue as a plain object.
10
+ */
11
+ export const databaseStateNames = (refName: string) => {
12
+ const className = capitalize(refName);
13
+ return {
14
+ model: refName,
15
+ modelLoading: `${refName}Loading`,
16
+ modelForm: `${refName}Form`,
17
+ modelFormLoading: `${refName}FormLoading`,
18
+ modelSubmit: `${refName}Submit`,
19
+ modelViewAt: `${refName}ViewAt`,
20
+ modelModal: `${refName}Modal`,
21
+ modelOperation: `${refName}Operation`,
22
+ defaultModel: `default${className}`,
23
+ };
24
+ };
25
+
26
+ /** Which of the model's classes each generated state key holds, for the keys that hold one at all. */
27
+ export const databaseStateModelTypes = {
28
+ model: "full",
29
+ modelForm: "input",
30
+ defaultModel: "full",
31
+ } as const satisfies { [key in keyof ReturnType<typeof databaseStateNames>]?: "full" | "input" | "light" | "insight" };