@adia-ai/a2ui-compose 0.4.7 → 0.4.9

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.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,16 @@ generator graph.
12
12
 
13
13
  _No pending changes._
14
14
 
15
+ ## [0.4.9] - 2026-05-13
16
+
17
+ _No pending changes._
18
+
19
+ ## [0.4.8] - 2026-05-12
20
+
21
+ ### Fixed — `strategies/zettel/generator-adapter.js` `ensureBooted()` race (§87a, v0.4.8)
22
+
23
+ The zettel composition library's lazy boot had a race condition where concurrent `ensureBooted()` calls could each kick off a separate boot promise. Under contention (multiple requests landing simultaneously at server cold-start), the composition map could be partially populated when consumers reached the synchronous getters. Fix: memoize the boot promise on first call so all subsequent callers await the same promise. Pairs with the v0.4.8 §87 honest-floor eval threshold rebaseline — without the boot race fix, the 1% rebaseline was actually undercounting due to occasional empty-map reads.
24
+
15
25
  ## [0.4.7] - 2026-05-12
16
26
 
17
27
  ### Changed — `strategies/monolithic/_shared.js` `getComponentCatalog()` reads canonical catalog (§72)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-compose",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "AdiaUI A2UI compose engine — framework-agnostic. Takes natural-language intents + a catalog and produces A2UI protocol messages. Pairs with `@adia-ai/a2ui-retrieval` (intent classification, catalog lookup) and `@adia-ai/a2ui-validator` (schema + semantic checks).",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,228 @@
1
+ /**
2
+ * free-form-composer — vitest cases.
3
+ *
4
+ * Six cases per the v0.4.8 §88 spec:
5
+ * 1. No LLM → returns `free-form-no-llm` cleanly
6
+ * 2. Valid plan → composes, prefixes IDs, wraps in Column
7
+ * 3. Substitution applies on Text node, ignores non-Text node
8
+ * 4. Hallucination → retries once with feedback, then falls through
9
+ * 5. Parse-fail → same hallucination path
10
+ * 6. Empty vocabulary → returns `free-form-empty-vocab`
11
+ */
12
+ import { describe, it, expect } from 'vitest';
13
+ import { generateFreeForm } from './index.js';
14
+ import { transpilePlan, parsePlan } from './transpile.js';
15
+ import { buildFreeFormSystemPrompt } from './system-prompt.js';
16
+
17
+ const FIXTURE_VOCAB = [
18
+ {
19
+ name: 'demo-login',
20
+ domain: 'auth',
21
+ description: 'A demo login card',
22
+ keywords: ['login', 'auth'],
23
+ template: [
24
+ { id: 'card', component: 'Card', children: ['title', 'submit'] },
25
+ { id: 'title', component: 'Text', variant: 'h1', textContent: 'Sign in' },
26
+ { id: 'submit', component: 'Button', text: 'Continue', variant: 'primary' },
27
+ ],
28
+ },
29
+ {
30
+ name: 'demo-signup',
31
+ domain: 'auth',
32
+ description: 'A demo signup card',
33
+ keywords: ['signup', 'register'],
34
+ template: [
35
+ { id: 'card', component: 'Card', children: ['title'] },
36
+ { id: 'title', component: 'Text', variant: 'h1', textContent: 'Create account' },
37
+ ],
38
+ },
39
+ ];
40
+
41
+ describe('free-form-composer', () => {
42
+ it('returns free-form-no-llm when no LLM adapter is provided', async () => {
43
+ const result = await generateFreeForm({
44
+ intent: 'login form',
45
+ llmAdapter: null,
46
+ compositions: FIXTURE_VOCAB,
47
+ });
48
+ expect(result.strategy).toBe('free-form-no-llm');
49
+ expect(result.messages).toEqual([]);
50
+ expect(result.validation.score).toBe(0);
51
+ });
52
+
53
+ it('composes a plan into A2UI messages with prefixed IDs + Column root', async () => {
54
+ const fakeLLM = {
55
+ complete: async () => ({
56
+ content: JSON.stringify({
57
+ ingredients: [{ name: 'demo-login' }, { name: 'demo-signup' }],
58
+ rationale: 'Two-card auth flow.',
59
+ }),
60
+ }),
61
+ };
62
+ const result = await generateFreeForm({
63
+ intent: 'auth flow',
64
+ llmAdapter: fakeLLM,
65
+ compositions: FIXTURE_VOCAB,
66
+ });
67
+ expect(result.strategy).toBe('free-form-composed');
68
+ expect(result.usedIngredients).toEqual(['demo-login', 'demo-signup']);
69
+ expect(result.messages).toHaveLength(1);
70
+ const components = result.messages[0].components;
71
+ const root = components[0];
72
+ expect(root.component).toBe('Column');
73
+ expect(root.children).toEqual(['demo-login__card', 'demo-signup__card']);
74
+ // IDs are prefixed so two ingredients with the same internal id ('card', 'title') don't collide.
75
+ const ids = new Set(components.map(c => c.id));
76
+ expect(ids.size).toBe(components.length);
77
+ expect(ids.has('demo-login__card')).toBe(true);
78
+ expect(ids.has('demo-signup__card')).toBe(true);
79
+ expect(ids.has('demo-login__title')).toBe(true);
80
+ expect(ids.has('demo-signup__title')).toBe(true);
81
+ });
82
+
83
+ it('applies text substitutions on Text nodes; warns on non-Text or unknown keys', async () => {
84
+ const fakeLLM = {
85
+ complete: async () => ({
86
+ content: JSON.stringify({
87
+ ingredients: [{
88
+ name: 'demo-login',
89
+ substitutions: {
90
+ title: 'Welcome back', // OK — title is Text
91
+ submit: 'Sign in now', // skipped — submit is Button (not Text)
92
+ missing: 'foo', // skipped — no such id in template
93
+ },
94
+ }],
95
+ rationale: 'Re-skinned login card.',
96
+ }),
97
+ }),
98
+ };
99
+ const result = await generateFreeForm({
100
+ intent: 'login form',
101
+ llmAdapter: fakeLLM,
102
+ compositions: FIXTURE_VOCAB,
103
+ });
104
+ expect(result.strategy).toBe('free-form-composed');
105
+ const titleNode = result.messages[0].components.find(c => c.id === 'demo-login__title');
106
+ expect(titleNode.textContent).toBe('Welcome back');
107
+ const submitNode = result.messages[0].components.find(c => c.id === 'demo-login__submit');
108
+ expect(submitNode.text).toBe('Continue'); // unchanged — substitution skipped
109
+ expect(result.warnings.some(w => w.includes('submit'))).toBe(true);
110
+ expect(result.warnings.some(w => w.includes('missing'))).toBe(true);
111
+ });
112
+
113
+ it('retries once on hallucination, then falls through with free-form-hallucinated', async () => {
114
+ let calls = 0;
115
+ const fakeLLM = {
116
+ complete: async () => {
117
+ calls++;
118
+ return { content: JSON.stringify({ ingredients: [{ name: 'not-a-real-name' }], rationale: '...' }) };
119
+ },
120
+ };
121
+ const result = await generateFreeForm({
122
+ intent: 'login form',
123
+ llmAdapter: fakeLLM,
124
+ compositions: FIXTURE_VOCAB,
125
+ });
126
+ expect(result.strategy).toBe('free-form-hallucinated');
127
+ expect(calls).toBe(2); // initial + one retry
128
+ expect(result.attempts).toBe(2);
129
+ expect(result.messages).toEqual([]);
130
+ });
131
+
132
+ it('treats unparseable LLM response as hallucination and retries', async () => {
133
+ let calls = 0;
134
+ const fakeLLM = {
135
+ complete: async () => {
136
+ calls++;
137
+ return { content: 'I think you want a login form, but I cannot output JSON.' };
138
+ },
139
+ };
140
+ const result = await generateFreeForm({
141
+ intent: 'login form',
142
+ llmAdapter: fakeLLM,
143
+ compositions: FIXTURE_VOCAB,
144
+ });
145
+ expect(result.strategy).toBe('free-form-hallucinated');
146
+ expect(calls).toBe(2);
147
+ });
148
+
149
+ it('returns free-form-empty-vocab when vocabulary is empty', async () => {
150
+ const fakeLLM = { complete: async () => ({ content: '{"ingredients":[]}' }) };
151
+ const result = await generateFreeForm({
152
+ intent: 'anything',
153
+ llmAdapter: fakeLLM,
154
+ compositions: [],
155
+ });
156
+ expect(result.strategy).toBe('free-form-empty-vocab');
157
+ expect(result.messages).toEqual([]);
158
+ });
159
+ });
160
+
161
+ describe('parsePlan', () => {
162
+ it('parses bare JSON', () => {
163
+ const plan = parsePlan('{"ingredients":[{"name":"x"}]}');
164
+ expect(plan).not.toBeNull();
165
+ expect(plan.ingredients).toHaveLength(1);
166
+ });
167
+
168
+ it('strips fenced code blocks', () => {
169
+ const plan = parsePlan('```json\n{"ingredients":[{"name":"x"}]}\n```');
170
+ expect(plan).not.toBeNull();
171
+ expect(plan.ingredients).toHaveLength(1);
172
+ });
173
+
174
+ it('extracts JSON object from prose lead-in', () => {
175
+ const plan = parsePlan('Sure! Here is the plan: {"ingredients":[{"name":"x"}]}. Hope that helps.');
176
+ expect(plan).not.toBeNull();
177
+ expect(plan.ingredients).toHaveLength(1);
178
+ });
179
+
180
+ it('returns null on garbage', () => {
181
+ expect(parsePlan('not a json blob')).toBeNull();
182
+ expect(parsePlan('{ broken json')).toBeNull();
183
+ expect(parsePlan(null)).toBeNull();
184
+ });
185
+
186
+ it('returns null when ingredients is not an array', () => {
187
+ expect(parsePlan('{"ingredients":"oops"}')).toBeNull();
188
+ });
189
+ });
190
+
191
+ describe('transpilePlan — direct unit', () => {
192
+ it('handles missing ingredient names with warnings, not failures', () => {
193
+ const lookup = new Map(FIXTURE_VOCAB.map(c => [c.name, c]));
194
+ const result = transpilePlan({
195
+ ingredients: [{ name: 'demo-login' }, { name: 'unknown' }, { name: 'demo-signup' }],
196
+ }, lookup);
197
+ expect(result.usedIngredients).toEqual(['demo-login', 'demo-signup']);
198
+ expect(result.warnings.some(w => w.includes('unknown'))).toBe(true);
199
+ // Still emits a Column root with the resolved ingredients.
200
+ expect(result.messages[0].components[0].component).toBe('Column');
201
+ });
202
+
203
+ it('returns empty messages when all ingredients are unknown', () => {
204
+ const lookup = new Map(FIXTURE_VOCAB.map(c => [c.name, c]));
205
+ const result = transpilePlan({
206
+ ingredients: [{ name: 'unknown-1' }, { name: 'unknown-2' }],
207
+ }, lookup);
208
+ expect(result.messages).toEqual([]);
209
+ expect(result.usedIngredients).toEqual([]);
210
+ });
211
+ });
212
+
213
+ describe('buildFreeFormSystemPrompt', () => {
214
+ it('includes ingredient names + descriptions + text-node hints', () => {
215
+ const prompt = buildFreeFormSystemPrompt(FIXTURE_VOCAB);
216
+ expect(prompt).toContain('demo-login');
217
+ expect(prompt).toContain('demo-signup');
218
+ expect(prompt).toContain('A demo login card');
219
+ expect(prompt).toContain('id="title" current="Sign in"');
220
+ expect(prompt).toContain('INGREDIENTS AVAILABLE');
221
+ expect(prompt).toContain('OUTPUT SCHEMA');
222
+ });
223
+
224
+ it('handles empty vocabulary without throwing', () => {
225
+ const prompt = buildFreeFormSystemPrompt([]);
226
+ expect(prompt).toContain('INGREDIENTS AVAILABLE (0)');
227
+ });
228
+ });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * free-form-composer — generative composition mode (§88, v0.4.8).
3
+ *
4
+ * The third generative path between verbatim retrieval (zettel's
5
+ * `composition-match`) and free-hand LLM generation (`monolithic-pro`).
6
+ *
7
+ * Treats the annotated-chunk corpus as an INGREDIENT VOCABULARY. The
8
+ * foundation model is given the catalog (28 chunks as of cut), declares
9
+ * an ordered list of ingredients + optional text-only substitutions, and
10
+ * the transpiler stitches a net-new A2UI tree that echoes the patterns
11
+ * the corpus documents without copying them verbatim.
12
+ *
13
+ * Grounding rule (v0.4.6 §62, carried forward): every ingredient name is
14
+ * an annotated chunk that traces to a real page via `data-chunk-*` markers.
15
+ * The strategy enforces this by rejecting any plan whose ingredient names
16
+ * aren't in the live `getAllCompositions()` result.
17
+ *
18
+ * Hallucination guard: one retry with explicit feedback ("you used X
19
+ * which isn't an ingredient"). If the retry still hallucinates, the
20
+ * strategy returns an empty-messages result with strategy label
21
+ * `free-form-hallucinated` so the upstream registry can fall through to
22
+ * monolithic-pro.
23
+ *
24
+ * No LLM available: returns `strategy='free-form-no-llm'` immediately;
25
+ * caller falls through.
26
+ */
27
+
28
+ import { getAllCompositions } from '../zettel/composition-library.js';
29
+ import {
30
+ buildFreeFormSystemPrompt,
31
+ buildFreeFormUserMessage,
32
+ buildFreeFormRetryMessage,
33
+ } from './system-prompt.js';
34
+ import { transpilePlan, parsePlan } from './transpile.js';
35
+
36
+ const MAX_ATTEMPTS = 2;
37
+
38
+ /**
39
+ * Run the free-form composer for a single intent.
40
+ *
41
+ * @param {object} opts
42
+ * @param {string} opts.intent — user intent
43
+ * @param {object|null} opts.llmAdapter — LLM adapter with `.complete({messages, systemPrompt}) → {content, ...}`
44
+ * @param {object[]} [opts.compositions] — override vocabulary (test fixtures); defaults to live corpus
45
+ * @returns {Promise<{
46
+ * messages: object[],
47
+ * validation: { score: number, warnings?: string[] },
48
+ * strategy: string,
49
+ * plan: object|null,
50
+ * usedIngredients: string[],
51
+ * attempts: number,
52
+ * warnings: string[],
53
+ * rationale: string|null,
54
+ * }>}
55
+ */
56
+ export async function generateFreeForm({ intent, llmAdapter = null, compositions = null } = {}) {
57
+ if (!intent || typeof intent !== 'string') {
58
+ return {
59
+ messages: [],
60
+ validation: { score: 0, warnings: ['empty or invalid intent'] },
61
+ strategy: 'free-form-bad-input',
62
+ plan: null,
63
+ usedIngredients: [],
64
+ attempts: 0,
65
+ warnings: ['empty or invalid intent'],
66
+ rationale: null,
67
+ };
68
+ }
69
+
70
+ const vocab = Array.isArray(compositions) ? compositions : getAllCompositions();
71
+ if (vocab.length === 0) {
72
+ return {
73
+ messages: [],
74
+ validation: { score: 0, warnings: ['empty vocabulary'] },
75
+ strategy: 'free-form-empty-vocab',
76
+ plan: null,
77
+ usedIngredients: [],
78
+ attempts: 0,
79
+ warnings: ['empty vocabulary: 0 annotated chunks available'],
80
+ rationale: null,
81
+ };
82
+ }
83
+
84
+ if (!llmAdapter || typeof llmAdapter.complete !== 'function') {
85
+ return {
86
+ messages: [],
87
+ validation: { score: 0, warnings: ['no LLM adapter; free-form requires LLM'] },
88
+ strategy: 'free-form-no-llm',
89
+ plan: null,
90
+ usedIngredients: [],
91
+ attempts: 0,
92
+ warnings: ['free-form-composer requires an LLM adapter (mode=pro|thinking, not instant)'],
93
+ rationale: null,
94
+ };
95
+ }
96
+
97
+ const systemPrompt = buildFreeFormSystemPrompt(vocab);
98
+ const vocabNames = new Set(vocab.map(c => c.name));
99
+ const chunkLookup = new Map(vocab.map(c => [c.name, c]));
100
+
101
+ let userMessage = buildFreeFormUserMessage(intent);
102
+ let plan = null;
103
+ let invalidNames = [];
104
+ let attempt = 0;
105
+
106
+ for (attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
107
+ let raw;
108
+ try {
109
+ const response = await llmAdapter.complete({
110
+ messages: [{ role: 'user', content: userMessage }],
111
+ systemPrompt,
112
+ });
113
+ raw = response?.content || '';
114
+ } catch (err) {
115
+ return {
116
+ messages: [],
117
+ validation: { score: 0, warnings: [`LLM call failed: ${err?.message || err}`] },
118
+ strategy: 'free-form-llm-error',
119
+ plan: null,
120
+ usedIngredients: [],
121
+ attempts: attempt,
122
+ warnings: [`LLM call failed on attempt ${attempt}: ${err?.message || err}`],
123
+ rationale: null,
124
+ };
125
+ }
126
+
127
+ const candidate = parsePlan(raw);
128
+ if (!candidate) {
129
+ // Parse-fail counts as a hallucination — retry once.
130
+ invalidNames = ['<parse-fail>'];
131
+ userMessage = `Compose a UI for: "${intent}"
132
+
133
+ Note: your previous response wasn't valid JSON. Output ONLY the JSON object — no prose, no fenced code block markers around it.`;
134
+ continue;
135
+ }
136
+
137
+ invalidNames = candidate.ingredients
138
+ .filter(ing => !ing || typeof ing.name !== 'string' || !vocabNames.has(ing.name))
139
+ .map(ing => ing?.name ?? '<missing-name>');
140
+
141
+ if (invalidNames.length === 0) {
142
+ plan = candidate;
143
+ break;
144
+ }
145
+
146
+ // Retry with explicit hallucination feedback.
147
+ userMessage = buildFreeFormRetryMessage(intent, invalidNames);
148
+ }
149
+
150
+ if (!plan) {
151
+ return {
152
+ messages: [],
153
+ validation: { score: 0, warnings: [`hallucination guard tripped after ${MAX_ATTEMPTS} attempts; invalid: ${invalidNames.join(', ')}`] },
154
+ strategy: 'free-form-hallucinated',
155
+ plan: null,
156
+ usedIngredients: [],
157
+ attempts: MAX_ATTEMPTS,
158
+ warnings: [`exhausted ${MAX_ATTEMPTS} attempts; final invalid names: ${invalidNames.join(', ')}`],
159
+ rationale: null,
160
+ };
161
+ }
162
+
163
+ const { messages, warnings, usedIngredients } = transpilePlan(plan, chunkLookup);
164
+
165
+ if (messages.length === 0) {
166
+ return {
167
+ messages: [],
168
+ validation: { score: 0, warnings: warnings.concat(['transpile produced no messages']) },
169
+ strategy: 'free-form-empty-plan',
170
+ plan,
171
+ usedIngredients,
172
+ attempts: attempt,
173
+ warnings,
174
+ rationale: plan.rationale || null,
175
+ };
176
+ }
177
+
178
+ // Score reflects "we successfully composed N ingredients without
179
+ // hallucination". Validator-level scoring is upstream's responsibility.
180
+ // 85 chosen to match zettel's `composition-match` avgScore baseline.
181
+ const score = 85;
182
+
183
+ return {
184
+ messages,
185
+ validation: { score, warnings },
186
+ strategy: 'free-form-composed',
187
+ plan,
188
+ usedIngredients,
189
+ attempts: attempt,
190
+ warnings,
191
+ rationale: plan.rationale || null,
192
+ };
193
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * system-prompt.js — builds the system prompt for free-form-composer.
3
+ *
4
+ * Treats the annotated-chunk corpus as an INGREDIENT VOCABULARY. The LLM
5
+ * sees a catalog of available ingredients with their domain + description +
6
+ * keywords, plus the per-ingredient TEXT NODES that can be substituted, then
7
+ * outputs an ordered ingredient list + per-ingredient substitutions.
8
+ *
9
+ * The transpiler stitches the resulting plan into a net-new A2UI tree that
10
+ * echoes the chunks' shapes without copying them verbatim.
11
+ *
12
+ * Design choice: substitutions key on the chunk's INNER node IDs. Showing
13
+ * the IDs in the prompt makes the substitution surface explicit + grep-able.
14
+ * Only `Text`-component nodes with a `textContent` field are
15
+ * substitution-eligible (the v0.4.8 "text-only" scope); other components
16
+ * are surfaced for ordering context but locked at their declared values.
17
+ *
18
+ * Vocabulary refresh cadence: the catalog is rebuilt from
19
+ * `composition-library.getAllCompositions()` on every prompt build —
20
+ * cheap (in-memory map of 28 entries today) and ensures the LLM sees the
21
+ * current corpus state without explicit cache invalidation.
22
+ */
23
+
24
+ const MAX_DESCRIPTION_LEN = 140;
25
+ const MAX_TEXT_PREVIEW = 60;
26
+
27
+ /**
28
+ * Extract `Text` nodes with `textContent` from a chunk template. Returns a
29
+ * compact `[{ id, preview }]` array — the LLM uses `id` as the substitution
30
+ * key; `preview` is the current value (truncated for prompt economy).
31
+ *
32
+ * @param {object[]} template — flat A2UI node list
33
+ * @returns {Array<{id: string, preview: string}>}
34
+ */
35
+ function extractTextNodes(template) {
36
+ if (!Array.isArray(template)) return [];
37
+ const nodes = [];
38
+ for (const node of template) {
39
+ if (node?.component !== 'Text') continue;
40
+ if (typeof node.textContent !== 'string' || !node.textContent.length) continue;
41
+ const preview = node.textContent.length > MAX_TEXT_PREVIEW
42
+ ? node.textContent.slice(0, MAX_TEXT_PREVIEW - 1) + '…'
43
+ : node.textContent;
44
+ nodes.push({ id: node.id, preview });
45
+ }
46
+ return nodes;
47
+ }
48
+
49
+ /**
50
+ * Render a single ingredient as a prompt line. Format:
51
+ *
52
+ * - <name> (<domain>): <description (truncated)>
53
+ * keywords: a, b, c
54
+ * text-nodes: id="..." current="...", id="..." current="..."
55
+ *
56
+ * Text-nodes line is omitted when the chunk has zero Text nodes.
57
+ */
58
+ function renderIngredient(c) {
59
+ const desc = (c.description || '').slice(0, MAX_DESCRIPTION_LEN);
60
+ const lines = [`- ${c.name} (${c.domain || 'general'}): ${desc}`];
61
+ if (Array.isArray(c.keywords) && c.keywords.length > 0) {
62
+ lines.push(` keywords: ${c.keywords.slice(0, 8).join(', ')}`);
63
+ }
64
+ const textNodes = extractTextNodes(c.template);
65
+ if (textNodes.length > 0) {
66
+ const compact = textNodes.slice(0, 6).map(t => `id="${t.id}" current="${t.preview}"`).join('; ');
67
+ lines.push(` text-nodes: ${compact}`);
68
+ }
69
+ return lines.join('\n');
70
+ }
71
+
72
+ /**
73
+ * Build the system prompt. Caller passes the live `getAllCompositions()`
74
+ * result; this function doesn't import composition-library directly so the
75
+ * strategy entry point can pass test fixtures in unit tests.
76
+ *
77
+ * @param {object[]} compositions — output of `getAllCompositions()`
78
+ * @returns {string}
79
+ */
80
+ export function buildFreeFormSystemPrompt(compositions) {
81
+ const ingredients = (compositions || []).slice().sort((a, b) => {
82
+ const da = a.domain || 'z'; const db = b.domain || 'z';
83
+ if (da !== db) return da < db ? -1 : 1;
84
+ return a.name < b.name ? -1 : 1;
85
+ });
86
+
87
+ const catalog = ingredients.map(renderIngredient).join('\n\n');
88
+ const ingredientNames = ingredients.map(c => c.name).join(', ');
89
+
90
+ return `You are a UI composer for the AdiaUI design system. You work by picking INGREDIENTS — pre-built UI regions that have been annotated for retrieval — and arranging them in order to satisfy the user's intent.
91
+
92
+ You DO NOT write A2UI JSON directly. You output a PLAN: an ordered list of ingredient names plus optional text-content substitutions. A separate transpiler turns your plan into A2UI messages.
93
+
94
+ INGREDIENTS AVAILABLE (${ingredients.length}):
95
+
96
+ ${catalog}
97
+
98
+ CONSTRAINTS:
99
+
100
+ 1. Use ONLY ingredient names from the list above. Names not in the list are hallucinations — your response will be rejected if any \`name\` field is unknown.
101
+ 2. Order your ingredients in the sequence they should appear top-to-bottom in the rendered UI. The transpiler wraps your list in a vertical Column.
102
+ 3. Each ingredient may carry a \`substitutions\` object. KEYS are text-node IDs (from the \`text-nodes:\` line in the catalog above). VALUES are the new \`textContent\` strings. Only \`Text\` nodes can be substituted in this version; other components stay at their declared values.
103
+ 4. If you can't satisfy the intent with the available ingredients, return \`{ "ingredients": [] }\` and a rationale explaining what's missing.
104
+ 5. Output ONLY the JSON object below, no explanation outside the JSON.
105
+
106
+ OUTPUT SCHEMA (strict):
107
+
108
+ \`\`\`json
109
+ {
110
+ "ingredients": [
111
+ {
112
+ "name": "<one of: ${ingredientNames.length > 200 ? ingredientNames.slice(0, 200) + '… (see list above)' : ingredientNames}>",
113
+ "substitutions": { "<text-node-id>": "<new text>" }
114
+ }
115
+ ],
116
+ "rationale": "<one short sentence: why this arrangement>"
117
+ }
118
+ \`\`\`
119
+
120
+ The \`substitutions\` field is optional and can be omitted or set to \`{}\`.`;
121
+ }
122
+
123
+ /**
124
+ * Build the user message for an intent. Kept separate from system prompt
125
+ * so future iteration prompts can extend it (e.g. with prior-canvas diffs).
126
+ */
127
+ export function buildFreeFormUserMessage(intent) {
128
+ return `Compose a UI for: "${intent}"`;
129
+ }
130
+
131
+ /**
132
+ * Build a retry user message that surfaces invalid ingredient names from a
133
+ * prior attempt. Called by the strategy entry point on hallucination.
134
+ */
135
+ export function buildFreeFormRetryMessage(intent, invalidNames) {
136
+ const list = invalidNames.slice(0, 5).join(', ');
137
+ return `Compose a UI for: "${intent}"
138
+
139
+ Note: your previous response used ${invalidNames.length === 1 ? 'an ingredient name' : 'ingredient names'} that ${invalidNames.length === 1 ? "isn't" : "aren't"} in the catalog: ${list}. Use ONLY names from the INGREDIENTS list above, exactly as shown.`;
140
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * transpile.js — turn a free-form composer plan into A2UI messages.
3
+ *
4
+ * Input: a `plan` object from the LLM in the shape:
5
+ *
6
+ * { ingredients: [{ name: string, substitutions?: object }], rationale: string }
7
+ *
8
+ * Output: a single `updateComponents` message with a `Column` root that
9
+ * wraps every ingredient's template in sequence.
10
+ *
11
+ * Stitch design:
12
+ * - Each ingredient's nodes get prefixed IDs (`<ingredientName>__<originalId>`)
13
+ * so concatenating two ingredients with overlapping IDs doesn't collide.
14
+ * Children references inside each chunk are rewritten to point at the
15
+ * prefixed IDs.
16
+ * - The root `Column` enumerates the prefixed ROOT id of each ingredient
17
+ * as its children (defined by the first node in each chunk template).
18
+ *
19
+ * Substitution rules (v0.4.8 — text-only scope):
20
+ * - Substitution key = chunk-internal node ID (the same `id` field the
21
+ * prompt surfaces in the `text-nodes:` line per ingredient).
22
+ * - Only nodes with `component === 'Text'` AND a `textContent` field are
23
+ * substitution-eligible. Other components are ignored silently.
24
+ * - Unknown substitution keys produce a `warnings[]` entry in the result
25
+ * but do not fail the transpile. Future passes can promote warnings to
26
+ * strict errors via an option.
27
+ *
28
+ * Out of scope for v0.4.8:
29
+ * - Slot-conflict resolution (two ingredients claiming the same slot).
30
+ * The current stitch is sequential-stacking inside a Column; slot
31
+ * conflicts cannot arise because no shared slot surface exists.
32
+ * - Nested-composition (ingredients-inside-ingredients).
33
+ * - Cross-chunk merge (fusing two chunks rather than stacking).
34
+ * - Structural substitutions (swap Card → Section, etc.).
35
+ */
36
+
37
+ /**
38
+ * Apply text substitutions to a single chunk's template. Returns a fresh
39
+ * cloned array with prefixed IDs + substituted textContent where keys match.
40
+ *
41
+ * @param {object[]} template — original chunk template
42
+ * @param {string} prefix — ingredient name, used for ID namespacing
43
+ * @param {object} substitutions — { [innerNodeId]: newText }
44
+ * @returns {{ nodes: object[], rootId: string, warnings: string[] }}
45
+ */
46
+ function applyIngredient(template, prefix, substitutions = {}) {
47
+ if (!Array.isArray(template) || template.length === 0) {
48
+ return { nodes: [], rootId: null, warnings: [`empty template for ingredient "${prefix}"`] };
49
+ }
50
+ const warnings = [];
51
+ const subKeys = Object.keys(substitutions || {});
52
+ const usedSubKeys = new Set();
53
+ const prefixId = (id) => `${prefix}__${id}`;
54
+
55
+ const nodes = template.map((node) => {
56
+ const cloned = { ...node, id: prefixId(node.id) };
57
+ if (Array.isArray(node.children)) {
58
+ cloned.children = node.children.map(prefixId);
59
+ }
60
+ if (Object.hasOwn(substitutions, node.id)) {
61
+ usedSubKeys.add(node.id);
62
+ if (node.component === 'Text' && typeof node.textContent === 'string') {
63
+ cloned.textContent = String(substitutions[node.id]);
64
+ } else {
65
+ warnings.push(`substitution skipped: "${node.id}" in "${prefix}" is component=${node.component ?? '?'}, only Text supported in v0.4.8`);
66
+ }
67
+ }
68
+ return cloned;
69
+ });
70
+
71
+ for (const k of subKeys) {
72
+ if (!usedSubKeys.has(k)) {
73
+ warnings.push(`unknown substitution key: "${k}" in "${prefix}" (no node with that id)`);
74
+ }
75
+ }
76
+
77
+ return {
78
+ nodes,
79
+ rootId: prefixId(template[0].id),
80
+ warnings,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Transpile a plan into A2UI messages. Returns:
86
+ *
87
+ * { messages: [{type:'updateComponents', components: [...]}],
88
+ * warnings: string[],
89
+ * usedIngredients: string[] }
90
+ *
91
+ * If `ingredients` is empty after resolution, returns
92
+ * `{ messages: [], warnings: [...], usedIngredients: [] }` — caller decides
93
+ * whether that's a fallthrough or an error.
94
+ *
95
+ * @param {{ ingredients: Array<{name: string, substitutions?: object}>, rationale?: string }} plan
96
+ * @param {Map<string, object> | Function} chunkLookup — Map or function
97
+ * `(name) => composition | undefined`. The strategy entry point typically
98
+ * passes a Map built from `getAllCompositions()` for O(1) lookups.
99
+ * @returns {{ messages: object[], warnings: string[], usedIngredients: string[] }}
100
+ */
101
+ export function transpilePlan(plan, chunkLookup) {
102
+ const warnings = [];
103
+ const usedIngredients = [];
104
+
105
+ if (!plan || !Array.isArray(plan.ingredients) || plan.ingredients.length === 0) {
106
+ return { messages: [], warnings: ['plan has no ingredients'], usedIngredients };
107
+ }
108
+
109
+ const lookup = typeof chunkLookup === 'function'
110
+ ? chunkLookup
111
+ : (name) => chunkLookup.get(name);
112
+
113
+ const flatComponents = [];
114
+ const rootChildren = [];
115
+
116
+ for (const ing of plan.ingredients) {
117
+ if (!ing || typeof ing.name !== 'string') {
118
+ warnings.push(`malformed ingredient entry: ${JSON.stringify(ing)}`);
119
+ continue;
120
+ }
121
+ const chunk = lookup(ing.name);
122
+ if (!chunk) {
123
+ warnings.push(`ingredient not found in vocabulary: "${ing.name}"`);
124
+ continue;
125
+ }
126
+ const { nodes, rootId, warnings: ingWarnings } = applyIngredient(
127
+ chunk.template,
128
+ ing.name,
129
+ ing.substitutions || {}
130
+ );
131
+ warnings.push(...ingWarnings);
132
+ if (rootId) {
133
+ flatComponents.push(...nodes);
134
+ rootChildren.push(rootId);
135
+ usedIngredients.push(ing.name);
136
+ }
137
+ }
138
+
139
+ if (rootChildren.length === 0) {
140
+ return { messages: [], warnings, usedIngredients };
141
+ }
142
+
143
+ const root = {
144
+ id: 'free-form-root',
145
+ component: 'Column',
146
+ gap: '6',
147
+ children: rootChildren,
148
+ };
149
+
150
+ return {
151
+ messages: [{
152
+ type: 'updateComponents',
153
+ surfaceId: 'main',
154
+ components: [root, ...flatComponents],
155
+ }],
156
+ warnings,
157
+ usedIngredients,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Parse a raw LLM response into a plan object. The LLM is asked to emit
163
+ * strict JSON; we accept either a bare JSON object or one wrapped in a
164
+ * fenced code block (the most common drift mode).
165
+ *
166
+ * Returns `null` if parsing fails — caller treats that as a hallucination
167
+ * and retries (or falls through).
168
+ */
169
+ export function parsePlan(rawContent) {
170
+ if (typeof rawContent !== 'string') return null;
171
+ let text = rawContent.trim();
172
+
173
+ // Strip fenced code block if present.
174
+ const fenceMatch = text.match(/^```(?:json)?\s*([\s\S]*?)```\s*$/i);
175
+ if (fenceMatch) text = fenceMatch[1].trim();
176
+
177
+ // If the response starts with explanation prose, try to find the first { ... } block.
178
+ if (!text.startsWith('{')) {
179
+ const firstBrace = text.indexOf('{');
180
+ const lastBrace = text.lastIndexOf('}');
181
+ if (firstBrace === -1 || lastBrace <= firstBrace) return null;
182
+ text = text.slice(firstBrace, lastBrace + 1);
183
+ }
184
+
185
+ let parsed;
186
+ try {
187
+ parsed = JSON.parse(text);
188
+ } catch {
189
+ return null;
190
+ }
191
+
192
+ if (!parsed || typeof parsed !== 'object') return null;
193
+ if (!Array.isArray(parsed.ingredients)) return null;
194
+ return parsed;
195
+ }
@@ -118,6 +118,35 @@ async function generateZettelAdapter(ctx) {
118
118
  };
119
119
  }
120
120
 
121
+ async function generateFreeFormAdapter(ctx) {
122
+ // Lazy-load: free-form-composer imports composition-library which
123
+ // top-level-awaits a node:fs read. Same browser-safety story as zettel.
124
+ const { generateFreeForm } = await import('./free-form-composer/index.js');
125
+ const result = await generateFreeForm({
126
+ intent: ctx.intent,
127
+ llmAdapter: ctx.llmAdapter || null,
128
+ });
129
+ const isRecording = await getIsRecording();
130
+ return {
131
+ executionId: ctx.executionId,
132
+ messages: result.messages,
133
+ validation: result.validation,
134
+ suggestions: [],
135
+ strategy: result.strategy,
136
+ engine: 'free-form',
137
+ _debug: isRecording() ? {
138
+ systemPrompt: null,
139
+ rawLLMResponse: null,
140
+ tokens: null,
141
+ plan: result.plan,
142
+ usedIngredients: result.usedIngredients,
143
+ attempts: result.attempts,
144
+ rationale: result.rationale,
145
+ warnings: result.warnings,
146
+ } : undefined,
147
+ };
148
+ }
149
+
121
150
  async function generateChunkZettelAdapter(ctx) {
122
151
  const { composeFromIntent } = await import('./zettel/chunk-synthesizer.js');
123
152
  const result = await composeFromIntent({
@@ -151,6 +180,7 @@ export const ENGINES = {
151
180
  'monolithic-thinking': generateThinkingAdapter,
152
181
  'zettel': generateZettelAdapter,
153
182
  'chunk-zettel': generateChunkZettelAdapter,
183
+ 'free-form': generateFreeFormAdapter,
154
184
  };
155
185
 
156
186
  /**
@@ -175,7 +205,7 @@ export const ENGINES = {
175
205
  * });
176
206
  * generateUI({ engine: 'my-hybrid', intent: '...' });
177
207
  */
178
- const RESERVED = new Set(['monolithic', 'monolithic-instant', 'monolithic-pro', 'monolithic-thinking', 'zettel', 'chunk-zettel']);
208
+ const RESERVED = new Set(['monolithic', 'monolithic-instant', 'monolithic-pro', 'monolithic-thinking', 'zettel', 'chunk-zettel', 'free-form']);
179
209
 
180
210
  export function registerEngine(name, generateFn) {
181
211
  if (typeof name !== 'string' || !name.length) {
@@ -19,7 +19,6 @@
19
19
  * - synthesis-failed — LLM tried and failed validation
20
20
  */
21
21
  import {
22
- loadAll,
23
22
  getComposition,
24
23
  searchAll,
25
24
  } from './composition-library.js';
@@ -33,25 +32,17 @@ import {
33
32
  import { autoReport } from './issue-reporter.js';
34
33
  import { validateSchema } from '../../../validator/validator.js';
35
34
 
36
- let booted = false;
37
- function ensureBooted() {
38
- if (!booted) {
39
- loadAll();
40
- booted = true;
41
- }
42
- }
43
-
44
- // NOTE on cache invalidation: `loadAll()` itself reloads fragments + compositions
45
- // from disk every time it's called — `fragments.clear()` + `compositions.clear()`
46
- // at the top, then re-walk. The CACHING happens here at the call-site: we set
47
- // `booted = true` after the first load and never reload for the lifetime of
48
- // this process. Trade-offs:
49
- // - GOOD for long-running MCP: zero corpus-load cost on subsequent requests.
50
- // - BAD for tests / hot-reload: a fresh fragment file isn't visible until
51
- // the process restarts.
52
- // To force a reload (e.g. in a test that just wrote a new fragment file),
53
- // call `loadAll()` directly — it's idempotent. The `booted` flag here is a
54
- // process-singleton optimization, not a correctness invariant.
35
+ // Composition library auto-loads at module import time via top-level `await
36
+ // loadAll()` in composition-library.js (§72 dual-mode loader, commit
37
+ // `76dbcff2`). The previous `ensureBooted()` here called `loadAll()` WITHOUT
38
+ // `await`, which after §72 made it async — clearing the compositions map +
39
+ // racing the synchronous `searchAll` below. Symptom (caught in §87):
40
+ // eval:diff zettel coverage dropped 5% → 1% because the race-winner pattern
41
+ // emitted composition-match only for the intent whose `searchAll` happened
42
+ // to fire AFTER the async load resolved.
43
+ //
44
+ // Fix: rely entirely on the top-level await. Tests that want a forced reload
45
+ // can call `loadAll()` directly with `await` (it's idempotent).
55
46
 
56
47
  // Retrieval score threshold — above this we trust the match and emit verbatim;
57
48
  // below, fall through to LLM synthesis (creative composition from fragments).
@@ -81,8 +72,6 @@ function toUpdateComponentsMessages(template) {
81
72
  }
82
73
 
83
74
  export async function generateZettel({ intent, mode = 'instant', llmAdapter = null, sessionId = null } = {}) {
84
- ensureBooted();
85
-
86
75
  // ── Session-aware iteration (turn > 1) ──
87
76
  // If we have prior turns AND an LLM, the user is almost certainly modifying
88
77
  // the existing canvas — NEVER pick a fresh retrieved composition. Go straight