@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.
@@ -1,148 +1,60 @@
1
1
  /**
2
- * Composer — resolves compositions and fragment references into flat A2UI templates.
2
+ * Composer — passes A2UI composition templates straight through.
3
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.
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
- * 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
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
- import { getFragment } from './fragment-library.js';
17
+ const ID_PREFIX_SEPARATOR = '--';
20
18
 
21
19
  /**
22
- * Separator used when prefixing a fragment's internal node ids with the
23
- * composition node id. Format: `{compNode}{ID_PREFIX_SEPARATOR}{fragNode}`.
20
+ * Return a defensive copy of a composition's flat template.
24
21
  *
25
- * Why double-dash: single `-` collides with kebab-case ids that primitives
26
- * commonly emit (`auth-card-header`, `card-header-heading`); double-dash
27
- * has no observed natural occurrence in either composition node ids or
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>} resolved flat nodes
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.$fragment) {
84
- out.push({ ...node });
85
- continue;
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: `⚠ unresolved fragment: ${node.$fragment}`,
41
+ textContent: `⚠ stale $fragment ref (compositions are pre-inlined): ${node.$fragment}`,
94
42
  });
95
43
  continue;
96
44
  }
97
-
98
- const { cloned, idMap, rootId } = cloneFragmentWithPrefix(fragment, node.id);
99
- applyBindings(cloned, fragment, idMap, node.bindings);
100
-
101
- // Substitute: composition-node id becomes the fragment root id.
102
- // So any sibling that references node.id still resolves to the expanded root.
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 resolved flat template into A2UI `beginComponent` messages
142
- * compatible with the existing validator / renderer. Strips zettel-only
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 './fragment-library.js';
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
- const v = process.env.A2UI_STATE_CACHE_SIZE;
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;