@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
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chunk-aware composition synthesizer.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the existing `synthesizer.js` (fragment-graph) but operates on the
|
|
5
|
+
* gen-UI training-chunk corpus instead of the fragment catalog. When pure
|
|
6
|
+
* retrieval can't surface a strong match for an intent, this asks the LLM to
|
|
7
|
+
* mix-and-match: pick a page-kind chunk, then bind block/panel chunks to its
|
|
8
|
+
* slots. Validator checks slot names + chunk-kind contracts before composing.
|
|
9
|
+
*
|
|
10
|
+
* Two-tier behavior:
|
|
11
|
+
* 1. Retrieval first — searchChunks returns a strong match (score > threshold)
|
|
12
|
+
* → return that chunk directly. Fast path; no LLM call.
|
|
13
|
+
* 2. Synthesis fallback — retrieval is weak → invoke the LLM to compose
|
|
14
|
+
* from the catalog. This is the creative mix-and-match path.
|
|
15
|
+
*
|
|
16
|
+
* Token-budget mitigation: pre-search filters the catalog to ~30 candidates
|
|
17
|
+
* before the LLM sees them. Mirrors the fragment-synthesizer technique.
|
|
18
|
+
*
|
|
19
|
+
* Plan: docs/plans/training-pipeline-chunk-harvest-2026-04-27.md (Phase C.2).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
getChunk,
|
|
24
|
+
searchChunks,
|
|
25
|
+
searchChunksAsync,
|
|
26
|
+
listChunksByKind,
|
|
27
|
+
getAllChunks,
|
|
28
|
+
} from '../../../corpus/scripts/chunk-library.js';
|
|
29
|
+
import { composeFromPlan, validatePlan } from './chunk-composer.js';
|
|
30
|
+
|
|
31
|
+
const STRONG_RETRIEVAL_SCORE = 8; // search-score threshold for fast path
|
|
32
|
+
const PRE_SEARCH_LIMIT = 30; // chunks shown to the LLM in the prompt
|
|
33
|
+
const DEFAULT_MAX_ATTEMPTS = 2;
|
|
34
|
+
|
|
35
|
+
const SYSTEM_PROMPT = `You compose web-app pages by binding training chunks into named slots.
|
|
36
|
+
|
|
37
|
+
You are given:
|
|
38
|
+
- A user intent (what they want to build).
|
|
39
|
+
- A catalog of available chunks. Each chunk is a discrete UI sample harvested
|
|
40
|
+
from a real page. Chunks have:
|
|
41
|
+
- name (stable ID)
|
|
42
|
+
- kind ("page", "panel", or "block")
|
|
43
|
+
- primary (the chunk's outermost element, e.g. "card-ui", "grid-ui")
|
|
44
|
+
- slots (only on page/panel chunks — named regions where blocks plug in)
|
|
45
|
+
- 1-3 example bindings as in-context anchors.
|
|
46
|
+
|
|
47
|
+
Return ONLY a JSON object shaped exactly like:
|
|
48
|
+
{
|
|
49
|
+
"page": "<name of a page-kind chunk to use as the layout shell>",
|
|
50
|
+
"slot_bindings": {
|
|
51
|
+
"<slot-name>": "<bound chunk name>" // single chunk
|
|
52
|
+
OR
|
|
53
|
+
"<slot-name>": ["<chunk1>", "<chunk2>"] // ordered list of chunks
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
Rules:
|
|
58
|
+
- The "page" must be a chunk with kind="page" or kind="panel".
|
|
59
|
+
- Every slot you bind must be declared by the page chunk (do not invent slots).
|
|
60
|
+
- Bound chunks should be kind="block" or kind="panel" — pages can't nest inside
|
|
61
|
+
pages.
|
|
62
|
+
- Return valid JSON. No prose, no comments, no markdown fences. Begin with "{".
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
function buildCatalogSummary(chunks) {
|
|
66
|
+
return chunks.map((c) => ({
|
|
67
|
+
name: c.name,
|
|
68
|
+
kind: c.kind,
|
|
69
|
+
primary: c.primary || c.instances?.[0]?.primary,
|
|
70
|
+
slots: (c.slots || c.instances?.[0]?.slots || []).map((s) => s.name),
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildExamples() {
|
|
75
|
+
// Hard-coded canonical example: "build me an admin dashboard". This anchors
|
|
76
|
+
// the LLM on the expected output shape.
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
intent: 'admin dashboard with KPIs and a conversion funnel',
|
|
80
|
+
output: {
|
|
81
|
+
page: 'dashboard-admin-page',
|
|
82
|
+
slot_bindings: {
|
|
83
|
+
'page-header': 'dashboard-page-header',
|
|
84
|
+
'page-content': ['dashboard-overview-panel', 'dashboard-funnel'],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildUserPrompt(intent, catalog, examples) {
|
|
92
|
+
const lines = [];
|
|
93
|
+
lines.push(`Intent: ${intent}`);
|
|
94
|
+
lines.push('');
|
|
95
|
+
lines.push('## Available chunks');
|
|
96
|
+
lines.push('');
|
|
97
|
+
for (const c of catalog) {
|
|
98
|
+
const slots = c.slots?.length ? ` slots=[${c.slots.join(', ')}]` : '';
|
|
99
|
+
lines.push(`- ${c.name} (kind=${c.kind}, primary=${c.primary})${slots}`);
|
|
100
|
+
}
|
|
101
|
+
lines.push('');
|
|
102
|
+
lines.push('## Example bindings');
|
|
103
|
+
lines.push('');
|
|
104
|
+
for (const ex of examples) {
|
|
105
|
+
lines.push(`Intent: ${ex.intent}`);
|
|
106
|
+
lines.push('Output:');
|
|
107
|
+
lines.push(JSON.stringify(ex.output, null, 2));
|
|
108
|
+
lines.push('');
|
|
109
|
+
}
|
|
110
|
+
lines.push('## Your turn');
|
|
111
|
+
lines.push('');
|
|
112
|
+
lines.push('Return JSON binding for the intent above. JSON only.');
|
|
113
|
+
return lines.join('\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function extractJSON(raw) {
|
|
117
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
118
|
+
const trimmed = raw.trim();
|
|
119
|
+
// Strip markdown fences if present
|
|
120
|
+
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
121
|
+
const candidate = fenced ? fenced[1].trim() : trimmed;
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(candidate);
|
|
124
|
+
} catch {
|
|
125
|
+
// Find the first { ... } balanced object
|
|
126
|
+
const m = candidate.match(/\{[\s\S]*\}/);
|
|
127
|
+
if (!m) return null;
|
|
128
|
+
try { return JSON.parse(m[0]); } catch { return null; }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Try retrieval-first; fall back to synthesis if retrieval is weak or missing.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} opts
|
|
136
|
+
* @param {string} opts.intent
|
|
137
|
+
* @param {object} opts.llmAdapter — { complete: async ({ messages, systemPrompt }) => { content|text } }
|
|
138
|
+
* @param {number} [opts.maxAttempts=2]
|
|
139
|
+
* @returns {Promise<{ html: string, plan: object|null, source: 'retrieval'|'synthesis', score?: number, warnings: string[], synthesis?: object }>}
|
|
140
|
+
*/
|
|
141
|
+
export async function composeFromIntent({ intent, llmAdapter, maxAttempts = DEFAULT_MAX_ATTEMPTS }) {
|
|
142
|
+
// Tier 1 — retrieval. Try semantic-blended hit first; fall back to keyword
|
|
143
|
+
// when embeddings are unavailable (no chunk-embeddings.json or no API key).
|
|
144
|
+
const hits = await searchChunksAsync(intent, { limit: 5 });
|
|
145
|
+
if (hits.length > 0 && hits[0].score >= STRONG_RETRIEVAL_SCORE) {
|
|
146
|
+
const top = getChunk(hits[0].name);
|
|
147
|
+
const html = top.html || top.instances?.[0]?.html || '';
|
|
148
|
+
return {
|
|
149
|
+
html,
|
|
150
|
+
plan: null,
|
|
151
|
+
source: 'retrieval',
|
|
152
|
+
score: hits[0].score,
|
|
153
|
+
cosineScore: hits[0].cosineScore,
|
|
154
|
+
warnings: [],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Tier 2 — synthesis. Need an LLM adapter.
|
|
159
|
+
if (!llmAdapter?.complete) {
|
|
160
|
+
return {
|
|
161
|
+
html: null,
|
|
162
|
+
plan: null,
|
|
163
|
+
source: 'synthesis',
|
|
164
|
+
warnings: ['retrieval was weak and no llmAdapter provided — cannot synthesize'],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Pre-search the catalog to keep the prompt token-budget bounded.
|
|
169
|
+
// Strategy: retrieve top N by semantic-blended search; ALWAYS include all
|
|
170
|
+
// page-kind chunks (small set, the LLM needs to see what shells are
|
|
171
|
+
// available). Block pre-search uses embeddings when available.
|
|
172
|
+
const pageChunks = listChunksByKind('page');
|
|
173
|
+
const panelChunks = listChunksByKind('panel');
|
|
174
|
+
const blockHits = await searchChunksAsync(intent, {
|
|
175
|
+
kind: 'block',
|
|
176
|
+
limit: PRE_SEARCH_LIMIT - pageChunks.length - panelChunks.length,
|
|
177
|
+
});
|
|
178
|
+
const blockChunks = blockHits.map((h) => getChunk(h.name)).filter(Boolean);
|
|
179
|
+
|
|
180
|
+
const filtered = [
|
|
181
|
+
...pageChunks,
|
|
182
|
+
...panelChunks,
|
|
183
|
+
...blockChunks,
|
|
184
|
+
];
|
|
185
|
+
const catalog = buildCatalogSummary(filtered);
|
|
186
|
+
const examples = buildExamples();
|
|
187
|
+
|
|
188
|
+
const userPrompt = buildUserPrompt(intent, catalog, examples);
|
|
189
|
+
let lastError = null;
|
|
190
|
+
const attempts = [];
|
|
191
|
+
|
|
192
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
193
|
+
const retryNudge = lastError
|
|
194
|
+
? `\n\nPREVIOUS ATTEMPT FAILED: ${lastError}. Return ONLY a JSON object shaped as { "page": "...", "slot_bindings": { ... } }. No prose, no questions.`
|
|
195
|
+
: '';
|
|
196
|
+
const messages = [{ role: 'user', content: userPrompt + retryNudge }];
|
|
197
|
+
const response = await llmAdapter.complete({ messages, systemPrompt: SYSTEM_PROMPT });
|
|
198
|
+
const raw = response?.content || response?.text || (typeof response === 'string' ? response : '');
|
|
199
|
+
attempts.push({ attempt: i + 1, raw });
|
|
200
|
+
|
|
201
|
+
const plan = extractJSON(raw);
|
|
202
|
+
if (!plan || !plan.page || !plan.slot_bindings) {
|
|
203
|
+
lastError = 'Response was not valid JSON with { page, slot_bindings } fields.';
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const validation = validatePlan(plan);
|
|
208
|
+
if (!validation.ok) {
|
|
209
|
+
lastError = validation.errors.slice(0, 3).join('; ');
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const composed = composeFromPlan(plan);
|
|
214
|
+
if (!composed.html) {
|
|
215
|
+
lastError = composed.warnings.join('; ') || 'composer returned null html';
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
html: composed.html,
|
|
221
|
+
plan,
|
|
222
|
+
source: 'synthesis',
|
|
223
|
+
warnings: composed.warnings,
|
|
224
|
+
synthesis: { attempts: i + 1, attemptsLog: attempts, validation },
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
html: null,
|
|
230
|
+
plan: null,
|
|
231
|
+
source: 'synthesis',
|
|
232
|
+
warnings: [`synthesis failed after ${maxAttempts} attempts: ${lastError}`],
|
|
233
|
+
synthesis: { attempts: maxAttempts, attemptsLog: attempts },
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue-reporter — first-class telemetry surface for the gen-UI engine.
|
|
3
|
+
*
|
|
4
|
+
* Three call paths converge here:
|
|
5
|
+
* 1. LLM self-fire — the `report_issue` MCP tool. ctx.reporter = 'llm'.
|
|
6
|
+
* 2. Consumer-fire — human user requests a bug ticket. ctx.reporter = 'user'.
|
|
7
|
+
* 3. Engine auto-fire — internal failure paths trigger `autoReport(reason)`.
|
|
8
|
+
* ctx.reporter = 'auto'. Suppressed when ctx.evalMode is true.
|
|
9
|
+
*
|
|
10
|
+
* Every issue lands as immutable JSON under `.brain/audit-history/issues/`.
|
|
11
|
+
* Traces > 200KB spill to a sidecar `.trace.json` next to the issue.
|
|
12
|
+
*
|
|
13
|
+
* Spec: docs/specs/genui-multiturn-architecture.md §3.5 + §4.6 + §11.
|
|
14
|
+
*
|
|
15
|
+
* Distinct from the curated `.tickets/` system (corpus/scripts/ticket.mjs):
|
|
16
|
+
* those are human-authored work items. Issues here are runtime telemetry —
|
|
17
|
+
* promoted to tickets during weekly triage when patterns emerge (spec §11.5).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
21
|
+
import { dirname, resolve, join } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const REPO_ROOT = resolve(__dirname, '../../../../..');
|
|
26
|
+
const DEFAULT_STORAGE_ROOT = resolve(REPO_ROOT, '.brain/audit-history/issues');
|
|
27
|
+
const TRACE_INLINE_THRESHOLD_BYTES = 200 * 1024;
|
|
28
|
+
|
|
29
|
+
const VALID_TYPES = new Set(['bug', 'training-gap', 'protocol-gap', 'ux-feedback']);
|
|
30
|
+
const VALID_SEVERITIES = new Set(['blocker', 'drift', 'nit']);
|
|
31
|
+
const VALID_OWNERS = new Set(['synthesis', 'retrieval', 'validator', 'chunk-corpus', 'mcp-protocol', 'unknown']);
|
|
32
|
+
const VALID_TRACE_DEPTHS = new Set(['full', 'summary', 'none']);
|
|
33
|
+
const VALID_REPORTER_KINDS = new Set(['auto', 'user', 'llm']);
|
|
34
|
+
|
|
35
|
+
const SEVERITY_RANK = { nit: 0, drift: 1, blocker: 2 };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Auto-fire policy: maps reason → defaults for type/severity/owner/title.
|
|
39
|
+
* Spec §11.3.
|
|
40
|
+
*/
|
|
41
|
+
export const AUTO_FIRE_POLICY = {
|
|
42
|
+
'synthesizer-exhausted': {
|
|
43
|
+
type: 'bug',
|
|
44
|
+
severity: 'drift',
|
|
45
|
+
suggested_owner: 'synthesis',
|
|
46
|
+
titleFor: (ctx) => `Synthesizer exhausted retries${ctx?.intent ? ` for "${truncate(ctx.intent, 50)}"` : ''}`,
|
|
47
|
+
},
|
|
48
|
+
'validator-exhausted': {
|
|
49
|
+
type: 'bug',
|
|
50
|
+
severity: 'blocker',
|
|
51
|
+
suggested_owner: 'validator',
|
|
52
|
+
titleFor: (ctx) => `Validator exhausted retries on ${ctx?.tool || 'refine_composition'}`,
|
|
53
|
+
},
|
|
54
|
+
'locator-empty-targets': {
|
|
55
|
+
type: 'bug',
|
|
56
|
+
severity: 'drift',
|
|
57
|
+
suggested_owner: 'synthesis',
|
|
58
|
+
titleFor: (ctx) => `Locator pass returned empty targets${ctx?.intent ? ` for "${truncate(ctx.intent, 50)}"` : ''}`,
|
|
59
|
+
},
|
|
60
|
+
'retrieval-zero-then-synthesis-fail': {
|
|
61
|
+
type: 'training-gap',
|
|
62
|
+
severity: 'drift',
|
|
63
|
+
suggested_owner: 'chunk-corpus',
|
|
64
|
+
titleFor: (ctx) => `Retrieval returned 0 candidates and synthesis failed${ctx?.intent ? ` for "${truncate(ctx.intent, 40)}"` : ''}`,
|
|
65
|
+
},
|
|
66
|
+
'cache-miss-on-known-state': {
|
|
67
|
+
type: 'bug',
|
|
68
|
+
severity: 'nit',
|
|
69
|
+
suggested_owner: 'mcp-protocol',
|
|
70
|
+
titleFor: (ctx) => `get_state called with unknown state_id "${truncate(ctx?.state_id || 'unknown', 40)}"`,
|
|
71
|
+
},
|
|
72
|
+
'ops-failed-after-apply': {
|
|
73
|
+
type: 'bug',
|
|
74
|
+
severity: 'drift',
|
|
75
|
+
suggested_owner: 'validator',
|
|
76
|
+
titleFor: () => `refine_composition ops_failed list non-empty after apply`,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function truncate(s, n = 60) {
|
|
81
|
+
if (!s || typeof s !== 'string') return '';
|
|
82
|
+
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function slugify(s, max = 40) {
|
|
86
|
+
return (s || '')
|
|
87
|
+
.toLowerCase()
|
|
88
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
89
|
+
.replace(/^-+|-+$/g, '')
|
|
90
|
+
.slice(0, max);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function mintIssueId(title) {
|
|
94
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
95
|
+
const titleSlug = slugify(title) || 'issue';
|
|
96
|
+
const rand4 = Math.floor(Math.random() * 0xffff).toString(16).padStart(4, '0');
|
|
97
|
+
return `${date}-${titleSlug}-${rand4}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getStorageRoot(ctx) {
|
|
101
|
+
return ctx?.storageRoot || DEFAULT_STORAGE_ROOT;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validateInput(input) {
|
|
105
|
+
if (!input || typeof input !== 'object') {
|
|
106
|
+
throw new Error('reportIssue: input is required');
|
|
107
|
+
}
|
|
108
|
+
if (!input.title || typeof input.title !== 'string') {
|
|
109
|
+
throw new Error('reportIssue: title (string) is required');
|
|
110
|
+
}
|
|
111
|
+
if (input.title.length > 80) {
|
|
112
|
+
throw new Error('reportIssue: title must be ≤ 80 chars');
|
|
113
|
+
}
|
|
114
|
+
if (typeof input.body !== 'string') {
|
|
115
|
+
throw new Error('reportIssue: body (string) is required');
|
|
116
|
+
}
|
|
117
|
+
if (!VALID_TYPES.has(input.type)) {
|
|
118
|
+
throw new Error(`reportIssue: type must be one of ${[...VALID_TYPES].join('|')}`);
|
|
119
|
+
}
|
|
120
|
+
if (!VALID_SEVERITIES.has(input.severity)) {
|
|
121
|
+
throw new Error(`reportIssue: severity must be one of ${[...VALID_SEVERITIES].join('|')}`);
|
|
122
|
+
}
|
|
123
|
+
if (input.suggested_owner != null && !VALID_OWNERS.has(input.suggested_owner)) {
|
|
124
|
+
throw new Error(`reportIssue: suggested_owner must be one of ${[...VALID_OWNERS].join('|')}`);
|
|
125
|
+
}
|
|
126
|
+
if (input.trace != null && !VALID_TRACE_DEPTHS.has(input.trace)) {
|
|
127
|
+
throw new Error(`reportIssue: trace must be one of ${[...VALID_TRACE_DEPTHS].join('|')}`);
|
|
128
|
+
}
|
|
129
|
+
if (input.tags != null && !Array.isArray(input.tags)) {
|
|
130
|
+
throw new Error('reportIssue: tags must be an array');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Pull a trace from the cached state-entry.
|
|
136
|
+
*
|
|
137
|
+
* `summary` — final intent + last 5 ops + warnings (compact).
|
|
138
|
+
* `full` — every prompt, every LLM response, every validator result, every retry.
|
|
139
|
+
*
|
|
140
|
+
* Returns null when state_id is absent from the cache.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} state_id
|
|
143
|
+
* @param {'full'|'summary'} depth
|
|
144
|
+
* @param {object} cache — StateCache instance (uses .peek() to avoid touching recency)
|
|
145
|
+
*/
|
|
146
|
+
export async function attachTrace(state_id, depth, cache) {
|
|
147
|
+
if (!cache || typeof cache.peek !== 'function') return null;
|
|
148
|
+
const entry = cache.peek(state_id);
|
|
149
|
+
if (!entry) return null;
|
|
150
|
+
if (depth === 'none') return null;
|
|
151
|
+
|
|
152
|
+
const baseTrace = {
|
|
153
|
+
state_id,
|
|
154
|
+
tool: entry.tool ?? null,
|
|
155
|
+
input: entry.input ?? null,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (depth === 'summary') {
|
|
159
|
+
return {
|
|
160
|
+
...baseTrace,
|
|
161
|
+
output: {
|
|
162
|
+
ops: (entry.ops_history || []).slice(-5),
|
|
163
|
+
delta_summary: entry.delta_summary ?? null,
|
|
164
|
+
},
|
|
165
|
+
warnings: entry.warnings || [],
|
|
166
|
+
duration_ms: entry.duration_ms ?? null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 'full'
|
|
171
|
+
return {
|
|
172
|
+
...baseTrace,
|
|
173
|
+
internal: entry.internal ?? null,
|
|
174
|
+
output: entry.output ?? {
|
|
175
|
+
ops: entry.ops_history || [],
|
|
176
|
+
delta_summary: entry.delta_summary ?? null,
|
|
177
|
+
},
|
|
178
|
+
warnings: entry.warnings || [],
|
|
179
|
+
duration_ms: entry.duration_ms ?? null,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Write an issue to disk.
|
|
185
|
+
*
|
|
186
|
+
* @returns {Promise<{ issue_id, path, ack: 'logged' }>}
|
|
187
|
+
*/
|
|
188
|
+
export async function reportIssue(input, ctx = {}) {
|
|
189
|
+
validateInput(input);
|
|
190
|
+
|
|
191
|
+
const storageRoot = getStorageRoot(ctx);
|
|
192
|
+
await mkdir(storageRoot, { recursive: true });
|
|
193
|
+
|
|
194
|
+
const issue_id = mintIssueId(input.title);
|
|
195
|
+
const created_at = new Date().toISOString();
|
|
196
|
+
|
|
197
|
+
const reporterKind = ctx.reporter && VALID_REPORTER_KINDS.has(ctx.reporter)
|
|
198
|
+
? ctx.reporter
|
|
199
|
+
: 'user';
|
|
200
|
+
|
|
201
|
+
const traceDepth = input.trace ?? (input.state_id ? 'summary' : 'none');
|
|
202
|
+
let trace = null;
|
|
203
|
+
if (input.state_id && traceDepth !== 'none') {
|
|
204
|
+
trace = await attachTrace(input.state_id, traceDepth, ctx.cache);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Spill oversized traces to a sidecar file
|
|
208
|
+
let tracePayload = trace;
|
|
209
|
+
if (trace && JSON.stringify(trace).length > TRACE_INLINE_THRESHOLD_BYTES) {
|
|
210
|
+
const tracesDir = join(storageRoot, 'traces');
|
|
211
|
+
await mkdir(tracesDir, { recursive: true });
|
|
212
|
+
const tracePath = join(tracesDir, `${issue_id}.trace.json`);
|
|
213
|
+
await writeFile(tracePath, JSON.stringify(trace, null, 2));
|
|
214
|
+
tracePayload = { sidecar: `traces/${issue_id}.trace.json` };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const issue = {
|
|
218
|
+
issue_id,
|
|
219
|
+
created_at,
|
|
220
|
+
type: input.type,
|
|
221
|
+
severity: input.severity,
|
|
222
|
+
status: 'open',
|
|
223
|
+
title: input.title,
|
|
224
|
+
body: input.body,
|
|
225
|
+
reporter: {
|
|
226
|
+
kind: reporterKind,
|
|
227
|
+
context: ctx.reporterContext ?? null,
|
|
228
|
+
},
|
|
229
|
+
trace: tracePayload,
|
|
230
|
+
environment: ctx.versionInfo ?? null,
|
|
231
|
+
suggested_owner: input.suggested_owner ?? 'unknown',
|
|
232
|
+
tags: input.tags ?? [],
|
|
233
|
+
related_issue_ids: ctx.related_issue_ids ?? [],
|
|
234
|
+
linked_specs: ctx.linked_specs ?? ['docs/specs/genui-multiturn-architecture.md'],
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const path = join(storageRoot, `${issue_id}.json`);
|
|
238
|
+
await writeFile(path, JSON.stringify(issue, null, 2));
|
|
239
|
+
|
|
240
|
+
return { issue_id, path, ack: 'logged' };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Auto-fire an issue based on a known reason.
|
|
245
|
+
*
|
|
246
|
+
* Looks up the policy table, derives type/severity/title/owner; the caller
|
|
247
|
+
* passes intent / state_id / extra context via `autoCtx`.
|
|
248
|
+
*
|
|
249
|
+
* Returns null when ctx.evalMode is true (eval runs don't pollute the ledger);
|
|
250
|
+
* manual reportIssue calls are unaffected by evalMode.
|
|
251
|
+
*
|
|
252
|
+
* @param {keyof typeof AUTO_FIRE_POLICY} reason
|
|
253
|
+
* @param {object} autoCtx — { intent?, state_id?, tool?, body?, trace?, tags? }
|
|
254
|
+
* @param {object} ctx — { cache, versionInfo, evalMode, storageRoot }
|
|
255
|
+
*/
|
|
256
|
+
export async function autoReport(reason, autoCtx = {}, ctx = {}) {
|
|
257
|
+
if (ctx.evalMode) return null;
|
|
258
|
+
const policy = AUTO_FIRE_POLICY[reason];
|
|
259
|
+
if (!policy) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`autoReport: unknown reason "${reason}". Valid: ${Object.keys(AUTO_FIRE_POLICY).join(', ')}`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
const title = policy.titleFor(autoCtx);
|
|
265
|
+
const body = autoCtx.body || `Auto-fired by engine. Reason: \`${reason}\`.`;
|
|
266
|
+
|
|
267
|
+
return await reportIssue(
|
|
268
|
+
{
|
|
269
|
+
type: policy.type,
|
|
270
|
+
severity: policy.severity,
|
|
271
|
+
title,
|
|
272
|
+
body,
|
|
273
|
+
state_id: autoCtx.state_id,
|
|
274
|
+
trace: autoCtx.trace ?? (autoCtx.state_id ? 'summary' : 'none'),
|
|
275
|
+
suggested_owner: policy.suggested_owner,
|
|
276
|
+
tags: ['auto-fire', reason, ...(autoCtx.tags || [])],
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
...ctx,
|
|
280
|
+
reporter: 'auto',
|
|
281
|
+
reporterContext: reason,
|
|
282
|
+
}
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Per-tool-call accumulator for auto-fired issues.
|
|
288
|
+
*
|
|
289
|
+
* Multiple fires within one call coalesce into a single issue (spec §6.4).
|
|
290
|
+
* The highest-severity entry dictates type/severity/owner; the body lists
|
|
291
|
+
* every reason. Engine code creates one accumulator per tool invocation,
|
|
292
|
+
* pushes auto-fires into it, and flushes at the end.
|
|
293
|
+
*/
|
|
294
|
+
export function createIssueAccumulator() {
|
|
295
|
+
const entries = [];
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
add(reason, autoCtx = {}) {
|
|
299
|
+
if (!AUTO_FIRE_POLICY[reason]) {
|
|
300
|
+
throw new Error(`accumulator.add: unknown reason "${reason}"`);
|
|
301
|
+
}
|
|
302
|
+
entries.push({ reason, autoCtx });
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
size() {
|
|
306
|
+
return entries.length;
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
reasons() {
|
|
310
|
+
return entries.map((e) => e.reason);
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Write the accumulated issue(s).
|
|
315
|
+
*
|
|
316
|
+
* - 0 entries → returns null without writing.
|
|
317
|
+
* - 1 entry → writes via autoReport (or no-op when evalMode).
|
|
318
|
+
* - N entries → coalesces into one issue. Highest-severity entry's policy
|
|
319
|
+
* wins; body lists every reason; tags include all reasons + 'coalesced'.
|
|
320
|
+
*/
|
|
321
|
+
async flush(ctx = {}) {
|
|
322
|
+
if (entries.length === 0) return null;
|
|
323
|
+
|
|
324
|
+
if (entries.length === 1) {
|
|
325
|
+
const { reason, autoCtx } = entries[0];
|
|
326
|
+
const result = await autoReport(reason, autoCtx, ctx);
|
|
327
|
+
entries.length = 0;
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Coalesce. Sort by severity desc, ties broken by insertion order.
|
|
332
|
+
const ranked = entries
|
|
333
|
+
.map((e, i) => ({ ...e, _idx: i }))
|
|
334
|
+
.sort((a, b) => {
|
|
335
|
+
const sa = SEVERITY_RANK[AUTO_FIRE_POLICY[a.reason].severity];
|
|
336
|
+
const sb = SEVERITY_RANK[AUTO_FIRE_POLICY[b.reason].severity];
|
|
337
|
+
return sb - sa || a._idx - b._idx;
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const primary = ranked[0];
|
|
341
|
+
const policy = AUTO_FIRE_POLICY[primary.reason];
|
|
342
|
+
const reasons = entries.map((e) => e.reason);
|
|
343
|
+
|
|
344
|
+
if (ctx.evalMode) {
|
|
345
|
+
entries.length = 0;
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const titles = entries.map((e) => AUTO_FIRE_POLICY[e.reason].titleFor(e.autoCtx));
|
|
350
|
+
const body = [
|
|
351
|
+
'## Coalesced auto-fires within one tool call',
|
|
352
|
+
'',
|
|
353
|
+
...entries.map((e, i) => `${i + 1}. **\`${e.reason}\`** — ${titles[i]}`),
|
|
354
|
+
'',
|
|
355
|
+
`Primary (highest severity) dictates type/severity/owner: \`${primary.reason}\` (severity \`${policy.severity}\`).`,
|
|
356
|
+
].join('\n');
|
|
357
|
+
|
|
358
|
+
const result = await reportIssue(
|
|
359
|
+
{
|
|
360
|
+
type: policy.type,
|
|
361
|
+
severity: policy.severity,
|
|
362
|
+
title: truncate(`Coalesced auto-fires: ${reasons.join(', ')}`, 80),
|
|
363
|
+
body,
|
|
364
|
+
state_id: primary.autoCtx?.state_id,
|
|
365
|
+
trace: primary.autoCtx?.trace ?? (primary.autoCtx?.state_id ? 'summary' : 'none'),
|
|
366
|
+
suggested_owner: policy.suggested_owner,
|
|
367
|
+
tags: ['auto-fire', 'coalesced', ...new Set(reasons)],
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
...ctx,
|
|
371
|
+
reporter: 'auto',
|
|
372
|
+
reporterContext: 'coalesced',
|
|
373
|
+
}
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
entries.length = 0;
|
|
377
|
+
return result;
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
}
|