@uicast/core 0.0.1-beta.1

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.
@@ -0,0 +1,233 @@
1
+ import * as acorn from "acorn";
2
+ import {
3
+ containsAwait,
4
+ extractFreeIdentifiers,
5
+ extractScopeReads,
6
+ } from "./analyze";
7
+ import { GLOBALS_TO_SHADOW } from "./globals";
8
+ import { SaferEvalError, validateNode } from "./validate";
9
+
10
+ // Compiles and runs the expressions validate.ts approves: parse + analyse once
11
+ // (cached), compile to a Function (AsyncFunction when the expr uses `await`),
12
+ // shadow ambient globals as undefined params, run in-realm so Proxy state works.
13
+ // Guardrail, not a sandbox — see the Expressions docs page, § Safety.
14
+
15
+ // Re-exported from validate.ts for callers of this module.
16
+ export { SaferEvalError };
17
+
18
+ // The AsyncFunction constructor, for expressions that use `await`.
19
+ const AsyncFunction = Object.getPrototypeOf(async () => {})
20
+ .constructor as typeof Function;
21
+
22
+ type CacheEntry = {
23
+ isAsync: boolean;
24
+ scopeReads: string[];
25
+ freeIds: string[];
26
+ // Built lazily on first compile(); validate()/scopeReads() leave it unset.
27
+ fn?: (context: Record<string, unknown>) => unknown;
28
+ };
29
+
30
+ export class SaferEval {
31
+ #cache = new Map<string, CacheEntry>();
32
+ #maxCacheSize: number;
33
+ #shadowParams: string[];
34
+ #allowGlobals: Set<string>;
35
+ #enforceAllowlist: boolean;
36
+
37
+ constructor(
38
+ options: {
39
+ maxCacheSize?: number;
40
+ extraGlobalsToShadow?: string[];
41
+ allowGlobals?: string[];
42
+ enforceAllowlist?: boolean;
43
+ } = {},
44
+ ) {
45
+ const {
46
+ maxCacheSize = 500,
47
+ extraGlobalsToShadow = [],
48
+ allowGlobals = [],
49
+ enforceAllowlist = false,
50
+ } = options;
51
+
52
+ this.#maxCacheSize = maxCacheSize;
53
+ this.#enforceAllowlist = enforceAllowlist;
54
+
55
+ const allowSet = new Set(allowGlobals);
56
+ this.#allowGlobals = allowSet;
57
+ this.#shadowParams = [...GLOBALS_TO_SHADOW, ...extraGlobalsToShadow].filter(
58
+ (g) => !allowSet.has(g),
59
+ );
60
+ }
61
+
62
+ // Parse + validate without running. Throws SaferEvalError if invalid; returns
63
+ // isAsync + scopeReads and caches the analysis (compile() builds the fn later).
64
+ validate(expression: string): {
65
+ isAsync: boolean;
66
+ scopeReads: string[];
67
+ freeIds: string[];
68
+ } {
69
+ if (typeof expression !== "string") {
70
+ throw new SaferEvalError("Expression must be a string", "expression-syntax");
71
+ }
72
+
73
+ const trimmed = expression.trim();
74
+ if (!trimmed) {
75
+ throw new SaferEvalError("Expression cannot be empty", "expression-syntax");
76
+ }
77
+
78
+ const cached = this.#cache.get(expression);
79
+ if (cached) {
80
+ return {
81
+ isAsync: cached.isAsync,
82
+ scopeReads: cached.scopeReads,
83
+ freeIds: cached.freeIds,
84
+ };
85
+ }
86
+
87
+ // Parse as `"use strict"; void (expr)`: void(...) forces expression context
88
+ // (so `{...}` is an object literal, not a block), and strict mode rejects
89
+ // sloppy-only constructs (octal, `with`, dup params) here, not at compile.
90
+ const wrapper = `"use strict"; void (${trimmed})`;
91
+ let ast: acorn.Program;
92
+ try {
93
+ ast = acorn.parse(wrapper, {
94
+ ecmaVersion: 2022,
95
+ sourceType: "script",
96
+ allowAwaitOutsideFunction: true,
97
+ });
98
+ } catch (e: unknown) {
99
+ throw new SaferEvalError(
100
+ `Syntax error: ${e instanceof Error ? e.message : String(e)}. Expression: ${expression}`,
101
+ "expression-syntax",
102
+ );
103
+ }
104
+
105
+ validateNode(ast);
106
+
107
+ const analysis = {
108
+ isAsync: containsAwait(ast),
109
+ scopeReads: extractScopeReads(ast),
110
+ freeIds: extractFreeIdentifiers(ast),
111
+ };
112
+
113
+ // Cache the analysis (no fn yet); evict oldest when full.
114
+ if (this.#cache.size >= this.#maxCacheSize) {
115
+ const firstKey = this.#cache.keys().next().value;
116
+ if (firstKey !== undefined) this.#cache.delete(firstKey);
117
+ }
118
+ this.#cache.set(expression, { ...analysis });
119
+
120
+ return analysis;
121
+ }
122
+
123
+ // Compile to a reusable fn (returns a Promise if the expr uses `await`).
124
+ compile(expression: string): (context: Record<string, unknown>) => unknown {
125
+ const { isAsync } = this.validate(expression);
126
+ const entry = this.#cache.get(expression);
127
+ if (!entry) {
128
+ // Unreachable — validate() just inserted this entry.
129
+ throw new SaferEvalError(
130
+ `Cache entry missing for expression: ${expression}`,
131
+ );
132
+ }
133
+ if (entry.fn) return entry.fn;
134
+
135
+ const expr = expression.trim();
136
+ const Ctor = isAsync ? AsyncFunction : Function;
137
+
138
+ // Single-slot cache: new Ctor bakes in the param names (context keys ∪ shadow
139
+ // params), so it recompiles only if the context shape changes — which it
140
+ // doesn't in practice (a given expr always sees the same keys).
141
+ let cachedSig: string | undefined;
142
+ let cachedFn: ((...args: unknown[]) => unknown) | undefined;
143
+
144
+ const evaluator = (context: Record<string, unknown>) => {
145
+ const contextKeys = Object.keys(context);
146
+ const sig = contextKeys.join("\u0000");
147
+ if (sig !== cachedSig) {
148
+ // Context keys become params; shadow params follow, bound to undefined.
149
+ const allParams = [...contextKeys, ...this.#shadowParams];
150
+ try {
151
+ cachedFn = new Ctor(
152
+ ...allParams,
153
+ `"use strict"; return (${expr})`,
154
+ ) as (...args: unknown[]) => unknown;
155
+ } catch (e: unknown) {
156
+ // Parsed but won't compile — surface as SaferEvalError, not a raw one.
157
+ throw new SaferEvalError(
158
+ `Failed to compile expression: ${
159
+ e instanceof Error ? e.message : String(e)
160
+ }. Expression: ${expression}`,
161
+ "expression-syntax",
162
+ );
163
+ }
164
+ cachedSig = sig;
165
+ }
166
+
167
+ // Args match allParams: context values (Object.keys order) then shadow fills.
168
+ const allArgs = [
169
+ ...Object.values(context),
170
+ ...new Array(this.#shadowParams.length).fill(undefined),
171
+ ];
172
+ return (cachedFn as (...args: unknown[]) => unknown)(...allArgs);
173
+ };
174
+
175
+ entry.fn = evaluator;
176
+ return evaluator;
177
+ }
178
+
179
+ isAsync(expression: string): boolean {
180
+ const cached = this.#cache.get(expression);
181
+ if (cached) return cached.isAsync;
182
+ return this.validate(expression).isAsync;
183
+ }
184
+
185
+ // Every `scopes.X.Y` path the expression reads — the renderer auto-subscribes
186
+ // to these instead of the LLM writing a deps array.
187
+ scopeReads(expression: string): string[] {
188
+ const cached = this.#cache.get(expression);
189
+ if (cached) return cached.scopeReads;
190
+ return this.validate(expression).scopeReads;
191
+ }
192
+
193
+ // Allowlist gate: every free identifier must be an injected context name, a
194
+ // base allowed global, or one the host opted into via allowedGlobals.
195
+ #checkAllowlist(
196
+ freeIds: string[],
197
+ contextKeys: string[],
198
+ allowedGlobals: string[],
199
+ ): void {
200
+ const allowed = new Set([
201
+ ...contextKeys,
202
+ ...this.#allowGlobals,
203
+ ...allowedGlobals,
204
+ ]);
205
+ for (const id of freeIds) {
206
+ if (!allowed.has(id)) {
207
+ // The expression named something that doesn't exist here — an
208
+ // unregistered function or a global outside the allowlist.
209
+ throw new SaferEvalError(
210
+ `"${id}" is not available in expressions`,
211
+ "unknown-reference",
212
+ );
213
+ }
214
+ }
215
+ }
216
+
217
+ eval(
218
+ expression: string,
219
+ context: Record<string, unknown> = {},
220
+ allowedGlobals: string[] = [],
221
+ ): unknown {
222
+ if (this.#enforceAllowlist) {
223
+ const { freeIds } = this.validate(expression);
224
+ this.#checkAllowlist(freeIds, Object.keys(context), allowedGlobals);
225
+ }
226
+ const evaluator = this.compile(expression);
227
+ return evaluator(context);
228
+ }
229
+
230
+ clearCache(): void {
231
+ this.#cache.clear();
232
+ }
233
+ }
@@ -0,0 +1,149 @@
1
+ import type * as acorn from "acorn";
2
+ import { isNode } from "./ast-utils";
3
+
4
+ // Expression validation — the guardrail. Walks a parsed expression and throws on
5
+ // anything that shouldn't run; safer-eval.ts compiles only what this approves.
6
+ // Guardrail, NOT a sandbox: a computed key (`obj["con"+"structor"]`) escapes it.
7
+ // Full threat model: the Expressions docs page, § Safety.
8
+
9
+ // Which EntryError reason an evaluation failure maps to. Defaults to
10
+ // "sandbox-violation" (every policy rejection in this file); the parse and
11
+ // allowlist sites in safer-eval.ts override it.
12
+ export type SaferEvalErrorReason =
13
+ | "expression-syntax"
14
+ | "sandbox-violation"
15
+ | "unknown-reference";
16
+
17
+ export class SaferEvalError extends Error {
18
+ readonly reason: SaferEvalErrorReason;
19
+
20
+ constructor(
21
+ message: string,
22
+ reason: SaferEvalErrorReason = "sandbox-violation",
23
+ ) {
24
+ super(message);
25
+ this.name = "SaferEvalError";
26
+ this.reason = reason;
27
+ }
28
+ }
29
+
30
+ // `eval` / `arguments` can't be shadowed as strict-mode params, so reject them as
31
+ // references here instead (the rest are shadowed — see GLOBALS_TO_SHADOW).
32
+ const FORBIDDEN_IDENTIFIERS = new Set(["eval", "arguments"]);
33
+
34
+ // Most dangerous syntax never reaches this walk: every expression is parsed as
35
+ // `"use strict"; void (EXPR)`, so a top-level statement / declaration / `with` /
36
+ // octal / import is a syntax error. What survives, to reject by hand, is below.
37
+
38
+ // Forbidden everywhere, even in a function body: dynamic `import()` loads code,
39
+ // and `import.meta` / `new.target` (MetaProperty) reach host / constructor
40
+ // internals. Keywords, so shadowing can't touch them — block structurally.
41
+ const FORBIDDEN_EXPRESSIONS = new Set(["ImportExpression", "MetaProperty"]);
42
+
43
+ // Forbidden as the whole expression (which must be one pure value); fine inside a
44
+ // callback body where imperative code is allowed.
45
+ const BODY_ONLY_EXPRESSIONS = new Set([
46
+ "AssignmentExpression", // x = 1, x += 1
47
+ "UpdateExpression", // x++, --x
48
+ "SequenceExpression", // a, b — comma operator
49
+ ]);
50
+
51
+ // Escape-hatch property names: `.constructor` reaches the Function constructor,
52
+ // `.__proto__` the prototype chain. Only static keys are caught; a computed key
53
+ // escapes (the guardrail gap — banning it would ban all `arr[i]` access).
54
+ const FORBIDDEN_PROPERTIES = new Set([
55
+ "constructor",
56
+ "__proto__",
57
+ "prototype",
58
+ "__defineGetter__",
59
+ "__defineSetter__",
60
+ "__lookupGetter__",
61
+ "__lookupSetter__",
62
+ ]);
63
+
64
+ // Recursively walk the parsed expression, throwing on anything disallowed.
65
+ // insideFunctionBody flips once inside an arrow / function body, where the
66
+ // BODY_ONLY_EXPRESSIONS become legal. Entry point is acorn's Program node.
67
+ export function validateNode(
68
+ node: acorn.AnyNode | null | undefined,
69
+ insideFunctionBody = false,
70
+ ): void {
71
+ if (!node || typeof node !== "object") return;
72
+
73
+ // The wrapped program must hold exactly one expression (the filter drops the
74
+ // "use strict" directive). More than one means the expr smuggled a `)` to break
75
+ // out of `void (...)` — e.g. `1); evil(); (2`.
76
+ if (node.type === "Program") {
77
+ const statements = node.body.filter(
78
+ (s) => !(s.type === "ExpressionStatement" && s.directive),
79
+ );
80
+ if (statements.length !== 1) {
81
+ throw new SaferEvalError(
82
+ `Expression must be a single expression, got ${statements.length} statements`,
83
+ );
84
+ }
85
+ validateNode(statements[0]);
86
+ return;
87
+ }
88
+
89
+ // Dynamic import / meta-properties: never allowed, not even in a body.
90
+ if (FORBIDDEN_EXPRESSIONS.has(node.type)) {
91
+ throw new SaferEvalError(`"${node.type}" is not allowed in expressions`);
92
+ }
93
+
94
+ // Assignment / update / comma: allowed in a callback body, not as the whole expr.
95
+ if (BODY_ONLY_EXPRESSIONS.has(node.type) && !insideFunctionBody) {
96
+ throw new SaferEvalError(
97
+ `"${node.type}" is only allowed inside a function body, not as the whole expression`,
98
+ );
99
+ }
100
+
101
+ // Tagged templates invoke an arbitrary tag function.
102
+ if (node.type === "TaggedTemplateExpression") {
103
+ throw new SaferEvalError("Tagged template literals are not allowed");
104
+ }
105
+
106
+ // Static access to escape-hatch properties; dynamic keys slip past.
107
+ if (node.type === "MemberExpression") {
108
+ const { property, computed } = node;
109
+ const staticKey =
110
+ !computed && property.type === "Identifier"
111
+ ? property.name
112
+ : computed &&
113
+ property.type === "Literal" &&
114
+ typeof property.value === "string"
115
+ ? property.value
116
+ : null;
117
+ if (staticKey !== null && FORBIDDEN_PROPERTIES.has(staticKey)) {
118
+ throw new SaferEvalError(`Access to "${staticKey}" is not allowed`);
119
+ }
120
+ }
121
+
122
+ // `eval` / `arguments` in reference position (the recursion below skips a
123
+ // non-computed member's `.property`, so a harmless `row.arguments` isn't hit).
124
+ if (node.type === "Identifier" && FORBIDDEN_IDENTIFIERS.has(node.name)) {
125
+ throw new SaferEvalError(`Access to "${node.name}" is not allowed`);
126
+ }
127
+
128
+ // Recurse. Two carries: descending into a function `body` sets
129
+ // insideFunctionBody for the subtree; a non-computed member's `.property` is a
130
+ // name already checked, so skip it (a computed `obj[eval]` is not skipped).
131
+ const fields = node as unknown as Record<string, unknown>;
132
+ const isFunction =
133
+ node.type === "ArrowFunctionExpression" ||
134
+ node.type === "FunctionExpression";
135
+ for (const key of Object.keys(node)) {
136
+ if (key === "type" || key === "start" || key === "end") continue;
137
+ if (node.type === "MemberExpression" && !node.computed && key === "property")
138
+ continue;
139
+ const child = fields[key];
140
+ const childInside = insideFunctionBody || (isFunction && key === "body");
141
+ if (Array.isArray(child)) {
142
+ for (const c of child) {
143
+ if (isNode(c)) validateNode(c, childInside);
144
+ }
145
+ } else if (isNode(child)) {
146
+ validateNode(child, childInside);
147
+ }
148
+ }
149
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ // uicast public API. Prompt-partial builders live in uicast/prompt.
2
+
3
+ export { createComponentDefinition } from "./def/create-component-definition";
4
+
5
+ export { evaluate } from "./expr/evaluate";
6
+ export { extractDeps } from "./expr/extract-deps";
7
+
8
+ export {
9
+ EntryError,
10
+ type EntryErrorReason,
11
+ type EntryFault,
12
+ } from "./entry-error";
13
+
14
+ export { createProxyScope, type ReactiveProxy } from "./scope/create-proxy-scope";
15
+ export { parseScope } from "./scope/parse-scope";
16
+
17
+ export { buildElementsById } from "./utils/utils";
18
+
19
+ export { streamJsonLines } from "./stream/stream-json-lines";
20
+
21
+ export {
22
+ type ComponentEntry,
23
+ type ComponentListEntry,
24
+ type ComponentDefinition,
25
+ type CombinedSpec,
26
+ isComponentEntry,
27
+ isComponentListEntry,
28
+ } from "./types";
@@ -0,0 +1,20 @@
1
+ import INSTRUCTIONS from "./md/INSTRUCTIONS.json" with { type: "json" };
2
+
3
+ /** No options yet — the type is reserved so the signature can grow without breaking callers. */
4
+ export type CommonInstructionsPromptOptions = Record<string, never>;
5
+
6
+ /**
7
+ * The LLM-facing instruction block — the contract authored in
8
+ * `md/INSTRUCTIONS.md` and imported here as a JSON string via the md-to-json
9
+ * pipeline (`npm run md-to-json` regenerates the `.json` sibling after edits).
10
+ *
11
+ * A partial-prompt primitive: the consuming app's prompt assembler composes
12
+ * this — alongside the other `get*PartialPrompt` builders — into the full
13
+ * system prompt it sends to the generation LLM. core ships the pieces; the app
14
+ * owns the assembly.
15
+ */
16
+ export function getCommonInstructionsPartialPrompt(
17
+ _options: CommonInstructionsPromptOptions = {},
18
+ ): string {
19
+ return INSTRUCTIONS;
20
+ }
@@ -0,0 +1,153 @@
1
+ import { type JSONSchema, JSONSchemaToTs } from "../prompt-utils/json-schema-to-ts";
2
+ import type { CombinedSpec, ComponentDefinition } from "../types";
3
+
4
+ const toJSONSchema = (spec: CombinedSpec): unknown =>
5
+ spec["~standard"].jsonSchema.input({ target: "draft-2020-12" });
6
+
7
+ const readId = (jsonSchema: unknown): string | undefined =>
8
+ (jsonSchema as { $id?: string } | null)?.$id;
9
+
10
+ export type ComponentsPromptOptions = {
11
+ /** The component defs to advertise — the catalog's `allDefinitions` (plus any app-local defs). */
12
+ definitions: ComponentDefinition[];
13
+ /** Shared event schemas (each with a JSON Schema `$id`), rendered once under `# Common Events`. */
14
+ commonEvents?: CombinedSpec[];
15
+ };
16
+
17
+ /**
18
+ * Render an object schema's properties as an indented, described bullet list:
19
+ *
20
+ * - count?: number — The number to display
21
+ *
22
+ * Each field's type comes from `JSONSchemaToTs`; its `description` (when set)
23
+ * follows after an em-dash. A non-object schema, or one without properties,
24
+ * yields `[]`. Shared by a component's props and a typed event's options — both
25
+ * are "object schema → described fields".
26
+ */
27
+ const describeFields = (jsonSchema: unknown, indent: string): string[] => {
28
+ const schema = jsonSchema as JSONSchema | null;
29
+ if (!schema?.properties) return [];
30
+ const required = schema.required ?? [];
31
+ return Object.entries(schema.properties).map(([name, field]) => {
32
+ const optional = required.includes(name) ? "" : "?";
33
+ const description = field.description;
34
+ return `${indent}- ${name}${optional}: ${JSONSchemaToTs(field)}${
35
+ description ? ` — ${description}` : ""
36
+ }`;
37
+ });
38
+ };
39
+
40
+ /**
41
+ * Render an array of component defs into the prompt's component section — a
42
+ * `# Available Components` names list, an optional `# Common Events` block, then
43
+ * `# Component Details`, one entry per visible def: its name + description, a
44
+ * `Props:` list (each prop's type + description) and an `Event handlers:` list
45
+ * (each handler's signature + description, a typed event's options described one
46
+ * level deeper). Types come from `JSONSchemaToTs`; every description rides after
47
+ * an em-dash, so nothing the author wrote on a prop, option, or handler is lost.
48
+ *
49
+ * `commonEvents` hoists handlers shared across many components (e.g. `onClick`)
50
+ * out of every entry. Each passed schema must carry a JSON Schema `$id`; its
51
+ * payload is rendered once as a named type under `# Common Events`, and a
52
+ * component whose callback is one of them references it as `evt: <$id>` instead
53
+ * of re-inlining the payload — killing the per-component duplication. A callback
54
+ * is matched by its `$id`, so it stays library-agnostic (anything that emits
55
+ * `$id` participates); non-common callbacks render inline as before.
56
+ *
57
+ * Host-only defs (`hidden: true`, e.g. RootFragment) are filtered out so the LLM
58
+ * never sees host infrastructure in its component menu. Catalog-agnostic: the
59
+ * caller passes whatever def set + common events it exposes.
60
+ *
61
+ * Mirrors `getFunctionsPartialPrompt` — same `# Available X` / `# X Details`
62
+ * two-section shape.
63
+ *
64
+ * Throws on a duplicate component `name`, a common event whose schema has no
65
+ * `$id`, or a duplicate common event `$id`.
66
+ */
67
+ export function getComponentsPartialPrompt({
68
+ definitions: defs,
69
+ commonEvents = [],
70
+ }: ComponentsPromptOptions): string {
71
+ const seen = new Set<string>();
72
+ for (const def of defs) {
73
+ if (seen.has(def.name)) {
74
+ throw new Error(`Duplicate component name: "${def.name}"`);
75
+ }
76
+ seen.add(def.name);
77
+ }
78
+
79
+ // Index the common events by `$id`, rendering each once. A missing `$id`
80
+ // would leave the handler unmatchable (it would silently inline on every
81
+ // component), so it's a hard error — as is a duplicate id, which would make
82
+ // the per-component reference ambiguous.
83
+ const commonIds = new Set<string>();
84
+ const commonLines: string[] = [];
85
+ for (const schema of commonEvents) {
86
+ const jsonSchema = toJSONSchema(schema);
87
+ const id = readId(jsonSchema);
88
+ if (!id) {
89
+ throw new Error(
90
+ 'Common event schema is missing a JSON Schema `$id` (add e.g. .meta({ $id: "onClick" }))',
91
+ );
92
+ }
93
+ if (commonIds.has(id)) {
94
+ throw new Error(`Duplicate common event id: "${id}"`);
95
+ }
96
+ commonIds.add(id);
97
+ const ts = JSONSchemaToTs(jsonSchema);
98
+ const description = (jsonSchema as { description?: string }).description;
99
+ commonLines.push(`- ${id}: ${ts}${description ? ` — ${description}` : ""}`);
100
+ }
101
+
102
+ const visible = defs.filter((def) => !def.hidden);
103
+
104
+ const detail = visible
105
+ .map(({ name, description, props, callbacks }) => {
106
+ const lines = [`- ${name} — ${description}`];
107
+
108
+ const propLines = describeFields(toJSONSchema(props), " ");
109
+ if (propLines.length) lines.push(" Props:", ...propLines);
110
+
111
+ const callbackLines = Object.entries(callbacks || {}).flatMap(
112
+ ([cbName, cbDef]) => {
113
+ const cbJSONSchema = toJSONSchema(cbDef);
114
+ const id = readId(cbJSONSchema);
115
+ if (id && commonIds.has(id)) {
116
+ // Common event — its payload is named + described once under
117
+ // `# Common Events`; reference it instead of re-inlining here.
118
+ return [` - ${cbName}(evt: ${id})`];
119
+ }
120
+ // The description documents the handler itself, not its `evt`
121
+ // argument — append it after the signature, as common events do.
122
+ const cbDescription = (cbJSONSchema as JSONSchema | null)?.description;
123
+ const tail = cbDescription ? ` — ${cbDescription}` : "";
124
+ // A `null` payload means the handler carries no event data — render it
125
+ // as a no-arg call rather than `(evt: null)`.
126
+ if ((cbJSONSchema as JSONSchema | null)?.type === "null") {
127
+ return [` - ${cbName}()${tail}`];
128
+ }
129
+ // An object payload's fields are the event's options — list them like
130
+ // props, one level deeper. Anything else keeps the inline `evt` type.
131
+ const optionLines = describeFields(cbJSONSchema, " ");
132
+ if (optionLines.length) {
133
+ return [` - ${cbName}(evt)${tail}`, ...optionLines];
134
+ }
135
+ return [` - ${cbName}(evt: ${JSONSchemaToTs(cbJSONSchema)})${tail}`];
136
+ },
137
+ );
138
+ if (callbackLines.length) lines.push(" Event handlers:", ...callbackLines);
139
+
140
+ return lines.join("\n");
141
+ })
142
+ .join("\n\n");
143
+
144
+ return (
145
+ "# Available Components\n\n" +
146
+ visible.map((def) => def.name).join(", ") +
147
+ (commonLines.length
148
+ ? `\n\n# Common Events\n\n${commonLines.join("\n")}`
149
+ : "") +
150
+ "\n\n# Component Details\n\n" +
151
+ detail
152
+ );
153
+ }
@@ -0,0 +1,37 @@
1
+ export type EditRequestPromptOptions = {
2
+ /** The user's change request, e.g. "Add pagination to the orders table". */
3
+ request: string;
4
+ /**
5
+ * Keys referenced as children in the replayed tree but never emitted —
6
+ * typically the tail of an earlier response that was cut off. Listing them
7
+ * tells the model those elements do NOT exist, so it re-emits them instead
8
+ * of keeping them by reference.
9
+ */
10
+ missingKeys?: string[];
11
+ };
12
+
13
+ /**
14
+ * The user-turn message for an incremental edit. The host replays the page's
15
+ * current JSONL as an assistant turn, then sends this as the next user turn:
16
+ * the change request plus the delta convention (re-emit a key to replace its
17
+ * subtree — the Partial Replacement rule the system prompt already teaches).
18
+ * The model then emits only the elements that change instead of the whole
19
+ * page — cheaper and faster, since output tokens dominate both.
20
+ *
21
+ * Not a system-prompt partial: this is per-turn message content, composed by
22
+ * the host into `messages`, not into `system`.
23
+ */
24
+ export function getEditRequestPrompt({
25
+ request,
26
+ missingKeys = [],
27
+ }: EditRequestPromptOptions): string {
28
+ return [
29
+ request,
30
+ "Update the previously generated page above. Emit ONLY the elements that change: re-emit an existing key to replace that element (its new `children` array defines the new subtree — children you still reference are kept as-is, so do not re-emit them), use fresh keys for new elements, and do not re-emit unchanged elements. Never re-emit the whole page. `seed` on re-emitted keys does NOT re-run — initialize any NEW state via the `seed` of a newly-keyed element instead.",
31
+ missingKeys.length > 0
32
+ ? `Warning: the page above references child keys that were never emitted (the earlier response was cut off): ${missingKeys.join(", ")}. These elements do NOT exist — emit them now, or re-emit their parents with corrected children.`
33
+ : "",
34
+ ]
35
+ .filter(Boolean)
36
+ .join("\n\n");
37
+ }
@@ -0,0 +1,37 @@
1
+ export type RenderFailure = {
2
+ /** The `key` of the element whose render failed. */
3
+ key: string;
4
+ /** The runtime error message, verbatim. */
5
+ message: string;
6
+ };
7
+
8
+ export type ErrorRecoveryPromptOptions = {
9
+ /** The failures to report, one per failed element. */
10
+ failures: RenderFailure[];
11
+ };
12
+
13
+ /**
14
+ * The user-turn message for user-triggered error recovery: an element failed
15
+ * at runtime (its error slot rendered), and the host asks the model to re-emit
16
+ * it corrected. Deliberately surface-neutral — it states the failures and the
17
+ * fix request, while the output conventions come from the surface's own
18
+ * prompt: on a page surface the host passes this through its edit pipeline
19
+ * (replayed document + `getEditRequestPrompt`, which carries the delta
20
+ * convention), on a chat surface it goes out as a plain user message and the
21
+ * model replies with a corrected fence.
22
+ *
23
+ * Not a system-prompt partial: this is per-turn message content, composed by
24
+ * the host into `messages`, not into `system`.
25
+ */
26
+ export function getErrorRecoveryPrompt({
27
+ failures,
28
+ }: ErrorRecoveryPromptOptions): string {
29
+ const list = failures
30
+ .map((failure) => `- Element \`${failure.key}\`: ${failure.message}`)
31
+ .join("\n");
32
+ return [
33
+ "The generated UI hit runtime errors:",
34
+ list,
35
+ "Diagnose each failure and emit a corrected version of the failed element, keeping its `key`. Fix the cause, not just the reported line — if the error comes from state or an expression that another element sets up (e.g. a `seed` elsewhere), correct that element as well.",
36
+ ].join("\n\n");
37
+ }