@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 +117 -1
- package/README.md +33 -29
- package/package.json +2 -2
- package/scripts/eval-chunk-synthesis.mjs +100 -0
- package/scripts/test-chunks.mjs +164 -0
- package/server.js +201 -1
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
|
-
|
|
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`](
|
|
9
|
+
> [`@adia-ai/web-components`](../../web-components); the A2UI protocol runtime
|
|
10
10
|
> (renderer, registry, streams, wiring) in
|
|
11
|
-
> [`@adia-ai/a2ui-utils`](../
|
|
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
|
|
46
|
-
|
|
47
|
-
| Tool
|
|
48
|
-
|
|
49
|
-
| `generate_ui`
|
|
50
|
-
| `validate_schema`
|
|
51
|
-
| `classify_intent`
|
|
52
|
-
| `lookup_component`
|
|
53
|
-
| `get_component_map`
|
|
54
|
-
| `search_patterns`
|
|
55
|
-
| `assemble_context`
|
|
56
|
-
| `check_anti_patterns`
|
|
57
|
-
| `get_traits`
|
|
58
|
-
| `convert_html`
|
|
59
|
-
| `get_wiring_catalog`
|
|
60
|
-
| `import_pattern`
|
|
61
|
-
| `submit_feedback`
|
|
62
|
-
| `get_quality_metrics`
|
|
63
|
-
| `get_training_gaps`
|
|
64
|
-
| `run_eval`
|
|
65
|
-
| `get_fragment`
|
|
66
|
-
| `get_composition`
|
|
67
|
-
| `resolve_composition`
|
|
68
|
-
| `get_graph`
|
|
69
|
-
| `zettel_stats`
|
|
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
|
-
└── @
|
|
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 (
|
|
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.
|
|
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.
|
|
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 '
|
|
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() {
|