@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 CHANGED
@@ -10,7 +10,92 @@ generator graph.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
- _Nothing yet._
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`](../web-components); the A2UI runtime (renderer,
9
- > registry, streams, wiring) in [`@adia-ai/a2ui-utils`](../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
- > Note: this package still lives under the legacy `@adiahealth` scope in its
14
- > own `package.json` name. The `@adia-ai` scope is the public npm scope
15
- > (only `@adia-ai/web-components` and `@adia-ai/a2ui-utils` are currently
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
+ }