@adia-ai/a2ui-compose 0.3.2 → 0.3.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 CHANGED
@@ -12,6 +12,84 @@ generator graph.
12
12
 
13
13
  _No pending changes._
14
14
 
15
+ ## [0.3.4] - 2026-05-07
16
+
17
+ ### Ride-along (no source changes)
18
+
19
+ Lockstep version bump only — source byte-identical to v0.3.3. Internal `@adia-ai/*` dep ranges remain at `^0.3.0`. See root [CHANGELOG.md `## [0.3.4]`](../../../CHANGELOG.md) for the cut narrative.
20
+
21
+ ## [0.3.3] - 2026-05-07
22
+
23
+ **Lockstep cut.** All 9 published `@adia-ai/*` packages now share version `0.3.3`, governed by [`docs/specs/package-architecture.md` § 15](../../../docs/specs/package-architecture.md#15-versioning-policy). Internal `@adia-ai/*` ranges stay at `^0.3.0` (patch-cut asymmetry — caret floats `0.3.x`).
24
+
25
+ ### Added
26
+
27
+ - **`iteration-synthesis-failure` auto-fire policy reason** in
28
+ `strategies/zettel/issue-reporter.js`. Generator-adapter's
29
+ iteration synthesis failures now route through
30
+ `autoReport('iteration-synthesis-failure', ...)` instead of bare
31
+ `console.error`. Suppressed in eval mode; emits to
32
+ `.brain/audit-history/issues/` in production. (closes backlog #49)
33
+
34
+ - **`ID_PREFIX_SEPARATOR` exported constant** in
35
+ `strategies/zettel/composer.js` — the magic string `'--'` used
36
+ when prefixing fragment internal node ids with composition node
37
+ id. Documented choice rationale (double-dash chosen because single
38
+ `-` collides with kebab-case ids in primitives). (closes backlog #50)
39
+
40
+ - **`computeScopeDrift` + `SCOPE_DRIFT_RATIO` + `SCOPE_DRIFT_MIN_ACTUAL`
41
+ exports** in `strategies/zettel/chunk-synthesizer.js`. Internal
42
+ function + constants are now exported for testability. New
43
+ `chunk-synthesizer.test.js` (8/8 pass) covers no-drift,
44
+ ratio-below-gate, drift-fires, floor-prevents-false-positive,
45
+ malformed-corpus zero-expected guard, multi-chunk aggregation,
46
+ instances[]-shape support, exports. (closes backlog #52)
47
+
48
+ - **`A2UI_COMPOSE_TRACE` env-var support** in `core/generator.js`.
49
+ When set to a directory path, writes a per-request JSON trace
50
+ (full `_debug` payload + result) per `generateUI()` call, named
51
+ `<ISO-timestamp>--<intent-slug>.json`. `dialog-recorder.isRecording()`
52
+ honors the trace var so `_debug` payload is populated. New
53
+ `npm run compose:trace` script. (closes backlog #89)
54
+
55
+ ### Changed
56
+
57
+ - **Cache-invalidation contract** documented inline in
58
+ `strategies/zettel/generator-adapter.js`. `loadAll()` itself is
59
+ idempotent (`fragments.clear()` + `compositions.clear()` + re-walk
60
+ on every call). The CACHING happens at the call-site:
61
+ `ensureBooted()` sets a process-singleton flag and never reloads
62
+ for the lifetime of the process. Trade-offs: good for long-running
63
+ MCP, bad for tests/hot-reload. To force a reload, call `loadAll()`
64
+ directly. (closes backlog #100)
65
+
66
+ ## [0.3.2] - 2026-05-06
67
+
68
+ **9-package lockstep patch cut to v0.3.2.** All lockstep members share
69
+ one version per [`docs/specs/package-architecture.md` § 15](../../../docs/specs/package-architecture.md#15-versioning-policy).
70
+ Internal `@adia-ai/*` dep ranges unchanged at `^0.3.0`.
71
+
72
+ ### Added
73
+
74
+ - **`chunk-zettel` engine** — registered as 5th built-in engine in
75
+ `strategies/registry.js`. Chunk-aware composition from training-chunk
76
+ corpus (page shells + block chunks). Fast path: sync keyword search
77
+ over async embeddings to prevent ranking drift.
78
+
79
+ ### Fixed
80
+
81
+ - **Keyword-first fast path** — `composeFromIntent` prefers sync
82
+ `searchChunks` over `searchChunksAsync` (embeddings) for retrieval.
83
+ - **Whole-word keyword matching** — `keywordScore()` splits chunk names
84
+ on `[-_]` and uses `nameWords.includes(tok)`; prevents `"pane"` from
85
+ matching `"panel"`.
86
+ - **Coverage scoring** — PascalCase → kebab-case regex fix for
87
+ multi-word components (`AgentTrace` → `agent-trace-ui`).
88
+
89
+ ### Changed
90
+
91
+ - `version`: `0.3.1` → `0.3.2`.
92
+
15
93
  ## [0.3.1] - 2026-05-06
16
94
 
17
95
  **9-package lockstep patch cut.** All 9 published `@adia-ai/*` packages bump 0.3.0 → 0.3.1 per [`docs/specs/package-architecture.md` § 15](../../../docs/specs/package-architecture.md#15-versioning-policy). Internal `@adia-ai/*` dep ranges remain at `^0.3.0` (covers `0.3.1` under semver — patch-cut asymmetry).
package/core/generator.js CHANGED
@@ -185,6 +185,37 @@ export async function generateUI({ intent, engine: engineName = 'monolithic', mo
185
185
  // Strip the _debug payload before returning — it's an internal collaboration
186
186
  // channel between engines and the recorder, not part of the public API.
187
187
  // Without this strip the proxy would echo a 12KB+ system prompt to clients.
188
+ //
189
+ // Compose-trace flag: A2UI_COMPOSE_TRACE=<dir> writes the full debug payload
190
+ // (system prompt, raw LLM response, retrieval log, strategy decisions) to
191
+ // a per-request JSON file BEFORE the strip. Useful for debugging
192
+ // strategy-label decisions or reproducing eval failures. Off by default;
193
+ // file writes happen synchronously and add ~1-5ms per request.
194
+ const traceDir = process.env.A2UI_COMPOSE_TRACE;
195
+ if (traceDir && result._debug) {
196
+ try {
197
+ const { writeFile, mkdir } = await import('node:fs/promises');
198
+ const { join: pathJoin } = await import('node:path');
199
+ await mkdir(traceDir, { recursive: true });
200
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
201
+ const safeIntent = String(intent || 'unknown').slice(0, 40).replace(/[^a-zA-Z0-9_-]/g, '_');
202
+ const tracePath = pathJoin(traceDir, `${ts}-${engineName}-${safeIntent}.json`);
203
+ await writeFile(tracePath, JSON.stringify({
204
+ timestamp: ts,
205
+ intent,
206
+ engine: engineName,
207
+ mode: effectiveMode,
208
+ executionId: result.executionId,
209
+ strategy: result.strategy || null,
210
+ validation: result.validation || null,
211
+ messageCount: result.messages?.length || 0,
212
+ debug: result._debug,
213
+ }, null, 2));
214
+ } catch (err) {
215
+ // Tracing is diagnostic — never let it break the request path.
216
+ console.error('[compose:trace] failed to write trace:', err.message);
217
+ }
218
+ }
188
219
  if (result._debug) delete result._debug;
189
220
  return result;
190
221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-compose",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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": {
@@ -39,4 +39,4 @@
39
39
  "@adia-ai/a2ui-validator": "^0.3.0",
40
40
  "@adia-ai/llm": "^0.3.0"
41
41
  }
42
- }
42
+ }
@@ -35,8 +35,8 @@ const DEFAULT_MAX_ATTEMPTS = 2;
35
35
  // chunks' component counts. A multiplier > SCOPE_DRIFT_RATIO trips a warning
36
36
  // + auto-fires a `scope-drift` issue. Floor prevents false positives on
37
37
  // small UIs where slot-wrapper noise dominates.
38
- const SCOPE_DRIFT_RATIO = 1.5;
39
- const SCOPE_DRIFT_MIN_ACTUAL = 20;
38
+ export const SCOPE_DRIFT_RATIO = 1.5;
39
+ export const SCOPE_DRIFT_MIN_ACTUAL = 20;
40
40
 
41
41
  const SYSTEM_PROMPT = `You compose web-app pages by binding training chunks into named slots.
42
42
 
@@ -397,7 +397,7 @@ function countComponents(html) {
397
397
  * Returns { actual, expected, ratio, drift } where `drift` is true when
398
398
  * actual exceeds SCOPE_DRIFT_RATIO × expected AND actual ≥ SCOPE_DRIFT_MIN_ACTUAL.
399
399
  */
400
- function computeScopeDrift(html, boundChunks) {
400
+ export function computeScopeDrift(html, boundChunks) {
401
401
  const actual = countComponents(html);
402
402
  let expected = 0;
403
403
  for (const c of boundChunks) {
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ computeScopeDrift,
4
+ SCOPE_DRIFT_RATIO,
5
+ SCOPE_DRIFT_MIN_ACTUAL,
6
+ } from './chunk-synthesizer.js';
7
+
8
+ // countComponents() in chunk-synthesizer counts elements that look like A2UI
9
+ // component instances. The exact regex matters for these tests; we synthesize
10
+ // HTML that's representative of real composed output.
11
+ function gridOfCards(n) {
12
+ const cards = Array.from({ length: n }, (_, i) =>
13
+ `<card-ui id="c${i}"><header-ui slot="heading">Card ${i}</header-ui><text-ui>body</text-ui></card-ui>`
14
+ ).join('');
15
+ return `<grid-ui columns="3">${cards}</grid-ui>`;
16
+ }
17
+
18
+ describe('SCOPE_DRIFT_RATIO + computeScopeDrift', () => {
19
+ it('exports the constants', () => {
20
+ expect(SCOPE_DRIFT_RATIO).toBe(1.5);
21
+ expect(SCOPE_DRIFT_MIN_ACTUAL).toBe(20);
22
+ });
23
+
24
+ it('reports no drift when actual ≤ expected', () => {
25
+ // 25 cards composed from a chunk with 25 cards
26
+ const html = gridOfCards(25);
27
+ const chunk = { html: gridOfCards(25) };
28
+ const drift = computeScopeDrift(html, [chunk]);
29
+ expect(drift.actual).toBeGreaterThanOrEqual(SCOPE_DRIFT_MIN_ACTUAL);
30
+ expect(drift.expected).toBeGreaterThanOrEqual(SCOPE_DRIFT_MIN_ACTUAL);
31
+ expect(drift.ratio).toBeLessThanOrEqual(1);
32
+ expect(drift.drift).toBe(false);
33
+ });
34
+
35
+ it('reports no drift when ratio is below the gate (1.0 < ratio ≤ 1.5)', () => {
36
+ // 30 cards composed from 25 — ratio 1.2× below the 1.5× gate
37
+ const html = gridOfCards(30);
38
+ const chunk = { html: gridOfCards(25) };
39
+ const drift = computeScopeDrift(html, [chunk]);
40
+ expect(drift.ratio).toBeGreaterThan(1.0);
41
+ expect(drift.ratio).toBeLessThanOrEqual(SCOPE_DRIFT_RATIO);
42
+ expect(drift.drift).toBe(false);
43
+ });
44
+
45
+ it('reports drift when ratio exceeds the gate (ratio > 1.5)', () => {
46
+ // 50 cards composed from 25 — ratio 2.0× exceeds the 1.5× gate
47
+ const html = gridOfCards(50);
48
+ const chunk = { html: gridOfCards(25) };
49
+ const drift = computeScopeDrift(html, [chunk]);
50
+ expect(drift.actual).toBeGreaterThanOrEqual(SCOPE_DRIFT_MIN_ACTUAL);
51
+ expect(drift.ratio).toBeGreaterThan(SCOPE_DRIFT_RATIO);
52
+ expect(drift.drift).toBe(true);
53
+ });
54
+
55
+ it('does not trip the drift floor on small UIs (actual < SCOPE_DRIFT_MIN_ACTUAL)', () => {
56
+ // Tiny html (5 tags) vs even-tinier chunk (1 tag) — ratio 5× far above
57
+ // gate, but actual=5 < 20 floor
58
+ const html = '<div><span></span><span></span><span></span><span></span></div>';
59
+ const chunk = { html: '<div></div>' };
60
+ const drift = computeScopeDrift(html, [chunk]);
61
+ expect(drift.actual).toBeLessThan(SCOPE_DRIFT_MIN_ACTUAL);
62
+ expect(drift.ratio).toBeGreaterThan(SCOPE_DRIFT_RATIO);
63
+ expect(drift.drift).toBe(false); // floor prevents false positive on tiny UIs
64
+ });
65
+
66
+ it('handles the malformed-corpus case (zero-expected, non-zero actual)', () => {
67
+ // Bound chunk has no html field at all
68
+ const html = gridOfCards(50);
69
+ const chunk = { /* no html */ };
70
+ const drift = computeScopeDrift(html, [chunk]);
71
+ expect(drift.expected).toBe(0);
72
+ expect(drift.ratio).toBe(null); // guard returns null instead of Infinity
73
+ expect(drift.drift).toBe(false); // gate is skipped, not failed
74
+ });
75
+
76
+ it('aggregates expected across multiple bound chunks', () => {
77
+ // 30 actual; bound chunks 10 + 12 = 22 expected; ratio 30/22 ≈ 1.36 < 1.5
78
+ const html = gridOfCards(30);
79
+ const chunks = [
80
+ { html: gridOfCards(10) },
81
+ { html: gridOfCards(12) },
82
+ ];
83
+ const drift = computeScopeDrift(html, chunks);
84
+ expect(drift.expected).toBeGreaterThanOrEqual(22);
85
+ expect(drift.ratio).toBeLessThanOrEqual(SCOPE_DRIFT_RATIO);
86
+ expect(drift.drift).toBe(false);
87
+ });
88
+
89
+ it('reads chunk html from instances[0] when top-level html is absent', () => {
90
+ // Multi-instance chunk format: { name, instances: [{ html, ... }, ...] }
91
+ const html = gridOfCards(25);
92
+ const chunk = { name: 'multi', instances: [{ html: gridOfCards(25) }] };
93
+ const drift = computeScopeDrift(html, [chunk]);
94
+ expect(drift.expected).toBeGreaterThanOrEqual(SCOPE_DRIFT_MIN_ACTUAL);
95
+ expect(drift.drift).toBe(false);
96
+ });
97
+ });
@@ -18,13 +18,30 @@
18
18
 
19
19
  import { getFragment } from './fragment-library.js';
20
20
 
21
+ /**
22
+ * Separator used when prefixing a fragment's internal node ids with the
23
+ * composition node id. Format: `{compNode}{ID_PREFIX_SEPARATOR}{fragNode}`.
24
+ *
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.
30
+ *
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
+
21
38
  function cloneFragmentWithPrefix(fragment, prefix) {
22
39
  const idMap = new Map();
23
40
  const cloned = fragment.template.map((n) => ({ ...n }));
24
41
 
25
42
  // Generate new ids
26
43
  for (const node of cloned) {
27
- const newId = `${prefix}--${node.id}`;
44
+ const newId = `${prefix}${ID_PREFIX_SEPARATOR}${node.id}`;
28
45
  idMap.set(node.id, newId);
29
46
  }
30
47
  // Rewrite ids and children refs
@@ -30,6 +30,7 @@ import {
30
30
  getTurns,
31
31
  buildHistorySummary,
32
32
  } from './session-store.js';
33
+ import { autoReport } from './issue-reporter.js';
33
34
  import { validateSchema } from '../../../validator/validator.js';
34
35
 
35
36
  let booted = false;
@@ -40,6 +41,18 @@ function ensureBooted() {
40
41
  }
41
42
  }
42
43
 
44
+ // NOTE on cache invalidation: `loadAll()` itself reloads fragments + compositions
45
+ // from disk every time it's called — `fragments.clear()` + `compositions.clear()`
46
+ // at the top, then re-walk. The CACHING happens here at the call-site: we set
47
+ // `booted = true` after the first load and never reload for the lifetime of
48
+ // this process. Trade-offs:
49
+ // - GOOD for long-running MCP: zero corpus-load cost on subsequent requests.
50
+ // - BAD for tests / hot-reload: a fresh fragment file isn't visible until
51
+ // the process restarts.
52
+ // To force a reload (e.g. in a test that just wrote a new fragment file),
53
+ // call `loadAll()` directly — it's idempotent. The `booted` flag here is a
54
+ // process-singleton optimization, not a correctness invariant.
55
+
43
56
  // Retrieval score threshold — above this we trust the match and emit verbatim;
44
57
  // below, fall through to LLM synthesis (creative composition from fragments).
45
58
  // Calibrated on the 100-intent held-out set:
@@ -105,8 +118,25 @@ export async function generateZettel({ intent, mode = 'instant', llmAdapter = nu
105
118
  };
106
119
  } catch (err) {
107
120
  // If iteration synthesis fails, fall through to the normal path. Record
108
- // the failure so the next turn can see we tried.
121
+ // the failure so the next turn can see we tried, and auto-fire an issue
122
+ // ticket so synthesis-owners can investigate the failure post-hoc.
109
123
  console.error('[zettel] iteration synthesis failed:', err.message);
124
+ try {
125
+ await autoReport(
126
+ 'iteration-synthesis-failure',
127
+ {
128
+ intent,
129
+ turn: priorTurns.length + 1,
130
+ state_id: sessionId,
131
+ body: `Auto-fired by generator-adapter. Iteration synthesis threw on turn ${priorTurns.length + 1}.\n\nError: \`${err.message}\``,
132
+ tags: ['generator-adapter', `turn-${priorTurns.length + 1}`],
133
+ },
134
+ { evalMode: mode === 'eval' }
135
+ );
136
+ } catch (reportErr) {
137
+ // Never let issue-reporting crash the request path.
138
+ console.error('[zettel] autoReport failed:', reportErr.message);
139
+ }
110
140
  }
111
141
  }
112
142
 
@@ -85,6 +85,16 @@ export const AUTO_FIRE_POLICY = {
85
85
  return `Scope drift${ratio ? ' ' + ratio : ''}: composed HTML exceeds bound-chunk envelope${intent}`;
86
86
  },
87
87
  },
88
+ 'iteration-synthesis-failure': {
89
+ type: 'bug',
90
+ severity: 'drift',
91
+ suggested_owner: 'synthesis',
92
+ titleFor: (ctx) => {
93
+ const turn = ctx?.turn != null ? ` on turn ${ctx.turn}` : '';
94
+ const intent = ctx?.intent ? ` for "${truncate(ctx.intent, 40)}"` : '';
95
+ return `Iteration synthesis failed${turn}${intent}`;
96
+ },
97
+ },
88
98
  };
89
99
 
90
100
  function truncate(s, n = 60) {