@adia-ai/a2ui-compose 0.4.3 → 0.4.5

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,39 @@ generator graph.
12
12
 
13
13
  _No pending changes._
14
14
 
15
+ ## [0.4.5] - 2026-05-12
16
+
17
+ ### Changed — GenUI overhaul prompt-engineering (§56, v0.4.5)
18
+
19
+ - **`strategies/monolithic/_shared.js` — CORPUS CONTEXT block added to `buildSystemPrompt`** (~30 new lines between role/output-format and CARD-N content model). Surgical insertion explaining the §36-§51 retrieval-augmented pipeline:
20
+ - Pipeline searches PATTERNS / COMPOSITIONS first (full-canvas A2UI templates: login-form, pricing-tiers, dashboard-admin-page)
21
+ - Pipeline also searches ANNOTATED CHUNKS (real production HTML with `metadata.{domain, keywords, description}`)
22
+ - When matched, `MATCHED PATTERN` / `STRUCTURAL REFERENCE` blocks carry the actual reference
23
+ - Only AVAILABLE COMPONENTS are usable — anything else is a hallucination
24
+
25
+ Cost: ~150 tokens per request. Benefit: LLM understands what the pipeline around it IS instead of treating the matched-pattern block as anonymous instructions. Existing rules unchanged; no removed lines.
26
+
27
+ - **`strategies/monolithic/generate-pro.js` — STRUCTURAL REFERENCE prose enriched.** Prior copy: "a real production block from the codebase matched this intent." New copy: "this chunk was retrieved from the AdiaUI training corpus (annotated production HTML, harvested from real app pages). It matched your intent on keyword/domain ranking." Threads chunk `metadata.domain`, `metadata.description`, `metadata.keywords` into the prompt when present. Reframes "do not copy the HTML" as "the chunk represents the SHAPE the user wants; instantiate it with their content" — more actionable framing.
28
+
29
+ See root [CHANGELOG.md `[Unreleased]`](../../../CHANGELOG.md) for the v0.4.5 overhaul arc + apps/genui/CHANGELOG.md `[Unreleased]` for the per-§ rollup.
30
+
31
+ ## [0.4.4] - 2026-05-12
32
+
33
+ ### Changed
34
+
35
+ - **`strategies/zettel/fragment-library.js` → `composition-library.js` rename (§38).** Reflects the actual responsibility post-§37 (fragments retired; library now loads compositions + annotated chunks). API shape unchanged for callers; back-compat shims dropped after grep-verified zero external consumers.
36
+ - **`strategies/zettel/composer.js` simplified (§37).** `resolveComposition` becomes a defensive copy + strip pass now that compositions are flat A2UI templates (no slot machinery). `templateToMessages` preserved for callers.
37
+ - **`strategies/zettel/composition-library.js` — read annotated chunks alongside hand-authored compositions (§41).** `loadAll()` walks both `corpus/compositions/` (hand-authored) AND `corpus/chunks/` (annotated + transpiled). Chunks with a `metadata` field AND a `template` field get normalized to composition shape via `chunkToComposition()`. Name collisions: hand-authored wins with a loud warn. Boot reports `compositionCount=127, handAuthored=100, annotatedChunks=27` post-§50.
38
+ - **`strategies/zettel/synthesizer.js` retired as throwing stub (§37).** Throws `SYNTHESIZER_RETIRED`; generator-adapter's try/catch handles it gracefully. Deferred follow-up: chunk-based synthesizer for zettel iteration.
39
+ - **`compose/transpiler/transpiler-maps.js` — `<a>` → `Link` (was: `Button + variant=ghost`) (§47).** button.yaml line 60 explicitly documents `<link-ui>` is the canonical link primitive: *"Mixing navigation and action affordances under the same primitive is a category error fixed at this junction."* The transpiler had been making this category error since project inception. Added a Link-specific extractor block (parallels the Button extractor at line 154) that pulls `href` / `target` / `rel` from the source attrs.
40
+
41
+ ### Fixed
42
+
43
+ - **`compose/strategies/monolithic/generate-pro.js` + companion `_shared/fragment-expander.js` — fragment machinery removed (§37).** Fragments retired; 85 compositions inlined with previously-referenced fragment content. Expander deleted; its caller's try/catch in `generator-adapter.js` handles the absence gracefully.
44
+ - **MCP server `mcp/server.js` (§37).** Removed `get_fragment` tool, simplified `zettel_stats`, updated imports + boot logs + engine description to reflect the post-fragment world.
45
+
46
+ See root [CHANGELOG.md `[Unreleased]`](../../../CHANGELOG.md) for the cross-cutting arc narrative + [docs/journal/2026/05/2026-05-12.md](../../../docs/journal/2026/05/2026-05-12.md) §§ 37 / 38 / 41 / 47 for per-§ details.
47
+
15
48
  ## [0.4.3] - 2026-05-11
16
49
 
17
50
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-compose",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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,208 @@
1
+ /**
2
+ * Dual-environment chunk loader — Node + browser.
3
+ *
4
+ * Mirrors the pattern in packages/a2ui/retrieval/pattern-library.js so the
5
+ * gen-UI training chunks at packages/a2ui/corpus/chunks/*.json are reachable
6
+ * from BOTH server-side engines (monolithic-pro via /api/generate's Node
7
+ * process) AND browser-side engines (monolithic-pro called directly from
8
+ * gen-ui playground's IIFE).
9
+ *
10
+ * The existing packages/a2ui/corpus/scripts/chunk-library.js is Node-only
11
+ * (uses node:fs / readdirSync). This module exists so monolithic-pro can
12
+ * search + fetch chunks WITHOUT pulling node:fs into the browser bundle.
13
+ *
14
+ * Surface:
15
+ * - searchChunks(query, { kind, limit }): keyword-ranked chunk matches
16
+ * - getChunk(name): full chunk record by name
17
+ *
18
+ * Both are async because the browser path uses dynamic imports.
19
+ */
20
+
21
+ const IS_NODE =
22
+ typeof process !== 'undefined' &&
23
+ typeof process.versions?.node === 'string';
24
+
25
+ // ── State ──────────────────────────────────────────────────────────
26
+ let _state = null; // { chunks: Map<name, record>, byKind: Map<kind, Set<name>> }
27
+ let _loadPromise = null;
28
+
29
+ // ── Browser glob ───────────────────────────────────────────────────
30
+ // Vite resolves this at build time; in Node the variable is unused.
31
+ let _globModules = null;
32
+ if (!IS_NODE) {
33
+ try {
34
+ _globModules = import.meta.glob('../../../corpus/chunks/*.json', {
35
+ query: '?raw',
36
+ import: 'default',
37
+ });
38
+ } catch {
39
+ // Not in a Vite context — fall back to empty corpus
40
+ }
41
+ }
42
+
43
+ // ── Public API ─────────────────────────────────────────────────────
44
+ async function _load() {
45
+ if (_state) return _state;
46
+ if (_loadPromise) return _loadPromise;
47
+ _loadPromise = (async () => {
48
+ if (IS_NODE) {
49
+ _state = await _loadNode();
50
+ } else {
51
+ _state = await _loadBrowser();
52
+ }
53
+ return _state;
54
+ })();
55
+ return _loadPromise;
56
+ }
57
+
58
+ async function _loadNode() {
59
+ const fs = await import(/* @vite-ignore */ 'node:fs/promises');
60
+ const path = await import(/* @vite-ignore */ 'node:path');
61
+ const url = await import(/* @vite-ignore */ 'node:url');
62
+
63
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
64
+ const chunksDir = path.resolve(__dirname, '..', '..', '..', 'corpus', 'chunks');
65
+
66
+ const chunks = new Map();
67
+ const byKind = new Map();
68
+ let entries;
69
+ try {
70
+ entries = await fs.readdir(chunksDir);
71
+ } catch {
72
+ return { chunks, byKind };
73
+ }
74
+ for (const file of entries) {
75
+ if (file === '_index.json' || !file.endsWith('.json')) continue;
76
+ try {
77
+ const raw = await fs.readFile(path.join(chunksDir, file), 'utf8');
78
+ const rec = JSON.parse(raw);
79
+ if (!rec?.name) continue;
80
+ chunks.set(rec.name, rec);
81
+ const kind = rec.kind || 'block';
82
+ if (!byKind.has(kind)) byKind.set(kind, new Set());
83
+ byKind.get(kind).add(rec.name);
84
+ } catch {
85
+ // Skip malformed chunks rather than fail the whole load
86
+ }
87
+ }
88
+ return { chunks, byKind };
89
+ }
90
+
91
+ async function _loadBrowser() {
92
+ const chunks = new Map();
93
+ const byKind = new Map();
94
+ if (!_globModules) return { chunks, byKind };
95
+
96
+ await Promise.all(
97
+ Object.entries(_globModules).map(async ([path, loader]) => {
98
+ if (path.endsWith('/_index.json')) return;
99
+ try {
100
+ const raw = await loader();
101
+ const rec = JSON.parse(raw);
102
+ if (!rec?.name) return;
103
+ chunks.set(rec.name, rec);
104
+ const kind = rec.kind || 'block';
105
+ if (!byKind.has(kind)) byKind.set(kind, new Set());
106
+ byKind.get(kind).add(rec.name);
107
+ } catch {
108
+ // Skip malformed
109
+ }
110
+ })
111
+ );
112
+
113
+ return { chunks, byKind };
114
+ }
115
+
116
+ /**
117
+ * Keyword-rank chunks against a query. Mirrors the scoring shape of
118
+ * corpus/scripts/chunk-library.js so behavior is consistent regardless
119
+ * of which loader was used.
120
+ *
121
+ * Scoring (per chunk):
122
+ * +3 query word in name (exact part match)
123
+ * +2 query word in primary tag
124
+ * +1 query word in nested chunk name
125
+ * +1 query word in source page path
126
+ *
127
+ * @param {string} query — user intent
128
+ * @param {object} [opts] — { kind?: 'block'|'page'|'panel', limit?: number }
129
+ * @returns {Promise<Array<{ name, score, kind, primary, record }>>}
130
+ */
131
+ export async function searchChunks(query, { kind, limit = 20 } = {}) {
132
+ if (!query || typeof query !== 'string') return [];
133
+ const { chunks } = await _load();
134
+ if (!chunks.size) return [];
135
+
136
+ const words = query.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
137
+ if (!words.length) return [];
138
+
139
+ const candidates = [];
140
+ for (const [name, rec] of chunks) {
141
+ if (kind && rec.kind !== kind) continue;
142
+ let score = 0;
143
+ const nameLow = name.toLowerCase();
144
+ const primary = (rec.primary || '').toLowerCase();
145
+ const source = (rec.source || rec.page || '').toLowerCase();
146
+ const nested = (rec.nested || []).map((n) => n.toLowerCase());
147
+
148
+ for (const w of words) {
149
+ if (nameLow.includes(w)) score += 3;
150
+ if (primary && primary.includes(w)) score += 2;
151
+ for (const n of nested) {
152
+ if (n.includes(w)) {
153
+ score += 1;
154
+ break;
155
+ }
156
+ }
157
+ if (source && source.includes(w)) score += 1;
158
+ }
159
+ if (score > 0) {
160
+ candidates.push({ name, score, kind: rec.kind, primary: rec.primary, record: rec });
161
+ }
162
+ }
163
+
164
+ candidates.sort((a, b) => b.score - a.score);
165
+ return candidates.slice(0, limit);
166
+ }
167
+
168
+ /**
169
+ * Fetch a chunk record by name.
170
+ * @param {string} name
171
+ * @returns {Promise<object|null>}
172
+ */
173
+ export async function getChunk(name) {
174
+ if (!name) return null;
175
+ const { chunks } = await _load();
176
+ return chunks.get(name) || null;
177
+ }
178
+
179
+ /**
180
+ * Recursively expand a chunk's nested-chunk references into a flat array
181
+ * of chunk records. Useful for materializing the "full structural context"
182
+ * of a top-level chunk into a single prompt-injection payload.
183
+ *
184
+ * Cycle-safe: tracks visited names. Depth-limited to prevent runaway.
185
+ *
186
+ * @param {string|object} nameOrRecord
187
+ * @param {object} [opts] — { maxDepth?: 4 }
188
+ * @returns {Promise<Array<object>>} — flat list of resolved chunk records, root first
189
+ */
190
+ export async function expandChunkGraph(nameOrRecord, { maxDepth = 4 } = {}) {
191
+ const visited = new Set();
192
+ const out = [];
193
+
194
+ async function walk(item, depth) {
195
+ if (depth > maxDepth) return;
196
+ const rec = typeof item === 'string' ? await getChunk(item) : item;
197
+ if (!rec || visited.has(rec.name)) return;
198
+ visited.add(rec.name);
199
+ out.push(rec);
200
+
201
+ for (const childName of rec.nested || []) {
202
+ await walk(childName, depth + 1);
203
+ }
204
+ }
205
+
206
+ await walk(nameOrRecord, 0);
207
+ return out;
208
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * chunk-loader.js — Node-side tests.
3
+ *
4
+ * Verifies that:
5
+ * 1. The dual-environment loader correctly loads chunks from the
6
+ * corpus on the Node path.
7
+ * 2. searchChunks ranks expected chunks for known intents (the
8
+ * ground-truth case from the user's signup-form screenshot).
9
+ * 3. expandChunkGraph traverses the `nested:` graph correctly with
10
+ * cycle safety + depth capping.
11
+ */
12
+ import { describe, it, expect } from 'vitest';
13
+ import { searchChunks, getChunk, expandChunkGraph } from './chunk-loader.js';
14
+
15
+ describe('chunk-loader / Node path', () => {
16
+ it('searchChunks returns top-ranked matches for a sign-in query', async () => {
17
+ const hits = await searchChunks('sign in with email', { limit: 5 });
18
+ expect(hits.length).toBeGreaterThan(0);
19
+ // auth-signin-card-email should be the top match — it's the canonical
20
+ // signin block in the corpus. If this assertion ever flips, look at
21
+ // chunk-loader's scoring weights or the corpus harvest.
22
+ expect(hits[0].name).toBe('auth-signin-card-email');
23
+ expect(hits[0].score).toBeGreaterThanOrEqual(5);
24
+ });
25
+
26
+ it('getChunk returns the full record by name', async () => {
27
+ const rec = await getChunk('auth-signin-card-email');
28
+ expect(rec).not.toBeNull();
29
+ expect(rec.name).toBe('auth-signin-card-email');
30
+ expect(rec.kind).toBe('block');
31
+ expect(rec.primary).toBe('card-ui');
32
+ expect(Array.isArray(rec.nested)).toBe(true);
33
+ expect(rec.nested).toContain('auth-card-header');
34
+ });
35
+
36
+ it('getChunk returns null for unknown names', async () => {
37
+ const rec = await getChunk('this-chunk-does-not-exist');
38
+ expect(rec).toBeNull();
39
+ });
40
+
41
+ it('expandChunkGraph traverses nested refs (root first, breadth-ish)', async () => {
42
+ const graph = await expandChunkGraph('auth-signin-card-email', { maxDepth: 3 });
43
+ // Root should be first
44
+ expect(graph[0].name).toBe('auth-signin-card-email');
45
+ // Nested chunks should appear in the output
46
+ const names = graph.map((g) => g.name);
47
+ expect(names).toContain('auth-card-header');
48
+ expect(names).toContain('auth-card-content');
49
+ expect(names).toContain('auth-email-entry');
50
+ expect(names).toContain('auth-social-auths');
51
+ });
52
+
53
+ it('expandChunkGraph respects maxDepth', async () => {
54
+ const shallow = await expandChunkGraph('auth-signin-card-email', { maxDepth: 0 });
55
+ expect(shallow.length).toBe(1); // root only
56
+ expect(shallow[0].name).toBe('auth-signin-card-email');
57
+ });
58
+
59
+ it('expandChunkGraph deduplicates revisits (cycle safety)', async () => {
60
+ const graph = await expandChunkGraph('auth-signin-card-email', { maxDepth: 5 });
61
+ const names = graph.map((g) => g.name);
62
+ const unique = new Set(names);
63
+ // No duplicates — visited set should catch any revisits even on
64
+ // cyclic graphs.
65
+ expect(names.length).toBe(unique.size);
66
+ });
67
+
68
+ it('searchChunks filters by kind when requested', async () => {
69
+ const pageHits = await searchChunks('admin dashboard', { kind: 'page', limit: 5 });
70
+ for (const h of pageHits) {
71
+ expect(h.kind).toBe('page');
72
+ }
73
+ });
74
+
75
+ it('searchChunks returns empty array for empty / non-string queries', async () => {
76
+ expect(await searchChunks('')).toEqual([]);
77
+ expect(await searchChunks(null)).toEqual([]);
78
+ expect(await searchChunks(undefined)).toEqual([]);
79
+ });
80
+ });
@@ -51,6 +51,32 @@ Output format: [{ "type": "updateComponents", "surfaceId": "default", "component
51
51
  Each component: { "id": "<unique>", "component": "<Type>", "children": ["<childId>", ...], ...props }
52
52
  The root must have id "root". Use short, descriptive IDs (e.g., "hdr", "email-field", "submit-btn").`);
53
53
 
54
+ // ── Corpus context (§56, v0.4.5) ──
55
+ // Tells the LLM how the system surrounding it is wired so it doesn't
56
+ // hallucinate component names and so it understands what MATCHED PATTERN /
57
+ // STRUCTURAL REFERENCE blocks actually ARE. Keep this section tight — every
58
+ // token costs latency + money.
59
+ parts.push(`CORPUS CONTEXT (the system around you):
60
+
61
+ You are part of a retrieval-augmented pipeline. Before this prompt was built,
62
+ the pipeline searched two corpora for matches to the user's intent:
63
+
64
+ 1. PATTERNS / COMPOSITIONS — full-canvas A2UI templates curated for the
65
+ AdiaUI design system. Examples: login-form, pricing-tiers,
66
+ dashboard-admin-page. When a strong match exists, you'll see a
67
+ "MATCHED PATTERN" block below with the canonical template.
68
+
69
+ 2. ANNOTATED CHUNKS — real production HTML blocks harvested from the AdiaUI
70
+ apps (auth flows, dashboards, error pages, etc.) with metadata
71
+ (domain, keywords, description). When a strong chunk match exists,
72
+ you'll see a "STRUCTURAL REFERENCE" block with the production HTML.
73
+ Match its component palette + information density, but translate to
74
+ A2UI components — do not copy raw HTML.
75
+
76
+ Both corpora use ONLY components from the AVAILABLE COMPONENTS list further
77
+ down. If you would invent a component name not in that list, you're
78
+ hallucinating — use the closest canonical name instead.`);
79
+
54
80
  // ── Card-N content model (critical for quality) ──
55
81
  parts.push(`CARD-N CONTENT MODEL (mandatory for any card surface):
56
82
  - Card > Header: for title/description. Use Text children with heading variants (h3, h4).
@@ -14,6 +14,7 @@ import { isConversational } from '../../../retrieval/intent/intent-gate.js';
14
14
  import { feedbackStore } from '../../../retrieval/feedback/feedback-store.js';
15
15
  import { store, engine } from '../../core/state.js';
16
16
  import { isRecording } from '../../../retrieval/feedback/dialog-recorder.js';
17
+ import { searchChunks, expandChunkGraph } from '../_shared/chunk-loader.js';
17
18
  import {
18
19
  buildSystemPrompt,
19
20
  buildChatMessages,
@@ -94,10 +95,43 @@ export async function generatePro({ intent, executionId, storeId, llmAdapter, an
94
95
  confidence: 0.85,
95
96
  });
96
97
 
97
- // ── Stage 3: Plan — search for best pattern ──
98
+ // ── Stage 3: Plan — search for best pattern + best chunk ──
98
99
  const patterns = searchBlocks(searchQuery, { domain: domain.domain });
99
100
  const bestPattern = patterns[0];
100
101
 
102
+ // ── Parallel chunk retrieval (training-corpus path) ──
103
+ // The chunks corpus at packages/a2ui/corpus/chunks/*.json captures real
104
+ // page-shape blocks harvested from working apps (auth/sign-in,
105
+ // settings pages, dashboards, etc.). When a query strongly matches a
106
+ // chunk, we inject the chunk's HTML as a STRUCTURAL REFERENCE into the
107
+ // adapt prompt — so the LLM has the canonical shape, not just a
108
+ // description of it. This closes the gap where monolithic-pro was
109
+ // describing chunks (via the score-card's pattern-match telemetry) but
110
+ // never actually using them in generation.
111
+ //
112
+ // Threshold rationale: searchChunks's keyword-score scale is the same
113
+ // family as the existing STRONG_RETRIEVAL_SCORE=8 in zettel's chunk
114
+ // synthesizer. We use a slightly lower threshold (5) for monolithic
115
+ // because the chunk is REFERENCE, not authoritative — the LLM still
116
+ // adapts, so we'd rather over-inject and let the LLM judge relevance.
117
+ let chunkMatch = null;
118
+ let chunkRefHtml = null;
119
+ try {
120
+ const chunkHits = await searchChunks(searchQuery, { limit: 1 });
121
+ if (chunkHits.length && chunkHits[0].score >= 5) {
122
+ chunkMatch = chunkHits[0];
123
+ // Expand the chunk's nested refs so the LLM sees the complete
124
+ // structural context, not just the outer shell. Depth-capped to
125
+ // protect against the recursive nested chain blowing token budget.
126
+ const graph = await expandChunkGraph(chunkMatch.record, { maxDepth: 3 });
127
+ chunkRefHtml = graph
128
+ .map((g) => `<!-- chunk: ${g.name} (${g.kind}/${g.primary}) -->\n${g.html || ''}`)
129
+ .join('\n\n');
130
+ }
131
+ } catch {
132
+ // Chunk loader is best-effort — never block generation on its failures
133
+ }
134
+
101
135
  // ── Compositional reasoning: decompose multi-section intents ──
102
136
  // ALWAYS attempt decomposition for compound intents — even when a single
103
137
  // best pattern matches. This lets the LLM receive fragment patterns for
@@ -128,10 +162,11 @@ export async function generatePro({ intent, executionId, storeId, llmAdapter, an
128
162
  const hasFragments = fragmentPatterns.length >= 2;
129
163
  engine.submitStage(execId, 'plan', {
130
164
  strategy: bestPattern?.template ? (hasFragments ? 'adapt-with-fragments' : 'adapt-pattern') : hasFragments ? 'compose-fragments' : 'generate-fresh',
131
- patternName: bestPattern?.name ?? null,
165
+ patternName: bestPattern?.name ?? (chunkMatch?.name ?? null),
166
+ chunkRef: chunkMatch ? { name: chunkMatch.name, score: chunkMatch.score, kind: chunkMatch.kind, primary: chunkMatch.primary } : null,
132
167
  fragments: hasFragments ? fragmentPatterns.map(f => f.pattern.name) : undefined,
133
168
  layout: hasFragments ? decomposition?.layout : undefined,
134
- confidence: bestPattern?.template ? 0.9 : hasFragments ? 0.85 : 0.7,
169
+ confidence: bestPattern?.template ? 0.9 : hasFragments ? 0.85 : chunkMatch ? 0.8 : 0.7,
135
170
  });
136
171
 
137
172
  // ── Stage 4: Generate ──
@@ -171,19 +206,52 @@ export async function generatePro({ intent, executionId, storeId, llmAdapter, an
171
206
  // when the recorder actually persists them to disk.
172
207
  let lastRawResponse = null;
173
208
  let lastTokens = null;
209
+
210
+ // Pattern templates are pre-inlined as of the 2026-05-12 fragment
211
+ // retirement (§37) — compositions/ no longer carry $fragment refs and
212
+ // there's nothing left to expand. We pass the template straight to the
213
+ // LLM. If a stale pattern with $fragment refs sneaks back in, the LLM
214
+ // will see the literal placeholder string and likely include it in
215
+ // output (acceptable failure mode; corpus drift is caught by the
216
+ // verify:corpus script).
217
+ const expandedPatternTemplate = bestPattern?.template ?? null;
218
+
219
+ // ── Chunk reference block — injected into the user prompt when a
220
+ // strong chunk match was found at retrieval time. This is the
221
+ // structural "look like this" hint that lets the LLM materialize the
222
+ // real chunk shape (e.g. auth-signin-card-email) instead of
223
+ // hallucinating a passable-but-wrong shape from the brief alone.
224
+ //
225
+ // §56 (v0.4.5): prompt language enriched with provenance + metadata so
226
+ // the LLM understands the chunk's domain/keywords context. Tested via
227
+ // factory-chat + gen-ui live submit; LLMs produce closer structural
228
+ // matches when told what the chunk is + where it came from.
229
+ const chunkReferenceBlock = chunkRefHtml
230
+ ? `\nSTRUCTURAL REFERENCE — this chunk was retrieved from the AdiaUI training corpus (annotated production HTML, harvested from real app pages). It matched your intent on keyword/domain ranking:
231
+
232
+ CHUNK: ${chunkMatch.name}
233
+ kind=${chunkMatch.kind}, primary=${chunkMatch.primary}, score=${chunkMatch.score}${chunkMatch.metadata?.domain ? `, domain=${chunkMatch.metadata.domain}` : ''}${chunkMatch.metadata?.description ? `\n description: ${chunkMatch.metadata.description}` : ''}${chunkMatch.metadata?.keywords ? `\n keywords: ${chunkMatch.metadata.keywords.join(', ')}` : ''}
234
+
235
+ Match this chunk's component palette, information density, and Card/Header/Section/Footer anatomy. Do NOT copy the HTML verbatim into A2UI — translate its semantic structure into the equivalent A2UI components. The chunk represents the SHAPE the user wants; your job is to instantiate that shape with their content:
236
+ ---
237
+ ${chunkRefHtml}
238
+ ---
239
+ `
240
+ : '';
241
+
174
242
  if (bestPattern && bestPattern.template) {
175
243
  // PRO PATH: Adapt the matched pattern via LLM
176
244
  systemPrompt = await buildSystemPrompt(context, patterns, '', intent);
177
245
 
178
246
  const adaptPrompt = hasPriorCanvas
179
247
  ? `Adapt this UI for the intent: "${intent}"${driftGuard}
180
-
248
+ ${chunkReferenceBlock}
181
249
  ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`
182
250
  : hasFragments
183
251
  ? `Adapt this UI for the intent: "${intent}"
184
-
252
+ ${chunkReferenceBlock}
185
253
  BASE TEMPLATE (use for overall structure — modify, don't generate from scratch):
186
- ${JSON.stringify(bestPattern.template, null, 2)}
254
+ ${JSON.stringify(expandedPatternTemplate, null, 2)}
187
255
 
188
256
  Pattern: "${bestPattern.name}" — ${bestPattern.description}
189
257
 
@@ -201,9 +269,9 @@ Instructions:
201
269
  - Include realistic text content, labels, and placeholder data — not generic text
202
270
  - Output ONLY the JSON array, no explanation`
203
271
  : `Adapt this UI for the intent: "${intent}"
204
-
272
+ ${chunkReferenceBlock}
205
273
  BASE TEMPLATE (modify this, don't generate from scratch):
206
- ${JSON.stringify(bestPattern.template, null, 2)}
274
+ ${JSON.stringify(expandedPatternTemplate, null, 2)}
207
275
 
208
276
  Pattern: "${bestPattern.name}" — ${bestPattern.description}
209
277
 
@@ -238,7 +306,7 @@ Instructions:
238
306
  : '';
239
307
 
240
308
  const composePrompt = `Compose a UI for: "${intent}"
241
- ${canvasContext}
309
+ ${chunkReferenceBlock}${canvasContext}
242
310
  This intent has ${fragmentPatterns.length} sections. ${layoutHint}
243
311
 
244
312
  FRAGMENT PATTERNS — use these as building blocks for each section:
@@ -269,7 +337,7 @@ Instructions:
269
337
  if (hasPriorCanvas) {
270
338
  // Multi-turn: inject current canvas as explicit iteration context
271
339
  const iteratePrompt = `Modify this UI for the intent: "${intent}"${driftGuard}
272
-
340
+ ${chunkReferenceBlock}
273
341
  ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
274
342
 
275
343
  const response = await llmAdapter.complete({
@@ -279,7 +347,21 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
279
347
  messages = parseA2UIResponse(response.content, { executionId: execId, intent, mode: 'pro', stopReason: response.stopReason });
280
348
  if (isRecording()) { lastRawResponse = response.content; lastTokens = response.usage || null; }
281
349
  } else {
350
+ // First-turn fresh generation. When we have a chunk match, prepend
351
+ // the structural reference to the user message so the LLM sees the
352
+ // canonical shape before the standard chat-built prompt.
282
353
  const chatMessages = buildChatMessages(intent, storeId || execId);
354
+ if (chunkReferenceBlock && chatMessages.length) {
355
+ // The last message in chatMessages is the user's brief — prepend
356
+ // the chunk reference to its content.
357
+ const lastIdx = chatMessages.length - 1;
358
+ if (chatMessages[lastIdx].role === 'user') {
359
+ chatMessages[lastIdx] = {
360
+ ...chatMessages[lastIdx],
361
+ content: `${chunkReferenceBlock}\n${chatMessages[lastIdx].content}`,
362
+ };
363
+ }
364
+ }
283
365
  const response = await llmAdapter.complete({ messages: chatMessages, systemPrompt });
284
366
  messages = parseA2UIResponse(response.content, { executionId: execId, intent, mode: 'pro', stopReason: response.stopReason });
285
367
  if (isRecording()) { lastRawResponse = response.content; lastTokens = response.usage || null; }
@@ -341,6 +423,7 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
341
423
  feedbackStore.logExecution({
342
424
  executionId: artifactId, intent, mode: 'pro',
343
425
  domain: domain.domain, patternMatch: bestPattern?.name,
426
+ chunkMatch: chunkMatch ? chunkMatch.name : null,
344
427
  composedFrom: hasFragments ? fragmentPatterns.map(f => f.pattern.name) : undefined,
345
428
  score: validation?.score, componentCount: messages[0]?.components?.length || 0,
346
429
  }).catch(() => {});
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  // Zettel is lazy-loaded — it transitively imports node:fs / node:path / node:url
18
- // (strategies/zettel/fragment-library.js), which Vite externalizes in the browser.
18
+ // (strategies/zettel/composition-library.js), which Vite externalizes in the browser.
19
19
  // Static-importing it here would break browser loads of core/generator.js.
20
20
  // In-browser callers reach zettel via POST /api/generate (server-side), never
21
21
  // through this registry, so lazy-loading is safe and incurs only a one-time
@@ -1,14 +1,11 @@
1
1
  #!/usr/bin/env node
2
- /** Smoke test: load library, resolve login-form, print resolved template. */
3
- import { loadAll, getAllFragments, getAllCompositions, searchAll, getGraph, getComposition } from './fragment-library.js';
2
+ /** Smoke test: load library, resolve login-form composition, print template. */
3
+ import { loadAll, getAllCompositions, searchAll, getComposition } from './composition-library.js';
4
4
  import { resolveComposition } from './composer.js';
5
5
 
6
6
  const boot = loadAll();
7
7
  console.log('boot:', boot);
8
8
 
9
- console.log('\n=== Fragments ===');
10
- for (const f of getAllFragments()) console.log(` - ${f.name} [${f.semantic_role}]`);
11
-
12
9
  console.log('\n=== Compositions ===');
13
10
  for (const c of getAllCompositions()) console.log(` - ${c.name} (${c.domain})`);
14
11
 
@@ -28,10 +25,3 @@ for (const n of resolved) {
28
25
  const label = n.label ? ` label="${n.label}"` : '';
29
26
  console.log(` ${n.id.padEnd(20)} ${n.component}${label}${text} ${kids}`);
30
27
  }
31
-
32
- console.log('\n=== Graph ===');
33
- const g = getGraph();
34
- console.log('fragments with usage:');
35
- for (const f of g.fragments) {
36
- if (f.used_by.length) console.log(` ${f.name} ← ${f.used_by.map(u => u.name).join(', ')}`);
37
- }