@adia-ai/a2ui-mcp 0.0.1 → 0.0.2
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 +65 -2
- package/README.md +4 -4
- package/package.json +2 -2
- package/scripts/test-chunks.mjs +164 -0
- package/server.js +201 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,8 +9,71 @@ zettel strategies.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## [0.0.2] - 2026-04-28
|
|
15
|
+
|
|
16
|
+
Adds **gen-UI training-chunk tools** that expose the new chunk corpus
|
|
17
|
+
(shipped in `@adia-ai/a2ui-corpus@0.0.3`) via the MCP transport. Also
|
|
18
|
+
adds a chunk-aware composition synthesizer that mixes-and-matches chunks
|
|
19
|
+
when retrieval can't find a 1:1 match — the LLM picks a page chunk + binds
|
|
20
|
+
block/panel chunks to its slots, validated against the chunk catalog
|
|
21
|
+
before HTML is materialized.
|
|
22
|
+
|
|
23
|
+
### Added (MCP tools)
|
|
24
|
+
|
|
25
|
+
- **`search_chunks(query, kind?, limit?)`** — keyword search over the
|
|
26
|
+
chunk corpus. Filters by `kind` (`block` | `panel` | `page`); returns
|
|
27
|
+
ranked candidates with relevance score, primary tag, and chunk name.
|
|
28
|
+
- **`get_chunk(name)`** — full record for a single chunk by name. For
|
|
29
|
+
reusable slot chunks (e.g. `auth-card-header`, `reg-step-header`)
|
|
30
|
+
returns an `instances` array — one entry per page.
|
|
31
|
+
- **`lookup_chunk(component_name)`** — list every chunk whose primary
|
|
32
|
+
element is `<component_name>`. Useful for "show me every page that
|
|
33
|
+
opens with a `<card-ui raw>`" or "every chunk built around a
|
|
34
|
+
`<grid-ui>` root."
|
|
35
|
+
- **`compose_from_chunks(intent?, plan?, max_attempts?)`** — two-tier
|
|
36
|
+
composition tool: retrieval-first (returns matched chunk HTML
|
|
37
|
+
directly), synthesis-fallback (LLM picks a page chunk + binds
|
|
38
|
+
block/panel chunks to its slots when retrieval is weak). The
|
|
39
|
+
synthesizer mirrors the existing fragment synthesizer's prompt
|
|
40
|
+
pattern, repointed at the chunk catalog. Pre-search filters the
|
|
41
|
+
catalog to ~30 candidates before the LLM sees them. Plan-only
|
|
42
|
+
invocation skips the LLM and just materializes HTML from a
|
|
43
|
+
pre-baked binding.
|
|
44
|
+
|
|
45
|
+
### Added (engine internals)
|
|
46
|
+
|
|
47
|
+
- `packages/a2ui/compose/engines/zettel/chunk-composer.js` —
|
|
48
|
+
HTML-string-level composer that walks a page chunk and substitutes
|
|
49
|
+
each `data-chunk-slot` region with the bound block-level chunks'
|
|
50
|
+
HTML. Companion `validatePlan()` checks slot names + chunk-kind
|
|
51
|
+
contracts before composition.
|
|
52
|
+
- `packages/a2ui/compose/engines/zettel/chunk-synthesizer.js` —
|
|
53
|
+
LLM-driven mix-and-match composition; pre-searches the catalog,
|
|
54
|
+
builds a prompt with a one-shot in-context example, validates the
|
|
55
|
+
LLM's binding plan against the catalog, retries with feedback on
|
|
56
|
+
validation failure (default 2 attempts).
|
|
57
|
+
|
|
58
|
+
### Smoke
|
|
59
|
+
|
|
60
|
+
- `npm run smoke:chunks` (33/33 passing, 2026-04-28) — covers
|
|
61
|
+
chunk-library indexing, retrieval scoring, plan validation, plan
|
|
62
|
+
composition, and synthesis fallback path with a stub LLM adapter.
|
|
63
|
+
|
|
64
|
+
### Convention reference
|
|
65
|
+
|
|
66
|
+
- Spec: [`docs/specs/genui-chunk-marker.md`](../../../docs/specs/genui-chunk-marker.md).
|
|
67
|
+
- Plan: [`docs/plans/training-pipeline-chunk-harvest-2026-04-27.md`](../../../docs/plans/training-pipeline-chunk-harvest-2026-04-27.md).
|
|
68
|
+
|
|
69
|
+
### Other
|
|
70
|
+
|
|
71
|
+
- Registry / transpilation scripts at
|
|
72
|
+
`packages/web-components/scripts/a2ui-to-html.cjs` and
|
|
73
|
+
`packages/web-components/scripts/mcp-pipeline.cjs` now mirror the
|
|
74
|
+
canonical `packages/web-components/a2ui/registry.js` for all A2UI
|
|
75
|
+
component → custom-element mappings. Previously these scripts had
|
|
76
|
+
several stale mappings that diverged from the runtime registry.
|
|
14
77
|
|
|
15
78
|
---
|
|
16
79
|
|
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
|
|
@@ -116,12 +116,12 @@ npm run eval:diff -- --engine zettel
|
|
|
116
116
|
└─────────────┘ │
|
|
117
117
|
├── @adia-ai/a2ui-compose (engines, validator, LLM bridge)
|
|
118
118
|
├── @adia-ai/a2ui-corpus (catalog, fragments, feedback)
|
|
119
|
-
└── @
|
|
119
|
+
└── @adia-ai/web-components (component schemas via .a2ui.json)
|
|
120
120
|
```
|
|
121
121
|
|
|
122
122
|
On start, the server:
|
|
123
123
|
|
|
124
|
-
1. Loads the component catalog (
|
|
124
|
+
1. Loads the component catalog (`@adia-ai/a2ui-corpus/catalog-a2ui_0_9.json`)
|
|
125
125
|
2. Lazy-initializes the zettel corpus on first `generate_ui`/`zettel_*` call
|
|
126
126
|
3. Resolves LLM adapter from env vars
|
|
127
127
|
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.2",
|
|
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.3",
|
|
33
33
|
"zod": "^3.24.0"
|
|
34
34
|
}
|
|
35
35
|
}
|
|
@@ -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() {
|