@adia-ai/a2ui-compose 0.4.5 → 0.4.7

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,50 @@ generator graph.
12
12
 
13
13
  _No pending changes._
14
14
 
15
+ ## [0.4.7] - 2026-05-12
16
+
17
+ ### Changed — `strategies/monolithic/_shared.js` `getComponentCatalog()` reads canonical catalog (§72)
18
+
19
+ 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.
20
+
21
+ Closes the §65 carry-over from v0.4.6 — `@adia-ai/a2ui-corpus` `patterns/` + `compositions/` are now deleted from disk + tarball.
22
+
23
+ ## [0.4.6] - 2026-05-12
24
+
25
+ ### Changed — `core/` retirement follow-through (§64, v0.4.6)
26
+
27
+ Companion to `@adia-ai/a2ui-retrieval@0.4.6` retiring `pattern-library.js`:
28
+
29
+ - **`compose/core/pattern-export.js`** retired — no consumers post-§64 (the export shape targeted the retired pattern-library surface).
30
+ - **`compose/core/reference.js`** — updated to delegate to `composition-library` instead of the retired `pattern-library`.
31
+ - **`compose/core/generator.js`** — dropped pattern-export wiring; system-prompt path consumes compositions directly via §66's `adaptV09Component()`.
32
+
33
+ ### Changed — `buildSystemPrompt` reads canonical `.a2ui.json` sidecars (§66, v0.4.6)
34
+
35
+ Closes the catalog-drift surface that §56 explicitly deferred ("different schema shapes; restructuring `getComponentCatalog()` is its own arc"). The monolithic prompt builder now reads from the v0.9 `.a2ui.json` sidecars (generated from yamls by `npm run components`) instead of the legacy hand-maintained `_components.json` catalog.
36
+
37
+ **Root cause uncovered during audit:** `strategies/monolithic/_shared.js` (pre-§66) called `getComponentData(typeName)` returning the raw v0.9 sidecar — `{title, description, properties, x-adiaui}` (JSON Schema shape) — but the prompt builder code did `a2uiData.props ? Object.entries(a2uiData.props)`. The v0.9 shape uses `properties`, not `props`. **Every prompt was silently falling through to the legacy `_components.json` catalog** because every v0.9 lookup looked empty. The "canonical sidecar reads" path was wired but dead code.
38
+
39
+ **Fix:** new `adaptV09Component()` helper at the top of `_shared.js` (~90 LOC) normalizes the v0.9 sidecar shape to the legacy `{tag, category, description, props, events, slots, examples, aliases, keywords}` shape the rest of the prompt builder was written for. Properties map 1:1 (skipping `component` discriminator + `children` container + `const`-only fields). Type names map JSON-Schema lowercase → legacy Title-Case (`string`→`String`, `boolean`→`Boolean`, etc.).
40
+
41
+ **Empirical fidelity gain** (Button as canonical primitive):
42
+
43
+ | Source | Props surfaced |
44
+ |---|---|
45
+ | `_components.json` (legacy, before §66) | 7: `text, variant, size, disabled, block, icon, type` (`block` is stale — removed from yaml in an earlier arc but never cleaned up in the hand-maintained catalog) |
46
+ | v0.9 sidecar (canonical, after §66) | 10: `type, aria-label, color, disabled, icon, size, stretch, text, textContent, variant` |
47
+
48
+ **§66 surfaces ~30% more catalog data** for the LLM, all traceable to a yaml SoT that `npm run components` regenerates on demand. This is exactly the catalog drift §56 flagged: when the team added `Select.size` (§50) and `inputmode`/`autocomplete` (§51) to the yamls, those props went into the v0.9 sidecars but never propagated to `_components.json`. The LLM was getting a stale catalog view that excluded recent additions. After §66, the LLM gets the actual ground truth.
49
+
50
+ **Pitfalls encoded in the adapter** (5 v0.9-shape gotchas):
51
+ 1. `events` is an object `{eventName: {description}}`, not an array. Normalize to `string[]`.
52
+ 2. `slots` is also object-shaped. Same fix.
53
+ 3. `synonyms.tags` may be null/undefined. Wrap in `Array.isArray()` guard.
54
+ 4. `component` and `children` properties on every v0.9 sidecar are discriminator/container fields, not user-facing props. Skip in the adapter loop.
55
+ 5. `const` fields (e.g. `{const: 'Button'}`) are discriminator-only. Skip.
56
+
57
+ Source: `packages/a2ui/compose/strategies/monolithic/_shared.js` (+91, -2 lines, commit `afda98f5`).
58
+
15
59
  ## [0.4.5] - 2026-05-12
16
60
 
17
61
  ### Changed — GenUI overhaul prompt-engineering (§56, v0.4.5)
package/core/generator.js CHANGED
@@ -17,7 +17,6 @@ import { store, engine } from './state.js';
17
17
  import { checkIntentAlignment } from '../../retrieval/intent/intent-alignment.js';
18
18
  import { decomposeIntent, composeSubtasks } from '../../retrieval/intent/decomposer.js';
19
19
  import { getWiringCatalog } from '../../retrieval/wiring-catalog.js';
20
- import { getComponentData } from '../../retrieval/pattern-library.js';
21
20
 
22
21
  import { StubLLMAdapter } from '../../../llm/llm-stub.js';
23
22
  import { createAdapter } from '../../../llm/llm-bridge.js';
package/core/reference.js CHANGED
@@ -1,16 +1,44 @@
1
1
  /**
2
- * Reference Tools — Delegates to A003 intelligence system.
2
+ * Reference Tools — stable generative-system API surface.
3
3
  *
4
- * Thin wrappers that provide a stable generative-system API surface
5
- * over the intelligence module. Used by the generator and pipeline stages
6
- * to look up components, patterns, and domain context.
4
+ * Thin wrappers used by the monolithic engines + generator pipeline.
5
+ * §64 migrated the "pattern" surface from `pattern-library.js` (retired)
6
+ * to the zettel `composition-library.js`. Function names kept
7
+ * (searchBlocks/lookupPattern/listPatterns/searchBlocksSemantic) for
8
+ * back-compat with engine call sites; internally they hit compositions.
7
9
  */
8
10
 
9
11
  import { getCatalog, getComponent, getComponentsByCategory } from '../../retrieval/index.js';
10
12
  import { classifyIntent, assembleContext } from '../../retrieval/index.js';
11
- import { getAllPatterns, searchPatterns, getPattern, semanticSearchPatterns } from '../../retrieval/index.js';
12
13
  import { checkAllAntiPatterns } from '../../retrieval/index.js';
13
14
  import { serializeEntry } from '../../retrieval/index.js';
15
+ import {
16
+ searchAll as searchCompositions,
17
+ getComposition,
18
+ getAllCompositions,
19
+ } from '../strategies/zettel/composition-library.js';
20
+
21
+ /**
22
+ * Compositions have `tags: { purpose: [...], complexity: "", layout: "" }`
23
+ * but the monolithic engines (instant/pro/thinking) all assume the old
24
+ * pattern shape `tags: [string]` and call `.map(...)` on it. Flatten on
25
+ * the way out so legacy call sites keep working without re-templating
26
+ * every engine. Annotated chunks expose flat-but-shallow `tags`; both
27
+ * are handled.
28
+ */
29
+ function normalize(record) {
30
+ if (!record) return record;
31
+ let tags = record.tags;
32
+ if (Array.isArray(tags)) return record;
33
+ if (tags && typeof tags === 'object') {
34
+ tags = Object.values(tags)
35
+ .flat()
36
+ .filter((v) => typeof v === 'string');
37
+ } else {
38
+ tags = [];
39
+ }
40
+ return { ...record, tags };
41
+ }
14
42
 
15
43
  /**
16
44
  * Look up a single component by A2UI type name.
@@ -50,40 +78,47 @@ export async function getComponentsByGroup(category) {
50
78
  }
51
79
 
52
80
  /**
53
- * Search the pattern library by query.
81
+ * Search the composition library by query.
82
+ * Returns full composition records (rehydrated from name hits).
54
83
  * @param {string} query — Natural language or keyword query
55
84
  * @returns {object[]}
56
85
  */
57
86
  export function searchBlocks(query, { domain } = {}) {
58
- return searchPatterns(query, { domain });
87
+ const hits = searchCompositions(query);
88
+ const records = hits.map((h) => getComposition(h.name)).filter(Boolean);
89
+ const filtered = domain ? records.filter((c) => c.domain === domain) : records;
90
+ return filtered.map(normalize);
59
91
  }
60
92
 
61
93
  /**
62
- * Get a specific named pattern.
63
- * @param {string} name — Pattern name (e.g. 'stat-cards', 'user-profile')
94
+ * Get a specific named composition.
95
+ * @param {string} name — Composition name
64
96
  * @returns {object|null}
65
97
  */
66
98
  export function lookupPattern(name) {
67
- return getPattern(name);
99
+ return normalize(getComposition(name));
68
100
  }
69
101
 
70
102
  /**
71
- * Get all available patterns.
103
+ * Get all available compositions.
72
104
  * @returns {object[]}
73
105
  */
74
106
  export function listPatterns() {
75
- return getAllPatterns();
107
+ return getAllCompositions().map(normalize);
76
108
  }
77
109
 
78
110
  /**
79
- * Semantic search LLM-enhanced pattern matching.
80
- * Falls back to keyword search if no adapter provided.
111
+ * Keyword search wrapped as the legacy `{ matches }` return shape so the
112
+ * monolithic-thinking engine's call site keeps working. The historical
113
+ * LLM-driven semantic path retired with `pattern-library.js` in §64;
114
+ * compositions don't have a semantic search backend yet.
115
+ *
81
116
  * @param {string} query
82
- * @param {object} [options] — { llmAdapter, remix }
83
- * @returns {Promise<{ matches: object[], remix?: object }>}
117
+ * @param {object} [_options] — accepted for back-compat; llmAdapter/remix ignored
118
+ * @returns {Promise<{ matches: object[] }>}
84
119
  */
85
- export async function searchBlocksSemantic(query, options = {}) {
86
- return semanticSearchPatterns(query, options);
120
+ export async function searchBlocksSemantic(query, _options = {}) {
121
+ return { matches: searchBlocks(query) };
87
122
  }
88
123
 
89
124
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-compose",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
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": {
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Dual-environment chunk loader — Node + browser.
3
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
4
+ * Mirrors the dual-environment loading approach historically used by
5
+ * pattern-library.js (retired §64) so the gen-UI training chunks at
6
+ * packages/a2ui/corpus/chunks/*.json are reachable from BOTH server-side
7
+ * engines (monolithic-pro via /api/generate's Node process) AND
8
+ * browser-side engines (monolithic-pro called directly from
8
9
  * gen-ui playground's IIFE).
9
10
  *
10
11
  * The existing packages/a2ui/corpus/scripts/chunk-library.js is Node-only
@@ -12,26 +12,129 @@ import { store } from '../../core/state.js';
12
12
  import { checkIntentAlignment } from '../../../retrieval/intent/intent-alignment.js';
13
13
  import { composeSubtasks } from '../../../retrieval/intent/decomposer.js';
14
14
  import { getWiringCatalog } from '../../../retrieval/wiring-catalog.js';
15
- import { getComponentData } from '../../../retrieval/pattern-library.js';
15
+ import { getComponentData } from '../../../retrieval/component-catalog.js';
16
+
17
+ // ── v0.9 .a2ui.json sidecar → prompt-catalog adapter ─────────────
18
+ //
19
+ // §66 (v0.4.6): the `.a2ui.json` sidecars produced by `npm run components`
20
+ // are v0.9 JSON Schemas with `{title, description, properties, x-adiaui}`.
21
+ // The legacy `_components.json` catalog this prompt builder originally
22
+ // read had a flatter `{tag, category, description, props, events, slots,
23
+ // examples, aliases, keywords}` shape. This adapter normalizes the v0.9
24
+ // sidecar into the legacy shape so the rest of the prompt builder can stay
25
+ // unchanged.
26
+ //
27
+ // Before §66, this code did `a2uiData.props` — which silently returned 0
28
+ // props for every component (the v0.9 shape uses `properties`, not
29
+ // `props`), causing every prompt to fall through to the `_components.json`
30
+ // catalog. After §66, the canonical sidecar drives the prompt directly
31
+ // and `_components.json` is retired (§64/§65).
32
+ function adaptV09Component(a2uiData) {
33
+ if (!a2uiData || typeof a2uiData !== 'object') return null;
34
+ const ext = a2uiData['x-adiaui'] || {};
35
+ const properties = a2uiData.properties || {};
36
+
37
+ // Map v0.9 property → legacy prop shape. Skip the always-present
38
+ // `component` (const PascalCase name) + `children` (slot container)
39
+ // fields since they aren't user-facing props.
40
+ const props = {};
41
+ for (const [name, spec] of Object.entries(properties)) {
42
+ if (name === 'component' || name === 'children') continue;
43
+ if (spec?.const) continue; // discriminator-only fields
44
+ const out = {};
45
+ // JSON Schema type names are lowercase; legacy catalog used Title-Case.
46
+ if (spec.type) {
47
+ out.type = spec.type === 'string' ? 'String'
48
+ : spec.type === 'boolean' ? 'Boolean'
49
+ : spec.type === 'number' || spec.type === 'integer' ? 'Number'
50
+ : spec.type === 'array' ? 'Array'
51
+ : spec.type === 'object' ? 'Object'
52
+ : String(spec.type);
53
+ }
54
+ if (spec.enum) out.enum = spec.enum;
55
+ if (spec.default !== undefined) out.default = spec.default;
56
+ if (spec.description) out.description = spec.description;
57
+ props[name] = out;
58
+ }
59
+
60
+ // Events in the legacy catalog were a flat string[]. The v0.9 sidecar
61
+ // stores them as an object: `{eventName: {description}}` (verified
62
+ // across all 94 sidecars on 2026-05-12). Older code paths accepted
63
+ // either shape; normalize to a string[] of event names.
64
+ const eventsRaw = ext.events;
65
+ let events = [];
66
+ if (Array.isArray(eventsRaw)) {
67
+ events = eventsRaw.map((e) => (typeof e === 'string' ? e : e?.name)).filter(Boolean);
68
+ } else if (eventsRaw && typeof eventsRaw === 'object') {
69
+ events = Object.keys(eventsRaw);
70
+ }
71
+
72
+ // Slots — also object-shaped (`{slotName: {description, type}}`) in v0.9.
73
+ // Legacy catalog stored a string[]. Normalize.
74
+ const slotsRaw = ext.slots;
75
+ let slots = [];
76
+ if (Array.isArray(slotsRaw)) {
77
+ slots = slotsRaw.map((s) => (typeof s === 'string' ? s : s?.name)).filter(Boolean);
78
+ } else if (slotsRaw && typeof slotsRaw === 'object') {
79
+ slots = Object.keys(slotsRaw);
80
+ }
16
81
 
17
- // Component prop catalog loaded lazily for prompt injection
82
+ // Aliases live under `x-adiaui.synonyms.tags` in v0.9. The synonyms
83
+ // field is always an object (verified across all 94 sidecars). Surface
84
+ // the `tags` array — the prompt renders them as "Button (also: SubmitButton)".
85
+ const synonyms = (ext.synonyms && typeof ext.synonyms === 'object') ? ext.synonyms : null;
86
+ const aliases = Array.isArray(synonyms?.tags) ? synonyms.tags : null;
87
+
88
+ return {
89
+ tag: ext.tag || null,
90
+ category: ext.category || 'layout',
91
+ description: a2uiData.description || '',
92
+ props,
93
+ events,
94
+ slots,
95
+ examples: Array.isArray(ext.examples) ? ext.examples : [],
96
+ aliases,
97
+ keywords: Array.isArray(ext.keywords) ? ext.keywords : [],
98
+ };
99
+ }
100
+
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.
18
110
  let _componentCatalog = null;
19
111
  async function getComponentCatalog() {
20
112
  if (_componentCatalog) return _componentCatalog;
21
113
  try {
22
114
  const IS_NODE = typeof process !== 'undefined' && process.versions?.node;
115
+ let catalogJson;
23
116
  if (IS_NODE) {
24
117
  const fs = await import(/* @vite-ignore */ 'node:fs/promises');
25
118
  const path = await import(/* @vite-ignore */ 'node:path');
26
119
  const url = await import(/* @vite-ignore */ 'node:url');
27
120
  const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
28
- const raw = await fs.readFile(path.join(__dirname, '../../../corpus/patterns/_components.json'), 'utf8');
29
- _componentCatalog = JSON.parse(raw);
121
+ const raw = await fs.readFile(path.join(__dirname, '../../../corpus/catalog-a2ui_0_9.json'), 'utf8');
122
+ catalogJson = JSON.parse(raw);
30
123
  } else {
31
- // Browser: use fetch (works in all Vite modes)
32
- const resp = await fetch(new URL('../../../corpus/patterns/_components.json', import.meta.url));
33
- if (resp.ok) _componentCatalog = await resp.json();
34
- else _componentCatalog = {};
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
+ };
35
138
  }
36
139
  } catch {
37
140
  _componentCatalog = {};
@@ -160,8 +263,13 @@ KEY RULES:
160
263
  return Array.isArray(a) && a.length ? ` (also: ${a.join(', ')})` : '';
161
264
  };
162
265
  for (const typeName of relevantTypes) {
163
- // Prefer .a2ui.json data (new source of truth), fallback to _components.json
164
- const a2uiData = getComponentData(typeName);
266
+ // §66 (v0.4.6): .a2ui.json sidecar is the canonical source of truth.
267
+ // The raw v0.9 shape uses `properties` (JSON Schema); adapt to the
268
+ // legacy `props` shape this prompt builder was originally written
269
+ // for. Falls back to the (soon-retired) `_components.json` only
270
+ // when no sidecar exists.
271
+ const v09Raw = getComponentData(typeName);
272
+ const a2uiData = v09Raw ? adaptV09Component(v09Raw) : null;
165
273
  if (a2uiData) {
166
274
  const props = a2uiData.props ? Object.entries(a2uiData.props).map(([k, v]) => {
167
275
  let desc = k;
@@ -163,7 +163,7 @@ export async function generateInstant({ intent, executionId, storeId, analysis,
163
163
 
164
164
  engine.submitStage(execId, 'generate', {
165
165
  messages,
166
- source: bestMatch ? 'pattern-library' : 'fallback',
166
+ source: bestMatch ? 'composition-library' : 'fallback',
167
167
  confidence: bestMatch ? 0.95 : 0.4,
168
168
  });
169
169
 
@@ -1,43 +1,51 @@
1
1
  /**
2
- * Composition Library — zettel-style loader for A2UI compositions.
2
+ * Composition Library — zettel-style loader for harvested A2UI chunks.
3
3
  *
4
- * Loads two kinds of records into a single in-memory map:
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
- * 1. `corpus/compositions/<domain>/<name>.json` hand-authored A2UI
7
- * templates. The legacy path.
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
- * 2. `corpus/chunks/<name>.json` harvested from production HTML
10
- * WHEN the chunk carries both `metadata` (from `data-chunk-*`
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
- import fs from 'node:fs';
25
- import path from 'node:path';
26
- import url from 'node:url';
27
-
28
- const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
29
- const COMPOSITIONS_ROOT = path.resolve(__dirname, '../../../corpus/compositions');
30
- const CHUNKS_ROOT = path.resolve(__dirname, '../../../corpus/chunks');
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
- function walk(dir, cb) {
36
- if (!fs.existsSync(dir)) return;
37
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
38
- const p = path.join(dir, entry.name);
39
- if (entry.isDirectory()) walk(p, cb);
40
- else if (entry.name.endsWith('.json') && !entry.name.startsWith('_')) cb(p);
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
 
@@ -62,48 +70,99 @@ function chunkToComposition(chunkDoc, sourcePath) {
62
70
  };
63
71
  }
64
72
 
65
- export function loadAll() {
66
- compositions.clear();
73
+ /**
74
+ * Track whether the eager top-level load has run. Multiple `loadAll`
75
+ * call sites (mcp/server.js bootstrap + any other consumer) re-clear
76
+ * + re-build, which is wasted work but not incorrect.
77
+ */
78
+ let _autoLoaded = false;
79
+ let _loadStats = { compositionCount: 0, handAuthoredCount: 0, annotatedChunkCount: 0 };
67
80
 
68
- // 1. Hand-authored compositions.
69
- walk(COMPOSITIONS_ROOT, (p) => {
70
- const doc = JSON.parse(fs.readFileSync(p, 'utf8'));
71
- if (doc.kind !== 'composition') return;
72
- doc._sourceFile = p;
73
- compositions.set(doc.name, doc);
74
- });
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
+ }
93
+
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
+ }
75
110
 
76
- // 2. Annotated chunks (§41). Filter: must have metadata.domain (or
77
- // metadata.keywords) AND a template field — otherwise the harvester
78
- // doesn't have enough to treat the chunk as a retrieval candidate.
79
111
  let annotatedChunkCount = 0;
80
112
  walk(CHUNKS_ROOT, (p) => {
81
113
  const doc = JSON.parse(fs.readFileSync(p, 'utf8'));
82
- if (!doc.metadata) return;
83
- if (!doc.template || !Array.isArray(doc.template) || doc.template.length === 0) return;
84
- const meta = doc.metadata;
85
- if (!meta.domain && !(meta.keywords && meta.keywords.length > 0)) return;
86
-
87
- // Chunk name MUST NOT collide with a hand-authored composition.
88
- // Hand-authored wins; warn so authors can reconcile (rename one or
89
- // delete the composition once the chunk supersedes it).
90
- if (compositions.has(doc.name)) {
91
- console.warn(
92
- `[composition-library] name collision: annotated chunk "${doc.name}" ` +
93
- `shadowed by hand-authored composition. Annotated chunk ignored — ` +
94
- `rename one or retire the composition.`
95
- );
96
- return;
97
- }
98
- compositions.set(doc.name, chunkToComposition(doc, p));
99
- annotatedChunkCount++;
114
+ annotatedChunkCount += registerChunk(doc, p);
100
115
  });
116
+ return annotatedChunkCount;
117
+ }
101
118
 
102
- return {
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
+ }
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();
142
+ _autoLoaded = true;
143
+ _loadStats = {
103
144
  compositionCount: compositions.size,
104
- handAuthoredCount: compositions.size - annotatedChunkCount,
145
+ handAuthoredCount: 0,
105
146
  annotatedChunkCount,
106
147
  };
148
+ return _loadStats;
149
+ }
150
+
151
+ // Eager top-level load so synchronous getters (getComposition,
152
+ // getAllCompositions, searchAll) work for consumers that don't call
153
+ // loadAll themselves — test harnesses, smoke scripts, and the
154
+ // retrieval-side helpers migrated off pattern-library in §64.
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 */ });
107
166
  }
108
167
 
109
168
  // Back-compat shims removed in §38 — fragment-library.js → composition-library.js
@@ -1,149 +0,0 @@
1
- /**
2
- * Pattern Export — Save generated UI as reusable named patterns.
3
- *
4
- * Produces two downloadable files:
5
- * {name}.json — A2UI flat adjacency pattern (importable into pattern-library)
6
- * {name}.html — Rendered HTML snapshot from the canvas
7
- *
8
- * Also supports importing patterns back into the runtime library.
9
- */
10
-
11
- import { registerPattern } from '../../retrieval/pattern-library.js';
12
-
13
- /**
14
- * Build a pattern-library-compatible JSON object from generation results.
15
- *
16
- * @param {string} name — Pattern name (kebab-case, e.g. "my-pricing-page")
17
- * @param {object} opts
18
- * @param {object[]} opts.components — Flat adjacency array from messages[0].components
19
- * @param {string} opts.intent — Original user intent
20
- * @param {string} [opts.domain] — Domain classification (forms, data, layout, agent, navigation)
21
- * @param {string} [opts.description] — Human description (falls back to intent)
22
- * @returns {object} — Pattern object matching pattern-library format
23
- */
24
- export function buildPatternJSON(name, { components, intent, domain, description }) {
25
- const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
26
-
27
- // Extract unique component type names
28
- const componentTypes = [...new Set(
29
- components.map(c => c.component).filter(Boolean)
30
- )];
31
-
32
- // Clean components: strip internal fields
33
- const template = components.map(c => {
34
- const { _surfaceId, ...rest } = c;
35
- return rest;
36
- });
37
-
38
- return {
39
- name: slug,
40
- description: description || intent || slug,
41
- domain: domain || 'layout',
42
- components: componentTypes,
43
- template,
44
- };
45
- }
46
-
47
- /**
48
- * Save a generation as a named pattern. Downloads .json + .html files.
49
- *
50
- * @param {string} name — Pattern name
51
- * @param {object} opts
52
- * @param {object[]} opts.messages — A2UI messages array
53
- * @param {object} opts.validation — Validation result (must have score >= 95)
54
- * @param {string} opts.intent — Original user intent
55
- * @param {string} [opts.domain] — Domain classification
56
- * @param {string} [opts.canvasHTML] — Rendered HTML from canvas
57
- * @param {number} [opts.minScore=95] — Minimum score to allow save
58
- * @returns {{ json: object, html: string }} — The saved payloads
59
- */
60
- export function savePattern(name, { messages, validation, intent, domain, canvasHTML, minScore = 95 }) {
61
- if (!validation || validation.score < minScore) {
62
- throw new Error(`Score ${validation?.score ?? 0} is below minimum ${minScore} for pattern save`);
63
- }
64
-
65
- const components = messages?.[0]?.components;
66
- if (!components || !components.length) {
67
- throw new Error('No components to save');
68
- }
69
-
70
- const json = buildPatternJSON(name, { components, intent, domain });
71
-
72
- // Download .json
73
- downloadFile(`${json.name}.json`, JSON.stringify(json, null, 2), 'application/json');
74
-
75
- // Download .html (if available)
76
- const html = canvasHTML || '';
77
- if (html) {
78
- const htmlDoc = `<!DOCTYPE html>
79
- <html lang="en">
80
- <head>
81
- <meta charset="UTF-8">
82
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
83
- <title>${json.name} — A2UI Pattern</title>
84
- <style>body { font-family: system-ui, sans-serif; padding: 2rem; }</style>
85
- </head>
86
- <body>
87
- ${html}
88
- </body>
89
- </html>`;
90
- downloadFile(`${json.name}.html`, htmlDoc, 'text/html');
91
- }
92
-
93
- // Also register in the runtime library
94
- registerPattern(json);
95
-
96
- return { json, html };
97
- }
98
-
99
- /**
100
- * Import a pattern JSON object into the runtime pattern library.
101
- *
102
- * @param {object|string} patternJSON — Pattern object or JSON string
103
- * @returns {{ success: boolean, name?: string, error?: string }}
104
- */
105
- export function importPattern(patternJSON) {
106
- let pattern = patternJSON;
107
- if (typeof pattern === 'string') {
108
- try { pattern = JSON.parse(pattern); }
109
- catch { return { success: false, error: 'Invalid JSON' }; }
110
- }
111
-
112
- if (!pattern?.name) return { success: false, error: 'Missing pattern name' };
113
- if (!pattern?.template || !Array.isArray(pattern.template)) {
114
- return { success: false, error: 'Missing or invalid template array' };
115
- }
116
-
117
- const registered = registerPattern(pattern);
118
- if (!registered) {
119
- return { success: false, error: `Pattern "${pattern.name}" already exists` };
120
- }
121
-
122
- return { success: true, name: pattern.name };
123
- }
124
-
125
- /**
126
- * Trigger a file download in the browser.
127
- *
128
- * @param {string} filename
129
- * @param {string} content
130
- * @param {string} [mimeType='application/json']
131
- */
132
- export function downloadFile(filename, content, mimeType = 'application/json') {
133
- if (typeof document === 'undefined') {
134
- throw new Error('downloadFile requires a browser environment');
135
- }
136
-
137
- const blob = new Blob([content], { type: mimeType });
138
- const url = URL.createObjectURL(blob);
139
- const a = document.createElement('a');
140
- a.href = url;
141
- a.download = filename;
142
- a.style.display = 'none';
143
- // Prevent SPA router from intercepting the blob URL click
144
- a.addEventListener('click', (e) => e.stopPropagation());
145
- document.body.appendChild(a);
146
- a.click();
147
- document.body.removeChild(a);
148
- URL.revokeObjectURL(url);
149
- }