@adia-ai/a2ui-compose 0.7.19 → 0.7.21
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 +13 -0
- package/package.json +1 -1
- package/strategies/_shared/chunk-loader.js +77 -4
- package/strategies/monolithic/_shared.js +57 -0
- package/strategies/monolithic/generate-instant.js +2 -3
- package/strategies/monolithic/generate-pro.js +3 -4
- package/strategies/monolithic/generate-thinking.js +3 -4
- package/strategies/zettel/composition-library.js +147 -7
- package/strategies/zettel/generator-adapter.js +19 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog — @adia-ai/a2ui-compose
|
|
2
2
|
|
|
3
|
+
## [0.7.21] — 2026-06-10
|
|
4
|
+
|
|
5
|
+
### Maintenance
|
|
6
|
+
- **Lockstep version bump only.** No source changes in this package; bumped to maintain the 9-package version coherence enforced by `scripts/release/check-lockstep.mjs`. Substantive v0.7.21 work shipped in corpus harvester scope expansion (gated web-components examples root) + 26 curated chunks (368->394). See `packages/a2ui/corpus/CHANGELOG.md#0721--2026-06-10` for details.
|
|
7
|
+
|
|
8
|
+
## [0.7.20] — 2026-06-10
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Corpus retrieval works on STATICALLY-served sites (no vite).** The browser corpus loaders (`strategies/zettel/composition-library.js`, `strategies/_shared/chunk-loader.js`) used `import.meta.glob` — a vite-only mechanism — so the corpus-first path silently loaded 0 chunks on the deployed static site and every intent fell through to the LLM. New **third runtime branch** (Node / vite-glob / **static-fetch**): one `_index.json` request carries the full scoring metadata; matched chunk bodies hydrate lazily. Detection = attempting the glob *call* in try/catch (`typeof import.meta.glob` is undefined in BOTH vite-dev and raw ESM — only the rewritten call discriminates). A parity guard in `generator-adapter` keeps static results identical to Node (template-less partials fall through instead of emitting empty surfaces). Verified: a plain static server with **no LLM** retrieves `contact-form-card` verbatim (4 labeled fields, 92/100, ~8 ms).
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **Anti-pattern fold-in for generated output** — `foldInAntiPatterns(messages, validation)` projects the flat component messages into an HTML sketch (via the runtime tag registry), runs the anti-pattern registry over it, and folds violations into the validation result (one warning each + a 10-point score dock). Wired into all monolithic generate paths (instant / thinking / pro — 5 call sites), so the canvas's validation badge now reflects anti-patterns: the incident's junk contact form scores 65 with 3 named violations instead of badging clean. Fails open — the fold-in can never break generation.
|
|
15
|
+
|
|
3
16
|
## [0.7.19] — 2026-06-10
|
|
4
17
|
|
|
5
18
|
### Maintenance
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/a2ui-compose",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.21",
|
|
4
4
|
"description": "AdiaUI A2UI compose engine \u2014 framework-agnostic. Takes natural-language intents + a catalog and produces A2UI protocol messages. Pairs with `@adia-ai/a2ui-retrieval` (intent classification, catalog lookup) and `@adia-ai/a2ui-validator` (schema + semantic checks).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -28,7 +28,13 @@ let _state = null; // { chunks: Map<name, record>, byKind: Map<kind, Set<n
|
|
|
28
28
|
let _loadPromise = null;
|
|
29
29
|
|
|
30
30
|
// ── Browser glob ───────────────────────────────────────────────────
|
|
31
|
-
// Vite
|
|
31
|
+
// Vite rewrites this CALL at transform time. On the static deploy (raw
|
|
32
|
+
// source, no Vite) the call hits the real `import.meta.glob` — which is
|
|
33
|
+
// undefined at runtime — and throws → caught → `_globModules` null → fetch
|
|
34
|
+
// branch. `import.meta.glob` is a compile-time macro, NOT a runtime
|
|
35
|
+
// function, so `typeof import.meta.glob` is undefined in BOTH Vite and
|
|
36
|
+
// static — the only reliable discriminator is whether this CALL yielded
|
|
37
|
+
// loaders. See composition-library.js for the full tri-mode rationale.
|
|
32
38
|
let _globModules = null;
|
|
33
39
|
if (!IS_NODE) {
|
|
34
40
|
try {
|
|
@@ -37,9 +43,18 @@ if (!IS_NODE) {
|
|
|
37
43
|
import: 'default',
|
|
38
44
|
});
|
|
39
45
|
} catch {
|
|
40
|
-
//
|
|
46
|
+
// No Vite transform here (static deploy) — fall back to the fetch branch.
|
|
41
47
|
}
|
|
42
48
|
}
|
|
49
|
+
const HAS_VITE_GLOB =
|
|
50
|
+
!IS_NODE && !!_globModules && Object.keys(_globModules).length > 0;
|
|
51
|
+
|
|
52
|
+
// Relative-to-module corpus base for the static branch (same path the glob
|
|
53
|
+
// trusts; mount-point-independent). Verified 2026-06-10.
|
|
54
|
+
const _CORPUS_CHUNKS_BASE =
|
|
55
|
+
!IS_NODE && !HAS_VITE_GLOB
|
|
56
|
+
? new URL('../../../corpus/chunks/', import.meta.url)
|
|
57
|
+
: null;
|
|
43
58
|
|
|
44
59
|
// ── Public API ─────────────────────────────────────────────────────
|
|
45
60
|
async function _load() {
|
|
@@ -48,14 +63,55 @@ async function _load() {
|
|
|
48
63
|
_loadPromise = (async () => {
|
|
49
64
|
if (IS_NODE) {
|
|
50
65
|
_state = await _loadNode();
|
|
51
|
-
} else {
|
|
66
|
+
} else if (HAS_VITE_GLOB) {
|
|
52
67
|
_state = await _loadBrowser();
|
|
68
|
+
} else {
|
|
69
|
+
_state = await _loadStatic();
|
|
53
70
|
}
|
|
54
71
|
return _state;
|
|
55
72
|
})();
|
|
56
73
|
return _loadPromise;
|
|
57
74
|
}
|
|
58
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Static-browser loader — fetch `_index.json` for keyword scoring. The
|
|
78
|
+
* index carries name/kind/primary/nested/pages (everything `searchChunks`
|
|
79
|
+
* ranks on); chunk BODIES (html / template) are fetched lazily by
|
|
80
|
+
* `getChunk` so page-load is one request, not 339. Degrades to an empty
|
|
81
|
+
* corpus (not a throw) on fetch / parse failure.
|
|
82
|
+
*/
|
|
83
|
+
async function _loadStatic() {
|
|
84
|
+
const chunks = new Map();
|
|
85
|
+
const byKind = new Map();
|
|
86
|
+
if (!_CORPUS_CHUNKS_BASE) return { chunks, byKind };
|
|
87
|
+
let index;
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(new URL('_index.json', _CORPUS_CHUNKS_BASE));
|
|
90
|
+
if (!res.ok) return { chunks, byKind };
|
|
91
|
+
index = await res.json();
|
|
92
|
+
} catch {
|
|
93
|
+
return { chunks, byKind };
|
|
94
|
+
}
|
|
95
|
+
const entries = Array.isArray(index?.chunks) ? index.chunks : [];
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
if (!entry?.name) continue;
|
|
98
|
+
// Normalize the index entry to the per-chunk record shape `searchChunks`
|
|
99
|
+
// expects: `pages` (array) → `source`/`page` (string). `_bodyUrl` +
|
|
100
|
+
// `_hydrated` mark the record for lazy body-fetch in getChunk.
|
|
101
|
+
const rec = {
|
|
102
|
+
...entry,
|
|
103
|
+
source: Array.isArray(entry.pages) ? entry.pages[0] : (entry.source || entry.page || ''),
|
|
104
|
+
_bodyUrl: new URL(`${entry.name}.json`, _CORPUS_CHUNKS_BASE).href,
|
|
105
|
+
_hydrated: false,
|
|
106
|
+
};
|
|
107
|
+
chunks.set(rec.name, rec);
|
|
108
|
+
const kind = rec.kind || 'block';
|
|
109
|
+
if (!byKind.has(kind)) byKind.set(kind, new Set());
|
|
110
|
+
byKind.get(kind).add(rec.name);
|
|
111
|
+
}
|
|
112
|
+
return { chunks, byKind };
|
|
113
|
+
}
|
|
114
|
+
|
|
59
115
|
async function _loadNode() {
|
|
60
116
|
const fs = await import(/* @vite-ignore */ 'node:fs/promises');
|
|
61
117
|
const path = await import(/* @vite-ignore */ 'node:path');
|
|
@@ -174,7 +230,24 @@ export async function searchChunks(query, { kind, limit = 20 } = {}) {
|
|
|
174
230
|
export async function getChunk(name) {
|
|
175
231
|
if (!name) return null;
|
|
176
232
|
const { chunks } = await _load();
|
|
177
|
-
|
|
233
|
+
const rec = chunks.get(name) || null;
|
|
234
|
+
if (!rec) return null;
|
|
235
|
+
// Static-browser: the record came from `_index.json` (scoring fields only).
|
|
236
|
+
// Fetch + merge the full body (html / template / slots / attrs) on first
|
|
237
|
+
// access, then cache. No-op in Node / Vite (full record already loaded).
|
|
238
|
+
if (rec._bodyUrl && !rec._hydrated) {
|
|
239
|
+
try {
|
|
240
|
+
const res = await fetch(rec._bodyUrl);
|
|
241
|
+
if (res.ok) {
|
|
242
|
+
const body = await res.json();
|
|
243
|
+
Object.assign(rec, body);
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// Leave the scoring-only record; consumers must tolerate a missing body.
|
|
247
|
+
}
|
|
248
|
+
rec._hydrated = true; // don't retry on every access, even if the fetch failed
|
|
249
|
+
}
|
|
250
|
+
return rec;
|
|
178
251
|
}
|
|
179
252
|
|
|
180
253
|
/**
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { listPatterns } from '../../core/reference.js';
|
|
11
|
+
import { resolveTag } from '../../../runtime/registry.js';
|
|
12
|
+
import { checkAllAntiPatterns } from '../../../retrieval/anti-patterns.js';
|
|
11
13
|
import { store } from '../../core/state.js';
|
|
12
14
|
import { checkIntentAlignment } from '../../../retrieval/intent/intent-alignment.js';
|
|
13
15
|
import { composeSubtasks } from '../../../retrieval/intent/decomposer.js';
|
|
@@ -1560,3 +1562,58 @@ function prettyComponentName(name) {
|
|
|
1560
1562
|
const article = /^[aeiou]/i.test(spaced) ? 'an' : 'a';
|
|
1561
1563
|
return `${article} ${spaced}`;
|
|
1562
1564
|
}
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Anti-pattern fold-in for generated output (the gen-UI contact-form
|
|
1568
|
+
* incident, 2026-06-10): validateSchema scores structure but never ran the
|
|
1569
|
+
* anti-pattern registry, so a generated form with a text-ui "label" inside
|
|
1570
|
+
* field-ui, display="none" attributes, and input-ui[type=textarea] badged
|
|
1571
|
+
* a clean score in the canvas.
|
|
1572
|
+
*
|
|
1573
|
+
* Projects the flat component messages into a light HTML sketch (open tags
|
|
1574
|
+
* + attrs, tree-ordered via children id refs) — enough surface for the
|
|
1575
|
+
* regex/function checks in retrieval/anti-patterns.js — runs
|
|
1576
|
+
* checkAllAntiPatterns over it, then folds violations into the validation
|
|
1577
|
+
* object: one warning per violation and a 10-point score dock each
|
|
1578
|
+
* (floored at 0). Mutates + returns `validation`.
|
|
1579
|
+
*
|
|
1580
|
+
* @param {object[]} messages — A2UI messages (flat adjacency components)
|
|
1581
|
+
* @param {{score: number, warnings?: string[]}} validation — from validateSchema
|
|
1582
|
+
* @returns {typeof validation}
|
|
1583
|
+
*/
|
|
1584
|
+
export function foldInAntiPatterns(messages, validation) {
|
|
1585
|
+
try {
|
|
1586
|
+
const components = (messages || []).flatMap((m) => m.components || []);
|
|
1587
|
+
if (!components.length || !validation) return validation;
|
|
1588
|
+
const byId = new Map(components.map((c) => [c.id, c]));
|
|
1589
|
+
const referenced = new Set(components.flatMap((c) => c.children || []));
|
|
1590
|
+
const roots = components.filter((c) => !referenced.has(c.id));
|
|
1591
|
+
const sketch = [];
|
|
1592
|
+
const emit = (c, depth) => {
|
|
1593
|
+
if (!c || depth > 12) return;
|
|
1594
|
+
const tag = resolveTag(c.type) || String(c.type || '').toLowerCase();
|
|
1595
|
+
const attrs = Object.entries(c.props || {})
|
|
1596
|
+
.filter(([, v]) => v !== false && v != null && typeof v !== 'object')
|
|
1597
|
+
.map(([k, v]) => ` ${k}="${String(v).replace(/"/g, '"')}"`)
|
|
1598
|
+
.join('');
|
|
1599
|
+
sketch.push(`<${tag}${attrs}>`);
|
|
1600
|
+
if (typeof (c.props || {}).textContent === 'string') sketch.push((c.props || {}).textContent);
|
|
1601
|
+
for (const id of c.children || []) emit(byId.get(id), depth + 1);
|
|
1602
|
+
sketch.push(`</${tag}>`);
|
|
1603
|
+
};
|
|
1604
|
+
for (const r of roots) emit(r, 0);
|
|
1605
|
+
const violations = checkAllAntiPatterns(sketch.join('\n'));
|
|
1606
|
+
if (violations.length) {
|
|
1607
|
+
validation.warnings = [
|
|
1608
|
+
...(validation.warnings || []),
|
|
1609
|
+
...violations.map((v) => `anti-pattern ${v.name}: ${v.description} Fix: ${v.fix}`),
|
|
1610
|
+
];
|
|
1611
|
+
validation.antiPatterns = violations.map((v) => v.name);
|
|
1612
|
+
validation.score = Math.max(0, (validation.score || 0) - 10 * violations.length);
|
|
1613
|
+
}
|
|
1614
|
+
return validation;
|
|
1615
|
+
} catch {
|
|
1616
|
+
// The fold-in must never break generation — fail open.
|
|
1617
|
+
return validation;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
@@ -13,8 +13,7 @@ import { feedbackStore } from '../../../retrieval/feedback/feedback-store.js';
|
|
|
13
13
|
import { store, engine } from '../../core/state.js';
|
|
14
14
|
import { isRecording } from '../../../retrieval/feedback/dialog-recorder.js';
|
|
15
15
|
import {
|
|
16
|
-
generateSuggestions,
|
|
17
|
-
} from './_shared.js';
|
|
16
|
+
generateSuggestions, foldInAntiPatterns } from './_shared.js';
|
|
18
17
|
|
|
19
18
|
export async function generateInstant({ intent, executionId, storeId, analysis, priorComponentsFromPayload, context }) {
|
|
20
19
|
// Forward-compat: instant mode is pattern-match-only and doesn't iterate
|
|
@@ -168,7 +167,7 @@ export async function generateInstant({ intent, executionId, storeId, analysis,
|
|
|
168
167
|
});
|
|
169
168
|
|
|
170
169
|
// ── Validate stage ──
|
|
171
|
-
const validation = validateSchema(messages, { intent, context });
|
|
170
|
+
const validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
|
|
172
171
|
engine.submitStage(execId, 'validate', {
|
|
173
172
|
...validation,
|
|
174
173
|
confidence: validation.score / 100,
|
|
@@ -22,8 +22,7 @@ import {
|
|
|
22
22
|
buildCanvasDiffPrompt,
|
|
23
23
|
parseA2UIResponse,
|
|
24
24
|
buildRepairPrompt,
|
|
25
|
-
generateSuggestions,
|
|
26
|
-
} from './_shared.js';
|
|
25
|
+
generateSuggestions, foldInAntiPatterns } from './_shared.js';
|
|
27
26
|
|
|
28
27
|
export async function generatePro({ intent, executionId, storeId, llmAdapter, analysis, priorComponentsFromPayload, context }) {
|
|
29
28
|
const execId = executionId;
|
|
@@ -404,7 +403,7 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
|
|
|
404
403
|
});
|
|
405
404
|
|
|
406
405
|
// ── Stage 5: Validate + repair ──
|
|
407
|
-
let validation = validateSchema(messages, { intent, context });
|
|
406
|
+
let validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
|
|
408
407
|
if (!validation.valid) {
|
|
409
408
|
try {
|
|
410
409
|
const failedChecks = validation.checks.filter(c => !c.passed);
|
|
@@ -413,7 +412,7 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
|
|
|
413
412
|
systemPrompt,
|
|
414
413
|
});
|
|
415
414
|
messages = parseA2UIResponse(repairResponse.content, { executionId: execId, intent, mode: 'pro', stopReason: repairResponse.stopReason });
|
|
416
|
-
validation = validateSchema(messages, { intent, context });
|
|
415
|
+
validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
|
|
417
416
|
} catch { /* keep original */ }
|
|
418
417
|
}
|
|
419
418
|
engine.submitStage(execId, 'validate', { ...validation, confidence: validation.score / 100 });
|
|
@@ -18,8 +18,7 @@ import {
|
|
|
18
18
|
buildChatMessages,
|
|
19
19
|
parseA2UIResponse,
|
|
20
20
|
buildRepairPrompt,
|
|
21
|
-
generateSuggestions,
|
|
22
|
-
} from './_shared.js';
|
|
21
|
+
generateSuggestions, foldInAntiPatterns } from './_shared.js';
|
|
23
22
|
|
|
24
23
|
export async function generateThinking({ intent, executionId, storeId, llmAdapter, analysis, priorComponentsFromPayload, context }) {
|
|
25
24
|
// Note: thinking mode currently composes from scratch each turn. The
|
|
@@ -129,7 +128,7 @@ export async function generateThinking({ intent, executionId, storeId, llmAdapte
|
|
|
129
128
|
// Either failing triggers a repair attempt. Catalog failures are typically
|
|
130
129
|
// small (unknown prop, wrong enum, missing required id) and fix cleanly in
|
|
131
130
|
// one repair round. Three-attempt cap preserved from the original loop.
|
|
132
|
-
let validation = validateSchema(messages, { intent, context });
|
|
131
|
+
let validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
|
|
133
132
|
let catalogValidation = await validateCatalog(messages);
|
|
134
133
|
let attempts = 0;
|
|
135
134
|
|
|
@@ -146,7 +145,7 @@ export async function generateThinking({ intent, executionId, storeId, llmAdapte
|
|
|
146
145
|
});
|
|
147
146
|
messages = parseA2UIResponse(repairResponse.content, { executionId: execId, mode: 'thinking', intent, stopReason: repairResponse.stopReason });
|
|
148
147
|
if (isRecording()) { lastRawResponse = repairResponse.content; lastTokens = repairResponse.usage || lastTokens; }
|
|
149
|
-
validation = validateSchema(messages, { intent, context });
|
|
148
|
+
validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
|
|
150
149
|
catalogValidation = await validateCatalog(messages);
|
|
151
150
|
} catch {
|
|
152
151
|
break; // stop repair loop on adapter error
|
|
@@ -28,6 +28,31 @@
|
|
|
28
28
|
// `import 'node:fs'` poisoned the browser bundle the moment any
|
|
29
29
|
// app/playground reached `core/reference.js` → composition-library.
|
|
30
30
|
// Pattern mirrors `retrieval/component-catalog.js`.
|
|
31
|
+
//
|
|
32
|
+
// 2026-06-10: TRI-mode loader. The original dual-mode pattern assumed
|
|
33
|
+
// every browser context is a Vite dev server. The STATIC deploy
|
|
34
|
+
// (ui-kit.exe.xyz) serves the raw source JS — there is no Vite to transform
|
|
35
|
+
// the glob — so the glob `try/catch` swallowed silently and loaded 0 chunks.
|
|
36
|
+
// Every canvas intent then "composition-retrieval missed" on prod even
|
|
37
|
+
// though the chunk scored 66 in Node. Third branch: static-browser → fetch
|
|
38
|
+
// the corpus over HTTP.
|
|
39
|
+
// - Node (fs) — server / eval / smoke
|
|
40
|
+
// - Vite browser (glob) — `npm run dev`
|
|
41
|
+
// - Static browser (fetch)— deployed dist (no Vite, no /api/generate)
|
|
42
|
+
//
|
|
43
|
+
// CRUX — how we tell Vite-browser from static-browser (2026-06-10):
|
|
44
|
+
// `import.meta.glob` is a Vite **compile-time macro on the CALL expression**,
|
|
45
|
+
// NOT a runtime function. Vite rewrites `import.meta.glob('…')` into an
|
|
46
|
+
// object literal at transform time, but a bare reference like
|
|
47
|
+
// `typeof import.meta.glob` is left untouched — so at RUNTIME
|
|
48
|
+
// `import.meta.glob` is `undefined` in BOTH Vite-dev and static contexts
|
|
49
|
+
// (verified empirically 2026-06-10). The only reliable discriminator is to
|
|
50
|
+
// ATTEMPT the call in a try/catch: in Vite it was already rewritten to the
|
|
51
|
+
// object literal (succeeds, returns N loaders); on the raw static site the
|
|
52
|
+
// call hits the real `import.meta.glob` which is undefined → throws
|
|
53
|
+
// `import.meta.glob is not a function` → caught → we take the fetch branch.
|
|
54
|
+
// (This is parse-safe: `import.meta` is a real object in every ES module;
|
|
55
|
+
// the throw is a runtime TypeError, not a SyntaxError.)
|
|
31
56
|
const IS_NODE =
|
|
32
57
|
typeof process !== 'undefined' &&
|
|
33
58
|
typeof process.versions?.node === 'string';
|
|
@@ -35,7 +60,8 @@ const IS_NODE =
|
|
|
35
60
|
/** @type {Map<string, object>} */
|
|
36
61
|
const compositions = new Map();
|
|
37
62
|
|
|
38
|
-
// Vite
|
|
63
|
+
// Vite rewrites this call at transform time; on the static site the call
|
|
64
|
+
// throws (no Vite) → `_globChunkModules` stays null → fetch branch runs.
|
|
39
65
|
let _globChunkModules = null;
|
|
40
66
|
if (!IS_NODE) {
|
|
41
67
|
try {
|
|
@@ -45,20 +71,27 @@ if (!IS_NODE) {
|
|
|
45
71
|
eager: false,
|
|
46
72
|
});
|
|
47
73
|
} catch {
|
|
48
|
-
//
|
|
74
|
+
// No Vite transform here (static deploy) — fall through to the fetch branch.
|
|
49
75
|
}
|
|
50
76
|
}
|
|
77
|
+
// `HAS_VITE_GLOB` is now decided by whether the glob CALL produced loaders,
|
|
78
|
+
// not by an (always-undefined-at-runtime) `typeof` probe.
|
|
79
|
+
const HAS_VITE_GLOB =
|
|
80
|
+
!IS_NODE && !!_globChunkModules && Object.keys(_globChunkModules).length > 0;
|
|
51
81
|
|
|
52
82
|
/**
|
|
53
83
|
* Promote an annotated chunk record to composition shape. Lifts
|
|
54
84
|
* `metadata.{domain,description,keywords,related,tags}` to top-level
|
|
55
85
|
* fields so the existing searchAll scorer can read them uniformly.
|
|
56
86
|
*/
|
|
57
|
-
function chunkToComposition(chunkDoc, sourcePath) {
|
|
87
|
+
function chunkToComposition(chunkDoc, sourcePath, { bodyUrl = null } = {}) {
|
|
58
88
|
const meta = chunkDoc.metadata || {};
|
|
59
89
|
return {
|
|
60
90
|
_kind: 'annotated-chunk',
|
|
61
91
|
_sourceFile: sourcePath,
|
|
92
|
+
// Static-browser only: URL to fetch the chunk body (template) on demand.
|
|
93
|
+
// null in Node / Vite, where `template` is already populated eagerly.
|
|
94
|
+
_bodyUrl: bodyUrl,
|
|
62
95
|
name: chunkDoc.name,
|
|
63
96
|
kind: 'composition', // honest with the searchAll filter
|
|
64
97
|
domain: meta.domain,
|
|
@@ -66,7 +99,7 @@ function chunkToComposition(chunkDoc, sourcePath) {
|
|
|
66
99
|
keywords: meta.keywords || [],
|
|
67
100
|
related: meta.related || [],
|
|
68
101
|
tags: meta.tags || {},
|
|
69
|
-
template: chunkDoc.template,
|
|
102
|
+
template: chunkDoc.template ?? null,
|
|
70
103
|
};
|
|
71
104
|
}
|
|
72
105
|
|
|
@@ -91,6 +124,28 @@ function registerChunk(doc, sourcePath) {
|
|
|
91
124
|
return 1;
|
|
92
125
|
}
|
|
93
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Static-browser only: register a chunk from its `_index.json` entry for
|
|
129
|
+
* SCORING (name + metadata.{domain,description,keywords} — verified at
|
|
130
|
+
* 2026-06-10 to be byte-identical to the per-chunk file's metadata). The
|
|
131
|
+
* `template` is NOT in the index; it is fetched lazily by
|
|
132
|
+
* `hydrateComposition(name)` when a strong match is actually emitted, so
|
|
133
|
+
* page-load costs one request (the 281 KB index) instead of 339.
|
|
134
|
+
*
|
|
135
|
+
* Index entries carry no `template`, so the eager `registerChunk` gate
|
|
136
|
+
* (which requires a non-empty template) would reject every one. We apply
|
|
137
|
+
* the SAME metadata gate (domain OR keywords present) but defer the
|
|
138
|
+
* template requirement to hydration time.
|
|
139
|
+
*/
|
|
140
|
+
function registerIndexEntry(entry, bodyUrl) {
|
|
141
|
+
if (!entry?.metadata || !entry.name) return 0;
|
|
142
|
+
const meta = entry.metadata;
|
|
143
|
+
if (!meta.domain && !(meta.keywords && meta.keywords.length > 0)) return 0;
|
|
144
|
+
// template: null here — hydrated on demand. searchAll never reads it.
|
|
145
|
+
compositions.set(entry.name, chunkToComposition(entry, entry.name, { bodyUrl }));
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
94
149
|
async function _loadAllNode() {
|
|
95
150
|
const fs = await import(/* @vite-ignore */ 'node:fs');
|
|
96
151
|
const path = await import(/* @vite-ignore */ 'node:path');
|
|
@@ -141,14 +196,61 @@ async function _loadAllBrowser() {
|
|
|
141
196
|
return annotatedChunkCount;
|
|
142
197
|
}
|
|
143
198
|
|
|
199
|
+
// Base URL for the corpus on a static deploy. Relative-to-module
|
|
200
|
+
// (`import.meta.url`) — NOT an absolute `/packages/...` path — deliberately:
|
|
201
|
+
// 1. It is the SAME path the Vite glob trusts ('../../../corpus/chunks/*'),
|
|
202
|
+
// so dev and prod resolve through one mental model.
|
|
203
|
+
// 2. It is mount-point-independent: works whether dist is served at `/`,
|
|
204
|
+
// under a sub-path, or via the dist symlink layout
|
|
205
|
+
// (dist/node_modules/@adia-ai/a2ui-compose → packages/a2ui/compose),
|
|
206
|
+
// because `import.meta.url` always reflects the actually-served path.
|
|
207
|
+
// Verified 2026-06-10: `new URL('../../../corpus/chunks/_index.json',
|
|
208
|
+
// import.meta.url)` → `<root>/packages/a2ui/corpus/chunks/_index.json`.
|
|
209
|
+
const _CORPUS_CHUNKS_BASE =
|
|
210
|
+
!IS_NODE && !HAS_VITE_GLOB
|
|
211
|
+
? new URL('../../../corpus/chunks/', import.meta.url)
|
|
212
|
+
: null;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Static-browser loader. Fetches `_index.json` ONCE and registers every
|
|
216
|
+
* chunk for scoring. Bodies (templates) are fetched lazily by
|
|
217
|
+
* `hydrateComposition` so page-load stays at a single request, not 339.
|
|
218
|
+
*
|
|
219
|
+
* Degrades to 0 chunks (not a throw) on any fetch / parse failure — same
|
|
220
|
+
* graceful-empty contract as the Node and Vite branches.
|
|
221
|
+
*/
|
|
222
|
+
async function _loadAllStatic() {
|
|
223
|
+
if (!_CORPUS_CHUNKS_BASE) return 0;
|
|
224
|
+
let index;
|
|
225
|
+
try {
|
|
226
|
+
const res = await fetch(new URL('_index.json', _CORPUS_CHUNKS_BASE));
|
|
227
|
+
if (!res.ok) return 0;
|
|
228
|
+
index = await res.json();
|
|
229
|
+
} catch {
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
const entries = Array.isArray(index?.chunks) ? index.chunks : [];
|
|
233
|
+
let count = 0;
|
|
234
|
+
for (const entry of entries) {
|
|
235
|
+
if (!entry?.name) continue;
|
|
236
|
+
const bodyUrl = new URL(`${entry.name}.json`, _CORPUS_CHUNKS_BASE).href;
|
|
237
|
+
count += registerIndexEntry(entry, bodyUrl);
|
|
238
|
+
}
|
|
239
|
+
return count;
|
|
240
|
+
}
|
|
241
|
+
|
|
144
242
|
/**
|
|
145
243
|
* Load all annotated chunks into the in-memory map. Returns a stats
|
|
146
|
-
* snapshot. In Node, runs synchronously via `fs.readFileSync`; in the
|
|
147
|
-
* browser, dispatches the
|
|
244
|
+
* snapshot. In Node, runs synchronously via `fs.readFileSync`; in the Vite
|
|
245
|
+
* browser, dispatches the glob loaders in parallel; on a static deploy,
|
|
246
|
+
* fetches the index (bodies lazy-loaded on emit).
|
|
148
247
|
*/
|
|
149
248
|
export async function loadAll() {
|
|
150
249
|
compositions.clear();
|
|
151
|
-
|
|
250
|
+
let annotatedChunkCount;
|
|
251
|
+
if (IS_NODE) annotatedChunkCount = await _loadAllNode();
|
|
252
|
+
else if (HAS_VITE_GLOB) annotatedChunkCount = await _loadAllBrowser();
|
|
253
|
+
else annotatedChunkCount = await _loadAllStatic();
|
|
152
254
|
_autoLoaded = true;
|
|
153
255
|
_loadStats = {
|
|
154
256
|
compositionCount: compositions.size,
|
|
@@ -158,6 +260,40 @@ export async function loadAll() {
|
|
|
158
260
|
return _loadStats;
|
|
159
261
|
}
|
|
160
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Ensure a composition's `template` (body) is loaded before a consumer
|
|
265
|
+
* resolves it. No-op in Node / Vite (the eager load already populated
|
|
266
|
+
* `template`). On a static deploy, fetches `<name>.json` once, patches the
|
|
267
|
+
* in-memory record's `template`, and caches it for subsequent calls.
|
|
268
|
+
*
|
|
269
|
+
* The zettel adapter's STRONG-MATCH branch awaits this immediately before
|
|
270
|
+
* `getComposition(name)` so verbatim emission has a body. Index-only
|
|
271
|
+
* scoring + lazy single-body hydration is what keeps the static page-load
|
|
272
|
+
* to one request — `searchAll` itself never needs the template.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} name
|
|
275
|
+
* @returns {Promise<object|undefined>} the (now-hydrated) composition record
|
|
276
|
+
*/
|
|
277
|
+
export async function hydrateComposition(name) {
|
|
278
|
+
const rec = compositions.get(name);
|
|
279
|
+
if (!rec) return undefined;
|
|
280
|
+
// Already has a body (Node / Vite, or a prior hydration) — done.
|
|
281
|
+
if (Array.isArray(rec.template) && rec.template.length > 0) return rec;
|
|
282
|
+
if (!rec._bodyUrl) return rec; // not a static-deferred record; nothing to fetch
|
|
283
|
+
try {
|
|
284
|
+
const res = await fetch(rec._bodyUrl);
|
|
285
|
+
if (!res.ok) return rec;
|
|
286
|
+
const doc = await res.json();
|
|
287
|
+
if (Array.isArray(doc?.template) && doc.template.length > 0) {
|
|
288
|
+
rec.template = doc.template;
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
// Leave template null — resolveComposition handles an empty template
|
|
292
|
+
// (emits nothing) rather than throwing.
|
|
293
|
+
}
|
|
294
|
+
return rec;
|
|
295
|
+
}
|
|
296
|
+
|
|
161
297
|
// Eager top-level load so synchronous getters (getComposition,
|
|
162
298
|
// getAllCompositions, searchAll) work for consumers that don't call
|
|
163
299
|
// loadAll themselves — test harnesses, smoke scripts, and the
|
|
@@ -172,6 +308,10 @@ if (IS_NODE && !_autoLoaded) {
|
|
|
172
308
|
await loadAll();
|
|
173
309
|
} else if (!IS_NODE && !_autoLoaded) {
|
|
174
310
|
// Fire-and-forget; do not block module import on the network round-trips.
|
|
311
|
+
// Covers BOTH browser branches (Vite glob + static fetch). Consumers that
|
|
312
|
+
// need the map populated before a synchronous searchAll MUST `await
|
|
313
|
+
// loadAll()` (or await the adapter's own boot). The canvas does: its
|
|
314
|
+
// generateViaZettel awaits a fresh loadAll on the static-browser path.
|
|
175
315
|
loadAll().catch(() => { /* swallow; browsers that don't need this path won't trip */ });
|
|
176
316
|
}
|
|
177
317
|
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import {
|
|
31
31
|
getComposition,
|
|
32
|
+
hydrateComposition,
|
|
32
33
|
searchAll,
|
|
33
34
|
} from './composition-library.js';
|
|
34
35
|
import { resolveComposition, templateToMessages } from './composer.js';
|
|
@@ -121,8 +122,25 @@ export async function generateZettel({ intent, mode = 'instant', llmAdapter = nu
|
|
|
121
122
|
|
|
122
123
|
// ── Strong retrieval match: emit verbatim (turn 1 only — iteration branch above handles repeats) ──
|
|
123
124
|
if (strongMatch) {
|
|
125
|
+
// On a static deploy the composition was registered from `_index.json`
|
|
126
|
+
// for SCORING only — its `template` is fetched lazily here, right before
|
|
127
|
+
// we resolve it. No-op in Node / Vite (template already populated). This
|
|
128
|
+
// keeps the static page-load to a single index request; only the
|
|
129
|
+
// actually-emitted match pays for its body. 2026-06-10.
|
|
130
|
+
await hydrateComposition(composition.name);
|
|
124
131
|
const comp = getComposition(composition.name);
|
|
125
132
|
const template = resolveComposition(comp);
|
|
133
|
+
// Parity guard: Node's `registerChunk` excludes template-less chunks, so
|
|
134
|
+
// they never reach this branch server-side. The static index-scoring gate
|
|
135
|
+
// is looser (it can't see templates in `_index.json`), so a handful of
|
|
136
|
+
// body-less partials (auth-card-header, onb-step-*, …) COULD surface as a
|
|
137
|
+
// strong match on a static deploy. If hydration produced no usable
|
|
138
|
+
// template, treat it as a non-match and fall through — keeping the
|
|
139
|
+
// static result identical to what Node would emit (synthesis / atoms),
|
|
140
|
+
// never an empty verbatim surface.
|
|
141
|
+
if (!Array.isArray(template) || template.length === 0) {
|
|
142
|
+
// fall through to the weak/no-match paths below
|
|
143
|
+
} else {
|
|
126
144
|
const messages = toUpdateComponentsMessages(template);
|
|
127
145
|
const validation = validateSchema(messages, { intent });
|
|
128
146
|
const rank = hits.findIndex((h) => h.type === 'composition' && h.name === composition.name) + 1;
|
|
@@ -147,6 +165,7 @@ export async function generateZettel({ intent, mode = 'instant', llmAdapter = nu
|
|
|
147
165
|
candidates: hits,
|
|
148
166
|
sessionTurns: priorTurns.length + 1,
|
|
149
167
|
};
|
|
168
|
+
} // end else (template present)
|
|
150
169
|
}
|
|
151
170
|
|
|
152
171
|
// ── Weak/no retrieval: bridge to chunk-zettel synthesis (the §37 successor) ──
|