@adia-ai/a2ui-compose 0.0.1
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 -0
- package/README.md +181 -0
- package/engine/artifacts.js +262 -0
- package/engine/constitution.md +78 -0
- package/engine/context-store.js +218 -0
- package/engine/generator.js +500 -0
- package/engine/pattern-export.js +149 -0
- package/engine/pipeline/engine.js +289 -0
- package/engine/pipeline/types.js +91 -0
- package/engine/reference.js +115 -0
- package/engine/state.js +15 -0
- package/engines/monolithic/_shared.js +1320 -0
- package/engines/monolithic/generate-instant.js +229 -0
- package/engines/monolithic/generate-pro.js +367 -0
- package/engines/monolithic/generate-thinking.js +211 -0
- package/engines/registry.js +195 -0
- package/engines/zettel/_smoke.js +37 -0
- package/engines/zettel/composer.js +146 -0
- package/engines/zettel/fragment-library.js +209 -0
- package/engines/zettel/generate.js +15 -0
- package/engines/zettel/generator-adapter.js +202 -0
- package/engines/zettel/session-store.js +121 -0
- package/engines/zettel/synthesizer.js +343 -0
- package/evals/harness.mjs +193 -0
- package/index.js +16 -0
- package/llm/adapters/anthropic.js +106 -0
- package/llm/adapters/gemini.js +99 -0
- package/llm/adapters/index.js +138 -0
- package/llm/adapters/openai.js +85 -0
- package/llm/adapters/sse.js +50 -0
- package/llm/llm-bridge.js +214 -0
- package/llm/llm-stub.js +69 -0
- package/package.json +41 -0
- package/transpiler/transpiler-maps.js +277 -0
- package/transpiler/transpiler.js +820 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fragment Library — zettel-style loader for A2UI fragments.
|
|
3
|
+
*
|
|
4
|
+
* Loads all /a2ui/fragments/**\/*.json into memory, builds a backlink graph,
|
|
5
|
+
* and exposes typed retrieval helpers. Fragments ARE A2UI chunks — the library
|
|
6
|
+
* preserves their flat-adjacency template verbatim.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import url from 'node:url';
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
14
|
+
const FRAGMENTS_ROOT = path.resolve(__dirname, '../../../corpus/fragments');
|
|
15
|
+
const COMPOSITIONS_ROOT = path.resolve(__dirname, '../../../corpus/compositions');
|
|
16
|
+
|
|
17
|
+
/** @type {Map<string, object>} */
|
|
18
|
+
const fragments = new Map();
|
|
19
|
+
/** @type {Map<string, object>} */
|
|
20
|
+
const compositions = new Map();
|
|
21
|
+
|
|
22
|
+
function walk(dir, cb) {
|
|
23
|
+
if (!fs.existsSync(dir)) return;
|
|
24
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
25
|
+
const p = path.join(dir, entry.name);
|
|
26
|
+
if (entry.isDirectory()) walk(p, cb);
|
|
27
|
+
else if (entry.name.endsWith('.json') && !entry.name.startsWith('_')) cb(p);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function loadAll() {
|
|
32
|
+
fragments.clear();
|
|
33
|
+
compositions.clear();
|
|
34
|
+
|
|
35
|
+
walk(FRAGMENTS_ROOT, (p) => {
|
|
36
|
+
const doc = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
37
|
+
if (doc.kind !== 'fragment') return;
|
|
38
|
+
doc._sourceFile = p;
|
|
39
|
+
fragments.set(doc.name, doc);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
walk(COMPOSITIONS_ROOT, (p) => {
|
|
43
|
+
const doc = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
44
|
+
if (doc.kind !== 'composition') return;
|
|
45
|
+
doc._sourceFile = p;
|
|
46
|
+
compositions.set(doc.name, doc);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
buildBacklinks();
|
|
50
|
+
return { fragmentCount: fragments.size, compositionCount: compositions.size };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Walk every composition and every fragment, populate `used_by` backlinks. */
|
|
54
|
+
function buildBacklinks() {
|
|
55
|
+
for (const f of fragments.values()) {
|
|
56
|
+
f.links = f.links || {};
|
|
57
|
+
f.links.used_by = [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// compositions -> fragments they reference
|
|
61
|
+
for (const comp of compositions.values()) {
|
|
62
|
+
for (const node of comp.template || []) {
|
|
63
|
+
if (node.$fragment) {
|
|
64
|
+
const frag = fragments.get(node.$fragment);
|
|
65
|
+
if (frag) frag.links.used_by.push({ type: 'composition', name: comp.name });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// fragments -> other fragments they embed (future: nested fragment refs)
|
|
71
|
+
for (const f of fragments.values()) {
|
|
72
|
+
for (const node of f.template || []) {
|
|
73
|
+
if (node.$fragment) {
|
|
74
|
+
const target = fragments.get(node.$fragment);
|
|
75
|
+
if (target) target.links.used_by.push({ type: 'fragment', name: f.name });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getFragment(name) { return fragments.get(name); }
|
|
82
|
+
export function getComposition(name) { return compositions.get(name); }
|
|
83
|
+
export function getAllFragments() { return [...fragments.values()]; }
|
|
84
|
+
export function getAllCompositions() { return [...compositions.values()]; }
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Keyword search across fragments AND compositions.
|
|
88
|
+
*
|
|
89
|
+
* Ranking design (v2 — fixes false-positive matching):
|
|
90
|
+
* 1. Tokenize query, drop generic UI stop-words ("form", "page", "view"…)
|
|
91
|
+
* so they can't be the sole evidence for a match.
|
|
92
|
+
* 2. Whole-word match, not substring — prevents "form" matching "login-form".
|
|
93
|
+
* 3. Require ≥2 content-token hits for compositions (they're concrete
|
|
94
|
+
* templates — promiscuous matching is expensive). Fragments can match on 1
|
|
95
|
+
* content token since they're meant to be widely reused.
|
|
96
|
+
* 4. Tiered weights: name hit > keyword hit > semantic_role hit > description.
|
|
97
|
+
* 5. Small leverage bias for fragments (reused fragments are more likely
|
|
98
|
+
* relevant when multiple candidates tie).
|
|
99
|
+
*/
|
|
100
|
+
const STOP_WORDS = new Set([
|
|
101
|
+
'a', 'an', 'the', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'at', 'for', 'with',
|
|
102
|
+
'form', 'forms', 'page', 'pages', 'view', 'views', 'screen', 'screens',
|
|
103
|
+
'ui', 'component', 'components', 'widget', 'widgets', 'block', 'blocks',
|
|
104
|
+
'section', 'sections', 'layout', 'layouts',
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
function tokenize(str) {
|
|
108
|
+
return (str || '')
|
|
109
|
+
.toLowerCase()
|
|
110
|
+
.split(/[^a-z0-9]+/)
|
|
111
|
+
.filter((t) => t.length >= 2);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function contentTokens(str) {
|
|
115
|
+
return tokenize(str).filter((t) => !STOP_WORDS.has(t));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function searchAll(query, { limit = 20 } = {}) {
|
|
119
|
+
const q = (query || '').trim();
|
|
120
|
+
if (!q) return [];
|
|
121
|
+
const queryTokens = tokenize(q);
|
|
122
|
+
const queryContent = queryTokens.filter((t) => !STOP_WORDS.has(t));
|
|
123
|
+
|
|
124
|
+
const scoreDoc = (doc, isComposition) => {
|
|
125
|
+
const nameTokens = new Set(tokenize(doc.name));
|
|
126
|
+
const roleTokens = new Set(tokenize(doc.semantic_role || ''));
|
|
127
|
+
const keywordTokens = new Set((doc.keywords || []).flatMap((k) => tokenize(k)));
|
|
128
|
+
const descTokens = new Set(tokenize(doc.description || ''));
|
|
129
|
+
const tagTokens = new Set(
|
|
130
|
+
Object.values(doc.tags || {}).flat().flatMap((v) => tokenize(String(v))),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
let contentHits = 0; // distinct non-stop-word tokens that matched anywhere
|
|
134
|
+
let s = 0;
|
|
135
|
+
const contentMatched = new Set();
|
|
136
|
+
|
|
137
|
+
for (const t of queryTokens) {
|
|
138
|
+
const isStop = STOP_WORDS.has(t);
|
|
139
|
+
let matched = false;
|
|
140
|
+
if (nameTokens.has(t)) { s += isStop ? 1 : 12; matched = true; }
|
|
141
|
+
if (keywordTokens.has(t)) { s += isStop ? 1 : 8; matched = true; }
|
|
142
|
+
if (roleTokens.has(t)) { s += isStop ? 1 : 6; matched = true; }
|
|
143
|
+
if (tagTokens.has(t)) { s += isStop ? 0 : 3; matched = true; }
|
|
144
|
+
if (descTokens.has(t)) { s += isStop ? 0 : 2; matched = true; }
|
|
145
|
+
if (matched && !isStop) {
|
|
146
|
+
contentMatched.add(t);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
contentHits = contentMatched.size;
|
|
150
|
+
|
|
151
|
+
// Gate: compositions normally require ≥2 content-token hits to avoid
|
|
152
|
+
// noisy matches. Exception: a direct name-token match (the query contains
|
|
153
|
+
// the composition's primary concept word, e.g. "weather" → weather-widget)
|
|
154
|
+
// is strong enough on its own.
|
|
155
|
+
const nameHit = queryTokens.some((t) => !STOP_WORDS.has(t) && nameTokens.has(t));
|
|
156
|
+
if (isComposition && contentHits < 2 && !nameHit) return 0;
|
|
157
|
+
// Fragments require ≥1 content-token hit — never match on stop-words alone.
|
|
158
|
+
if (!isComposition && contentHits < 1) return 0;
|
|
159
|
+
|
|
160
|
+
// Coverage bonus: more distinct content tokens matched = better match
|
|
161
|
+
s += contentHits * 3;
|
|
162
|
+
|
|
163
|
+
// Small leverage bias for fragments (reused ones slightly preferred on ties)
|
|
164
|
+
if (!isComposition && doc.metrics?.leverage) {
|
|
165
|
+
s += Math.min(doc.metrics.leverage / 20, 2);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return s;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const all = [
|
|
172
|
+
...getAllFragments().map((d) => ({ doc: d, type: 'fragment' })),
|
|
173
|
+
...getAllCompositions().map((d) => ({ doc: d, type: 'composition' })),
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
return all
|
|
177
|
+
.map((x) => ({ ...x, score: scoreDoc(x.doc, x.type === 'composition') }))
|
|
178
|
+
.filter((x) => x.score > 0)
|
|
179
|
+
.sort((a, b) => b.score - a.score)
|
|
180
|
+
.slice(0, limit)
|
|
181
|
+
.map((x) => ({
|
|
182
|
+
type: x.type,
|
|
183
|
+
name: x.doc.name,
|
|
184
|
+
score: Math.round(x.score * 100) / 100,
|
|
185
|
+
description: x.doc.description,
|
|
186
|
+
semantic_role: x.doc.semantic_role,
|
|
187
|
+
domain: x.doc.domain,
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Return the full graph for inspection / visualization. */
|
|
192
|
+
export function getGraph() {
|
|
193
|
+
return {
|
|
194
|
+
fragments: getAllFragments().map((f) => ({
|
|
195
|
+
name: f.name,
|
|
196
|
+
role: f.semantic_role,
|
|
197
|
+
uses_components: f.links?.uses_components || [],
|
|
198
|
+
used_by: f.links?.used_by || [],
|
|
199
|
+
leverage: f.metrics?.leverage || 0,
|
|
200
|
+
})),
|
|
201
|
+
compositions: getAllCompositions().map((c) => ({
|
|
202
|
+
name: c.name,
|
|
203
|
+
domain: c.domain,
|
|
204
|
+
uses_fragments: (c.template || [])
|
|
205
|
+
.filter((n) => n.$fragment)
|
|
206
|
+
.map((n) => n.$fragment),
|
|
207
|
+
})),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adia-ai/a2ui-compose — zettel engine facade.
|
|
3
|
+
*
|
|
4
|
+
* Fragment-graph composition. Three reasoning layers:
|
|
5
|
+
* 1. Retrieval (always) — keyword-rank corpus, resolve top composition
|
|
6
|
+
* 2. LLM synthesis (when llmAdapter + weak retrieval) — compose a new
|
|
7
|
+
* composition from fragments
|
|
8
|
+
* 3. Session-aware iteration (when sessionId + prior turns) — modify
|
|
9
|
+
* existing canvas instead of regenerating
|
|
10
|
+
*
|
|
11
|
+
* The actual implementation is generator-adapter.js; this file is the
|
|
12
|
+
* spec-compliant entry point at engines/zettel/generate.js per §11.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export { generateZettel as generate } from './generator-adapter.js';
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generator adapter for zettel MCP — conforms to the eval harness contract:
|
|
3
|
+
*
|
|
4
|
+
* generate({ intent, mode, llmAdapter, sessionId }) -> { messages, validation, strategy, ...extra }
|
|
5
|
+
*
|
|
6
|
+
* Three reasoning layers:
|
|
7
|
+
* 1. Retrieval (always available) — keyword-rank the corpus, resolve top composition.
|
|
8
|
+
* 2. LLM synthesis (when llmAdapter provided AND retrieval weak) — compose a new
|
|
9
|
+
* composition from fragments, have the composer resolve it into A2UI messages.
|
|
10
|
+
* 3. Session-aware iteration (when sessionId provided AND prior turns exist) —
|
|
11
|
+
* pass prior-turn history to the LLM so follow-ups ("add a button", "hydrate
|
|
12
|
+
* with real images") modify the existing canvas instead of regenerating.
|
|
13
|
+
*
|
|
14
|
+
* Strategy labels in the return:
|
|
15
|
+
* - composition-match — fresh retrieval, strong match, emitted verbatim
|
|
16
|
+
* - composition-synthesized — fresh LLM composition (no prior turns)
|
|
17
|
+
* - composition-iterated — LLM modified prior turn's template
|
|
18
|
+
* - fragment-candidates — retrieval weak + no LLM, returning atoms only
|
|
19
|
+
* - synthesis-failed — LLM tried and failed validation
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
loadAll,
|
|
23
|
+
getComposition,
|
|
24
|
+
searchAll,
|
|
25
|
+
} from './fragment-library.js';
|
|
26
|
+
import { resolveComposition, templateToMessages } from './composer.js';
|
|
27
|
+
import { synthesizeComposition } from './synthesizer.js';
|
|
28
|
+
import {
|
|
29
|
+
recordTurn,
|
|
30
|
+
getTurns,
|
|
31
|
+
buildHistorySummary,
|
|
32
|
+
} from './session-store.js';
|
|
33
|
+
import { validateSchema } from '../../../validator/validator.js';
|
|
34
|
+
|
|
35
|
+
let booted = false;
|
|
36
|
+
function ensureBooted() {
|
|
37
|
+
if (!booted) {
|
|
38
|
+
loadAll();
|
|
39
|
+
booted = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Retrieval score threshold — above this we trust the match and emit verbatim;
|
|
44
|
+
// below, fall through to LLM synthesis (creative composition from fragments).
|
|
45
|
+
// Calibrated on the 100-intent held-out set:
|
|
46
|
+
// strong matches: login-form=24 (exact), signup-form=27, chart-dashboard=48, pricing-tiers=54
|
|
47
|
+
// weak/false-positive matches: drop below 22 on off-topic queries
|
|
48
|
+
// Raised from 22 → 40 (2026-04-19) so that only near-perfect retrievals play
|
|
49
|
+
// verbatim; merely-good matches (login-form, signup-form) fall through to LLM
|
|
50
|
+
// synthesis instead of being emitted as canned output. Tradeoff: more LLM calls
|
|
51
|
+
// (slower, costlier) in exchange for compositional variety. The previous 22
|
|
52
|
+
// threshold maximized verbatim-cache hits at the cost of repetitive output.
|
|
53
|
+
const STRONG_MATCH_THRESHOLD = 40;
|
|
54
|
+
|
|
55
|
+
function toUpdateComponentsMessages(template) {
|
|
56
|
+
return [{
|
|
57
|
+
type: 'updateComponents',
|
|
58
|
+
components: template.map((n) => {
|
|
59
|
+
const {
|
|
60
|
+
id, component, children,
|
|
61
|
+
$fragment, bindings,
|
|
62
|
+
prependChildren, appendChildren,
|
|
63
|
+
...rest
|
|
64
|
+
} = n;
|
|
65
|
+
return { id, component, children: children || [], ...rest };
|
|
66
|
+
}),
|
|
67
|
+
}];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function generateZettel({ intent, mode = 'instant', llmAdapter = null, sessionId = null } = {}) {
|
|
71
|
+
ensureBooted();
|
|
72
|
+
|
|
73
|
+
// ── Session-aware iteration (turn > 1) ──
|
|
74
|
+
// If we have prior turns AND an LLM, the user is almost certainly modifying
|
|
75
|
+
// the existing canvas — NEVER pick a fresh retrieved composition. Go straight
|
|
76
|
+
// to synthesis with history context. This is what makes follow-ups work.
|
|
77
|
+
const priorTurns = sessionId ? getTurns(sessionId) : [];
|
|
78
|
+
const hasHistory = priorTurns.length > 0;
|
|
79
|
+
|
|
80
|
+
if (hasHistory && llmAdapter) {
|
|
81
|
+
try {
|
|
82
|
+
const historySummary = buildHistorySummary(sessionId, 3);
|
|
83
|
+
const synth = await synthesizeComposition({ intent, llmAdapter, historySummary });
|
|
84
|
+
const validation = validateSchema(synth.messages, { intent });
|
|
85
|
+
const fragments = (synth.template || []).filter((n) => n.$fragment).map((n) => n.$fragment);
|
|
86
|
+
recordTurn(sessionId, {
|
|
87
|
+
intent,
|
|
88
|
+
messages: synth.messages,
|
|
89
|
+
template: synth.template,
|
|
90
|
+
composition: null,
|
|
91
|
+
strategy: 'composition-iterated',
|
|
92
|
+
fragments,
|
|
93
|
+
validation,
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
messages: synth.messages,
|
|
97
|
+
validation,
|
|
98
|
+
strategy: 'composition-iterated',
|
|
99
|
+
retrieval: { hit: false, rank: null, candidate: null, reason: `iteration on turn ${priorTurns.length + 1}` },
|
|
100
|
+
composition: null,
|
|
101
|
+
fragments_used: fragments,
|
|
102
|
+
synthesized_template: synth.template,
|
|
103
|
+
synthesis: synth.synthesis,
|
|
104
|
+
sessionTurns: priorTurns.length + 1,
|
|
105
|
+
};
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// If iteration synthesis fails, fall through to the normal path. Record
|
|
108
|
+
// the failure so the next turn can see we tried.
|
|
109
|
+
console.error('[zettel] iteration synthesis failed:', err.message);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const hits = searchAll(intent, { limit: 5 });
|
|
114
|
+
const composition = hits.find((h) => h.type === 'composition');
|
|
115
|
+
const strongMatch = composition && composition.score >= STRONG_MATCH_THRESHOLD;
|
|
116
|
+
|
|
117
|
+
// ── Strong retrieval match: emit verbatim (turn 1 only — iteration branch above handles repeats) ──
|
|
118
|
+
if (strongMatch) {
|
|
119
|
+
const comp = getComposition(composition.name);
|
|
120
|
+
const template = resolveComposition(comp);
|
|
121
|
+
const messages = toUpdateComponentsMessages(template);
|
|
122
|
+
const validation = validateSchema(messages, { intent });
|
|
123
|
+
const rank = hits.findIndex((h) => h.type === 'composition' && h.name === composition.name) + 1;
|
|
124
|
+
const fragments = (comp.template || []).filter((n) => n.$fragment).map((n) => n.$fragment);
|
|
125
|
+
recordTurn(sessionId, {
|
|
126
|
+
intent,
|
|
127
|
+
messages,
|
|
128
|
+
template: comp.template,
|
|
129
|
+
composition: composition.name,
|
|
130
|
+
strategy: 'composition-match',
|
|
131
|
+
fragments,
|
|
132
|
+
validation,
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
messages,
|
|
136
|
+
validation,
|
|
137
|
+
strategy: 'composition-match',
|
|
138
|
+
retrieval: { hit: true, rank, candidate: composition.name },
|
|
139
|
+
composition: composition.name,
|
|
140
|
+
retrievalScore: composition.score,
|
|
141
|
+
fragments_used: fragments,
|
|
142
|
+
candidates: hits,
|
|
143
|
+
sessionTurns: priorTurns.length + 1,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Weak/no retrieval: try LLM synthesis if available (fresh, no history) ──
|
|
148
|
+
if (llmAdapter && mode !== 'instant-only') {
|
|
149
|
+
try {
|
|
150
|
+
const synth = await synthesizeComposition({ intent, llmAdapter });
|
|
151
|
+
const validation = validateSchema(synth.messages, { intent });
|
|
152
|
+
const fragments = (synth.template || []).filter((n) => n.$fragment).map((n) => n.$fragment);
|
|
153
|
+
recordTurn(sessionId, {
|
|
154
|
+
intent,
|
|
155
|
+
messages: synth.messages,
|
|
156
|
+
template: synth.template,
|
|
157
|
+
composition: null,
|
|
158
|
+
strategy: 'composition-synthesized',
|
|
159
|
+
fragments,
|
|
160
|
+
validation,
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
messages: synth.messages,
|
|
164
|
+
validation,
|
|
165
|
+
strategy: 'composition-synthesized',
|
|
166
|
+
retrieval: {
|
|
167
|
+
hit: false,
|
|
168
|
+
rank: composition ? hits.findIndex((h) => h.type === 'composition' && h.name === composition.name) + 1 : null,
|
|
169
|
+
candidate: composition?.name ?? null,
|
|
170
|
+
reason: composition ? `top score ${composition.score} below threshold ${STRONG_MATCH_THRESHOLD}` : 'no composition retrieved',
|
|
171
|
+
},
|
|
172
|
+
composition: null,
|
|
173
|
+
fragments_used: fragments,
|
|
174
|
+
synthesized_template: synth.template,
|
|
175
|
+
synthesis: synth.synthesis,
|
|
176
|
+
candidates: hits,
|
|
177
|
+
sessionTurns: priorTurns.length + 1,
|
|
178
|
+
};
|
|
179
|
+
} catch (err) {
|
|
180
|
+
return {
|
|
181
|
+
messages: [],
|
|
182
|
+
validation: { score: 0 },
|
|
183
|
+
strategy: 'synthesis-failed',
|
|
184
|
+
retrieval: { hit: false, rank: null, candidate: hits[0]?.name ?? null },
|
|
185
|
+
candidates: hits,
|
|
186
|
+
error: err.message,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── No LLM, no strong match: return atoms for downstream assembly ──
|
|
192
|
+
return {
|
|
193
|
+
messages: [],
|
|
194
|
+
validation: { score: 0 },
|
|
195
|
+
strategy: 'fragment-candidates',
|
|
196
|
+
retrieval: { hit: false, rank: null, candidate: hits[0]?.name ?? null },
|
|
197
|
+
candidates: hits,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Re-export session management so server.js / MCP tools can reset a session
|
|
202
|
+
export { clearSession, getTurns, getDrift } from './session-store.js';
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zettel session store — multi-turn artifact tracking for the fragment-graph engine.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the shared ArtifactStore to record every zettel turn:
|
|
5
|
+
* - the user intent
|
|
6
|
+
* - the composition that was retrieved OR synthesized
|
|
7
|
+
* - the resolved A2UI messages
|
|
8
|
+
* - the fragments that were used
|
|
9
|
+
* - strategy (composition-match | composition-synthesized)
|
|
10
|
+
*
|
|
11
|
+
* Subsequent turns can read this history and feed it to the LLM so that
|
|
12
|
+
* "hydrate with real images" refers back to the previous "grid of images"
|
|
13
|
+
* instead of being generated in isolation.
|
|
14
|
+
*
|
|
15
|
+
* Sessions are keyed by an opaque `sessionId` minted by the caller. The store
|
|
16
|
+
* is in-memory — survives for the lifetime of the proxy process, cleared on
|
|
17
|
+
* restart. Good enough for a dev server; a persistent store is a later phase.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { ArtifactStore } from '../../engine/artifacts.js';
|
|
21
|
+
|
|
22
|
+
const store = new ArtifactStore();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Record a zettel turn.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} sessionId
|
|
28
|
+
* @param {object} turn
|
|
29
|
+
* @param {string} turn.intent — user's text for this turn
|
|
30
|
+
* @param {object[]} turn.messages — resolved A2UI messages
|
|
31
|
+
* @param {object[]} [turn.template] — the composition template (with $fragment refs)
|
|
32
|
+
* @param {string} [turn.composition] — retrieved composition name (null if synthesized)
|
|
33
|
+
* @param {string} turn.strategy — composition-match | composition-synthesized | …
|
|
34
|
+
* @param {string[]} [turn.fragments] — fragment names used
|
|
35
|
+
* @param {object} [turn.validation] — validator result
|
|
36
|
+
*/
|
|
37
|
+
export function recordTurn(sessionId, { intent, messages, template, composition, strategy, fragments, validation }) {
|
|
38
|
+
if (!sessionId) return;
|
|
39
|
+
store.record(sessionId, {
|
|
40
|
+
intent,
|
|
41
|
+
messages,
|
|
42
|
+
summary: composition
|
|
43
|
+
? `${strategy} → ${composition}`
|
|
44
|
+
: (strategy || 'zettel turn'),
|
|
45
|
+
validation: validation || null,
|
|
46
|
+
});
|
|
47
|
+
// Stash zettel-specific fields by augmenting the latest turn record.
|
|
48
|
+
// (ArtifactStore is intentionally generic; we attach extras after the fact.)
|
|
49
|
+
const latest = store.get(sessionId, -1);
|
|
50
|
+
if (latest) {
|
|
51
|
+
latest.composition = composition || null;
|
|
52
|
+
latest.strategy = strategy || null;
|
|
53
|
+
latest.fragments = fragments || [];
|
|
54
|
+
latest.template = template || null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get the full turn history for a session.
|
|
60
|
+
* @returns {object[]} — empty array if session unknown
|
|
61
|
+
*/
|
|
62
|
+
export function getTurns(sessionId) {
|
|
63
|
+
if (!sessionId) return [];
|
|
64
|
+
return store.getAll(sessionId) || [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get the latest turn only (nullable).
|
|
69
|
+
*/
|
|
70
|
+
export function getLatestTurn(sessionId) {
|
|
71
|
+
if (!sessionId) return null;
|
|
72
|
+
return store.get(sessionId, -1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Drift metrics across turns — reuses ArtifactStore's logic. */
|
|
76
|
+
export function getDrift(sessionId) {
|
|
77
|
+
if (!sessionId) return null;
|
|
78
|
+
return store.getDriftMetrics(sessionId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Clear a session (user hit "reset canvas"). */
|
|
82
|
+
export function clearSession(sessionId) {
|
|
83
|
+
if (!sessionId) return;
|
|
84
|
+
store.delete(sessionId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build a compact history summary suitable for inclusion in an LLM prompt.
|
|
89
|
+
* Limits to the last N turns and trims each template to a skeletal form
|
|
90
|
+
* (ids + components/$fragment only) so we don't blow the context window.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} sessionId
|
|
93
|
+
* @param {number} [maxTurns=3]
|
|
94
|
+
* @returns {string|null}
|
|
95
|
+
*/
|
|
96
|
+
export function buildHistorySummary(sessionId, maxTurns = 3) {
|
|
97
|
+
const turns = getTurns(sessionId);
|
|
98
|
+
if (!turns.length) return null;
|
|
99
|
+
|
|
100
|
+
const recent = turns.slice(-maxTurns);
|
|
101
|
+
const lines = recent.map((t, i) => {
|
|
102
|
+
const turnNum = turns.length - recent.length + i + 1;
|
|
103
|
+
const header = `--- turn ${turnNum}: "${t.intent}" (${t.strategy || 'unknown'}${t.composition ? ` → ${t.composition}` : ''}) ---`;
|
|
104
|
+
const skeleton = t.template
|
|
105
|
+
? JSON.stringify(t.template.map(n => {
|
|
106
|
+
if (n.$fragment) return { id: n.id, $fragment: n.$fragment, bindings: n.bindings };
|
|
107
|
+
const { id, component, children, ...rest } = n;
|
|
108
|
+
// Keep shape-critical props, drop verbose content
|
|
109
|
+
const keep = { id, component };
|
|
110
|
+
if (children) keep.children = children;
|
|
111
|
+
// Keep variant/type-level props, drop textContent/placeholder (too verbose)
|
|
112
|
+
for (const k of ['variant', 'gap', 'columns', 'align', 'justify']) {
|
|
113
|
+
if (rest[k] !== undefined) keep[k] = rest[k];
|
|
114
|
+
}
|
|
115
|
+
return keep;
|
|
116
|
+
}), null, 2)
|
|
117
|
+
: '(no template recorded)';
|
|
118
|
+
return `${header}\n${skeleton}`;
|
|
119
|
+
});
|
|
120
|
+
return lines.join('\n\n');
|
|
121
|
+
}
|