@gakim-digital/dexter-bridge 0.12.2 → 0.13.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/LICENSE +16 -0
- package/package.json +1 -1
- package/src/agent.js +166 -55
- package/src/agentOutput.js +6 -0
- package/src/api.js +75 -0
- package/src/cli.js +160 -110
- package/src/config.js +22 -2
- package/src/framerRequirementContract.js +303 -0
- package/src/framerTools.js +215 -56
- package/src/protocol.js +66 -5
- package/src/providers/codexAppServer.js +50 -34
- package/src/taskEvidence.js +245 -0
- package/src/webflowTools.js +1045 -0
- package/src/framerBehaviorContract.js +0 -229
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// The model turns the user's request into requirements it can check: a value
|
|
2
|
+
// a layer must carry, a link, a size, a distance, a layer that must stay
|
|
3
|
+
// inside its parent. This module checks them against live Framer data, so a
|
|
4
|
+
// requirement counts as met because Framer shows it, not because the model
|
|
5
|
+
// says so. Dexter does not model Framer's features: the model names the layer
|
|
6
|
+
// and the property, and this module reads what is there.
|
|
7
|
+
|
|
8
|
+
const MAX_SEARCH_DEPTH = 6;
|
|
9
|
+
const MAX_OBSERVED_CHARS = 1000;
|
|
10
|
+
const DEFAULT_TOLERANCE_PX = 2;
|
|
11
|
+
export const MEASURES = ['width', 'height', 'gap', 'insideParent'];
|
|
12
|
+
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function text(value, maximum = 500) {
|
|
18
|
+
return typeof value === 'string'
|
|
19
|
+
? value.trim().slice(0, maximum)
|
|
20
|
+
: '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function uniqueStrings(values, maximum = 50) {
|
|
24
|
+
return Array.from(new Set(
|
|
25
|
+
(Array.isArray(values) ? values : [])
|
|
26
|
+
.map((value) => text(value, 500))
|
|
27
|
+
.filter(Boolean),
|
|
28
|
+
)).slice(0, maximum);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function contractError(code, message) {
|
|
32
|
+
return Object.assign(new Error(message), { code, statusCode: 400 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeRequirement(raw, index) {
|
|
36
|
+
const id = text(raw?.id, 120);
|
|
37
|
+
const description = text(raw?.text, 500);
|
|
38
|
+
if (!isRecord(raw) || !id || description.length < 3) {
|
|
39
|
+
throw contractError(
|
|
40
|
+
'FRAMER_REQUIREMENT_INVALID',
|
|
41
|
+
`Requirement ${index + 1} needs an id and the text of what must be true.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return { id, text: description, nodeIds: uniqueStrings(raw.nodeIds, 50) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeRequirements(rawRequirements) {
|
|
48
|
+
const requirements = (Array.isArray(rawRequirements) ? rawRequirements : [])
|
|
49
|
+
.slice(0, 40)
|
|
50
|
+
.map(normalizeRequirement);
|
|
51
|
+
const ids = new Set();
|
|
52
|
+
for (const requirement of requirements) {
|
|
53
|
+
if (ids.has(requirement.id)) {
|
|
54
|
+
throw contractError(
|
|
55
|
+
'FRAMER_REQUIREMENT_INVALID',
|
|
56
|
+
`Requirement ids must be unique; "${requirement.id}" is duplicated.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
ids.add(requirement.id);
|
|
60
|
+
}
|
|
61
|
+
return requirements;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function mergeRequirements(current, next) {
|
|
65
|
+
const byId = new Map(current.map((item) => [item.id, item]));
|
|
66
|
+
next.forEach((item) => byId.set(item.id, item));
|
|
67
|
+
return [...byId.values()];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function finiteNumber(value) {
|
|
71
|
+
const number = Number(value);
|
|
72
|
+
return Number.isFinite(number) ? number : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function normalizeChecks(rawChecks, requirements) {
|
|
76
|
+
const requirementIds = new Set(requirements.map((item) => item.id));
|
|
77
|
+
const checks = (Array.isArray(rawChecks) ? rawChecks : []).slice(0, 80);
|
|
78
|
+
if (!checks.length) {
|
|
79
|
+
throw contractError(
|
|
80
|
+
'FRAMER_CHECKS_REQUIRED',
|
|
81
|
+
'Send at least one check: a requirement id, a layer id, and either a property or a measure.',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return checks.map((raw, index) => {
|
|
85
|
+
const requirementId = text(raw?.requirementId, 120);
|
|
86
|
+
const nodeId = text(raw?.nodeId, 200);
|
|
87
|
+
const property = text(raw?.property, 120);
|
|
88
|
+
const measure = text(raw?.measure, 40);
|
|
89
|
+
const invalid = (reason) => contractError(
|
|
90
|
+
'FRAMER_CHECK_INVALID',
|
|
91
|
+
`Check ${index + 1} ${reason}`,
|
|
92
|
+
);
|
|
93
|
+
if (!isRecord(raw) || !requirementIds.has(requirementId) || !nodeId) {
|
|
94
|
+
throw invalid(`must name a requirement from your plan (${[...requirementIds].join(', ') || 'none planned'}) and a layer id.`);
|
|
95
|
+
}
|
|
96
|
+
if (Boolean(property) === Boolean(measure)) {
|
|
97
|
+
throw invalid('needs either a property or a measure, not both.');
|
|
98
|
+
}
|
|
99
|
+
if (property) {
|
|
100
|
+
return { requirementId, nodeId, property, contains: text(raw.contains, 500) };
|
|
101
|
+
}
|
|
102
|
+
if (!MEASURES.includes(measure)) {
|
|
103
|
+
throw invalid(`has an unknown measure; use one of ${MEASURES.join(', ')}.`);
|
|
104
|
+
}
|
|
105
|
+
const otherNodeId = text(raw.otherNodeId, 200);
|
|
106
|
+
if (measure === 'gap' && !otherNodeId) throw invalid('measures a gap, so it needs otherNodeId.');
|
|
107
|
+
const expected = finiteNumber(raw.expected);
|
|
108
|
+
if (measure !== 'insideParent' && expected === null) {
|
|
109
|
+
throw invalid(`measures ${measure}, so it needs the expected number of pixels.`);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
requirementId,
|
|
113
|
+
nodeId,
|
|
114
|
+
measure,
|
|
115
|
+
...(otherNodeId ? { otherNodeId } : {}),
|
|
116
|
+
...(expected !== null ? { expected } : {}),
|
|
117
|
+
tolerance: Math.max(0, finiteNumber(raw.tolerance) ?? DEFAULT_TOLERANCE_PX),
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function checkNodeIds(checks) {
|
|
123
|
+
return uniqueStrings(checks.filter((check) => check.property).map((check) => check.nodeId), 200);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function measureChecks(checks) {
|
|
127
|
+
return checks.filter((check) => check.measure);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function hasContent(value) {
|
|
131
|
+
if (value === undefined || value === null || value === '') return false;
|
|
132
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
133
|
+
if (isRecord(value)) return Object.keys(value).length > 0;
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Finds the property on the node itself. Child nodes are skipped so a
|
|
138
|
+
// descendant cannot satisfy a claim about its parent. A dotted name such as
|
|
139
|
+
// `link.href` or `hoverEffect.opacity` reads the field inside the property's value.
|
|
140
|
+
function propertyValues(node, property) {
|
|
141
|
+
const values = keyValues(node, property);
|
|
142
|
+
const [head, ...path] = property.split('.');
|
|
143
|
+
if (values.length > 0 || path.length === 0) return values;
|
|
144
|
+
return keyValues(node, head)
|
|
145
|
+
.map((value) => path.reduce(
|
|
146
|
+
(current, key) => (isRecord(current) ? current[key] : undefined),
|
|
147
|
+
value,
|
|
148
|
+
))
|
|
149
|
+
.filter(hasContent);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function keyValues(node, property) {
|
|
153
|
+
const values = [];
|
|
154
|
+
const visit = (current, depth) => {
|
|
155
|
+
if (depth > MAX_SEARCH_DEPTH || values.length >= 10) return;
|
|
156
|
+
if (Array.isArray(current)) {
|
|
157
|
+
current.forEach((item) => visit(item, depth + 1));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!isRecord(current)) return;
|
|
161
|
+
for (const [key, nested] of Object.entries(current)) {
|
|
162
|
+
if (key === 'children') continue;
|
|
163
|
+
if (key === property) {
|
|
164
|
+
if (hasContent(nested)) values.push(nested);
|
|
165
|
+
} else {
|
|
166
|
+
visit(nested, depth + 1);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
visit(node, 0);
|
|
171
|
+
return values;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function serialized(value) {
|
|
175
|
+
try {
|
|
176
|
+
return JSON.stringify(value);
|
|
177
|
+
} catch {
|
|
178
|
+
return '';
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function propertyVerdict(check, nodes) {
|
|
183
|
+
const node = nodes instanceof Map ? nodes.get(check.nodeId) : nodes?.[check.nodeId];
|
|
184
|
+
if (!node) return { ...check, verdict: 'unknown', reason: `layer ${check.nodeId} was not found` };
|
|
185
|
+
const values = propertyValues(node, check.property);
|
|
186
|
+
if (values.length === 0) {
|
|
187
|
+
return { ...check, verdict: 'unknown', reason: `${check.property} is not set on ${check.nodeId}` };
|
|
188
|
+
}
|
|
189
|
+
const observed = serialized(values.length === 1 ? values[0] : values);
|
|
190
|
+
if (check.contains && !observed.toLowerCase().includes(check.contains.toLowerCase())) {
|
|
191
|
+
return {
|
|
192
|
+
...check,
|
|
193
|
+
verdict: 'contradicted',
|
|
194
|
+
reason: `${check.property} on ${check.nodeId} is ${observed.slice(0, 200)}, not ${check.contains}`,
|
|
195
|
+
observedValue: observed.slice(0, MAX_OBSERVED_CHARS),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return { ...check, verdict: 'verified', observedValue: observed.slice(0, MAX_OBSERVED_CHARS) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// `measured` holds what the measuring program found for each measure check,
|
|
202
|
+
// in the same order: { value } in pixels, { inside, overflow } for
|
|
203
|
+
// insideParent, or { error }.
|
|
204
|
+
function measureVerdict(check, measured) {
|
|
205
|
+
if (!measured || measured.error) {
|
|
206
|
+
return { ...check, verdict: 'unknown', reason: measured?.error || `${check.nodeId} could not be measured` };
|
|
207
|
+
}
|
|
208
|
+
if (check.measure === 'insideParent') {
|
|
209
|
+
return measured.inside
|
|
210
|
+
? { ...check, verdict: 'verified', observedValue: 'inside its parent' }
|
|
211
|
+
: {
|
|
212
|
+
...check,
|
|
213
|
+
verdict: 'contradicted',
|
|
214
|
+
reason: `${check.nodeId} sticks out of its parent by ${measured.overflow}px`,
|
|
215
|
+
observedValue: `overflows by ${measured.overflow}px`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const value = Number(measured.value);
|
|
219
|
+
const label = check.measure === 'gap' ? `gap between ${check.nodeId} and ${check.otherNodeId}` : `${check.measure} of ${check.nodeId}`;
|
|
220
|
+
if (Math.abs(value - check.expected) <= check.tolerance) {
|
|
221
|
+
return { ...check, verdict: 'verified', observedValue: `${value}px` };
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
...check,
|
|
225
|
+
verdict: 'contradicted',
|
|
226
|
+
reason: `${label} measures ${value}px, not ${check.expected}px`,
|
|
227
|
+
observedValue: `${value}px`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function verifyChecks({ checks, nodes, measurements = [] }) {
|
|
232
|
+
let measureIndex = 0;
|
|
233
|
+
return checks.map((check) => (check.property
|
|
234
|
+
? propertyVerdict(check, nodes)
|
|
235
|
+
: measureVerdict(check, measurements[measureIndex++])));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Summarizes the latest check results for every requirement. A requirement is
|
|
239
|
+
// met once every layer it names has a verified check (or, naming none, once
|
|
240
|
+
// one check verified it) and no check contradicts it.
|
|
241
|
+
// status: missing (a requirement or one of its layers was never checked),
|
|
242
|
+
// contradicted (Framer shows something else), unverified (a property or
|
|
243
|
+
// layer could not be found), or verified.
|
|
244
|
+
export function summarizeRequirements(requirements, checkResults) {
|
|
245
|
+
const items = requirements.map((requirement) => {
|
|
246
|
+
const results = checkResults.filter((result) => result.requirementId === requirement.id);
|
|
247
|
+
const verifiedNodeIds = new Set(
|
|
248
|
+
results.filter((result) => result.verdict === 'verified').map((result) => result.nodeId),
|
|
249
|
+
);
|
|
250
|
+
const contradictions = results
|
|
251
|
+
.filter((result) => result.verdict === 'contradicted')
|
|
252
|
+
.map((result) => result.reason);
|
|
253
|
+
const checkedNodeIds = new Set(results.map((result) => result.nodeId));
|
|
254
|
+
const uncheckedNodeIds = requirement.nodeIds.filter((nodeId) => !checkedNodeIds.has(nodeId));
|
|
255
|
+
const covered = requirement.nodeIds.length > 0
|
|
256
|
+
? requirement.nodeIds.every((nodeId) => verifiedNodeIds.has(nodeId))
|
|
257
|
+
: verifiedNodeIds.size > 0;
|
|
258
|
+
return {
|
|
259
|
+
requirementId: requirement.id,
|
|
260
|
+
text: requirement.text,
|
|
261
|
+
checked: results.length > 0 && uncheckedNodeIds.length === 0,
|
|
262
|
+
verdict: contradictions.length > 0 ? 'contradicted' : covered ? 'verified' : 'unknown',
|
|
263
|
+
uncheckedNodeIds,
|
|
264
|
+
contradictions: uniqueStrings(contradictions, 20),
|
|
265
|
+
unknowns: uniqueStrings(
|
|
266
|
+
results.filter((result) => result.verdict === 'unknown').map((result) => result.reason),
|
|
267
|
+
20,
|
|
268
|
+
),
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
const status = items.some((item) => !item.checked)
|
|
272
|
+
? 'missing'
|
|
273
|
+
: items.some((item) => item.verdict === 'contradicted')
|
|
274
|
+
? 'contradicted'
|
|
275
|
+
: items.some((item) => item.verdict === 'unknown')
|
|
276
|
+
? 'unverified'
|
|
277
|
+
: 'verified';
|
|
278
|
+
return { status, requirements: items };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Compares two snapshots of layers that had to stay as they were. A snapshot
|
|
282
|
+
// maps a layer id to { name, rect, props } where props is the serialized
|
|
283
|
+
// layer without children.
|
|
284
|
+
export function unchangedViolations(before, after, tolerance = 1) {
|
|
285
|
+
const changes = [];
|
|
286
|
+
for (const [id, earlier] of Object.entries(before || {})) {
|
|
287
|
+
const later = after?.[id];
|
|
288
|
+
const name = earlier.name || id;
|
|
289
|
+
if (!later) {
|
|
290
|
+
changes.push(`${name} was removed`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const a = earlier.rect;
|
|
294
|
+
const b = later.rect;
|
|
295
|
+
if (a && b && ['x', 'y', 'width', 'height'].some((key) => Math.abs(a[key] - b[key]) > tolerance)) {
|
|
296
|
+
changes.push(`${name} moved or resized (${a.width}×${a.height} at ${a.x},${a.y} → ${b.width}×${b.height} at ${b.x},${b.y})`);
|
|
297
|
+
} else if (earlier.props !== later.props) {
|
|
298
|
+
changes.push(`${name} has different settings`);
|
|
299
|
+
}
|
|
300
|
+
if (changes.length >= 20) break;
|
|
301
|
+
}
|
|
302
|
+
return changes;
|
|
303
|
+
}
|