@adia-ai/a2ui-compose 0.4.2 → 0.4.4
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 +27 -0
- package/README.md +12 -1
- package/core/generator.js +8 -1
- package/package.json +1 -1
- package/strategies/_shared/chunk-loader.js +208 -0
- package/strategies/_shared/chunk-loader.test.js +80 -0
- package/strategies/monolithic/generate-pro.js +85 -10
- package/strategies/registry.js +1 -1
- package/strategies/zettel/_smoke.js +2 -12
- package/strategies/zettel/composer.js +36 -121
- package/strategies/zettel/composition-library.js +207 -0
- package/strategies/zettel/generator-adapter.js +1 -1
- package/strategies/zettel/state-cache.js +6 -1
- package/strategies/zettel/synthesizer.js +16 -337
- package/transpiler/transpiler-maps.js +13 -6
- package/strategies/zettel/fragment-library.js +0 -209
|
@@ -1,148 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Composer —
|
|
2
|
+
* Composer — passes A2UI composition templates straight through.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* History: this file used to expand `$fragment` refs in compositions
|
|
5
|
+
* via a slot-binding mechanism (zettel-style fragment graph). As of
|
|
6
|
+
* §37 (2026-05-12) fragments are retired — all compositions are
|
|
7
|
+
* pre-inlined into flat A2UI templates by /tmp/inline-compositions.py.
|
|
8
|
+
* `resolveComposition` is now a defensive copy + strip pass; any
|
|
9
|
+
* lingering `$fragment` ref is treated as a placeholder (the LLM /
|
|
10
|
+
* renderer will fail noisily, surfacing corpus drift).
|
|
6
11
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
12
|
+
* Kept as a thin wrapper rather than deleted because zettel's
|
|
13
|
+
* generator-adapter still calls `resolveComposition(comp)` —
|
|
14
|
+
* downstream rename is a follow-up arc.
|
|
17
15
|
*/
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
const ID_PREFIX_SEPARATOR = '--';
|
|
20
18
|
|
|
21
19
|
/**
|
|
22
|
-
*
|
|
23
|
-
* composition node id. Format: `{compNode}{ID_PREFIX_SEPARATOR}{fragNode}`.
|
|
20
|
+
* Return a defensive copy of a composition's flat template.
|
|
24
21
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* fragment node ids, so the parse is unambiguous if anyone ever needs to
|
|
29
|
-
* reverse the prefixing.
|
|
22
|
+
* Compositions are pre-inlined, so there's nothing to expand. We
|
|
23
|
+
* still strip zettel-era fields that may linger in the corpus —
|
|
24
|
+
* future-proof against accidental re-introduction.
|
|
30
25
|
*
|
|
31
|
-
* EXTERNAL CONTRACT: the renderer treats node ids as opaque strings, so
|
|
32
|
-
* changing this separator does not break A2UI consumers. But anything
|
|
33
|
-
* upstream that splits on `--` (issue-reporter ticket rendering, eval
|
|
34
|
-
* trace inspection, debug logs) will need to be updated in lockstep.
|
|
35
|
-
*/
|
|
36
|
-
export const ID_PREFIX_SEPARATOR = '--';
|
|
37
|
-
|
|
38
|
-
function cloneFragmentWithPrefix(fragment, prefix) {
|
|
39
|
-
const idMap = new Map();
|
|
40
|
-
const cloned = fragment.template.map((n) => ({ ...n }));
|
|
41
|
-
|
|
42
|
-
// Generate new ids
|
|
43
|
-
for (const node of cloned) {
|
|
44
|
-
const newId = `${prefix}${ID_PREFIX_SEPARATOR}${node.id}`;
|
|
45
|
-
idMap.set(node.id, newId);
|
|
46
|
-
}
|
|
47
|
-
// Rewrite ids and children refs
|
|
48
|
-
for (const node of cloned) {
|
|
49
|
-
node.id = idMap.get(node.id);
|
|
50
|
-
if (Array.isArray(node.children)) {
|
|
51
|
-
node.children = node.children
|
|
52
|
-
.map((c) => (typeof c === 'string' ? idMap.get(c) || c : c));
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return { cloned, idMap, rootId: idMap.get(fragment.template[0].id) };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function applyBindings(nodes, fragment, idMap, bindings = {}) {
|
|
59
|
-
if (!bindings) return;
|
|
60
|
-
for (const slot of fragment.slots || []) {
|
|
61
|
-
const internalId = idMap.get(slot.targetId);
|
|
62
|
-
const target = nodes.find((n) => n.id === internalId);
|
|
63
|
-
if (!target) continue;
|
|
64
|
-
|
|
65
|
-
const value = bindings[slot.name] ?? slot.defaultValue;
|
|
66
|
-
if (value === undefined) continue;
|
|
67
|
-
|
|
68
|
-
const attr = slot.attribute || 'textContent';
|
|
69
|
-
target[attr] = value;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Expand a composition (or any template) into a flat A2UI node list.
|
|
75
26
|
* @param {object} composition - { template: [...] }
|
|
76
|
-
* @returns {Array<object>}
|
|
27
|
+
* @returns {Array<object>}
|
|
77
28
|
*/
|
|
78
29
|
export function resolveComposition(composition) {
|
|
30
|
+
const template = composition?.template || [];
|
|
79
31
|
const out = [];
|
|
80
|
-
const template = composition.template || [];
|
|
81
|
-
|
|
82
32
|
for (const node of template) {
|
|
83
|
-
if (!node
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const fragment = getFragment(node.$fragment);
|
|
89
|
-
if (!fragment) {
|
|
33
|
+
if (!node || typeof node !== 'object') continue;
|
|
34
|
+
if (node.$fragment) {
|
|
35
|
+
// Stale $fragment refs shouldn't exist after the §37 inline pass.
|
|
36
|
+
// Surface them as a visible placeholder so corpus drift is easy
|
|
37
|
+
// to spot at render time.
|
|
90
38
|
out.push({
|
|
91
|
-
id: node.id
|
|
39
|
+
id: node.id || `stale-${node.$fragment}`,
|
|
92
40
|
component: 'Text',
|
|
93
|
-
textContent: `⚠
|
|
41
|
+
textContent: `⚠ stale $fragment ref (compositions are pre-inlined): ${node.$fragment}`,
|
|
94
42
|
});
|
|
95
43
|
continue;
|
|
96
44
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const rootNode = cloned.find((n) => n.id === rootId);
|
|
104
|
-
if (rootNode) {
|
|
105
|
-
rootNode.id = node.id;
|
|
106
|
-
// fix up any sibling refs inside cloned
|
|
107
|
-
for (const n of cloned) {
|
|
108
|
-
if (Array.isArray(n.children)) {
|
|
109
|
-
n.children = n.children.map((c) => (c === rootId ? node.id : c));
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Allow composition to inject extra children into the fragment root.
|
|
115
|
-
// children — alias for appendChildren (legacy; append to end)
|
|
116
|
-
// appendChildren — append composition-owned nodes to the fragment's children
|
|
117
|
-
// prependChildren — prepend composition-owned nodes before the fragment's children
|
|
118
|
-
// This lets a composition add things INSIDE a fragment's root (e.g. a logo above
|
|
119
|
-
// a card-header's title) without having to inline the whole fragment.
|
|
120
|
-
if (rootNode) {
|
|
121
|
-
const prepend = Array.isArray(node.prependChildren) ? node.prependChildren : [];
|
|
122
|
-
const append = Array.isArray(node.appendChildren)
|
|
123
|
-
? node.appendChildren
|
|
124
|
-
: (Array.isArray(node.children) ? node.children : []);
|
|
125
|
-
if (prepend.length || append.length) {
|
|
126
|
-
rootNode.children = [
|
|
127
|
-
...prepend,
|
|
128
|
-
...(rootNode.children || []),
|
|
129
|
-
...append,
|
|
130
|
-
];
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
out.push(...cloned);
|
|
45
|
+
const {
|
|
46
|
+
// Strip composition-only fields that never reach the renderer
|
|
47
|
+
$fragment, bindings, prependChildren, appendChildren,
|
|
48
|
+
...rest
|
|
49
|
+
} = node;
|
|
50
|
+
out.push({ ...rest });
|
|
135
51
|
}
|
|
136
|
-
|
|
137
52
|
return out;
|
|
138
53
|
}
|
|
139
54
|
|
|
140
55
|
/**
|
|
141
|
-
* Convert a
|
|
142
|
-
*
|
|
143
|
-
* fields (`$fragment`, `bindings`, `prependChildren`, `appendChildren`) that
|
|
144
|
-
* were already consumed by resolveComposition — they'd be dead weight in the
|
|
145
|
-
* emitted A2UI messages.
|
|
56
|
+
* Convert a flat A2UI template into `beginComponent` messages for the
|
|
57
|
+
* validator / renderer. Strips composition-era fields.
|
|
146
58
|
*/
|
|
147
59
|
export function templateToMessages(template) {
|
|
148
60
|
return template.map((node) => {
|
|
@@ -161,3 +73,6 @@ export function templateToMessages(template) {
|
|
|
161
73
|
};
|
|
162
74
|
});
|
|
163
75
|
}
|
|
76
|
+
|
|
77
|
+
// Re-export the separator for any downstream code that still uses it.
|
|
78
|
+
export { ID_PREFIX_SEPARATOR };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition Library — zettel-style loader for A2UI compositions.
|
|
3
|
+
*
|
|
4
|
+
* Loads two kinds of records into a single in-memory map:
|
|
5
|
+
*
|
|
6
|
+
* 1. `corpus/compositions/<domain>/<name>.json` — hand-authored A2UI
|
|
7
|
+
* templates. The legacy path.
|
|
8
|
+
*
|
|
9
|
+
* 2. `corpus/chunks/<name>.json` — harvested from production HTML —
|
|
10
|
+
* WHEN the chunk carries both `metadata` (from `data-chunk-*`
|
|
11
|
+
* source-HTML attrs per §40) AND `template` (from the harvester's
|
|
12
|
+
* transpileHTML pass per §41). Such chunks are normalized to
|
|
13
|
+
* composition shape by hoisting `metadata.*` to top level, so they
|
|
14
|
+
* compete with hand-authored compositions in the same search.
|
|
15
|
+
*
|
|
16
|
+
* Records from chunks carry `_kind: 'annotated-chunk'` and the
|
|
17
|
+
* source-of-truth path so consumers can distinguish if they care; most
|
|
18
|
+
* shouldn't.
|
|
19
|
+
*
|
|
20
|
+
* Renamed from fragment-library.js in §38. Fragments retired §37.
|
|
21
|
+
* Annotated-chunk loading added §41.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import url from 'node:url';
|
|
27
|
+
|
|
28
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
29
|
+
const COMPOSITIONS_ROOT = path.resolve(__dirname, '../../../corpus/compositions');
|
|
30
|
+
const CHUNKS_ROOT = path.resolve(__dirname, '../../../corpus/chunks');
|
|
31
|
+
|
|
32
|
+
/** @type {Map<string, object>} */
|
|
33
|
+
const compositions = new Map();
|
|
34
|
+
|
|
35
|
+
function walk(dir, cb) {
|
|
36
|
+
if (!fs.existsSync(dir)) return;
|
|
37
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
38
|
+
const p = path.join(dir, entry.name);
|
|
39
|
+
if (entry.isDirectory()) walk(p, cb);
|
|
40
|
+
else if (entry.name.endsWith('.json') && !entry.name.startsWith('_')) cb(p);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Promote an annotated chunk record to composition shape. Lifts
|
|
46
|
+
* `metadata.{domain,description,keywords,related,tags}` to top-level
|
|
47
|
+
* fields so the existing searchAll scorer can read them uniformly.
|
|
48
|
+
*/
|
|
49
|
+
function chunkToComposition(chunkDoc, sourcePath) {
|
|
50
|
+
const meta = chunkDoc.metadata || {};
|
|
51
|
+
return {
|
|
52
|
+
_kind: 'annotated-chunk',
|
|
53
|
+
_sourceFile: sourcePath,
|
|
54
|
+
name: chunkDoc.name,
|
|
55
|
+
kind: 'composition', // honest with the searchAll filter
|
|
56
|
+
domain: meta.domain,
|
|
57
|
+
description: meta.description,
|
|
58
|
+
keywords: meta.keywords || [],
|
|
59
|
+
related: meta.related || [],
|
|
60
|
+
tags: meta.tags || {},
|
|
61
|
+
template: chunkDoc.template,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function loadAll() {
|
|
66
|
+
compositions.clear();
|
|
67
|
+
|
|
68
|
+
// 1. Hand-authored compositions.
|
|
69
|
+
walk(COMPOSITIONS_ROOT, (p) => {
|
|
70
|
+
const doc = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
71
|
+
if (doc.kind !== 'composition') return;
|
|
72
|
+
doc._sourceFile = p;
|
|
73
|
+
compositions.set(doc.name, doc);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 2. Annotated chunks (§41). Filter: must have metadata.domain (or
|
|
77
|
+
// metadata.keywords) AND a template field — otherwise the harvester
|
|
78
|
+
// doesn't have enough to treat the chunk as a retrieval candidate.
|
|
79
|
+
let annotatedChunkCount = 0;
|
|
80
|
+
walk(CHUNKS_ROOT, (p) => {
|
|
81
|
+
const doc = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
82
|
+
if (!doc.metadata) return;
|
|
83
|
+
if (!doc.template || !Array.isArray(doc.template) || doc.template.length === 0) return;
|
|
84
|
+
const meta = doc.metadata;
|
|
85
|
+
if (!meta.domain && !(meta.keywords && meta.keywords.length > 0)) return;
|
|
86
|
+
|
|
87
|
+
// Chunk name MUST NOT collide with a hand-authored composition.
|
|
88
|
+
// Hand-authored wins; warn so authors can reconcile (rename one or
|
|
89
|
+
// delete the composition once the chunk supersedes it).
|
|
90
|
+
if (compositions.has(doc.name)) {
|
|
91
|
+
console.warn(
|
|
92
|
+
`[composition-library] name collision: annotated chunk "${doc.name}" ` +
|
|
93
|
+
`shadowed by hand-authored composition. Annotated chunk ignored — ` +
|
|
94
|
+
`rename one or retire the composition.`
|
|
95
|
+
);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
compositions.set(doc.name, chunkToComposition(doc, p));
|
|
99
|
+
annotatedChunkCount++;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
compositionCount: compositions.size,
|
|
104
|
+
handAuthoredCount: compositions.size - annotatedChunkCount,
|
|
105
|
+
annotatedChunkCount,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Back-compat shims removed in §38 — fragment-library.js → composition-library.js
|
|
110
|
+
// rename. The retired `getFragment` / `getAllFragments` exports had zero
|
|
111
|
+
// external callers; drop them.
|
|
112
|
+
|
|
113
|
+
export function getComposition(name) { return compositions.get(name); }
|
|
114
|
+
export function getAllCompositions() { return [...compositions.values()]; }
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Keyword search across compositions. (Fragments retired; only one
|
|
118
|
+
* corpus class to search now.)
|
|
119
|
+
*
|
|
120
|
+
* Ranking design:
|
|
121
|
+
* 1. Tokenize query, drop generic UI stop-words ("form", "page", "view"…)
|
|
122
|
+
* so they can't be the sole evidence for a match.
|
|
123
|
+
* 2. Whole-word match, not substring — prevents "form" matching "login-form".
|
|
124
|
+
* 3. Require ≥2 content-token hits (compositions are concrete templates —
|
|
125
|
+
* promiscuous matching is expensive). Exception: a direct name-token
|
|
126
|
+
* match is strong enough on its own.
|
|
127
|
+
* 4. Tiered weights: name hit > keyword hit > description.
|
|
128
|
+
*/
|
|
129
|
+
const STOP_WORDS = new Set([
|
|
130
|
+
'a', 'an', 'the', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'at', 'for', 'with',
|
|
131
|
+
'form', 'forms', 'page', 'pages', 'view', 'views', 'screen', 'screens',
|
|
132
|
+
'ui', 'component', 'components', 'widget', 'widgets', 'block', 'blocks',
|
|
133
|
+
'section', 'sections', 'layout', 'layouts',
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
function tokenize(str) {
|
|
137
|
+
return (str || '')
|
|
138
|
+
.toLowerCase()
|
|
139
|
+
.split(/[^a-z0-9]+/)
|
|
140
|
+
.filter((t) => t.length >= 2);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function searchAll(query, { limit = 20 } = {}) {
|
|
144
|
+
const q = (query || '').trim();
|
|
145
|
+
if (!q) return [];
|
|
146
|
+
const queryTokens = tokenize(q);
|
|
147
|
+
|
|
148
|
+
const scoreDoc = (doc) => {
|
|
149
|
+
const nameTokens = new Set(tokenize(doc.name));
|
|
150
|
+
const keywordTokens = new Set((doc.keywords || []).flatMap((k) => tokenize(k)));
|
|
151
|
+
const descTokens = new Set(tokenize(doc.description || ''));
|
|
152
|
+
const tagTokens = new Set(
|
|
153
|
+
Object.values(doc.tags || {}).flat().flatMap((v) => tokenize(String(v))),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
let s = 0;
|
|
157
|
+
const contentMatched = new Set();
|
|
158
|
+
|
|
159
|
+
for (const t of queryTokens) {
|
|
160
|
+
const isStop = STOP_WORDS.has(t);
|
|
161
|
+
let matched = false;
|
|
162
|
+
if (nameTokens.has(t)) { s += isStop ? 1 : 12; matched = true; }
|
|
163
|
+
if (keywordTokens.has(t)) { s += isStop ? 1 : 8; matched = true; }
|
|
164
|
+
if (tagTokens.has(t)) { s += isStop ? 0 : 3; matched = true; }
|
|
165
|
+
if (descTokens.has(t)) { s += isStop ? 0 : 2; matched = true; }
|
|
166
|
+
if (matched && !isStop) {
|
|
167
|
+
contentMatched.add(t);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const contentHits = contentMatched.size;
|
|
171
|
+
|
|
172
|
+
// Gate: compositions require ≥2 content-token hits, OR a direct
|
|
173
|
+
// name-token match (the query contains the composition's primary
|
|
174
|
+
// concept word — strong evidence on its own).
|
|
175
|
+
const nameHit = queryTokens.some((t) => !STOP_WORDS.has(t) && nameTokens.has(t));
|
|
176
|
+
if (contentHits < 2 && !nameHit) return 0;
|
|
177
|
+
|
|
178
|
+
// Coverage bonus: more distinct content tokens matched = better match
|
|
179
|
+
s += contentHits * 3;
|
|
180
|
+
|
|
181
|
+
return s;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
return getAllCompositions()
|
|
185
|
+
.map((doc) => ({ doc, score: scoreDoc(doc) }))
|
|
186
|
+
.filter((x) => x.score > 0)
|
|
187
|
+
.sort((a, b) => b.score - a.score)
|
|
188
|
+
.slice(0, limit)
|
|
189
|
+
.map((x) => ({
|
|
190
|
+
type: 'composition',
|
|
191
|
+
name: x.doc.name,
|
|
192
|
+
score: Math.round(x.score * 100) / 100,
|
|
193
|
+
description: x.doc.description,
|
|
194
|
+
domain: x.doc.domain,
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Return composition catalog for inspection / visualization. */
|
|
199
|
+
export function getGraph() {
|
|
200
|
+
return {
|
|
201
|
+
compositions: getAllCompositions().map((c) => ({
|
|
202
|
+
name: c.name,
|
|
203
|
+
domain: c.domain,
|
|
204
|
+
node_count: (c.template || []).length,
|
|
205
|
+
})),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
loadAll,
|
|
23
23
|
getComposition,
|
|
24
24
|
searchAll,
|
|
25
|
-
} from './
|
|
25
|
+
} from './composition-library.js';
|
|
26
26
|
import { resolveComposition, templateToMessages } from './composer.js';
|
|
27
27
|
import { synthesizeComposition } from './synthesizer.js';
|
|
28
28
|
import {
|
|
@@ -27,7 +27,12 @@
|
|
|
27
27
|
const DEFAULT_MAX_SIZE = 64;
|
|
28
28
|
|
|
29
29
|
function envMaxSize() {
|
|
30
|
-
|
|
30
|
+
// Node-only: `process` is undefined in the browser. Without this guard the
|
|
31
|
+
// StateCache constructor throws `ReferenceError: process is not defined`
|
|
32
|
+
// the moment the zettel engine is selected in the gen-ui playground.
|
|
33
|
+
// Matches the convention used in retrieval/* and compose/core/generator.js.
|
|
34
|
+
const IS_NODE = typeof process !== 'undefined' && process.versions?.node;
|
|
35
|
+
const v = IS_NODE ? process.env.A2UI_STATE_CACHE_SIZE : null;
|
|
31
36
|
if (!v) return null;
|
|
32
37
|
const n = parseInt(v, 10);
|
|
33
38
|
return Number.isFinite(n) && n > 0 ? n : null;
|