@adia-ai/a2ui-compose 0.0.1

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.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @adia-ai/a2ui-compose — monolithic engine, thinking mode.
3
+ *
4
+ * Extracted from engine/generator.js per spec §11 Phase 2. Shares state
5
+ * (ArtifactStore, PipelineEngine) via engine/state.js. Pure helpers live
6
+ * in engines/monolithic/_shared.js.
7
+ */
8
+
9
+ import { validateSchema } from '../../../validator/validator.js';
10
+ import { validateMessages as validateCatalog } from '../../../validator/catalog-validator.js';
11
+ import { getContext, searchBlocksSemantic, lookupDomain } from '../../engine/reference.js';
12
+ import { assessClarity } from '../../../retrieval/clarity.js';
13
+ import { feedbackStore } from '../../../retrieval/feedback-store.js';
14
+ import { store, engine } from '../../engine/state.js';
15
+ import { isRecording } from '../../../retrieval/dialog-recorder.js';
16
+ import {
17
+ buildSystemPrompt,
18
+ buildChatMessages,
19
+ parseA2UIResponse,
20
+ buildRepairPrompt,
21
+ generateSuggestions,
22
+ } from './_shared.js';
23
+
24
+ export async function generateThinking({ intent, executionId, storeId, llmAdapter, analysis, priorComponentsFromPayload }) {
25
+ // Note: thinking mode currently composes from scratch each turn. The
26
+ // priorComponentsFromPayload param is accepted for forward-compatibility
27
+ // with multi-turn thinking iterations; not used in this pass yet.
28
+ void priorComponentsFromPayload;
29
+ const execId = executionId;
30
+ const searchQuery = analysis?.steelman || intent;
31
+ const analysisHint = (analysis?.analyzed && (
32
+ analysis.steelman !== analysis.raw ||
33
+ analysis.impliedComponents?.length ||
34
+ analysis.concepts?.length
35
+ ))
36
+ ? [
37
+ analysis.steelman && analysis.steelman !== analysis.raw ? `BRIEF: ${analysis.steelman}` : null,
38
+ analysis.impliedComponents?.length ? `EXPECTED COMPONENTS: ${analysis.impliedComponents.join(', ')}` : null,
39
+ analysis.concepts?.length ? `CONCEPTS: ${analysis.concepts.join(', ')}` : null,
40
+ analysis.styleHints?.length ? `STYLE: ${analysis.styleHints.join(', ')}` : null,
41
+ ].filter(Boolean).join('\n') + '\n\n'
42
+ : '';
43
+
44
+ // ── Stage 1: Interpret ──
45
+ const domain = lookupDomain(intent);
46
+ engine.submitStage(execId, 'interpret', {
47
+ domain,
48
+ intent,
49
+ confidence: domain.confidence,
50
+ });
51
+
52
+ // ── Clarity gate (defense-in-depth) ──
53
+ if (domain.confidence === 0) {
54
+ const clarity = assessClarity(intent, domain);
55
+ if (!clarity.clear) {
56
+ engine.submitStage(execId, 'clarify', { clarity, reason: 'no-ui-signals' });
57
+ return {
58
+ executionId: storeId,
59
+ messages: [],
60
+ validation: { score: 0, errors: [], warnings: ['Intent does not describe a UI component'] },
61
+ suggestions: clarity.questions.map(q => q.text),
62
+ clarify: {
63
+ needed: true,
64
+ questions: clarity.questions,
65
+ score: clarity.score,
66
+ dimensions: clarity.dimensions,
67
+ summary: clarity.summary,
68
+ },
69
+ pipeline: engine.getState(execId),
70
+ };
71
+ }
72
+ }
73
+
74
+ // ── Stage 2: Analyze (tier 2 = 5000 token budget) ──
75
+ const context = await getContext(intent, 2);
76
+ engine.submitStage(execId, 'analyze', {
77
+ context,
78
+ componentCount: context.components.length,
79
+ patternCount: context.patterns.length,
80
+ confidence: context.components.length > 0 ? 0.85 : 0.5,
81
+ });
82
+
83
+ // ── Stage 3: Plan ──
84
+ // Use semantic search — LLM-enhanced pattern matching when adapter available
85
+ const { matches: patterns } = await searchBlocksSemantic(searchQuery, { llmAdapter });
86
+ const plan = {
87
+ patterns: patterns.map(p => p.name),
88
+ strategy: patterns.length > 0 ? 'adapt-pattern' : 'generate-fresh',
89
+ confidence: patterns.length > 0 ? 0.9 : 0.7,
90
+ };
91
+ engine.submitStage(execId, 'plan', plan);
92
+
93
+ // ── Stage 4: Generate via LLM ──
94
+ const systemPrompt = await buildSystemPrompt(context, patterns, '', intent);
95
+ const chatMessages = buildChatMessages(intent, storeId || execId);
96
+ // Prepend the analyzer's enriched signals to the first user message so the
97
+ // LLM treats them as ground truth instead of re-deriving from a terse prompt.
98
+ if (analysisHint && chatMessages[0]?.role === 'user') {
99
+ chatMessages[0] = { ...chatMessages[0], content: analysisHint + chatMessages[0].content };
100
+ }
101
+
102
+ let response;
103
+ try {
104
+ response = await llmAdapter.complete({
105
+ messages: chatMessages,
106
+ systemPrompt,
107
+ });
108
+ } catch (err) {
109
+ engine.fail(execId, 'generate', `LLM call failed: ${err.message}`);
110
+ throw err;
111
+ }
112
+
113
+ let messages = parseA2UIResponse(response.content, { executionId: execId, mode: 'thinking', intent, stopReason: response.stopReason });
114
+ // Capture for the dialog recorder when ADIA_LOG_DIALOGS is on. See pro engine
115
+ // for rationale — same pattern, same _debug shape on the result.
116
+ let lastRawResponse = isRecording() ? response.content : null;
117
+ let lastTokens = isRecording() ? (response.usage || null) : null;
118
+ engine.submitStage(execId, 'generate', {
119
+ messages,
120
+ tokenUsage: response.usage,
121
+ source: 'llm',
122
+ confidence: 0.8,
123
+ });
124
+
125
+ // ── Stage 5: Validate + repair loop ──
126
+ // Two orthogonal validators run in parallel:
127
+ // - scored: weighted heuristic checks (intent alignment, card model, etc.)
128
+ // - catalog: AJV against v0.9 catalog schema (strict structural correctness)
129
+ // Either failing triggers a repair attempt. Catalog failures are typically
130
+ // small (unknown prop, wrong enum, missing required id) and fix cleanly in
131
+ // one repair round. Three-attempt cap preserved from the original loop.
132
+ let validation = validateSchema(messages, { intent });
133
+ let catalogValidation = await validateCatalog(messages);
134
+ let attempts = 0;
135
+
136
+ while ((!validation.valid || !catalogValidation.valid) && attempts < 3) {
137
+ attempts++;
138
+ const failedChecks = validation.checks.filter(c => !c.passed);
139
+ const catalogFailures = catalogValidation.failures || [];
140
+ const repairPrompt = buildRepairPrompt(failedChecks, messages, catalogFailures);
141
+
142
+ try {
143
+ const repairResponse = await llmAdapter.complete({
144
+ messages: [{ role: 'user', content: repairPrompt }],
145
+ systemPrompt,
146
+ });
147
+ messages = parseA2UIResponse(repairResponse.content, { executionId: execId, mode: 'thinking', intent, stopReason: repairResponse.stopReason });
148
+ if (isRecording()) { lastRawResponse = repairResponse.content; lastTokens = repairResponse.usage || lastTokens; }
149
+ validation = validateSchema(messages, { intent });
150
+ catalogValidation = await validateCatalog(messages);
151
+ } catch {
152
+ break; // stop repair loop on adapter error
153
+ }
154
+ }
155
+
156
+ engine.submitStage(execId, 'validate', {
157
+ ...validation,
158
+ repairAttempts: attempts,
159
+ confidence: validation.score / 100,
160
+ });
161
+
162
+ // ── Stage 6: Render (store artifact) ──
163
+ // Store under storeId (previous execution) for multi-turn history continuity.
164
+ // First-turn analysis is recorded so iteration turns can recover concepts +
165
+ // implied components for canvas-aware suggestions.
166
+ const artifactId = storeId || execId;
167
+ store.record(artifactId, {
168
+ messages,
169
+ summary: intent,
170
+ intent,
171
+ validation,
172
+ analysis,
173
+ });
174
+
175
+ engine.submitStage(execId, 'render', {
176
+ stored: true,
177
+ success: validation.valid,
178
+ repairAttempts: attempts,
179
+ confidence: validation.valid ? 1 : 0.5,
180
+ });
181
+
182
+ // ── Suggestions (canvas-aware — see generate-pro.js for rationale) ──
183
+ const originalAnalysis = store.getOriginalAnalysis(artifactId);
184
+ const originalIntent = store.getOriginalIntent(artifactId);
185
+ const suggestions = generateSuggestions({ intent, domain, messages, originalAnalysis, originalIntent });
186
+
187
+ // Fire-and-forget feedback logging
188
+ feedbackStore.logExecution({
189
+ executionId: artifactId, intent, mode: 'thinking',
190
+ domain: domain.domain, patternMatch: patterns[0]?.name,
191
+ score: validation?.score, componentCount: messages[0]?.components?.length || 0,
192
+ }).catch(() => {});
193
+
194
+ // ── Drift detection ──
195
+ const driftMetrics = store.getDriftMetrics(artifactId);
196
+
197
+ return {
198
+ executionId: artifactId,
199
+ messages,
200
+ validation,
201
+ suggestions,
202
+ drift: driftMetrics,
203
+ pipeline: engine.getState(execId),
204
+ _debug: isRecording() ? {
205
+ systemPrompt,
206
+ rawLLMResponse: lastRawResponse,
207
+ tokens: lastTokens,
208
+ patterns: patterns.slice(0, 8).map(p => ({ name: p.name, score: p.score, keywords: p.keywords })),
209
+ } : undefined,
210
+ };
211
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Engine registry + dispatcher — the seam between gen-ui's public API and the
3
+ * per-engine implementations. Per docs/specs/package-architecture.md §11 Phase 5.
4
+ *
5
+ * Two engines are currently wired:
6
+ * - monolithic → pattern-match + adapt (instant | pro | thinking modes)
7
+ * - zettel → fragment-graph composition (single mode: instant)
8
+ *
9
+ * The registry deliberately does NOT own intent-gating, clarity assessment, or
10
+ * execution lifecycle — the public `generateUI()` in engine/generator.js does
11
+ * that, then delegates the actual work through `pick()`.
12
+ *
13
+ * Shape invariant: every engine function returns
14
+ * { executionId, messages, validation, suggestions?, strategy?, ... }
15
+ */
16
+
17
+ // Zettel is lazy-loaded — it transitively imports node:fs / node:path / node:url
18
+ // (engines/zettel/fragment-library.js), which Vite externalizes in the browser.
19
+ // Static-importing it here would break browser loads of engine/generator.js.
20
+ // In-browser callers reach zettel via POST /api/generate (server-side), never
21
+ // through this registry, so lazy-loading is safe and incurs only a one-time
22
+ // import on first server-side invocation.
23
+ let _generateZettel = null;
24
+ async function getGenerateZettel() {
25
+ if (!_generateZettel) {
26
+ const mod = await import('./zettel/generator-adapter.js');
27
+ _generateZettel = mod.generateZettel;
28
+ }
29
+ return _generateZettel;
30
+ }
31
+
32
+ // Same lazy-load story for isRecording — keeps the cold-import surface tight.
33
+ let _isRecording = null;
34
+ async function getIsRecording() {
35
+ if (!_isRecording) {
36
+ const mod = await import('../../retrieval/dialog-recorder.js');
37
+ _isRecording = mod.isRecording;
38
+ }
39
+ return _isRecording;
40
+ }
41
+
42
+ // The three monolithic paths live inside engine/generator.js as internal
43
+ // functions. They're injected at boot via `registerMonolithicEngines()` to
44
+ // avoid a circular import (engine/generator.js owns the registry caller).
45
+ let _monolithicInstant = null;
46
+ let _monolithicPro = null;
47
+ let _monolithicThinking = null;
48
+
49
+ export function registerMonolithicEngines({ instant, pro, thinking }) {
50
+ _monolithicInstant = instant;
51
+ _monolithicPro = pro;
52
+ _monolithicThinking = thinking;
53
+ }
54
+
55
+ // Engine adapters — uniform call signature `(ctx) => Promise<result>` where
56
+ // ctx is prepared by the public generateUI() orchestrator.
57
+ async function generateInstantAdapter(ctx) {
58
+ if (!_monolithicInstant) throw new Error('monolithic-instant engine not registered');
59
+ return _monolithicInstant(ctx);
60
+ }
61
+ async function generateProAdapter(ctx) {
62
+ if (!_monolithicPro) throw new Error('monolithic-pro engine not registered');
63
+ return _monolithicPro(ctx);
64
+ }
65
+ async function generateThinkingAdapter(ctx) {
66
+ if (!_monolithicThinking) throw new Error('monolithic-thinking engine not registered');
67
+ return _monolithicThinking(ctx);
68
+ }
69
+
70
+ async function generateZettelAdapter(ctx) {
71
+ // Map the monolithic ctx to the zettel adapter's expected shape.
72
+ // storeId (monolithic's multi-turn handle) → sessionId (zettel's turn history).
73
+ // The steelman from the baseline analyzer drives both retrieval (better
74
+ // keyword match against the corpus) and LLM synthesis (richer brief). Falls
75
+ // back to raw intent on iteration turns when analyzer was skipped.
76
+ // Note: ctx.priorComponentsFromPayload is forwarded for forward-compat —
77
+ // zettel's session-store currently owns iteration history, but a future
78
+ // pass should let payload override session state for true statelessness.
79
+ const generateZettel = await getGenerateZettel();
80
+ const enrichedIntent = ctx.analysis?.steelman || ctx.intent;
81
+ const result = await generateZettel({
82
+ intent: enrichedIntent,
83
+ mode: 'instant',
84
+ llmAdapter: ctx.llmAdapter || null,
85
+ sessionId: ctx.storeId || ctx.executionId || null,
86
+ priorComponentsFromPayload: ctx.priorComponentsFromPayload || null,
87
+ });
88
+
89
+ // Dialog-recorder hook for zettel. No "system prompt" to capture (the
90
+ // synthesizer builds its own internally), but the strategy + composition +
91
+ // candidates are the equivalent reasoning surface — they explain why this
92
+ // turn produced what it did. Surfaced via _debug so the recorder picks
93
+ // them up the same way it picks up monolithic engines' system prompts.
94
+ const isRecording = await getIsRecording();
95
+ return {
96
+ executionId: ctx.executionId,
97
+ messages: result.messages,
98
+ validation: result.validation,
99
+ suggestions: [],
100
+ strategy: result.strategy,
101
+ engine: 'zettel',
102
+ _debug: isRecording() ? {
103
+ systemPrompt: null,
104
+ rawLLMResponse: null,
105
+ tokens: null,
106
+ patterns: (result.candidates || []).slice(0, 8).map(c => ({
107
+ name: c.name,
108
+ score: c.score,
109
+ type: c.type,
110
+ })),
111
+ strategy: result.strategy || null,
112
+ composition: result.composition || result.patternName || null,
113
+ fragmentsUsed: result.fragments_used || null,
114
+ retrievalScore: result.retrievalScore ?? null,
115
+ synthesisAttempts: result.synthesis?.attempts ?? null,
116
+ sessionTurns: result.sessionTurns ?? null,
117
+ } : undefined,
118
+ };
119
+ }
120
+
121
+ export const ENGINES = {
122
+ 'monolithic-instant': generateInstantAdapter,
123
+ 'monolithic-pro': generateProAdapter,
124
+ 'monolithic-thinking': generateThinkingAdapter,
125
+ 'zettel': generateZettelAdapter,
126
+ };
127
+
128
+ /**
129
+ * Register a third-party generation engine at runtime (OD-5 plugin path).
130
+ *
131
+ * Contract:
132
+ * generate(ctx) → Promise<{ executionId, messages, validation, strategy?, suggestions?, engine }>
133
+ *
134
+ * ctx = { intent, executionId, storeId, llmAdapter, sessionId, catalog? }
135
+ *
136
+ * Built-in engine names (`monolithic-*`, `zettel`) are reserved and cannot be
137
+ * overwritten. Custom engines are dispatched via `pick({ engine: name })` —
138
+ * mode is ignored for custom engines unless the implementation uses it.
139
+ *
140
+ * @example
141
+ * import { registerEngine } from '@adia-ai/a2ui-compose';
142
+ * registerEngine('my-hybrid', async (ctx) => {
143
+ * const result = await myCustomPipeline(ctx.intent);
144
+ * return { executionId: ctx.executionId, messages: result.messages,
145
+ * validation: { score: result.score }, strategy: 'hybrid',
146
+ * engine: 'my-hybrid' };
147
+ * });
148
+ * generateUI({ engine: 'my-hybrid', intent: '...' });
149
+ */
150
+ const RESERVED = new Set(['monolithic', 'monolithic-instant', 'monolithic-pro', 'monolithic-thinking', 'zettel']);
151
+
152
+ export function registerEngine(name, generateFn) {
153
+ if (typeof name !== 'string' || !name.length) {
154
+ throw new Error('registerEngine: name must be a non-empty string');
155
+ }
156
+ if (RESERVED.has(name)) {
157
+ throw new Error(`registerEngine: "${name}" is a reserved built-in engine name`);
158
+ }
159
+ if (typeof generateFn !== 'function') {
160
+ throw new Error('registerEngine: generateFn must be a function');
161
+ }
162
+ ENGINES[name] = generateFn;
163
+ }
164
+
165
+ /**
166
+ * Remove a previously-registered custom engine. Reserved built-ins are
167
+ * protected — this throws rather than silently noop-ing on a reserved name.
168
+ */
169
+ export function unregisterEngine(name) {
170
+ if (RESERVED.has(name)) {
171
+ throw new Error(`unregisterEngine: "${name}" is a reserved built-in engine name`);
172
+ }
173
+ return delete ENGINES[name];
174
+ }
175
+
176
+ /**
177
+ * Pick the right engine function for an `{ engine, mode }` pair.
178
+ * Dispatch order:
179
+ * 1. Custom-registered name → direct lookup
180
+ * 2. engine === 'zettel' → zettel adapter
181
+ * 3. `monolithic-${mode}` → monolithic variant
182
+ * 4. Fallback → monolithic-instant
183
+ */
184
+ export function pick({ engine = 'monolithic', mode = 'instant' } = {}) {
185
+ // Custom engines have priority over built-in name-parsing.
186
+ if (engine && engine !== 'monolithic' && ENGINES[engine]) return ENGINES[engine];
187
+ if (engine === 'zettel') return ENGINES.zettel;
188
+ const key = `monolithic-${mode}`;
189
+ return ENGINES[key] || ENGINES['monolithic-instant'];
190
+ }
191
+
192
+ /** Introspection for tooling / docs. */
193
+ export function listEngines() {
194
+ return Object.keys(ENGINES);
195
+ }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ /** Smoke test: load library, resolve login-form, print resolved template. */
3
+ import { loadAll, getAllFragments, getAllCompositions, searchAll, getGraph, getComposition } from './fragment-library.js';
4
+ import { resolveComposition } from './composer.js';
5
+
6
+ const boot = loadAll();
7
+ console.log('boot:', boot);
8
+
9
+ console.log('\n=== Fragments ===');
10
+ for (const f of getAllFragments()) console.log(` - ${f.name} [${f.semantic_role}]`);
11
+
12
+ console.log('\n=== Compositions ===');
13
+ for (const c of getAllCompositions()) console.log(` - ${c.name} (${c.domain})`);
14
+
15
+ console.log('\n=== Search: "login" ===');
16
+ console.log(searchAll('login form password', { limit: 5 }));
17
+
18
+ console.log('\n=== Search: "card header title" ===');
19
+ console.log(searchAll('card header title', { limit: 5 }));
20
+
21
+ console.log('\n=== Resolve login-form ===');
22
+ const comp = getComposition('login-form');
23
+ const resolved = resolveComposition(comp);
24
+ console.log(`resolved ${resolved.length} nodes:`);
25
+ for (const n of resolved) {
26
+ const kids = n.children ? `→[${n.children.join(',')}]` : '';
27
+ const text = n.textContent ? ` "${n.textContent}"` : '';
28
+ const label = n.label ? ` label="${n.label}"` : '';
29
+ console.log(` ${n.id.padEnd(20)} ${n.component}${label}${text} ${kids}`);
30
+ }
31
+
32
+ console.log('\n=== Graph ===');
33
+ const g = getGraph();
34
+ console.log('fragments with usage:');
35
+ for (const f of g.fragments) {
36
+ if (f.used_by.length) console.log(` ${f.name} ← ${f.used_by.map(u => u.name).join(', ')}`);
37
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Composer — resolves compositions and fragment references into flat A2UI templates.
3
+ *
4
+ * Input: a composition (or any template array containing $fragment nodes)
5
+ * Output: a standard A2UI flat-adjacency node list, ready to validate / render.
6
+ *
7
+ * Algorithm:
8
+ * For each node in the composition template:
9
+ * If node.$fragment:
10
+ * - clone the fragment's template
11
+ * - prefix every internal id with the composition-node id (to avoid collisions)
12
+ * - apply slot bindings (set the attribute on the slot's targetId)
13
+ * - if node.children was provided, append those ids to the fragment root's children
14
+ * - emit the fragment root under node.id, then the rest of the fragment nodes
15
+ * Else:
16
+ * - emit the node as-is
17
+ */
18
+
19
+ import { getFragment } from './fragment-library.js';
20
+
21
+ function cloneFragmentWithPrefix(fragment, prefix) {
22
+ const idMap = new Map();
23
+ const cloned = fragment.template.map((n) => ({ ...n }));
24
+
25
+ // Generate new ids
26
+ for (const node of cloned) {
27
+ const newId = `${prefix}--${node.id}`;
28
+ idMap.set(node.id, newId);
29
+ }
30
+ // Rewrite ids and children refs
31
+ for (const node of cloned) {
32
+ node.id = idMap.get(node.id);
33
+ if (Array.isArray(node.children)) {
34
+ node.children = node.children
35
+ .map((c) => (typeof c === 'string' ? idMap.get(c) || c : c));
36
+ }
37
+ }
38
+ return { cloned, idMap, rootId: idMap.get(fragment.template[0].id) };
39
+ }
40
+
41
+ function applyBindings(nodes, fragment, idMap, bindings = {}) {
42
+ if (!bindings) return;
43
+ for (const slot of fragment.slots || []) {
44
+ const internalId = idMap.get(slot.targetId);
45
+ const target = nodes.find((n) => n.id === internalId);
46
+ if (!target) continue;
47
+
48
+ const value = bindings[slot.name] ?? slot.defaultValue;
49
+ if (value === undefined) continue;
50
+
51
+ const attr = slot.attribute || 'textContent';
52
+ target[attr] = value;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Expand a composition (or any template) into a flat A2UI node list.
58
+ * @param {object} composition - { template: [...] }
59
+ * @returns {Array<object>} resolved flat nodes
60
+ */
61
+ export function resolveComposition(composition) {
62
+ const out = [];
63
+ const template = composition.template || [];
64
+
65
+ for (const node of template) {
66
+ if (!node.$fragment) {
67
+ out.push({ ...node });
68
+ continue;
69
+ }
70
+
71
+ const fragment = getFragment(node.$fragment);
72
+ if (!fragment) {
73
+ out.push({
74
+ id: node.id,
75
+ component: 'Text',
76
+ textContent: `⚠ unresolved fragment: ${node.$fragment}`,
77
+ });
78
+ continue;
79
+ }
80
+
81
+ const { cloned, idMap, rootId } = cloneFragmentWithPrefix(fragment, node.id);
82
+ applyBindings(cloned, fragment, idMap, node.bindings);
83
+
84
+ // Substitute: composition-node id becomes the fragment root id.
85
+ // So any sibling that references node.id still resolves to the expanded root.
86
+ const rootNode = cloned.find((n) => n.id === rootId);
87
+ if (rootNode) {
88
+ rootNode.id = node.id;
89
+ // fix up any sibling refs inside cloned
90
+ for (const n of cloned) {
91
+ if (Array.isArray(n.children)) {
92
+ n.children = n.children.map((c) => (c === rootId ? node.id : c));
93
+ }
94
+ }
95
+ }
96
+
97
+ // Allow composition to inject extra children into the fragment root.
98
+ // children — alias for appendChildren (legacy; append to end)
99
+ // appendChildren — append composition-owned nodes to the fragment's children
100
+ // prependChildren — prepend composition-owned nodes before the fragment's children
101
+ // This lets a composition add things INSIDE a fragment's root (e.g. a logo above
102
+ // a card-header's title) without having to inline the whole fragment.
103
+ if (rootNode) {
104
+ const prepend = Array.isArray(node.prependChildren) ? node.prependChildren : [];
105
+ const append = Array.isArray(node.appendChildren)
106
+ ? node.appendChildren
107
+ : (Array.isArray(node.children) ? node.children : []);
108
+ if (prepend.length || append.length) {
109
+ rootNode.children = [
110
+ ...prepend,
111
+ ...(rootNode.children || []),
112
+ ...append,
113
+ ];
114
+ }
115
+ }
116
+
117
+ out.push(...cloned);
118
+ }
119
+
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * Convert a resolved flat template into A2UI `beginComponent` messages
125
+ * compatible with the existing validator / renderer. Strips zettel-only
126
+ * fields (`$fragment`, `bindings`, `prependChildren`, `appendChildren`) that
127
+ * were already consumed by resolveComposition — they'd be dead weight in the
128
+ * emitted A2UI messages.
129
+ */
130
+ export function templateToMessages(template) {
131
+ return template.map((node) => {
132
+ const {
133
+ id, component, children,
134
+ $fragment, bindings,
135
+ prependChildren, appendChildren,
136
+ ...rest
137
+ } = node;
138
+ return {
139
+ messageType: 'beginComponent',
140
+ componentId: id,
141
+ componentType: component,
142
+ children: children || [],
143
+ ...rest,
144
+ };
145
+ });
146
+ }