@adia-ai/a2ui-compose 0.4.2 → 0.4.4

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,33 @@ generator graph.
12
12
 
13
13
  _No pending changes._
14
14
 
15
+ ## [0.4.4] - 2026-05-12
16
+
17
+ ### Changed
18
+
19
+ - **`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.
20
+ - **`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.
21
+ - **`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.
22
+ - **`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.
23
+ - **`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.
24
+
25
+ ### Fixed
26
+
27
+ - **`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.
28
+ - **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.
29
+
30
+ 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.
31
+
32
+ ## [0.4.3] - 2026-05-11
33
+
34
+ ### Fixed
35
+
36
+ - **`process.env` browser-compat guard** — `core/generator.js` and `strategies/zettel/state-cache.js` were accessing `process.env` directly, which throws `ReferenceError: process is not defined` in browser execution paths (e.g. the gen-ui playground importing compose directly). Both sites now guard via `const IS_NODE = typeof process !== 'undefined' && process.versions?.node;` before reading `process.env`. The `A2UI_COMPOSE_TRACE` debug-trace and `STATE_CACHE_DIR` env-lookup paths are Node-only by design; the dynamic `node:fs/promises` + `node:path` imports also short-circuit when `IS_NODE` is false.
37
+
38
+ ### Lockstep
39
+
40
+ 9-package coordinated PATCH cut to v0.4.3 (per [`docs/specs/package-architecture.md` § 15](../../../docs/specs/package-architecture.md#15-versioning-policy)). Internal `@adia-ai/*` dep ranges stay at `^0.4.0` (patch-cut asymmetry — `^0.4.0` covers `0.4.x` under semver). Source change scoped to `core/generator.js` + `strategies/zettel/state-cache.js`. Rides alongside `@adia-ai/web-components` v0.4.3 (input-ui locale + thousands grouping + hold-to-repeat). See root [CHANGELOG.md `## [0.4.3]`](../../../CHANGELOG.md) for the cut narrative.
41
+
15
42
  ## [0.4.2] - 2026-05-11
16
43
 
17
44
  ### Ride-along (no source changes)
package/README.md CHANGED
@@ -14,7 +14,18 @@ ready for a renderer.
14
14
  >
15
15
  > Published to the public `@adia-ai` scope on 2026-04-24 alongside
16
16
  > `a2ui-corpus`, `a2ui-mcp`, `a2ui-retrieval`, and `a2ui-validator`.
17
- > Install with `npm i @adia-ai/a2ui-compose`.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install @adia-ai/a2ui-compose
22
+ ```
23
+
24
+ Typically paired with `@adia-ai/a2ui-corpus` (the pattern corpus the engine reads from) and `@adia-ai/llm` (the LLM client; runtime peer-dep):
25
+
26
+ ```bash
27
+ npm install @adia-ai/a2ui-compose @adia-ai/a2ui-corpus @adia-ai/llm
28
+ ```
18
29
 
19
30
  ## What it does
20
31
 
package/core/generator.js CHANGED
@@ -191,7 +191,14 @@ export async function generateUI({ intent, engine: engineName = 'monolithic', mo
191
191
  // a per-request JSON file BEFORE the strip. Useful for debugging
192
192
  // strategy-label decisions or reproducing eval failures. Off by default;
193
193
  // file writes happen synchronously and add ~1-5ms per request.
194
- const traceDir = process.env.A2UI_COMPOSE_TRACE;
194
+ //
195
+ // Node-only: `process` is undefined in the browser. Guard the env lookup
196
+ // so the post-await codepath doesn't throw `ReferenceError: process is
197
+ // not defined` when generator.js runs in the gen-ui playground. The
198
+ // dynamic node:fs/path imports below would also fail in the browser, but
199
+ // the guard short-circuits before we get there.
200
+ const IS_NODE = typeof process !== 'undefined' && process.versions?.node;
201
+ const traceDir = IS_NODE ? process.env.A2UI_COMPOSE_TRACE : null;
195
202
  if (traceDir && result._debug) {
196
203
  try {
197
204
  const { writeFile, mkdir } = await import('node:fs/promises');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-compose",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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
+ });
@@ -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,44 @@ 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
+ const chunkReferenceBlock = chunkRefHtml
225
+ ? `\nSTRUCTURAL REFERENCE — a real production block from the codebase matched this intent. Match its 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:
226
+
227
+ CHUNK: ${chunkMatch.name} (${chunkMatch.kind}, primary=${chunkMatch.primary}, score=${chunkMatch.score})
228
+ ---
229
+ ${chunkRefHtml}
230
+ ---
231
+ `
232
+ : '';
233
+
174
234
  if (bestPattern && bestPattern.template) {
175
235
  // PRO PATH: Adapt the matched pattern via LLM
176
236
  systemPrompt = await buildSystemPrompt(context, patterns, '', intent);
177
237
 
178
238
  const adaptPrompt = hasPriorCanvas
179
239
  ? `Adapt this UI for the intent: "${intent}"${driftGuard}
180
-
240
+ ${chunkReferenceBlock}
181
241
  ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`
182
242
  : hasFragments
183
243
  ? `Adapt this UI for the intent: "${intent}"
184
-
244
+ ${chunkReferenceBlock}
185
245
  BASE TEMPLATE (use for overall structure — modify, don't generate from scratch):
186
- ${JSON.stringify(bestPattern.template, null, 2)}
246
+ ${JSON.stringify(expandedPatternTemplate, null, 2)}
187
247
 
188
248
  Pattern: "${bestPattern.name}" — ${bestPattern.description}
189
249
 
@@ -201,9 +261,9 @@ Instructions:
201
261
  - Include realistic text content, labels, and placeholder data — not generic text
202
262
  - Output ONLY the JSON array, no explanation`
203
263
  : `Adapt this UI for the intent: "${intent}"
204
-
264
+ ${chunkReferenceBlock}
205
265
  BASE TEMPLATE (modify this, don't generate from scratch):
206
- ${JSON.stringify(bestPattern.template, null, 2)}
266
+ ${JSON.stringify(expandedPatternTemplate, null, 2)}
207
267
 
208
268
  Pattern: "${bestPattern.name}" — ${bestPattern.description}
209
269
 
@@ -238,7 +298,7 @@ Instructions:
238
298
  : '';
239
299
 
240
300
  const composePrompt = `Compose a UI for: "${intent}"
241
- ${canvasContext}
301
+ ${chunkReferenceBlock}${canvasContext}
242
302
  This intent has ${fragmentPatterns.length} sections. ${layoutHint}
243
303
 
244
304
  FRAGMENT PATTERNS — use these as building blocks for each section:
@@ -269,7 +329,7 @@ Instructions:
269
329
  if (hasPriorCanvas) {
270
330
  // Multi-turn: inject current canvas as explicit iteration context
271
331
  const iteratePrompt = `Modify this UI for the intent: "${intent}"${driftGuard}
272
-
332
+ ${chunkReferenceBlock}
273
333
  ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
274
334
 
275
335
  const response = await llmAdapter.complete({
@@ -279,7 +339,21 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
279
339
  messages = parseA2UIResponse(response.content, { executionId: execId, intent, mode: 'pro', stopReason: response.stopReason });
280
340
  if (isRecording()) { lastRawResponse = response.content; lastTokens = response.usage || null; }
281
341
  } else {
342
+ // First-turn fresh generation. When we have a chunk match, prepend
343
+ // the structural reference to the user message so the LLM sees the
344
+ // canonical shape before the standard chat-built prompt.
282
345
  const chatMessages = buildChatMessages(intent, storeId || execId);
346
+ if (chunkReferenceBlock && chatMessages.length) {
347
+ // The last message in chatMessages is the user's brief — prepend
348
+ // the chunk reference to its content.
349
+ const lastIdx = chatMessages.length - 1;
350
+ if (chatMessages[lastIdx].role === 'user') {
351
+ chatMessages[lastIdx] = {
352
+ ...chatMessages[lastIdx],
353
+ content: `${chunkReferenceBlock}\n${chatMessages[lastIdx].content}`,
354
+ };
355
+ }
356
+ }
283
357
  const response = await llmAdapter.complete({ messages: chatMessages, systemPrompt });
284
358
  messages = parseA2UIResponse(response.content, { executionId: execId, intent, mode: 'pro', stopReason: response.stopReason });
285
359
  if (isRecording()) { lastRawResponse = response.content; lastTokens = response.usage || null; }
@@ -341,6 +415,7 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
341
415
  feedbackStore.logExecution({
342
416
  executionId: artifactId, intent, mode: 'pro',
343
417
  domain: domain.domain, patternMatch: bestPattern?.name,
418
+ chunkMatch: chunkMatch ? chunkMatch.name : null,
344
419
  composedFrom: hasFragments ? fragmentPatterns.map(f => f.pattern.name) : undefined,
345
420
  score: validation?.score, componentCount: messages[0]?.components?.length || 0,
346
421
  }).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
- }