@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,209 @@
1
+ /**
2
+ * Fragment Library — zettel-style loader for A2UI fragments.
3
+ *
4
+ * Loads all /a2ui/fragments/**\/*.json into memory, builds a backlink graph,
5
+ * and exposes typed retrieval helpers. Fragments ARE A2UI chunks — the library
6
+ * preserves their flat-adjacency template verbatim.
7
+ */
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import url from 'node:url';
12
+
13
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
14
+ const FRAGMENTS_ROOT = path.resolve(__dirname, '../../../corpus/fragments');
15
+ const COMPOSITIONS_ROOT = path.resolve(__dirname, '../../../corpus/compositions');
16
+
17
+ /** @type {Map<string, object>} */
18
+ const fragments = new Map();
19
+ /** @type {Map<string, object>} */
20
+ const compositions = new Map();
21
+
22
+ function walk(dir, cb) {
23
+ if (!fs.existsSync(dir)) return;
24
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
25
+ const p = path.join(dir, entry.name);
26
+ if (entry.isDirectory()) walk(p, cb);
27
+ else if (entry.name.endsWith('.json') && !entry.name.startsWith('_')) cb(p);
28
+ }
29
+ }
30
+
31
+ export function loadAll() {
32
+ fragments.clear();
33
+ compositions.clear();
34
+
35
+ walk(FRAGMENTS_ROOT, (p) => {
36
+ const doc = JSON.parse(fs.readFileSync(p, 'utf8'));
37
+ if (doc.kind !== 'fragment') return;
38
+ doc._sourceFile = p;
39
+ fragments.set(doc.name, doc);
40
+ });
41
+
42
+ walk(COMPOSITIONS_ROOT, (p) => {
43
+ const doc = JSON.parse(fs.readFileSync(p, 'utf8'));
44
+ if (doc.kind !== 'composition') return;
45
+ doc._sourceFile = p;
46
+ compositions.set(doc.name, doc);
47
+ });
48
+
49
+ buildBacklinks();
50
+ return { fragmentCount: fragments.size, compositionCount: compositions.size };
51
+ }
52
+
53
+ /** Walk every composition and every fragment, populate `used_by` backlinks. */
54
+ function buildBacklinks() {
55
+ for (const f of fragments.values()) {
56
+ f.links = f.links || {};
57
+ f.links.used_by = [];
58
+ }
59
+
60
+ // compositions -> fragments they reference
61
+ for (const comp of compositions.values()) {
62
+ for (const node of comp.template || []) {
63
+ if (node.$fragment) {
64
+ const frag = fragments.get(node.$fragment);
65
+ if (frag) frag.links.used_by.push({ type: 'composition', name: comp.name });
66
+ }
67
+ }
68
+ }
69
+
70
+ // fragments -> other fragments they embed (future: nested fragment refs)
71
+ for (const f of fragments.values()) {
72
+ for (const node of f.template || []) {
73
+ if (node.$fragment) {
74
+ const target = fragments.get(node.$fragment);
75
+ if (target) target.links.used_by.push({ type: 'fragment', name: f.name });
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ export function getFragment(name) { return fragments.get(name); }
82
+ export function getComposition(name) { return compositions.get(name); }
83
+ export function getAllFragments() { return [...fragments.values()]; }
84
+ export function getAllCompositions() { return [...compositions.values()]; }
85
+
86
+ /**
87
+ * Keyword search across fragments AND compositions.
88
+ *
89
+ * Ranking design (v2 — fixes false-positive matching):
90
+ * 1. Tokenize query, drop generic UI stop-words ("form", "page", "view"…)
91
+ * so they can't be the sole evidence for a match.
92
+ * 2. Whole-word match, not substring — prevents "form" matching "login-form".
93
+ * 3. Require ≥2 content-token hits for compositions (they're concrete
94
+ * templates — promiscuous matching is expensive). Fragments can match on 1
95
+ * content token since they're meant to be widely reused.
96
+ * 4. Tiered weights: name hit > keyword hit > semantic_role hit > description.
97
+ * 5. Small leverage bias for fragments (reused fragments are more likely
98
+ * relevant when multiple candidates tie).
99
+ */
100
+ const STOP_WORDS = new Set([
101
+ 'a', 'an', 'the', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'at', 'for', 'with',
102
+ 'form', 'forms', 'page', 'pages', 'view', 'views', 'screen', 'screens',
103
+ 'ui', 'component', 'components', 'widget', 'widgets', 'block', 'blocks',
104
+ 'section', 'sections', 'layout', 'layouts',
105
+ ]);
106
+
107
+ function tokenize(str) {
108
+ return (str || '')
109
+ .toLowerCase()
110
+ .split(/[^a-z0-9]+/)
111
+ .filter((t) => t.length >= 2);
112
+ }
113
+
114
+ function contentTokens(str) {
115
+ return tokenize(str).filter((t) => !STOP_WORDS.has(t));
116
+ }
117
+
118
+ export function searchAll(query, { limit = 20 } = {}) {
119
+ const q = (query || '').trim();
120
+ if (!q) return [];
121
+ const queryTokens = tokenize(q);
122
+ const queryContent = queryTokens.filter((t) => !STOP_WORDS.has(t));
123
+
124
+ const scoreDoc = (doc, isComposition) => {
125
+ const nameTokens = new Set(tokenize(doc.name));
126
+ const roleTokens = new Set(tokenize(doc.semantic_role || ''));
127
+ const keywordTokens = new Set((doc.keywords || []).flatMap((k) => tokenize(k)));
128
+ const descTokens = new Set(tokenize(doc.description || ''));
129
+ const tagTokens = new Set(
130
+ Object.values(doc.tags || {}).flat().flatMap((v) => tokenize(String(v))),
131
+ );
132
+
133
+ let contentHits = 0; // distinct non-stop-word tokens that matched anywhere
134
+ let s = 0;
135
+ const contentMatched = new Set();
136
+
137
+ for (const t of queryTokens) {
138
+ const isStop = STOP_WORDS.has(t);
139
+ let matched = false;
140
+ if (nameTokens.has(t)) { s += isStop ? 1 : 12; matched = true; }
141
+ if (keywordTokens.has(t)) { s += isStop ? 1 : 8; matched = true; }
142
+ if (roleTokens.has(t)) { s += isStop ? 1 : 6; matched = true; }
143
+ if (tagTokens.has(t)) { s += isStop ? 0 : 3; matched = true; }
144
+ if (descTokens.has(t)) { s += isStop ? 0 : 2; matched = true; }
145
+ if (matched && !isStop) {
146
+ contentMatched.add(t);
147
+ }
148
+ }
149
+ contentHits = contentMatched.size;
150
+
151
+ // Gate: compositions normally require ≥2 content-token hits to avoid
152
+ // noisy matches. Exception: a direct name-token match (the query contains
153
+ // the composition's primary concept word, e.g. "weather" → weather-widget)
154
+ // is strong enough on its own.
155
+ const nameHit = queryTokens.some((t) => !STOP_WORDS.has(t) && nameTokens.has(t));
156
+ if (isComposition && contentHits < 2 && !nameHit) return 0;
157
+ // Fragments require ≥1 content-token hit — never match on stop-words alone.
158
+ if (!isComposition && contentHits < 1) return 0;
159
+
160
+ // Coverage bonus: more distinct content tokens matched = better match
161
+ s += contentHits * 3;
162
+
163
+ // Small leverage bias for fragments (reused ones slightly preferred on ties)
164
+ if (!isComposition && doc.metrics?.leverage) {
165
+ s += Math.min(doc.metrics.leverage / 20, 2);
166
+ }
167
+
168
+ return s;
169
+ };
170
+
171
+ const all = [
172
+ ...getAllFragments().map((d) => ({ doc: d, type: 'fragment' })),
173
+ ...getAllCompositions().map((d) => ({ doc: d, type: 'composition' })),
174
+ ];
175
+
176
+ return all
177
+ .map((x) => ({ ...x, score: scoreDoc(x.doc, x.type === 'composition') }))
178
+ .filter((x) => x.score > 0)
179
+ .sort((a, b) => b.score - a.score)
180
+ .slice(0, limit)
181
+ .map((x) => ({
182
+ type: x.type,
183
+ name: x.doc.name,
184
+ score: Math.round(x.score * 100) / 100,
185
+ description: x.doc.description,
186
+ semantic_role: x.doc.semantic_role,
187
+ domain: x.doc.domain,
188
+ }));
189
+ }
190
+
191
+ /** Return the full graph for inspection / visualization. */
192
+ export function getGraph() {
193
+ return {
194
+ fragments: getAllFragments().map((f) => ({
195
+ name: f.name,
196
+ role: f.semantic_role,
197
+ uses_components: f.links?.uses_components || [],
198
+ used_by: f.links?.used_by || [],
199
+ leverage: f.metrics?.leverage || 0,
200
+ })),
201
+ compositions: getAllCompositions().map((c) => ({
202
+ name: c.name,
203
+ domain: c.domain,
204
+ uses_fragments: (c.template || [])
205
+ .filter((n) => n.$fragment)
206
+ .map((n) => n.$fragment),
207
+ })),
208
+ };
209
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @adia-ai/a2ui-compose — zettel engine facade.
3
+ *
4
+ * Fragment-graph composition. Three reasoning layers:
5
+ * 1. Retrieval (always) — keyword-rank corpus, resolve top composition
6
+ * 2. LLM synthesis (when llmAdapter + weak retrieval) — compose a new
7
+ * composition from fragments
8
+ * 3. Session-aware iteration (when sessionId + prior turns) — modify
9
+ * existing canvas instead of regenerating
10
+ *
11
+ * The actual implementation is generator-adapter.js; this file is the
12
+ * spec-compliant entry point at engines/zettel/generate.js per §11.
13
+ */
14
+
15
+ export { generateZettel as generate } from './generator-adapter.js';
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Generator adapter for zettel MCP — conforms to the eval harness contract:
3
+ *
4
+ * generate({ intent, mode, llmAdapter, sessionId }) -> { messages, validation, strategy, ...extra }
5
+ *
6
+ * Three reasoning layers:
7
+ * 1. Retrieval (always available) — keyword-rank the corpus, resolve top composition.
8
+ * 2. LLM synthesis (when llmAdapter provided AND retrieval weak) — compose a new
9
+ * composition from fragments, have the composer resolve it into A2UI messages.
10
+ * 3. Session-aware iteration (when sessionId provided AND prior turns exist) —
11
+ * pass prior-turn history to the LLM so follow-ups ("add a button", "hydrate
12
+ * with real images") modify the existing canvas instead of regenerating.
13
+ *
14
+ * Strategy labels in the return:
15
+ * - composition-match — fresh retrieval, strong match, emitted verbatim
16
+ * - composition-synthesized — fresh LLM composition (no prior turns)
17
+ * - composition-iterated — LLM modified prior turn's template
18
+ * - fragment-candidates — retrieval weak + no LLM, returning atoms only
19
+ * - synthesis-failed — LLM tried and failed validation
20
+ */
21
+ import {
22
+ loadAll,
23
+ getComposition,
24
+ searchAll,
25
+ } from './fragment-library.js';
26
+ import { resolveComposition, templateToMessages } from './composer.js';
27
+ import { synthesizeComposition } from './synthesizer.js';
28
+ import {
29
+ recordTurn,
30
+ getTurns,
31
+ buildHistorySummary,
32
+ } from './session-store.js';
33
+ import { validateSchema } from '../../../validator/validator.js';
34
+
35
+ let booted = false;
36
+ function ensureBooted() {
37
+ if (!booted) {
38
+ loadAll();
39
+ booted = true;
40
+ }
41
+ }
42
+
43
+ // Retrieval score threshold — above this we trust the match and emit verbatim;
44
+ // below, fall through to LLM synthesis (creative composition from fragments).
45
+ // Calibrated on the 100-intent held-out set:
46
+ // strong matches: login-form=24 (exact), signup-form=27, chart-dashboard=48, pricing-tiers=54
47
+ // weak/false-positive matches: drop below 22 on off-topic queries
48
+ // Raised from 22 → 40 (2026-04-19) so that only near-perfect retrievals play
49
+ // verbatim; merely-good matches (login-form, signup-form) fall through to LLM
50
+ // synthesis instead of being emitted as canned output. Tradeoff: more LLM calls
51
+ // (slower, costlier) in exchange for compositional variety. The previous 22
52
+ // threshold maximized verbatim-cache hits at the cost of repetitive output.
53
+ const STRONG_MATCH_THRESHOLD = 40;
54
+
55
+ function toUpdateComponentsMessages(template) {
56
+ return [{
57
+ type: 'updateComponents',
58
+ components: template.map((n) => {
59
+ const {
60
+ id, component, children,
61
+ $fragment, bindings,
62
+ prependChildren, appendChildren,
63
+ ...rest
64
+ } = n;
65
+ return { id, component, children: children || [], ...rest };
66
+ }),
67
+ }];
68
+ }
69
+
70
+ export async function generateZettel({ intent, mode = 'instant', llmAdapter = null, sessionId = null } = {}) {
71
+ ensureBooted();
72
+
73
+ // ── Session-aware iteration (turn > 1) ──
74
+ // If we have prior turns AND an LLM, the user is almost certainly modifying
75
+ // the existing canvas — NEVER pick a fresh retrieved composition. Go straight
76
+ // to synthesis with history context. This is what makes follow-ups work.
77
+ const priorTurns = sessionId ? getTurns(sessionId) : [];
78
+ const hasHistory = priorTurns.length > 0;
79
+
80
+ if (hasHistory && llmAdapter) {
81
+ try {
82
+ const historySummary = buildHistorySummary(sessionId, 3);
83
+ const synth = await synthesizeComposition({ intent, llmAdapter, historySummary });
84
+ const validation = validateSchema(synth.messages, { intent });
85
+ const fragments = (synth.template || []).filter((n) => n.$fragment).map((n) => n.$fragment);
86
+ recordTurn(sessionId, {
87
+ intent,
88
+ messages: synth.messages,
89
+ template: synth.template,
90
+ composition: null,
91
+ strategy: 'composition-iterated',
92
+ fragments,
93
+ validation,
94
+ });
95
+ return {
96
+ messages: synth.messages,
97
+ validation,
98
+ strategy: 'composition-iterated',
99
+ retrieval: { hit: false, rank: null, candidate: null, reason: `iteration on turn ${priorTurns.length + 1}` },
100
+ composition: null,
101
+ fragments_used: fragments,
102
+ synthesized_template: synth.template,
103
+ synthesis: synth.synthesis,
104
+ sessionTurns: priorTurns.length + 1,
105
+ };
106
+ } catch (err) {
107
+ // If iteration synthesis fails, fall through to the normal path. Record
108
+ // the failure so the next turn can see we tried.
109
+ console.error('[zettel] iteration synthesis failed:', err.message);
110
+ }
111
+ }
112
+
113
+ const hits = searchAll(intent, { limit: 5 });
114
+ const composition = hits.find((h) => h.type === 'composition');
115
+ const strongMatch = composition && composition.score >= STRONG_MATCH_THRESHOLD;
116
+
117
+ // ── Strong retrieval match: emit verbatim (turn 1 only — iteration branch above handles repeats) ──
118
+ if (strongMatch) {
119
+ const comp = getComposition(composition.name);
120
+ const template = resolveComposition(comp);
121
+ const messages = toUpdateComponentsMessages(template);
122
+ const validation = validateSchema(messages, { intent });
123
+ const rank = hits.findIndex((h) => h.type === 'composition' && h.name === composition.name) + 1;
124
+ const fragments = (comp.template || []).filter((n) => n.$fragment).map((n) => n.$fragment);
125
+ recordTurn(sessionId, {
126
+ intent,
127
+ messages,
128
+ template: comp.template,
129
+ composition: composition.name,
130
+ strategy: 'composition-match',
131
+ fragments,
132
+ validation,
133
+ });
134
+ return {
135
+ messages,
136
+ validation,
137
+ strategy: 'composition-match',
138
+ retrieval: { hit: true, rank, candidate: composition.name },
139
+ composition: composition.name,
140
+ retrievalScore: composition.score,
141
+ fragments_used: fragments,
142
+ candidates: hits,
143
+ sessionTurns: priorTurns.length + 1,
144
+ };
145
+ }
146
+
147
+ // ── Weak/no retrieval: try LLM synthesis if available (fresh, no history) ──
148
+ if (llmAdapter && mode !== 'instant-only') {
149
+ try {
150
+ const synth = await synthesizeComposition({ intent, llmAdapter });
151
+ const validation = validateSchema(synth.messages, { intent });
152
+ const fragments = (synth.template || []).filter((n) => n.$fragment).map((n) => n.$fragment);
153
+ recordTurn(sessionId, {
154
+ intent,
155
+ messages: synth.messages,
156
+ template: synth.template,
157
+ composition: null,
158
+ strategy: 'composition-synthesized',
159
+ fragments,
160
+ validation,
161
+ });
162
+ return {
163
+ messages: synth.messages,
164
+ validation,
165
+ strategy: 'composition-synthesized',
166
+ retrieval: {
167
+ hit: false,
168
+ rank: composition ? hits.findIndex((h) => h.type === 'composition' && h.name === composition.name) + 1 : null,
169
+ candidate: composition?.name ?? null,
170
+ reason: composition ? `top score ${composition.score} below threshold ${STRONG_MATCH_THRESHOLD}` : 'no composition retrieved',
171
+ },
172
+ composition: null,
173
+ fragments_used: fragments,
174
+ synthesized_template: synth.template,
175
+ synthesis: synth.synthesis,
176
+ candidates: hits,
177
+ sessionTurns: priorTurns.length + 1,
178
+ };
179
+ } catch (err) {
180
+ return {
181
+ messages: [],
182
+ validation: { score: 0 },
183
+ strategy: 'synthesis-failed',
184
+ retrieval: { hit: false, rank: null, candidate: hits[0]?.name ?? null },
185
+ candidates: hits,
186
+ error: err.message,
187
+ };
188
+ }
189
+ }
190
+
191
+ // ── No LLM, no strong match: return atoms for downstream assembly ──
192
+ return {
193
+ messages: [],
194
+ validation: { score: 0 },
195
+ strategy: 'fragment-candidates',
196
+ retrieval: { hit: false, rank: null, candidate: hits[0]?.name ?? null },
197
+ candidates: hits,
198
+ };
199
+ }
200
+
201
+ // Re-export session management so server.js / MCP tools can reset a session
202
+ export { clearSession, getTurns, getDrift } from './session-store.js';
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Zettel session store — multi-turn artifact tracking for the fragment-graph engine.
3
+ *
4
+ * Wraps the shared ArtifactStore to record every zettel turn:
5
+ * - the user intent
6
+ * - the composition that was retrieved OR synthesized
7
+ * - the resolved A2UI messages
8
+ * - the fragments that were used
9
+ * - strategy (composition-match | composition-synthesized)
10
+ *
11
+ * Subsequent turns can read this history and feed it to the LLM so that
12
+ * "hydrate with real images" refers back to the previous "grid of images"
13
+ * instead of being generated in isolation.
14
+ *
15
+ * Sessions are keyed by an opaque `sessionId` minted by the caller. The store
16
+ * is in-memory — survives for the lifetime of the proxy process, cleared on
17
+ * restart. Good enough for a dev server; a persistent store is a later phase.
18
+ */
19
+
20
+ import { ArtifactStore } from '../../engine/artifacts.js';
21
+
22
+ const store = new ArtifactStore();
23
+
24
+ /**
25
+ * Record a zettel turn.
26
+ *
27
+ * @param {string} sessionId
28
+ * @param {object} turn
29
+ * @param {string} turn.intent — user's text for this turn
30
+ * @param {object[]} turn.messages — resolved A2UI messages
31
+ * @param {object[]} [turn.template] — the composition template (with $fragment refs)
32
+ * @param {string} [turn.composition] — retrieved composition name (null if synthesized)
33
+ * @param {string} turn.strategy — composition-match | composition-synthesized | …
34
+ * @param {string[]} [turn.fragments] — fragment names used
35
+ * @param {object} [turn.validation] — validator result
36
+ */
37
+ export function recordTurn(sessionId, { intent, messages, template, composition, strategy, fragments, validation }) {
38
+ if (!sessionId) return;
39
+ store.record(sessionId, {
40
+ intent,
41
+ messages,
42
+ summary: composition
43
+ ? `${strategy} → ${composition}`
44
+ : (strategy || 'zettel turn'),
45
+ validation: validation || null,
46
+ });
47
+ // Stash zettel-specific fields by augmenting the latest turn record.
48
+ // (ArtifactStore is intentionally generic; we attach extras after the fact.)
49
+ const latest = store.get(sessionId, -1);
50
+ if (latest) {
51
+ latest.composition = composition || null;
52
+ latest.strategy = strategy || null;
53
+ latest.fragments = fragments || [];
54
+ latest.template = template || null;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Get the full turn history for a session.
60
+ * @returns {object[]} — empty array if session unknown
61
+ */
62
+ export function getTurns(sessionId) {
63
+ if (!sessionId) return [];
64
+ return store.getAll(sessionId) || [];
65
+ }
66
+
67
+ /**
68
+ * Get the latest turn only (nullable).
69
+ */
70
+ export function getLatestTurn(sessionId) {
71
+ if (!sessionId) return null;
72
+ return store.get(sessionId, -1);
73
+ }
74
+
75
+ /** Drift metrics across turns — reuses ArtifactStore's logic. */
76
+ export function getDrift(sessionId) {
77
+ if (!sessionId) return null;
78
+ return store.getDriftMetrics(sessionId);
79
+ }
80
+
81
+ /** Clear a session (user hit "reset canvas"). */
82
+ export function clearSession(sessionId) {
83
+ if (!sessionId) return;
84
+ store.delete(sessionId);
85
+ }
86
+
87
+ /**
88
+ * Build a compact history summary suitable for inclusion in an LLM prompt.
89
+ * Limits to the last N turns and trims each template to a skeletal form
90
+ * (ids + components/$fragment only) so we don't blow the context window.
91
+ *
92
+ * @param {string} sessionId
93
+ * @param {number} [maxTurns=3]
94
+ * @returns {string|null}
95
+ */
96
+ export function buildHistorySummary(sessionId, maxTurns = 3) {
97
+ const turns = getTurns(sessionId);
98
+ if (!turns.length) return null;
99
+
100
+ const recent = turns.slice(-maxTurns);
101
+ const lines = recent.map((t, i) => {
102
+ const turnNum = turns.length - recent.length + i + 1;
103
+ const header = `--- turn ${turnNum}: "${t.intent}" (${t.strategy || 'unknown'}${t.composition ? ` → ${t.composition}` : ''}) ---`;
104
+ const skeleton = t.template
105
+ ? JSON.stringify(t.template.map(n => {
106
+ if (n.$fragment) return { id: n.id, $fragment: n.$fragment, bindings: n.bindings };
107
+ const { id, component, children, ...rest } = n;
108
+ // Keep shape-critical props, drop verbose content
109
+ const keep = { id, component };
110
+ if (children) keep.children = children;
111
+ // Keep variant/type-level props, drop textContent/placeholder (too verbose)
112
+ for (const k of ['variant', 'gap', 'columns', 'align', 'justify']) {
113
+ if (rest[k] !== undefined) keep[k] = rest[k];
114
+ }
115
+ return keep;
116
+ }), null, 2)
117
+ : '(no template recorded)';
118
+ return `${header}\n${skeleton}`;
119
+ });
120
+ return lines.join('\n\n');
121
+ }