@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.
- package/CHANGELOG.md +86 -0
- package/README.md +181 -0
- package/engine/artifacts.js +262 -0
- package/engine/constitution.md +78 -0
- package/engine/context-store.js +218 -0
- package/engine/generator.js +500 -0
- package/engine/pattern-export.js +149 -0
- package/engine/pipeline/engine.js +289 -0
- package/engine/pipeline/types.js +91 -0
- package/engine/reference.js +115 -0
- package/engine/state.js +15 -0
- package/engines/monolithic/_shared.js +1320 -0
- package/engines/monolithic/generate-instant.js +229 -0
- package/engines/monolithic/generate-pro.js +367 -0
- package/engines/monolithic/generate-thinking.js +211 -0
- package/engines/registry.js +195 -0
- package/engines/zettel/_smoke.js +37 -0
- package/engines/zettel/composer.js +146 -0
- package/engines/zettel/fragment-library.js +209 -0
- package/engines/zettel/generate.js +15 -0
- package/engines/zettel/generator-adapter.js +202 -0
- package/engines/zettel/session-store.js +121 -0
- package/engines/zettel/synthesizer.js +343 -0
- package/evals/harness.mjs +193 -0
- package/index.js +16 -0
- package/llm/adapters/anthropic.js +106 -0
- package/llm/adapters/gemini.js +99 -0
- package/llm/adapters/index.js +138 -0
- package/llm/adapters/openai.js +85 -0
- package/llm/adapters/sse.js +50 -0
- package/llm/llm-bridge.js +214 -0
- package/llm/llm-stub.js +69 -0
- package/package.json +41 -0
- package/transpiler/transpiler-maps.js +277 -0
- package/transpiler/transpiler.js +820 -0
|
@@ -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
|
+
}
|