@adia-ai/a2ui-compose 0.4.6 → 0.4.8
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 +14 -0
- package/package.json +1 -1
- package/strategies/free-form-composer/free-form-composer.test.js +228 -0
- package/strategies/free-form-composer/index.js +193 -0
- package/strategies/free-form-composer/system-prompt.js +140 -0
- package/strategies/free-form-composer/transpile.js +195 -0
- package/strategies/monolithic/_shared.js +26 -7
- package/strategies/registry.js +31 -1
- package/strategies/zettel/composition-library.js +106 -61
- package/strategies/zettel/generator-adapter.js +11 -22
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,20 @@ generator graph.
|
|
|
12
12
|
|
|
13
13
|
_No pending changes._
|
|
14
14
|
|
|
15
|
+
## [0.4.8] - 2026-05-12
|
|
16
|
+
|
|
17
|
+
### Fixed — `strategies/zettel/generator-adapter.js` `ensureBooted()` race (§87a, v0.4.8)
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
## [0.4.7] - 2026-05-12
|
|
22
|
+
|
|
23
|
+
### Changed — `strategies/monolithic/_shared.js` `getComponentCatalog()` reads canonical catalog (§72)
|
|
24
|
+
|
|
25
|
+
The legacy reader of `@adia-ai/a2ui-corpus/patterns/_components.json` has been migrated to read `@adia-ai/a2ui-corpus/catalog-a2ui_0_9.json` (the canonical v0.9 catalog, already the package root export). Per-component aliases are now lifted from `components[name].x-adiaui.synonyms.tags`. Same legacy output shape (`{ <Name>: { aliases: string[] } }`); zero behavior change for downstream callers.
|
|
26
|
+
|
|
27
|
+
Closes the §65 carry-over from v0.4.6 — `@adia-ai/a2ui-corpus` `patterns/` + `compositions/` are now deleted from disk + tarball.
|
|
28
|
+
|
|
15
29
|
## [0.4.6] - 2026-05-12
|
|
16
30
|
|
|
17
31
|
### Changed — `core/` retirement follow-through (§64, v0.4.6)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/a2ui-compose",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
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
|
+
}
|
|
@@ -98,24 +98,43 @@ function adaptV09Component(a2uiData) {
|
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
// Component prop catalog — loaded lazily for prompt injection
|
|
101
|
+
// Component prop catalog — loaded lazily for prompt injection.
|
|
102
|
+
//
|
|
103
|
+
// Since §65 (v0.4.7), reads from the canonical v0.9 catalog at
|
|
104
|
+
// `corpus/catalog-a2ui_0_9.json` (assembled from yamls by `npm run
|
|
105
|
+
// components`). Previously read the hand-maintained
|
|
106
|
+
// `corpus/patterns/_components.json` which was retired in §65.
|
|
107
|
+
// Aliases come from `x-adiaui.synonyms.tags` per the v0.9 sidecar shape;
|
|
108
|
+
// migrated in §65 step 1 — 17 yamls populated with synonyms.tags from
|
|
109
|
+
// the prior `_components.json` aliases field.
|
|
102
110
|
let _componentCatalog = null;
|
|
103
111
|
async function getComponentCatalog() {
|
|
104
112
|
if (_componentCatalog) return _componentCatalog;
|
|
105
113
|
try {
|
|
106
114
|
const IS_NODE = typeof process !== 'undefined' && process.versions?.node;
|
|
115
|
+
let catalogJson;
|
|
107
116
|
if (IS_NODE) {
|
|
108
117
|
const fs = await import(/* @vite-ignore */ 'node:fs/promises');
|
|
109
118
|
const path = await import(/* @vite-ignore */ 'node:path');
|
|
110
119
|
const url = await import(/* @vite-ignore */ 'node:url');
|
|
111
120
|
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
112
|
-
const raw = await fs.readFile(path.join(__dirname, '../../../corpus/
|
|
113
|
-
|
|
121
|
+
const raw = await fs.readFile(path.join(__dirname, '../../../corpus/catalog-a2ui_0_9.json'), 'utf8');
|
|
122
|
+
catalogJson = JSON.parse(raw);
|
|
114
123
|
} else {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
124
|
+
const resp = await fetch(new URL('../../../corpus/catalog-a2ui_0_9.json', import.meta.url));
|
|
125
|
+
catalogJson = resp.ok ? await resp.json() : {};
|
|
126
|
+
}
|
|
127
|
+
// Adapt the v0.9 catalog (JSON Schema with `components: {Name: {x-adiaui: {...}}}`)
|
|
128
|
+
// into the legacy {Name: {aliases, ...}} shape getComponentCatalog consumers
|
|
129
|
+
// expect. Aliases live at `x-adiaui.synonyms.tags`.
|
|
130
|
+
const comps = catalogJson?.components || {};
|
|
131
|
+
_componentCatalog = {};
|
|
132
|
+
for (const [name, def] of Object.entries(comps)) {
|
|
133
|
+
const ext = def?.['x-adiaui'] || {};
|
|
134
|
+
const syns = (ext.synonyms && typeof ext.synonyms === 'object') ? ext.synonyms : null;
|
|
135
|
+
_componentCatalog[name] = {
|
|
136
|
+
aliases: Array.isArray(syns?.tags) ? syns.tags : [],
|
|
137
|
+
};
|
|
119
138
|
}
|
|
120
139
|
} catch {
|
|
121
140
|
_componentCatalog = {};
|
package/strategies/registry.js
CHANGED
|
@@ -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) {
|
|
@@ -1,43 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Composition Library — zettel-style loader for A2UI
|
|
2
|
+
* Composition Library — zettel-style loader for harvested A2UI chunks.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Reads `corpus/chunks/<name>.json` whenever the chunk carries both
|
|
5
|
+
* `metadata` (from `data-chunk-*` source-HTML attrs per §40) AND
|
|
6
|
+
* `template` (from the harvester's transpileHTML pass per §41). Chunks
|
|
7
|
+
* are normalized to composition shape by hoisting `metadata.*` to
|
|
8
|
+
* top level so consumers can search them uniformly.
|
|
5
9
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
10
|
+
* The legacy `corpus/compositions/<domain>/<name>.json` glob was
|
|
11
|
+
* retired in v0.4.7 (§65). The harvested-chunks substrate (~30
|
|
12
|
+
* annotated chunks at landing time) is the canonical retrieval
|
|
13
|
+
* surface; per the project's "source-of-truth = shipped /site/"
|
|
14
|
+
* principle, anything not in a shipped surface should fall through
|
|
15
|
+
* to LLM generation rather than be retrieval-matched from a curated
|
|
16
|
+
* JSON. The 199 MODE-C-MAYBE + 9 KEEP-AS-CHUNK + 3 DELETE
|
|
17
|
+
* composition/pattern files all retired alongside the dir.
|
|
8
18
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* source-HTML attrs per §40) AND `template` (from the harvester's
|
|
12
|
-
* transpileHTML pass per §41). Such chunks are normalized to
|
|
13
|
-
* composition shape by hoisting `metadata.*` to top level, so they
|
|
14
|
-
* compete with hand-authored compositions in the same search.
|
|
15
|
-
*
|
|
16
|
-
* Records from chunks carry `_kind: 'annotated-chunk'` and the
|
|
17
|
-
* source-of-truth path so consumers can distinguish if they care; most
|
|
18
|
-
* shouldn't.
|
|
19
|
+
* Records carry `_kind: 'annotated-chunk'` and the source-of-truth
|
|
20
|
+
* path so consumers can distinguish if they care; most shouldn't.
|
|
19
21
|
*
|
|
20
22
|
* Renamed from fragment-library.js in §38. Fragments retired §37.
|
|
21
|
-
* Annotated-chunk loading added §41.
|
|
23
|
+
* Annotated-chunk loading added §41. Compositions-glob retired §65.
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
import
|
|
26
|
-
import
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
26
|
+
// §72 follow-up (v0.4.7): dual-mode loader so this module is safe to
|
|
27
|
+
// statically import from browser entrypoints. Previously the top-level
|
|
28
|
+
// `import 'node:fs'` poisoned the browser bundle the moment any
|
|
29
|
+
// app/playground reached `core/reference.js` → composition-library.
|
|
30
|
+
// Pattern mirrors `retrieval/component-catalog.js`.
|
|
31
|
+
const IS_NODE =
|
|
32
|
+
typeof process !== 'undefined' &&
|
|
33
|
+
typeof process.versions?.node === 'string';
|
|
31
34
|
|
|
32
35
|
/** @type {Map<string, object>} */
|
|
33
36
|
const compositions = new Map();
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
// Vite resolves this at build time; at runtime in Node the variable is unused.
|
|
39
|
+
let _globChunkModules = null;
|
|
40
|
+
if (!IS_NODE) {
|
|
41
|
+
try {
|
|
42
|
+
_globChunkModules = import.meta.glob('../../../corpus/chunks/*.json', {
|
|
43
|
+
query: '?raw',
|
|
44
|
+
import: 'default',
|
|
45
|
+
eager: false,
|
|
46
|
+
});
|
|
47
|
+
} catch {
|
|
48
|
+
// Not in a Vite context — no chunk data in this realm.
|
|
41
49
|
}
|
|
42
50
|
}
|
|
43
51
|
|
|
@@ -68,57 +76,94 @@ function chunkToComposition(chunkDoc, sourcePath) {
|
|
|
68
76
|
* + re-build, which is wasted work but not incorrect.
|
|
69
77
|
*/
|
|
70
78
|
let _autoLoaded = false;
|
|
79
|
+
let _loadStats = { compositionCount: 0, handAuthoredCount: 0, annotatedChunkCount: 0 };
|
|
71
80
|
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Filter + register an annotated chunk. Returns 1 if it qualified
|
|
83
|
+
* (had `metadata` + non-empty `template` + domain/keywords), else 0.
|
|
84
|
+
*/
|
|
85
|
+
function registerChunk(doc, sourcePath) {
|
|
86
|
+
if (!doc?.metadata) return 0;
|
|
87
|
+
if (!doc.template || !Array.isArray(doc.template) || doc.template.length === 0) return 0;
|
|
88
|
+
const meta = doc.metadata;
|
|
89
|
+
if (!meta.domain && !(meta.keywords && meta.keywords.length > 0)) return 0;
|
|
90
|
+
compositions.set(doc.name, chunkToComposition(doc, sourcePath));
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
74
93
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
94
|
+
async function _loadAllNode() {
|
|
95
|
+
const fs = await import(/* @vite-ignore */ 'node:fs');
|
|
96
|
+
const path = await import(/* @vite-ignore */ 'node:path');
|
|
97
|
+
const url = await import(/* @vite-ignore */ 'node:url');
|
|
98
|
+
|
|
99
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
100
|
+
const CHUNKS_ROOT = path.resolve(__dirname, '../../../corpus/chunks');
|
|
101
|
+
|
|
102
|
+
function walk(dir, cb) {
|
|
103
|
+
if (!fs.existsSync(dir)) return;
|
|
104
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
105
|
+
const p = path.join(dir, entry.name);
|
|
106
|
+
if (entry.isDirectory()) walk(p, cb);
|
|
107
|
+
else if (entry.name.endsWith('.json') && !entry.name.startsWith('_')) cb(p);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
82
110
|
|
|
83
|
-
// 2. Annotated chunks (§41). Filter: must have metadata.domain (or
|
|
84
|
-
// metadata.keywords) AND a template field — otherwise the harvester
|
|
85
|
-
// doesn't have enough to treat the chunk as a retrieval candidate.
|
|
86
111
|
let annotatedChunkCount = 0;
|
|
87
112
|
walk(CHUNKS_ROOT, (p) => {
|
|
88
113
|
const doc = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
89
|
-
|
|
90
|
-
if (!doc.template || !Array.isArray(doc.template) || doc.template.length === 0) return;
|
|
91
|
-
const meta = doc.metadata;
|
|
92
|
-
if (!meta.domain && !(meta.keywords && meta.keywords.length > 0)) return;
|
|
93
|
-
|
|
94
|
-
// Chunk name MUST NOT collide with a hand-authored composition.
|
|
95
|
-
// Hand-authored wins; warn so authors can reconcile (rename one or
|
|
96
|
-
// delete the composition once the chunk supersedes it).
|
|
97
|
-
if (compositions.has(doc.name)) {
|
|
98
|
-
console.warn(
|
|
99
|
-
`[composition-library] name collision: annotated chunk "${doc.name}" ` +
|
|
100
|
-
`shadowed by hand-authored composition. Annotated chunk ignored — ` +
|
|
101
|
-
`rename one or retire the composition.`
|
|
102
|
-
);
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
compositions.set(doc.name, chunkToComposition(doc, p));
|
|
106
|
-
annotatedChunkCount++;
|
|
114
|
+
annotatedChunkCount += registerChunk(doc, p);
|
|
107
115
|
});
|
|
116
|
+
return annotatedChunkCount;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function _loadAllBrowser() {
|
|
120
|
+
if (!_globChunkModules) return 0;
|
|
121
|
+
let annotatedChunkCount = 0;
|
|
122
|
+
for (const [globPath, loader] of Object.entries(_globChunkModules)) {
|
|
123
|
+
try {
|
|
124
|
+
const raw = await loader();
|
|
125
|
+
const doc = JSON.parse(raw);
|
|
126
|
+
annotatedChunkCount += registerChunk(doc, globPath);
|
|
127
|
+
} catch {
|
|
128
|
+
// skip malformed chunk JSON
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return annotatedChunkCount;
|
|
132
|
+
}
|
|
108
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Load all annotated chunks into the in-memory map. Returns a stats
|
|
136
|
+
* snapshot. In Node, runs synchronously via `fs.readFileSync`; in the
|
|
137
|
+
* browser, dispatches the Vite glob loaders in parallel.
|
|
138
|
+
*/
|
|
139
|
+
export async function loadAll() {
|
|
140
|
+
compositions.clear();
|
|
141
|
+
const annotatedChunkCount = IS_NODE ? await _loadAllNode() : await _loadAllBrowser();
|
|
109
142
|
_autoLoaded = true;
|
|
110
|
-
|
|
143
|
+
_loadStats = {
|
|
111
144
|
compositionCount: compositions.size,
|
|
112
|
-
handAuthoredCount:
|
|
145
|
+
handAuthoredCount: 0,
|
|
113
146
|
annotatedChunkCount,
|
|
114
147
|
};
|
|
148
|
+
return _loadStats;
|
|
115
149
|
}
|
|
116
150
|
|
|
117
151
|
// Eager top-level load so synchronous getters (getComposition,
|
|
118
152
|
// getAllCompositions, searchAll) work for consumers that don't call
|
|
119
153
|
// loadAll themselves — test harnesses, smoke scripts, and the
|
|
120
154
|
// retrieval-side helpers migrated off pattern-library in §64.
|
|
121
|
-
|
|
155
|
+
//
|
|
156
|
+
// In Node we top-level-await the load so the map is populated before
|
|
157
|
+
// any importer reaches the synchronous getters. In the browser the
|
|
158
|
+
// auto-load is fire-and-forget (the map fills as chunk JSONs come back
|
|
159
|
+
// from Vite's glob loaders); callers that need the map populated MUST
|
|
160
|
+
// `await loadAll()` themselves. Documented in §72-follow-up.
|
|
161
|
+
if (IS_NODE && !_autoLoaded) {
|
|
162
|
+
await loadAll();
|
|
163
|
+
} else if (!IS_NODE && !_autoLoaded) {
|
|
164
|
+
// Fire-and-forget; do not block module import on the network round-trips.
|
|
165
|
+
loadAll().catch(() => { /* swallow; browsers that don't need this path won't trip */ });
|
|
166
|
+
}
|
|
122
167
|
|
|
123
168
|
// Back-compat shims removed in §38 — fragment-library.js → composition-library.js
|
|
124
169
|
// rename. The retired `getFragment` / `getAllFragments` exports had zero
|
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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
|