@adia-ai/a2ui-compose 0.0.1 → 0.1.0
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 -1
- package/README.md +5 -6
- package/engines/zettel/chunk-composer.js +182 -0
- package/engines/zettel/chunk-refiner.js +514 -0
- package/engines/zettel/chunk-synthesizer.js +235 -0
- package/engines/zettel/issue-reporter.js +380 -0
- package/engines/zettel/state-cache.js +153 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,7 +10,92 @@ generator graph.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## [0.1.0] - 2026-04-28
|
|
16
|
+
|
|
17
|
+
**Multi-turn refinement engine (Phase A).** Adds three new modules to the
|
|
18
|
+
zettel engine that turn `compose_from_chunks` from single-shot into a
|
|
19
|
+
multi-turn surface, plus a first-class telemetry surface that auto-fires
|
|
20
|
+
issue tickets on engine failure paths.
|
|
21
|
+
|
|
22
|
+
Spec: [`docs/specs/genui-multiturn-architecture.md`](../../../docs/specs/genui-multiturn-architecture.md) (Active v0.1.0).
|
|
23
|
+
Plan: [`docs/plans/genui-multiturn-rollout-2026-04-28.md`](../../../docs/plans/genui-multiturn-rollout-2026-04-28.md) (Phase A scoped).
|
|
24
|
+
ADR: [`0008-multiturn-genui-architecture.md`](../../../.brain/adrs/0008-multiturn-genui-architecture.md).
|
|
25
|
+
|
|
26
|
+
### Added (engine modules)
|
|
27
|
+
|
|
28
|
+
- **`engines/zettel/state-cache.js`** — bounded LRU cache for multi-turn
|
|
29
|
+
compositions (default 64 entries; configurable via
|
|
30
|
+
`A2UI_STATE_CACHE_SIZE`). API: `set / get / peek / has / evict / list /
|
|
31
|
+
size / clear`. State-id minting helpers `mintStateId(intent, version)`
|
|
32
|
+
and `mintNextStateId(parent, version)` produce stems of the form
|
|
33
|
+
`<intent-prefix>-<rand4>-v<N>-<unix-min>` so refinement chains keep a
|
|
34
|
+
visible lineage. Module-level singleton via `getStateCache() /
|
|
35
|
+
resetStateCache()`. Pure data structure; no I/O, no async.
|
|
36
|
+
- **`engines/zettel/issue-reporter.js`** — first-class telemetry surface.
|
|
37
|
+
`reportIssue(input, ctx)` writes immutable JSON to
|
|
38
|
+
`.brain/audit-history/issues/<issue_id>.json` (matches the existing
|
|
39
|
+
audit-ledger convention). `autoReport(reason, autoCtx, ctx)` looks up
|
|
40
|
+
a 6-row policy table for engine-side auto-fires (`synthesizer-exhausted`,
|
|
41
|
+
`validator-exhausted`, `locator-empty-targets`,
|
|
42
|
+
`retrieval-zero-then-synthesis-fail`, `cache-miss-on-known-state`,
|
|
43
|
+
`ops-failed-after-apply`) and threads severity / type / suggested-owner
|
|
44
|
+
defaults. `attachTrace(state_id, depth, cache)` pulls from the
|
|
45
|
+
state-cache via `peek` (no recency disturbance). Sidecar trace files
|
|
46
|
+
for traces > 200 KB. Three reporter kinds: `auto | user | llm`.
|
|
47
|
+
`evalMode: true` in ctx suppresses auto-fire (manual `reportIssue`
|
|
48
|
+
calls still write). Per-tool-call `IssueAccumulator` coalesces
|
|
49
|
+
multiple auto-fires into a single issue with the highest-severity
|
|
50
|
+
policy as primary; reasons listed in body + tags.
|
|
51
|
+
- **`engines/zettel/chunk-refiner.js`** — sibling to `chunk-synthesizer.js`.
|
|
52
|
+
Two-pass synthesis (locator → modifier) for refinement intents.
|
|
53
|
+
`refineFromIntent({ priorState, intent, llmAdapter, maxAttempts,
|
|
54
|
+
issueAccumulator, catalog })` runs a locator pass to identify which
|
|
55
|
+
slots to modify, then a modifier pass to emit chunk-plan ops on the
|
|
56
|
+
targeted slice. Validator-driven retry loop with feedback-on-error
|
|
57
|
+
(default `maxAttempts: 2`, mirrors `chunk-synthesizer`). `validateOps`
|
|
58
|
+
checks op shape, slot references, chunk references, page-kind
|
|
59
|
+
contracts. `applyOps({ priorState, ops })` mutates a deep-cloned
|
|
60
|
+
plan and re-materializes HTML via `composeFromPlan`. `opsToA2UI(ops,
|
|
61
|
+
newState)` translates chunk-plan ops to wire-format `updateComponents`
|
|
62
|
+
A2UI messages (Phase A simplification: `components[].html` carries
|
|
63
|
+
the materialized payload; Phase B will upgrade to component-tree shape).
|
|
64
|
+
|
|
65
|
+
### Phase A op format
|
|
66
|
+
|
|
67
|
+
Internal: `rebindSlot | appendToSlot | removeFromSlot | replacePage`.
|
|
68
|
+
Wire: standard `updateComponents` messages targeting `slot-<name>` ids
|
|
69
|
+
(or `main` for `replacePage`). Documented as a Phase A simplification —
|
|
70
|
+
the chunk pipeline emits HTML, so the wire-format wrapper is intentionally
|
|
71
|
+
thin until Phase B introduces typed component-tree refinements.
|
|
72
|
+
|
|
73
|
+
### Smoke + eval
|
|
74
|
+
|
|
75
|
+
- `npm run smoke:state-cache` — 34/34 (LRU recency, peek, env-driven sizing,
|
|
76
|
+
state-id minting + chain).
|
|
77
|
+
- `npm run smoke:issues` — 62/62 (write, validation, trace attach, sidecar
|
|
78
|
+
spill, auto-fire policy lookup, evalMode suppression, coalescing).
|
|
79
|
+
- `npm run smoke:refine` — 51/51 (validateOps, applyOps × 4 op types,
|
|
80
|
+
opsToA2UI, two-pass with stub LLM, retries, exhaustion auto-fire,
|
|
81
|
+
end-to-end refine → apply → wire).
|
|
82
|
+
- **`npm run eval:refine-synthesis`** — **15/15 PASS** real-LLM eval.
|
|
83
|
+
Ops rate 100%, validate rate 100%, 0 auto-fires, 67 s. All 15
|
|
84
|
+
refinements completed on the first attempt.
|
|
85
|
+
|
|
86
|
+
### Out of scope (Phase B / C)
|
|
87
|
+
|
|
88
|
+
- Persistent state across server restarts (SQLite-backed cache).
|
|
89
|
+
- Branching / version trees.
|
|
90
|
+
- LLM-streamed op emission.
|
|
91
|
+
- Multi-user concurrent refinement (CRDT / OT).
|
|
92
|
+
- A2UI `updateDataModel` + `wireComponents` synthesis (current scope is
|
|
93
|
+
`updateComponents` + `deleteSurface` only).
|
|
94
|
+
|
|
95
|
+
### Migration
|
|
96
|
+
|
|
97
|
+
Additive surface; no breaking changes. Existing consumers calling
|
|
98
|
+
`composeFromIntent` continue to work unchanged.
|
|
14
99
|
|
|
15
100
|
---
|
|
16
101
|
|
package/README.md
CHANGED
|
@@ -5,15 +5,14 @@ an A2UI component catalog and produces a tree of A2UI protocol messages
|
|
|
5
5
|
ready for a renderer.
|
|
6
6
|
|
|
7
7
|
> This package is pipeline runtime only. UI components live in
|
|
8
|
-
> [`@adia-ai/web-components`](
|
|
9
|
-
> registry, streams, wiring) in [`@adia-ai/a2ui-utils`](../
|
|
8
|
+
> [`@adia-ai/web-components`](../../web-components); the A2UI runtime (renderer,
|
|
9
|
+
> registry, streams, wiring) in [`@adia-ai/a2ui-utils`](../utils);
|
|
10
10
|
> the pattern corpus in [`@adia-ai/a2ui-corpus`](../corpus);
|
|
11
11
|
> the MCP server in [`@adia-ai/a2ui-mcp`](../mcp).
|
|
12
12
|
>
|
|
13
|
-
>
|
|
14
|
-
>
|
|
15
|
-
>
|
|
16
|
-
> published).
|
|
13
|
+
> Published to the public `@adia-ai` scope on 2026-04-24 alongside
|
|
14
|
+
> `a2ui-corpus`, `a2ui-mcp`, `a2ui-retrieval`, and `a2ui-validator`.
|
|
15
|
+
> Install with `npm i @adia-ai/a2ui-compose`.
|
|
17
16
|
|
|
18
17
|
## What it does
|
|
19
18
|
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chunk composer — materializes a chunk-binding plan into a single HTML
|
|
3
|
+
* document by walking a page chunk's HTML and substituting each slot
|
|
4
|
+
* region with the bound block-level chunks' HTML.
|
|
5
|
+
*
|
|
6
|
+
* Input plan shape:
|
|
7
|
+
* {
|
|
8
|
+
* page: "dashboard-admin-page",
|
|
9
|
+
* slot_bindings: {
|
|
10
|
+
* "page-header": "dashboard-page-header",
|
|
11
|
+
* "page-content": ["dashboard-overview-panel", "dashboard-funnel"]
|
|
12
|
+
* }
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* Output:
|
|
16
|
+
* {
|
|
17
|
+
* html: "<article>...</article>", // composed HTML
|
|
18
|
+
* plan: { ... }, // echoed back for traceability
|
|
19
|
+
* warnings: ["..."] // non-fatal issues
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* Plan: docs/plans/training-pipeline-chunk-harvest-2026-04-27.md (Phase C.2).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { getChunk } from '../../../corpus/scripts/chunk-library.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a chunk record's authoritative HTML. For multi-instance reusable
|
|
29
|
+
* slot chunks (auth-card-header, reg-step-header), pick the first instance.
|
|
30
|
+
*/
|
|
31
|
+
function chunkHtml(rec) {
|
|
32
|
+
if (!rec) return null;
|
|
33
|
+
if (rec.html) return rec.html;
|
|
34
|
+
if (Array.isArray(rec.instances) && rec.instances[0]?.html) return rec.instances[0].html;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Find an element with `data-chunk-slot="<name>"` inside the page HTML.
|
|
40
|
+
* Returns { tagName, openEnd, innerStart, innerEnd, outerEnd } or null.
|
|
41
|
+
*
|
|
42
|
+
* Mirrors the harvester's findElementSpan logic — depth-tracked walk for
|
|
43
|
+
* the matching close tag.
|
|
44
|
+
*/
|
|
45
|
+
function findSlotInHtml(html, slotName) {
|
|
46
|
+
const re = new RegExp(`<([a-zA-Z][a-zA-Z0-9-]*)([^>]*?\\bdata-chunk-slot\\s*=\\s*"${slotName}"[^>]*?)>`, 'i');
|
|
47
|
+
const m = html.match(re);
|
|
48
|
+
if (!m) return null;
|
|
49
|
+
const idx = m.index;
|
|
50
|
+
const tagName = m[1];
|
|
51
|
+
const openEnd = html.indexOf('>', idx);
|
|
52
|
+
const lower = tagName.toLowerCase();
|
|
53
|
+
const openRe = new RegExp(`<${lower}\\b[^>]*?>`, 'gi');
|
|
54
|
+
const closeRe = new RegExp(`</${lower}\\s*>`, 'gi');
|
|
55
|
+
let depth = 1;
|
|
56
|
+
let cursor = openEnd + 1;
|
|
57
|
+
while (cursor < html.length && depth > 0) {
|
|
58
|
+
openRe.lastIndex = cursor;
|
|
59
|
+
closeRe.lastIndex = cursor;
|
|
60
|
+
const o = openRe.exec(html);
|
|
61
|
+
const c = closeRe.exec(html);
|
|
62
|
+
if (!c) return null;
|
|
63
|
+
if (o && o.index < c.index) {
|
|
64
|
+
if (!o[0].endsWith('/>')) depth++;
|
|
65
|
+
cursor = o.index + o[0].length;
|
|
66
|
+
} else {
|
|
67
|
+
depth--;
|
|
68
|
+
if (depth === 0) {
|
|
69
|
+
return {
|
|
70
|
+
tagName,
|
|
71
|
+
openTag: html.slice(idx, openEnd + 1),
|
|
72
|
+
outerStart: idx,
|
|
73
|
+
outerEnd: c.index + c[0].length,
|
|
74
|
+
innerStart: openEnd + 1,
|
|
75
|
+
innerEnd: c.index,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
cursor = c.index + c[0].length;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Materialize a binding plan into composed HTML.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} plan
|
|
88
|
+
* @param {string} plan.page — name of the page-kind chunk
|
|
89
|
+
* @param {Object<string, string|string[]>} plan.slot_bindings — slot name → bound chunk name(s)
|
|
90
|
+
* @returns {{ html: string|null, plan: object, warnings: string[] }}
|
|
91
|
+
*/
|
|
92
|
+
export function composeFromPlan(plan) {
|
|
93
|
+
const warnings = [];
|
|
94
|
+
if (!plan || typeof plan !== 'object') {
|
|
95
|
+
return { html: null, plan, warnings: ['plan must be an object with { page, slot_bindings }'] };
|
|
96
|
+
}
|
|
97
|
+
const pageRec = getChunk(plan.page);
|
|
98
|
+
if (!pageRec) {
|
|
99
|
+
return { html: null, plan, warnings: [`page chunk not found: ${plan.page}`] };
|
|
100
|
+
}
|
|
101
|
+
if (pageRec.kind !== 'page' && pageRec.kind !== 'panel') {
|
|
102
|
+
warnings.push(`page chunk "${plan.page}" has kind="${pageRec.kind}" — expected "page" or "panel"`);
|
|
103
|
+
}
|
|
104
|
+
let html = chunkHtml(pageRec);
|
|
105
|
+
if (!html) {
|
|
106
|
+
return { html: null, plan, warnings: [...warnings, `page chunk "${plan.page}" has no HTML`] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const bindings = plan.slot_bindings || {};
|
|
110
|
+
for (const [slotName, bound] of Object.entries(bindings)) {
|
|
111
|
+
const slot = findSlotInHtml(html, slotName);
|
|
112
|
+
if (!slot) {
|
|
113
|
+
warnings.push(`slot "${slotName}" not found in page "${plan.page}"`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const names = Array.isArray(bound) ? bound : [bound];
|
|
117
|
+
const parts = [];
|
|
118
|
+
for (const childName of names) {
|
|
119
|
+
const childRec = getChunk(childName);
|
|
120
|
+
if (!childRec) {
|
|
121
|
+
warnings.push(`bound chunk "${childName}" (slot "${slotName}") not found`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const childHtml = chunkHtml(childRec);
|
|
125
|
+
if (!childHtml) {
|
|
126
|
+
warnings.push(`bound chunk "${childName}" has no HTML`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
parts.push(childHtml);
|
|
130
|
+
}
|
|
131
|
+
// Replace slot inner with bound children (preserves the slot's own outer
|
|
132
|
+
// wrapper so the page's grid/flex layout still applies).
|
|
133
|
+
const before = html.slice(0, slot.innerStart);
|
|
134
|
+
const after = html.slice(slot.innerEnd);
|
|
135
|
+
html = before + '\n' + parts.join('\n') + '\n' + after;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { html, plan, warnings };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Validate a binding plan WITHOUT materializing — used by the synthesizer to
|
|
143
|
+
* give the LLM feedback before round-tripping HTML composition.
|
|
144
|
+
*/
|
|
145
|
+
export function validatePlan(plan) {
|
|
146
|
+
const errors = [];
|
|
147
|
+
if (!plan || typeof plan !== 'object') {
|
|
148
|
+
errors.push('plan must be an object');
|
|
149
|
+
return { ok: false, errors };
|
|
150
|
+
}
|
|
151
|
+
if (!plan.page || typeof plan.page !== 'string') {
|
|
152
|
+
errors.push('plan.page must be a chunk name string');
|
|
153
|
+
}
|
|
154
|
+
const pageRec = plan.page ? getChunk(plan.page) : null;
|
|
155
|
+
if (plan.page && !pageRec) errors.push(`plan.page chunk "${plan.page}" not found`);
|
|
156
|
+
if (pageRec && pageRec.kind !== 'page' && pageRec.kind !== 'panel') {
|
|
157
|
+
errors.push(`plan.page "${plan.page}" must be kind "page" or "panel"; got "${pageRec.kind}"`);
|
|
158
|
+
}
|
|
159
|
+
const bindings = plan.slot_bindings || {};
|
|
160
|
+
if (typeof bindings !== 'object') {
|
|
161
|
+
errors.push('plan.slot_bindings must be an object');
|
|
162
|
+
return { ok: errors.length === 0, errors };
|
|
163
|
+
}
|
|
164
|
+
// Validate slot names exist on the page
|
|
165
|
+
if (pageRec) {
|
|
166
|
+
const declaredSlots = new Set((pageRec.slots || []).map((s) => s.name));
|
|
167
|
+
for (const slotName of Object.keys(bindings)) {
|
|
168
|
+
if (!declaredSlots.has(slotName)) {
|
|
169
|
+
errors.push(`slot "${slotName}" not declared on page "${plan.page}"`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Validate bound chunk names exist
|
|
174
|
+
for (const [slot, bound] of Object.entries(bindings)) {
|
|
175
|
+
const names = Array.isArray(bound) ? bound : [bound];
|
|
176
|
+
for (const n of names) {
|
|
177
|
+
const rec = getChunk(n);
|
|
178
|
+
if (!rec) errors.push(`bound chunk "${n}" (slot "${slot}") not found`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { ok: errors.length === 0, errors };
|
|
182
|
+
}
|