@adia-ai/a2ui-mcp 0.0.1 → 0.0.3

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
@@ -9,8 +9,124 @@ zettel strategies.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ---
13
+
14
+ ## [0.0.3] - 2026-04-28
15
+
16
+ Same-day follow-up to `0.0.2` shipped earlier today. Wires the corpus
17
+ embedding vectors (from `@adia-ai/a2ui-corpus@0.0.4`) into the chunk
18
+ retrieval path so the synthesizer mixes-and-matches against semantic
19
+ similarity, not just keyword overlap.
20
+
21
+ ### Added
22
+
23
+ - **`chunk-embedding-retriever.js`** (`packages/a2ui/retrieval/`) — sibling
24
+ to the existing pattern-embedding retriever. Lazy-loads
25
+ `chunk-embeddings.json`, scores cosine similarity against the same
26
+ provider that built the index. Graceful no-op when index is missing or
27
+ no API key is available.
28
+ - **`searchChunksAsync(query, opts)`** in `chunk-library.js` — embedding-
29
+ blended search. Returns top candidates ranked by `keyword + 5×cosine`
30
+ when embeddings are available; falls back to keyword-only otherwise.
31
+ Sync `searchChunks()` is preserved for callers that want the keyword
32
+ floor without the network round-trip.
33
+
12
34
  ### Changed
13
- - Registry / transpilation scripts at `packages/web-components/scripts/a2ui-to-html.cjs` and `packages/web-components/scripts/mcp-pipeline.cjs` now mirror the canonical `packages/web-components/a2ui/registry.js` for all A2UI component → custom-element mappings. Previously these scripts had several stale mappings that diverged from the runtime registry.
35
+
36
+ - `chunk-synthesizer.composeFromIntent()` — both tier-1 retrieval AND
37
+ tier-2 pre-search now go through `searchChunksAsync` instead of the
38
+ keyword-only `searchChunks`. The pre-search filters the prompt token
39
+ budget more accurately; the retrieval-first path catches more direct
40
+ hits before the LLM is invoked.
41
+
42
+ ### Measured impact
43
+
44
+ `eval:chunk-synthesis` (10 hold-out intents, real Anthropic LLM):
45
+
46
+ | | 0.0.2 (keyword) | **0.0.3 (semantic)** |
47
+ |---|---|---|
48
+ | Retrieval-tier hits | 7/10 | **9/10** |
49
+ | Synthesis-tier attempts | 3 | **1** |
50
+ | Synthesis plans validated | 3/3 | 1/1 |
51
+ | Total time | 5.0s | 5.1s |
52
+
53
+ Examples that flipped from synthesis → direct retrieval:
54
+ - "kpi grid with 4 stat cards" → matches `dashboard-kpi-grid` cleanly.
55
+ - "conversion funnel chart" → matches `dashboard-funnel`.
56
+
57
+ The remaining synthesis intent ("recovery page with backup-code form +
58
+ contact-support link") is genuinely novel — it composes from parts that
59
+ don't have a 1:1 chunk and exercises the slot-validator path correctly.
60
+
61
+ ### Dependencies
62
+
63
+ - Bumps `@adia-ai/a2ui-corpus` requirement from `^0.0.3` to `^0.0.4`.
64
+
65
+ ---
66
+
67
+ ## [0.0.2] - 2026-04-28
68
+
69
+ Adds **gen-UI training-chunk tools** that expose the new chunk corpus
70
+ (shipped in `@adia-ai/a2ui-corpus@0.0.3`) via the MCP transport. Also
71
+ adds a chunk-aware composition synthesizer that mixes-and-matches chunks
72
+ when retrieval can't find a 1:1 match — the LLM picks a page chunk + binds
73
+ block/panel chunks to its slots, validated against the chunk catalog
74
+ before HTML is materialized.
75
+
76
+ ### Added (MCP tools)
77
+
78
+ - **`search_chunks(query, kind?, limit?)`** — keyword search over the
79
+ chunk corpus. Filters by `kind` (`block` | `panel` | `page`); returns
80
+ ranked candidates with relevance score, primary tag, and chunk name.
81
+ - **`get_chunk(name)`** — full record for a single chunk by name. For
82
+ reusable slot chunks (e.g. `auth-card-header`, `reg-step-header`)
83
+ returns an `instances` array — one entry per page.
84
+ - **`lookup_chunk(component_name)`** — list every chunk whose primary
85
+ element is `<component_name>`. Useful for "show me every page that
86
+ opens with a `<card-ui raw>`" or "every chunk built around a
87
+ `<grid-ui>` root."
88
+ - **`compose_from_chunks(intent?, plan?, max_attempts?)`** — two-tier
89
+ composition tool: retrieval-first (returns matched chunk HTML
90
+ directly), synthesis-fallback (LLM picks a page chunk + binds
91
+ block/panel chunks to its slots when retrieval is weak). The
92
+ synthesizer mirrors the existing fragment synthesizer's prompt
93
+ pattern, repointed at the chunk catalog. Pre-search filters the
94
+ catalog to ~30 candidates before the LLM sees them. Plan-only
95
+ invocation skips the LLM and just materializes HTML from a
96
+ pre-baked binding.
97
+
98
+ ### Added (engine internals)
99
+
100
+ - `packages/a2ui/compose/engines/zettel/chunk-composer.js` —
101
+ HTML-string-level composer that walks a page chunk and substitutes
102
+ each `data-chunk-slot` region with the bound block-level chunks'
103
+ HTML. Companion `validatePlan()` checks slot names + chunk-kind
104
+ contracts before composition.
105
+ - `packages/a2ui/compose/engines/zettel/chunk-synthesizer.js` —
106
+ LLM-driven mix-and-match composition; pre-searches the catalog,
107
+ builds a prompt with a one-shot in-context example, validates the
108
+ LLM's binding plan against the catalog, retries with feedback on
109
+ validation failure (default 2 attempts).
110
+
111
+ ### Smoke
112
+
113
+ - `npm run smoke:chunks` (33/33 passing, 2026-04-28) — covers
114
+ chunk-library indexing, retrieval scoring, plan validation, plan
115
+ composition, and synthesis fallback path with a stub LLM adapter.
116
+
117
+ ### Convention reference
118
+
119
+ - Spec: [`docs/specs/genui-chunk-marker.md`](../../../docs/specs/genui-chunk-marker.md).
120
+ - Plan: [`docs/plans/training-pipeline-chunk-harvest-2026-04-27.md`](../../../docs/plans/training-pipeline-chunk-harvest-2026-04-27.md).
121
+
122
+ ### Other
123
+
124
+ - Registry / transpilation scripts at
125
+ `packages/web-components/scripts/a2ui-to-html.cjs` and
126
+ `packages/web-components/scripts/mcp-pipeline.cjs` now mirror the
127
+ canonical `packages/web-components/a2ui/registry.js` for all A2UI
128
+ component → custom-element mappings. Previously these scripts had
129
+ several stale mappings that diverged from the runtime registry.
14
130
 
15
131
  ---
16
132
 
package/README.md CHANGED
@@ -6,9 +6,9 @@ feedback loop as stdio tools for Claude Desktop, Claude Code, and any
6
6
  other MCP-speaking host.
7
7
 
8
8
  > Runtime only. Generation logic lives in `@adia-ai/a2ui-compose`; UI atoms in
9
- > [`@adia-ai/web-components`](../web-components); the A2UI protocol runtime
9
+ > [`@adia-ai/web-components`](../../web-components); the A2UI protocol runtime
10
10
  > (renderer, registry, streams, wiring) in
11
- > [`@adia-ai/a2ui-utils`](../a2ui/utils); corpus in
11
+ > [`@adia-ai/a2ui-utils`](../utils); corpus in
12
12
  > [`@adia-ai/a2ui-corpus`](../corpus).
13
13
 
14
14
  ## Quick start
@@ -42,31 +42,35 @@ export GEMINI_API_KEY=AIza…
42
42
 
43
43
  ## Tools
44
44
 
45
- The server registers 21 tools. Shape is stable; argument schemas via Zod.
46
-
47
- | Tool | What it does |
48
- |------------------------|-----------------------------------------------------------|
49
- | `generate_ui` | Intent → A2UI tree. Engine (`monolithic`/`zettel`) + mode. |
50
- | `validate_schema` | Run the 15-check validator on an A2UI tree; returns 0-100. |
51
- | `classify_intent` | Extract concepts, entities, implied components, steelman. |
52
- | `lookup_component` | Resolve a component name (alias-aware) to its schema. |
53
- | `get_component_map` | Full tag→class map including alias normalizations. |
54
- | `search_patterns` | Keyword-rank the monolithic pattern corpus. |
55
- | `assemble_context` | Build the system prompt context for a given intent. |
56
- | `check_anti_patterns` | Scan a tree for canonical anti-patterns (chart-legend, …). |
57
- | `get_traits` | List trait catalog + their host-binding rules. |
58
- | `convert_html` | Raw HTML → best-effort A2UI tree (import path). |
59
- | `get_wiring_catalog` | Declarative wiring-engine recipes. |
60
- | `import_pattern` | Commit a generated result into the pattern library. |
61
- | `submit_feedback` | Append a user-feedback event to the feedback store. |
62
- | `get_quality_metrics` | Aggregate pass/fail scores over a window. |
63
- | `get_training_gaps` | Intents that currently miss coverage. |
64
- | `run_eval` | Run the held-out benchmark; return pass/fail per intent. |
65
- | `get_fragment` | Fetch a single zettel fragment by id. |
66
- | `get_composition` | Fetch a named multi-fragment composition. |
67
- | `resolve_composition` | Expand a composition reference into its fragments. |
68
- | `get_graph` | Dump the zettel fragment-dependency graph. |
69
- | `zettel_stats` | Corpus counts (fragments, compositions, reuse ratio, …). |
45
+ The server registers 25 tools. Shape is stable; argument schemas via Zod.
46
+
47
+ | Tool | What it does |
48
+ |-------------------------|-----------------------------------------------------------|
49
+ | `generate_ui` | Intent → A2UI tree. Engine (`monolithic`/`zettel`) + mode. |
50
+ | `validate_schema` | Run the 15-check validator on an A2UI tree; returns 0-100. |
51
+ | `classify_intent` | Extract concepts, entities, implied components, steelman. |
52
+ | `lookup_component` | Resolve a component name (alias-aware) to its schema. |
53
+ | `get_component_map` | Full tag→class map including alias normalizations. |
54
+ | `search_patterns` | Keyword-rank the monolithic pattern corpus. |
55
+ | `assemble_context` | Build the system prompt context for a given intent. |
56
+ | `check_anti_patterns` | Scan a tree for canonical anti-patterns (chart-legend, …). |
57
+ | `get_traits` | List trait catalog + their host-binding rules. |
58
+ | `convert_html` | Raw HTML → best-effort A2UI tree (import path). |
59
+ | `get_wiring_catalog` | Declarative wiring-engine recipes. |
60
+ | `import_pattern` | Commit a generated result into the pattern library. |
61
+ | `submit_feedback` | Append a user-feedback event to the feedback store. |
62
+ | `get_quality_metrics` | Aggregate pass/fail scores over a window. |
63
+ | `get_training_gaps` | Intents that currently miss coverage. |
64
+ | `run_eval` | Run the held-out benchmark; return pass/fail per intent. |
65
+ | `get_fragment` | Fetch a single zettel fragment by id. |
66
+ | `get_composition` | Fetch a named multi-fragment composition. |
67
+ | `resolve_composition` | Expand a composition reference into its fragments. |
68
+ | `get_graph` | Dump the zettel fragment-dependency graph. |
69
+ | `zettel_stats` | Corpus counts (fragments, compositions, reuse ratio, …). |
70
+ | **`search_chunks`** | Semantic + keyword search over the gen-UI training-chunk corpus (since 0.0.2). |
71
+ | **`get_chunk`** | Full record (HTML + metadata + slots) for one chunk. |
72
+ | **`lookup_chunk`** | List every chunk whose primary element is `<component>`. |
73
+ | **`compose_from_chunks`** | Retrieval-first / LLM-mix-and-match composition. Picks a page chunk + binds block/panel chunks to its slots when retrieval is weak. Validator enforces slot+kind contracts. **Embedding-blended retrieval as of 0.0.3** (was keyword-only in 0.0.2). |
70
74
 
71
75
  ## Layout
72
76
 
@@ -116,12 +120,12 @@ npm run eval:diff -- --engine zettel
116
120
  └─────────────┘ │
117
121
  ├── @adia-ai/a2ui-compose (engines, validator, LLM bridge)
118
122
  ├── @adia-ai/a2ui-corpus (catalog, fragments, feedback)
119
- └── @adiahealth/web-components (component schemas via .a2ui.json)
123
+ └── @adia-ai/web-components (component schemas via .a2ui.json)
120
124
  ```
121
125
 
122
126
  On start, the server:
123
127
 
124
- 1. Loads the component catalog (`gen-ui-training/catalog-a2ui_0_9.json`)
128
+ 1. Loads the component catalog (`@adia-ai/a2ui-corpus/catalog-a2ui_0_9.json`)
125
129
  2. Lazy-initializes the zettel corpus on first `generate_ui`/`zettel_*` call
126
130
  3. Resolves LLM adapter from env vars
127
131
  4. Registers tools + opens stdio transport
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-mcp",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "AdiaUI A2UI MCP server. Exposes the compose engine over MCP with an engine selector for monolithic + zettel strategies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,7 +29,7 @@
29
29
  "@adia-ai/a2ui-compose": "^0.0.1",
30
30
  "@adia-ai/a2ui-retrieval": "^0.0.1",
31
31
  "@adia-ai/a2ui-validator": "^0.0.1",
32
- "@adia-ai/a2ui-corpus": "^0.0.1",
32
+ "@adia-ai/a2ui-corpus": "^0.0.4",
33
33
  "zod": "^3.24.0"
34
34
  }
35
35
  }
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Real-LLM eval set for the chunk-aware composition synthesizer.
4
+ *
5
+ * Walks a hold-out set of intents that DON'T have a 1:1 chunk match in the
6
+ * corpus and exercises the full retrieval-first → synthesis-fallback path.
7
+ * For each intent, records:
8
+ * - which tier handled it (retrieval direct hit vs LLM synthesis)
9
+ * - whether HTML was produced
10
+ * - whether the synthesizer's plan validated against slot/kind contracts
11
+ * - composed HTML byte length (rough proxy for "not empty")
12
+ *
13
+ * Pass criterion: ≥ 80% of intents produce non-empty HTML; all synthesizer
14
+ * plans pass the slot+kind validator.
15
+ *
16
+ * Usage:
17
+ * node packages/a2ui/mcp/scripts/eval-chunk-synthesis.mjs
18
+ * ANTHROPIC_API_KEY=… node packages/a2ui/mcp/scripts/eval-chunk-synthesis.mjs
19
+ */
20
+
21
+ import '../../../../scripts/load-env.mjs';
22
+ import { composeFromIntent } from '../../compose/engines/zettel/chunk-synthesizer.js';
23
+ import { createAdapter } from '../../compose/llm/llm-bridge.js';
24
+
25
+ // Hold-out intents — chosen to NOT have a 1:1 chunk match (so synthesis path
26
+ // is exercised). Mix of dashboard-shape, auth-shape, and novel composites.
27
+ const INTENTS = [
28
+ // Dashboard composites that don't exist as a single chunk:
29
+ 'admin dashboard with KPI grid and conversion funnel',
30
+ 'analytics page with audience KPIs and a country list',
31
+ 'reports page with a transactions table and a sparkline grid',
32
+
33
+ // Auth-card variations that aren't pre-baked:
34
+ 'sign-in page with email + password + remember-me + magic-link alternative',
35
+ 'recovery page with backup-code form and contact-support link',
36
+
37
+ // Generic page-shaped composites:
38
+ 'page with a sticky header and a tabbed body',
39
+ 'data-rich settings page with a members table and an integrations grid',
40
+
41
+ // Things that should retrieve directly (control group):
42
+ 'kpi grid with 4 stat cards', // → likely matches dashboard-kpi-grid via search
43
+ 'conversion funnel chart', // → dashboard-funnel
44
+ 'sign in form with email', // → auth-signin-card-email
45
+ ];
46
+
47
+ const startedAt = Date.now();
48
+ const results = [];
49
+
50
+ console.log(`▶ chunk-synthesis eval — ${INTENTS.length} intents\n`);
51
+
52
+ const llmAdapter = await createAdapter();
53
+
54
+ for (const intent of INTENTS) {
55
+ const t0 = Date.now();
56
+ let row = { intent, ms: 0, source: null, hasHtml: false, htmlBytes: 0, planValid: null, error: null };
57
+ try {
58
+ const result = await composeFromIntent({ intent, llmAdapter, maxAttempts: 2 });
59
+ row.ms = Date.now() - t0;
60
+ row.source = result.source;
61
+ row.hasHtml = !!result.html;
62
+ row.htmlBytes = result.html ? result.html.length : 0;
63
+ row.planValid = result.synthesis ? !!result.synthesis.validation?.ok : null;
64
+ row.warnings = (result.warnings || []).length;
65
+ } catch (e) {
66
+ row.ms = Date.now() - t0;
67
+ row.error = e.message;
68
+ }
69
+ results.push(row);
70
+ const flag = row.hasHtml ? '✓' : '✗';
71
+ const tag = row.source === 'retrieval' ? '[ret]' : row.source === 'synthesis' ? '[syn]' : '[err]';
72
+ console.log(` ${flag} ${tag} ${row.ms.toString().padStart(5)}ms ${intent}`);
73
+ if (row.error) console.log(` error: ${row.error}`);
74
+ if (row.warnings) console.log(` ${row.warnings} warning(s)`);
75
+ }
76
+
77
+ const passed = results.filter((r) => r.hasHtml).length;
78
+ const passRate = passed / results.length;
79
+ const synthAttempts = results.filter((r) => r.source === 'synthesis');
80
+ const retrievalAttempts = results.filter((r) => r.source === 'retrieval');
81
+ const synthValidPlans = synthAttempts.filter((r) => r.planValid).length;
82
+
83
+ console.log(`\n── Summary ──`);
84
+ console.log(` Total intents: ${results.length}`);
85
+ console.log(` Produced HTML: ${passed} (${(passRate * 100).toFixed(0)}%)`);
86
+ console.log(` Retrieval-tier hits: ${retrievalAttempts.length}`);
87
+ console.log(` Synthesis-tier attempts: ${synthAttempts.length}`);
88
+ if (synthAttempts.length) {
89
+ console.log(` Synthesis plans validated: ${synthValidPlans}/${synthAttempts.length}`);
90
+ }
91
+ console.log(` Total time: ${((Date.now() - startedAt) / 1000).toFixed(1)}s`);
92
+
93
+ const passThreshold = 0.8;
94
+ if (passRate >= passThreshold) {
95
+ console.log(`\n✓ PASS — ≥${passThreshold * 100}% intents produced HTML`);
96
+ process.exit(0);
97
+ } else {
98
+ console.log(`\n✗ FAIL — pass rate ${(passRate * 100).toFixed(0)}% < ${passThreshold * 100}% threshold`);
99
+ process.exit(1);
100
+ }
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Smoke test for the gen-UI chunk MCP tools.
4
+ *
5
+ * Tests search_chunks / get_chunk / lookup_chunk against known chunks
6
+ * harvested from site/pages/* and corpus/exemplars/*.
7
+ *
8
+ * Wired into:
9
+ * - npm run smoke:chunks
10
+ * - Phase D verification gate (docs/plans/training-pipeline-chunk-harvest-2026-04-27.md)
11
+ */
12
+
13
+ import {
14
+ getChunk,
15
+ getChunkIndex,
16
+ lookupChunksByPrimary,
17
+ searchChunks,
18
+ } from '../../corpus/scripts/chunk-library.js';
19
+
20
+ let passed = 0;
21
+ let failed = 0;
22
+ const failures = [];
23
+
24
+ function assert(name, cond, detail) {
25
+ if (cond) { passed++; console.log(` ✓ ${name}`); }
26
+ else { failed++; failures.push(`${name}: ${detail || 'failed'}`); console.log(` ✗ ${name}: ${detail || 'failed'}`); }
27
+ }
28
+
29
+ console.log('── chunk-library smoke ──');
30
+
31
+ const idx = getChunkIndex();
32
+ assert('index loaded', idx && idx.unique_names > 0, idx ? `got ${idx.unique_names}` : 'no index');
33
+ assert('expected counts', idx && idx.unique_names >= 700 && idx.total_instances >= 800,
34
+ idx ? `${idx.unique_names} unique / ${idx.total_instances} instances (need ≥700/800)` : 'no index');
35
+ assert('by_kind covers block + panel + page', idx && idx.by_kind.block && idx.by_kind.panel && idx.by_kind.page,
36
+ idx ? JSON.stringify(idx.by_kind) : 'no index');
37
+
38
+ console.log('\n── get_chunk ──');
39
+
40
+ const signin = getChunk('auth-signin-card-email');
41
+ assert('get_chunk(auth-signin-card-email) returns record', signin && signin.name === 'auth-signin-card-email');
42
+ assert('record has primary=card-ui', signin && signin.primary === 'card-ui',
43
+ signin ? `got primary=${signin.primary}` : '');
44
+ assert('record HTML is non-empty', signin && typeof signin.html === 'string' && signin.html.length > 100);
45
+
46
+ const kpi = getChunk('dashboard-kpi-grid');
47
+ assert('get_chunk(dashboard-kpi-grid) returns record', kpi && kpi.name === 'dashboard-kpi-grid');
48
+ assert('kpi primary=grid-ui', kpi && kpi.primary === 'grid-ui',
49
+ kpi ? `got primary=${kpi.primary}` : '');
50
+
51
+ const adminPage = getChunk('dashboard-admin-page');
52
+ assert('get_chunk(dashboard-admin-page) returns record', adminPage && adminPage.name === 'dashboard-admin-page');
53
+ assert('admin-page is kind=page', adminPage && adminPage.kind === 'page',
54
+ adminPage ? `got kind=${adminPage.kind}` : '');
55
+ assert('admin-page declares page-header + page-content slots',
56
+ adminPage && (adminPage.slots || []).map(s => s.name).includes('page-header') &&
57
+ (adminPage.slots || []).map(s => s.name).includes('page-content'),
58
+ adminPage ? JSON.stringify((adminPage.slots || []).map(s => s.name)) : '');
59
+
60
+ const reusable = getChunk('reg-step-header');
61
+ assert('reusable slot chunk has instances array', reusable && Array.isArray(reusable.instances) && reusable.instances.length >= 18,
62
+ reusable ? `instances=${reusable.instances?.length}` : '');
63
+
64
+ const missing = getChunk('definitely-does-not-exist');
65
+ assert('non-existent chunk returns null', missing === null);
66
+
67
+ console.log('\n── search_chunks ──');
68
+
69
+ const results = searchChunks('dashboard');
70
+ assert('search_chunks(dashboard) returns ≥10', results.length >= 10, `got ${results.length}`);
71
+ assert('search results sorted by score', results.length === 0 || results.every((r, i, a) => i === 0 || a[i - 1].score >= r.score));
72
+
73
+ const blockOnly = searchChunks('dashboard', { kind: 'block' });
74
+ assert('kind=block filter excludes pages', blockOnly.every((r) => r.kind === 'block'));
75
+
76
+ const auth = searchChunks('auth signin');
77
+ assert('search_chunks(auth signin) finds auth-signin-card-email',
78
+ auth.some((r) => r.name === 'auth-signin-card-email'));
79
+
80
+ const code = searchChunks('code language');
81
+ assert('search_chunks(code language) finds code-language', code.some((r) => r.name === 'code-language'));
82
+
83
+ console.log('\n── lookup_chunk ──');
84
+
85
+ const cardChunks = lookupChunksByPrimary('card-ui');
86
+ assert('lookup_chunk(card-ui) returns ≥5', cardChunks.length >= 5, `got ${cardChunks.length}`);
87
+ assert('all card-ui chunks have primary=card-ui', cardChunks.every((c) => c.primary === 'card-ui'));
88
+
89
+ const drawerChunks = lookupChunksByPrimary('drawer-ui');
90
+ assert('lookup_chunk(drawer-ui) returns ≥10', drawerChunks.length >= 10, `got ${drawerChunks.length}`);
91
+
92
+ const articleChunks = lookupChunksByPrimary('article');
93
+ assert('lookup_chunk(article) finds dashboard-admin-page',
94
+ articleChunks.some((c) => c.name === 'dashboard-admin-page'));
95
+
96
+ // ── chunk-composer + chunk-synthesizer ──
97
+ import { composeFromPlan, validatePlan } from '../../compose/engines/zettel/chunk-composer.js';
98
+ import { composeFromIntent } from '../../compose/engines/zettel/chunk-synthesizer.js';
99
+
100
+ console.log('\n── validatePlan ──');
101
+
102
+ const goodPlan = {
103
+ page: 'dashboard-admin-page',
104
+ slot_bindings: {
105
+ 'page-header': 'dashboard-page-header',
106
+ 'page-content': ['dashboard-overview-panel', 'dashboard-funnel'],
107
+ },
108
+ };
109
+ const v1 = validatePlan(goodPlan);
110
+ assert('validatePlan(goodPlan) ok', v1.ok, JSON.stringify(v1.errors));
111
+
112
+ const badPagePlan = { page: 'definitely-not-real', slot_bindings: {} };
113
+ const v2 = validatePlan(badPagePlan);
114
+ assert('validatePlan(bad page) fails', !v2.ok && v2.errors.length > 0);
115
+
116
+ const badSlotPlan = {
117
+ page: 'dashboard-admin-page',
118
+ slot_bindings: { 'made-up-slot': 'dashboard-funnel' },
119
+ };
120
+ const v3 = validatePlan(badSlotPlan);
121
+ assert('validatePlan(undeclared slot) fails', !v3.ok && v3.errors.some((e) => e.includes('slot')));
122
+
123
+ const badBoundPlan = {
124
+ page: 'dashboard-admin-page',
125
+ slot_bindings: { 'page-content': 'no-such-chunk' },
126
+ };
127
+ const v4 = validatePlan(badBoundPlan);
128
+ assert('validatePlan(bad bound chunk) fails', !v4.ok && v4.errors.some((e) => e.includes('not found')));
129
+
130
+ console.log('\n── composeFromPlan ──');
131
+
132
+ const composed = composeFromPlan(goodPlan);
133
+ assert('composeFromPlan returns html', composed.html && composed.html.length > 1000,
134
+ composed.html ? `len=${composed.html.length}` : 'null');
135
+ assert('composed html includes admin-page wrapper', composed.html && composed.html.includes('dashboard-admin-page'));
136
+ assert('composed html injects funnel content',
137
+ composed.html && composed.html.includes('Conversion funnel'),
138
+ composed.html ? 'no funnel text found' : 'no html');
139
+
140
+ console.log('\n── composeFromIntent (retrieval-first, no LLM) ──');
141
+
142
+ // Use a stub adapter — synthesis path shouldn't be hit when retrieval matches
143
+ const stubLLM = { complete: async () => ({ content: '{"page":"dashboard-admin-page","slot_bindings":{"page-content":["dashboard-funnel"]}}' }) };
144
+
145
+ const direct = await composeFromIntent({ intent: 'dashboard-kpi-grid', llmAdapter: stubLLM });
146
+ assert('retrieval direct hit returns html', direct.html && direct.html.length > 50,
147
+ direct.warnings?.join('; '));
148
+ assert('retrieval source labeled correctly', direct.source === 'retrieval',
149
+ `got ${direct.source}`);
150
+
151
+ const synthesized = await composeFromIntent({ intent: 'admin dashboard with KPIs', llmAdapter: stubLLM });
152
+ assert('synthesis path produces html when retrieval is weak',
153
+ synthesized.html && synthesized.html.length > 1000,
154
+ synthesized.warnings?.join('; ') || 'no html');
155
+ // Source could be either retrieval or synthesis depending on score — both are
156
+ // acceptable as long as we got HTML out.
157
+ assert('synthesis source labeled', ['retrieval', 'synthesis'].includes(synthesized.source));
158
+
159
+ console.log(`\n${passed} passed, ${failed} failed`);
160
+ if (failed) {
161
+ console.log('\nFailures:');
162
+ for (const f of failures) console.log(` ${f}`);
163
+ process.exit(1);
164
+ }
package/server.js CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  // ── Load .env for API keys (Node doesn't read .env automatically) ──
22
- import '../../scripts/load-env.mjs';
22
+ import '../../../scripts/load-env.mjs';
23
23
 
24
24
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
25
25
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
@@ -66,6 +66,23 @@ console.error(
66
66
  `[adiaui-mcp] zettel corpus: ${_zettelBoot.fragmentCount} fragments, ${_zettelBoot.compositionCount} compositions`,
67
67
  );
68
68
 
69
+ // ── Gen-UI training-chunk corpus ──
70
+ import {
71
+ getChunk as getGenUIChunk,
72
+ getChunkIndex,
73
+ lookupChunksByPrimary,
74
+ searchChunks as searchGenUIChunks,
75
+ } from '../corpus/scripts/chunk-library.js';
76
+
77
+ const _chunkIndex = getChunkIndex();
78
+ if (_chunkIndex) {
79
+ console.error(
80
+ `[adiaui-mcp] gen-ui chunks: ${_chunkIndex.unique_names} unique chunks (${_chunkIndex.total_instances} instances; block=${_chunkIndex.by_kind.block || 0}, panel=${_chunkIndex.by_kind.panel || 0}, page=${_chunkIndex.by_kind.page || 0})`,
81
+ );
82
+ } else {
83
+ console.error('[adiaui-mcp] gen-ui chunks: index not found — run `npm run harvest:chunks`');
84
+ }
85
+
69
86
  // ── Server ──
70
87
 
71
88
  const server = new McpServer({
@@ -547,6 +564,189 @@ server.tool(
547
564
  },
548
565
  );
549
566
 
567
+ // ── Gen-UI training-chunk tools ──────────────────────────────────────
568
+ // Spec: docs/specs/genui-chunk-marker.md (Draft v0.1.0).
569
+ // Plan: docs/plans/training-pipeline-chunk-harvest-2026-04-27.md.
570
+
571
+ server.tool(
572
+ 'search_chunks',
573
+ `Search the gen-UI training-chunk corpus by keyword.
574
+
575
+ The chunk corpus comes from \`packages/a2ui/corpus/chunks/\` — JSON records
576
+ extracted from every \`[data-chunk]\` element in site/pages/* and the corpus
577
+ exemplars. There are three kinds:
578
+ - block (default): atomic UI fragment (KPI grid, sign-in form, table)
579
+ - panel: tab-panel fragment of a page (e.g. dashboard-overview-panel)
580
+ - page: full-page composition (e.g. dashboard-admin-page)
581
+
582
+ Returns ranked candidates with chunk name, kind, primary tag, and a relevance
583
+ score. Use \`get_chunk\` to fetch the full record (HTML + slot bindings + nested
584
+ chunks) for a specific name.`,
585
+ {
586
+ query: z.string().describe('Keyword query — chunk name fragment, intent words, primary-tag name'),
587
+ kind: z.enum(['block', 'panel', 'page']).optional().describe('Filter by chunk kind'),
588
+ limit: z.number().int().min(1).max(50).default(20).describe('Max results'),
589
+ },
590
+ async ({ query, kind, limit }) => {
591
+ const results = searchGenUIChunks(query, { kind, limit });
592
+ return {
593
+ content: [{
594
+ type: 'text',
595
+ text: JSON.stringify({ query, kind: kind || 'any', count: results.length, results }, null, 2),
596
+ }],
597
+ };
598
+ },
599
+ );
600
+
601
+ server.tool(
602
+ 'get_chunk',
603
+ `Fetch the full record for a single gen-UI training chunk by name.
604
+
605
+ Returns the chunk's bounding HTML, slot annotations, nested chunk names, and
606
+ metadata (primary tag, kind, source page). For chunks that appear on multiple
607
+ pages (reusable slot chunks like \`auth-card-header\`, \`reg-step-header\`),
608
+ returns an \`instances\` array — one entry per page where the chunk appears.
609
+
610
+ The HTML is suitable for direct rendering / inclusion in an A2UI message
611
+ construction prompt.`,
612
+ {
613
+ name: z.string().describe('The chunk name, e.g. "dashboard-kpi-grid", "auth-signin-card-email", "code-language"'),
614
+ },
615
+ async ({ name }) => {
616
+ const rec = getGenUIChunk(name);
617
+ if (!rec) {
618
+ return {
619
+ isError: true,
620
+ content: [{ type: 'text', text: JSON.stringify({ error: 'chunk not found', name }, null, 2) }],
621
+ };
622
+ }
623
+ return { content: [{ type: 'text', text: JSON.stringify(rec, null, 2) }] };
624
+ },
625
+ );
626
+
627
+ server.tool(
628
+ 'lookup_chunk',
629
+ `List every chunk whose primary element is \`<component_name>\`.
630
+
631
+ Useful for "show me every page that opens with a \`<card-ui raw>\`" or "every
632
+ chunk built around a \`<grid-ui>\` root." Returns chunk names + kinds + sources.
633
+
634
+ Pair with \`get_chunk\` to fetch full records for any of the returned names.`,
635
+ {
636
+ component_name: z.string().describe('Component tag name, e.g. "card-ui", "grid-ui", "drawer-ui"'),
637
+ },
638
+ async ({ component_name }) => {
639
+ const recs = lookupChunksByPrimary(component_name);
640
+ return {
641
+ content: [{
642
+ type: 'text',
643
+ text: JSON.stringify({
644
+ component: component_name,
645
+ count: recs.length,
646
+ chunks: recs.map((r) => ({
647
+ name: r.name,
648
+ kind: r.kind,
649
+ page: r.page || r.instances?.[0]?.page,
650
+ slots: (r.slots || r.instances?.[0]?.slots || []).map((s) => s.name),
651
+ nested: r.nested || r.instances?.[0]?.nested || [],
652
+ })),
653
+ }, null, 2),
654
+ }],
655
+ };
656
+ },
657
+ );
658
+
659
+ // ── Chunk-aware composition synthesizer (Phase C.2) ──────────────────
660
+ // Mix-and-match: when retrieval returns no strong match, the LLM picks a
661
+ // page chunk + binds block/panel chunks to its slots. Validator checks
662
+ // chunk names + kind contracts; composer materializes to HTML.
663
+ //
664
+ // Spec: docs/specs/genui-chunk-marker.md (§ "Harvester contract", future:
665
+ // composition reasoning). Plan: docs/plans/training-pipeline-chunk-harvest-2026-04-27.md.
666
+
667
+ import { composeFromIntent as composeFromChunksImpl } from '../compose/engines/zettel/chunk-synthesizer.js';
668
+ import { composeFromPlan, validatePlan } from '../compose/engines/zettel/chunk-composer.js';
669
+ import { createAdapter as createLLMAdapter } from '../compose/llm/llm-bridge.js';
670
+
671
+ server.tool(
672
+ 'compose_from_chunks',
673
+ `Compose a UI page from training chunks — retrieval-first, synthesis-fallback.
674
+
675
+ Mix-and-match composition for intents that don't have a 1:1 chunk match. Workflow:
676
+ 1. Pure-retrieval tier: if \`search_chunks\` returns a strong direct match, return
677
+ that chunk's HTML immediately (no LLM call).
678
+ 2. Synthesis tier: when retrieval is weak, the LLM picks a page-kind chunk and
679
+ binds block/panel chunks to its named slots. Output validated against the
680
+ chunk catalog (slot names exist, bound chunks exist, kinds match).
681
+
682
+ Returns the composed HTML string + a binding plan describing which chunks plug
683
+ where. Useful when the prompt is novel ("dashboard with KPI grid + funnel +
684
+ country list") and no exact chunk has all those parts together — the LLM mixes
685
+ and matches from the corpus.
686
+
687
+ Two-call mode also available via \`plan\` parameter — pass a pre-baked binding
688
+ plan to skip the LLM call and just materialize HTML.`,
689
+ {
690
+ intent: z.string().optional().describe('Natural-language description of what to build (uses LLM synthesis)'),
691
+ plan: z.object({
692
+ page: z.string(),
693
+ slot_bindings: z.record(z.union([z.string(), z.array(z.string())])),
694
+ }).optional().describe('Pre-baked binding plan (skips LLM, materializes directly)'),
695
+ max_attempts: z.number().int().min(1).max(5).default(2).describe('LLM retry budget for synthesis'),
696
+ },
697
+ async ({ intent, plan, max_attempts }) => {
698
+ if (plan) {
699
+ const validation = validatePlan(plan);
700
+ if (!validation.ok) {
701
+ return {
702
+ isError: true,
703
+ content: [{ type: 'text', text: JSON.stringify({ error: 'invalid plan', errors: validation.errors }, null, 2) }],
704
+ };
705
+ }
706
+ const result = composeFromPlan(plan);
707
+ return {
708
+ content: [{ type: 'text', text: JSON.stringify({
709
+ html: result.html,
710
+ plan: result.plan,
711
+ warnings: result.warnings,
712
+ source: 'plan',
713
+ }, null, 2) }],
714
+ };
715
+ }
716
+
717
+ if (!intent) {
718
+ return {
719
+ isError: true,
720
+ content: [{ type: 'text', text: JSON.stringify({ error: 'must provide either intent or plan' }, null, 2) }],
721
+ };
722
+ }
723
+
724
+ try {
725
+ const llmAdapter = await createLLMAdapter();
726
+ const result = await composeFromChunksImpl({
727
+ intent,
728
+ llmAdapter,
729
+ maxAttempts: max_attempts,
730
+ });
731
+ return {
732
+ content: [{ type: 'text', text: JSON.stringify({
733
+ html: result.html,
734
+ plan: result.plan,
735
+ source: result.source,
736
+ score: result.score,
737
+ warnings: result.warnings,
738
+ synthesis: result.synthesis ? { attempts: result.synthesis.attempts } : undefined,
739
+ }, null, 2) }],
740
+ };
741
+ } catch (e) {
742
+ return {
743
+ isError: true,
744
+ content: [{ type: 'text', text: JSON.stringify({ error: e.message }, null, 2) }],
745
+ };
746
+ }
747
+ },
748
+ );
749
+
550
750
  // ── Start ──
551
751
 
552
752
  async function main() {