@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 CHANGED
@@ -9,8 +9,71 @@ zettel strategies.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
- ### 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.
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`](../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
@@ -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
- └── @adiahealth/web-components (component schemas via .a2ui.json)
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 (`gen-ui-training/catalog-a2ui_0_9.json`)
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.1",
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.1",
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 '../../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() {