@gakim-digital/dexter-bridge 0.13.4 → 0.13.6
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/package.json +1 -1
- package/src/framerRequirementContract.js +55 -15
- package/src/framerTools.js +44 -17
package/package.json
CHANGED
|
@@ -67,6 +67,20 @@ export function mergeRequirements(current, next) {
|
|
|
67
67
|
return [...byId.values()];
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// A requirement may name layers its own edit creates by their temporary ids,
|
|
71
|
+
// or a breakpoint copy of one as the breakpoint id followed by that temporary
|
|
72
|
+
// id. Once Framer gives those layers real ids, the requirement names them.
|
|
73
|
+
export function renameRequirementNodes(requirements, renamedIds = {}) {
|
|
74
|
+
const renames = Object.entries(renamedIds || {}).filter(([from, to]) => from && to && from !== to);
|
|
75
|
+
if (!renames.length) return requirements;
|
|
76
|
+
const rename = (id) => {
|
|
77
|
+
if (renamedIds[id]) return renamedIds[id];
|
|
78
|
+
const copy = renames.find(([from]) => id.endsWith(from) && /^[A-Za-z0-9_-]{9}$/.test(id.slice(0, -from.length)));
|
|
79
|
+
return copy ? `${id.slice(0, -copy[0].length)}${copy[1]}` : id;
|
|
80
|
+
};
|
|
81
|
+
return requirements.map((item) => ({ ...item, nodeIds: [...new Set(item.nodeIds.map(rename))] }));
|
|
82
|
+
}
|
|
83
|
+
|
|
70
84
|
function finiteNumber(value) {
|
|
71
85
|
const number = Number(value);
|
|
72
86
|
return Number.isFinite(number) ? number : null;
|
|
@@ -140,6 +154,13 @@ function hasContent(value) {
|
|
|
140
154
|
// descendant cannot satisfy a claim about its parent. A dotted name such as
|
|
141
155
|
// `link.href` or `hoverEffect.opacity` reads the field inside the property's value.
|
|
142
156
|
function propertyValues(node, property) {
|
|
157
|
+
// Child layers are named by id, name, type, and text, not their settings.
|
|
158
|
+
if (property === 'children') {
|
|
159
|
+
const children = Array.isArray(node.children) ? node.children.filter(isRecord) : [];
|
|
160
|
+
return children.length
|
|
161
|
+
? [children.map((child) => ({ id: child.id, name: child.name ?? null, type: child.type, ...(child.text ? { text: child.text } : {}) }))]
|
|
162
|
+
: [];
|
|
163
|
+
}
|
|
143
164
|
const values = keyValues(node, property);
|
|
144
165
|
const [head, ...path] = property.split('.');
|
|
145
166
|
if (values.length > 0 || path.length === 0) return values;
|
|
@@ -305,27 +326,46 @@ export function summarizeRequirements(requirements, checkResults) {
|
|
|
305
326
|
}
|
|
306
327
|
|
|
307
328
|
// Compares two snapshots of layers that had to stay as they were. A snapshot
|
|
308
|
-
// maps a layer id to { name, rect, props } where props is
|
|
309
|
-
// layer without children
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
329
|
+
// maps a layer id to { name, origin, rect, props, children } where props is
|
|
330
|
+
// the serialized layer without children, origin is the layer a breakpoint or
|
|
331
|
+
// variant copy follows, rect is null when Dexter could not measure the layer,
|
|
332
|
+
// so only its settings are compared, and children lists its child layers in
|
|
333
|
+
// order as { id, name, origin }. Sizes are compared only when both readings
|
|
334
|
+
// came from the same source (the user's editor or Framer MCP), and only size
|
|
335
|
+
// counts, not position: a section that grows moves every section below it.
|
|
336
|
+
// The order of child layers does count: a layer inserted without an index
|
|
337
|
+
// lands at the end of its parent.
|
|
338
|
+
// A change is edited when the run edited the layer, the layer it follows, or
|
|
339
|
+
// for a new order one of the child layers; any other change happened without
|
|
340
|
+
// the model touching the layer.
|
|
341
|
+
function childOrder(layer) {
|
|
342
|
+
return layer.children.map((child) => child.id).join(',');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function unchangedViolations(before, after, edited = new Set(), tolerance = 1) {
|
|
313
346
|
const changes = [];
|
|
314
347
|
for (const [id, earlier] of Object.entries(before || {})) {
|
|
315
348
|
const later = after?.[id];
|
|
316
349
|
const name = earlier.name || id;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
350
|
+
const editedLayer = (layer) => edited.has(layer.id) || Boolean(layer.origin && edited.has(layer.origin));
|
|
351
|
+
let source = edited.has(id) ? 'self' : earlier.origin && edited.has(earlier.origin) ? 'origin' : null;
|
|
321
352
|
const a = earlier.rect;
|
|
322
|
-
const b = later
|
|
353
|
+
const b = later?.rect;
|
|
323
354
|
const comparable = a && b && (a.source || null) === (b.source || null);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
355
|
+
let text = null;
|
|
356
|
+
if (!later) text = `${name} was removed`;
|
|
357
|
+
else if (comparable && ['width', 'height'].some((key) => Math.abs(a[key] - b[key]) > tolerance)) {
|
|
358
|
+
text = `${name} changed size (${a.width}×${a.height} → ${b.width}×${b.height})`;
|
|
359
|
+
} else if (earlier.children && later.children && childOrder(earlier) !== childOrder(later)) {
|
|
360
|
+
text = `${name} now holds its layers in this order: ${later.children.slice(0, 12).map((child) => child.name || child.id).join(', ')}`;
|
|
361
|
+
if (!source && [...earlier.children, ...later.children].some(editedLayer)) source = 'children';
|
|
362
|
+
} else if (earlier.props !== later.props) text = `${name} has different settings`;
|
|
363
|
+
const note = source === 'self'
|
|
364
|
+
? ' (you edited it)'
|
|
365
|
+
: source === 'origin'
|
|
366
|
+
? ` (it follows your edit of ${earlier.origin})`
|
|
367
|
+
: source === 'children' ? ' (you edited layers inside it)' : '';
|
|
368
|
+
if (text) changes.push({ text: `${text}${note}`, edited: Boolean(source) });
|
|
329
369
|
if (changes.length >= 20) break;
|
|
330
370
|
}
|
|
331
371
|
return changes;
|
package/src/framerTools.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
normalizeChecks,
|
|
10
10
|
normalizeRequirements,
|
|
11
11
|
summarizeRequirements,
|
|
12
|
+
renameRequirementNodes,
|
|
12
13
|
unchangedViolations,
|
|
13
14
|
verifyChecks,
|
|
14
15
|
} from './framerRequirementContract.js';
|
|
@@ -76,7 +77,7 @@ const CHECK_SCHEMA = {
|
|
|
76
77
|
properties: {
|
|
77
78
|
requirementId: { type: 'string' },
|
|
78
79
|
nodeId: { type: 'string' },
|
|
79
|
-
property: { type: 'string', description: 'A Framer property to read, such as fill, link.href, or text. Use either property or measure.' },
|
|
80
|
+
property: { type: 'string', description: 'A Framer property to read, such as fill, link.href, or text; children lists the child layers of the layer by name. Use either property or measure.' },
|
|
80
81
|
contains: { type: 'string', description: 'Text the property value must contain, such as #151515 or /contact.' },
|
|
81
82
|
absent: { type: 'boolean', description: 'True when the requirement is that the property is not set on the layer, such as no appearEffect on a background image.' },
|
|
82
83
|
measure: { type: 'string', enum: MEASURES, description: 'width, height, gap (to otherNodeId), or insideParent (the layer stays inside its parent).' },
|
|
@@ -789,10 +790,15 @@ function helpersUsed(program) {
|
|
|
789
790
|
|
|
790
791
|
// Reads what framer_verify needs in one program: the layers whose properties
|
|
791
792
|
// are checked, and each measure in order.
|
|
792
|
-
|
|
793
|
+
// Layers whose checks name children are read one level deep so their
|
|
794
|
+
// child layers can be checked.
|
|
795
|
+
export function verifyProgram(propertyNodeIds, measures, childNodeIds = []) {
|
|
793
796
|
return `${MEASURE_PRELUDE}
|
|
794
797
|
const tree = api.tree;
|
|
795
|
-
const
|
|
798
|
+
const withChildren = new Set(${JSON.stringify(childNodeIds)});
|
|
799
|
+
const flat = await tree.serializeNodes(${JSON.stringify(propertyNodeIds)}.filter((id) => !withChildren.has(id)), { depth: 0 });
|
|
800
|
+
const nested = withChildren.size ? await tree.serializeNodes([...withChildren], { depth: 1 }) : [];
|
|
801
|
+
const nodes = [...(flat || []), ...(nested || [])];
|
|
796
802
|
const measured = [];
|
|
797
803
|
for (const check of ${JSON.stringify(measures)}) {
|
|
798
804
|
try {
|
|
@@ -824,8 +830,9 @@ return { nodes, measured };`;
|
|
|
824
830
|
}
|
|
825
831
|
|
|
826
832
|
// Records layers the user wants left as they are: each layer's measured box,
|
|
827
|
-
// when Framer has laid it out,
|
|
828
|
-
// layer inside them.
|
|
833
|
+
// when Framer has laid it out, a fingerprint of its settings, and the layer a
|
|
834
|
+
// breakpoint or variant copy follows, for every layer inside them. The read
|
|
835
|
+
// after the run passes walk: false to read back exactly the recorded layers.
|
|
829
836
|
// Layers name colour styles only as var(--token-id). Reads carry the style's
|
|
830
837
|
// name and values next to them, so a model can tell a dark page colour from
|
|
831
838
|
// a light surface without guessing from the id.
|
|
@@ -851,14 +858,16 @@ export function describeColorStyle(style) {
|
|
|
851
858
|
return `${style?.name || 'Unnamed colour style'}${values ? ` (${values})` : ''}`;
|
|
852
859
|
}
|
|
853
860
|
|
|
854
|
-
export function snapshotProgram(rootIds) {
|
|
861
|
+
export function snapshotProgram(rootIds, { walk = true } = {}) {
|
|
855
862
|
return `${LAYOUT_CHECK}
|
|
856
863
|
const tree = api.tree;
|
|
857
864
|
const fingerprint = (text) => { let hash = 2166136261; for (let i = 0; i < text.length; i += 1) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 16777619); } return (hash >>> 0).toString(36); };
|
|
858
865
|
const ids = new Set();
|
|
859
866
|
for (const root of ${JSON.stringify(rootIds)}) {
|
|
867
|
+
if (${walk ? 'true' : 'false'} && ids.size >= ${UNCHANGED_LAYER_LIMIT}) break;
|
|
860
868
|
if (!tree.get(root)) continue;
|
|
861
869
|
ids.add(root);
|
|
870
|
+
if (!${walk ? 'true' : 'false'}) continue;
|
|
862
871
|
for (const node of tree.walk(root)) {
|
|
863
872
|
if (ids.size >= ${UNCHANGED_LAYER_LIMIT}) break;
|
|
864
873
|
if (node && node.id) ids.add(node.id);
|
|
@@ -871,7 +880,12 @@ for (const id of ids) {
|
|
|
871
880
|
const rect = (await __dexterLaidOut(id)) ? await __dexterRect(id) : null;
|
|
872
881
|
let props = '';
|
|
873
882
|
try { const { children, ...rest } = JSON.parse(JSON.stringify(node)); props = fingerprint(JSON.stringify(rest)); } catch {}
|
|
874
|
-
|
|
883
|
+
const children = (Array.isArray(node.children) ? node.children : []).slice(0, 60).map((child) => {
|
|
884
|
+
const childId = child && typeof child === 'object' ? child.id : child;
|
|
885
|
+
const childNode = childId ? tree.get(childId) : null;
|
|
886
|
+
return { id: childId, name: (childNode && childNode.name) || null, origin: (childNode && childNode.$originalId) || null };
|
|
887
|
+
}).filter((child) => child.id);
|
|
888
|
+
out[id] = { name: node.name || null, origin: node.$originalId || null, rect: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null, props, children };
|
|
875
889
|
}
|
|
876
890
|
return out;`;
|
|
877
891
|
}
|
|
@@ -923,6 +937,7 @@ export function createFramerAgentToolRuntime({
|
|
|
923
937
|
const unchangedBefore = {};
|
|
924
938
|
let unchangedPending = [];
|
|
925
939
|
let unchangedChanges = [];
|
|
940
|
+
const weighedPropertyErrors = new Set();
|
|
926
941
|
const colorStyles = new Map();
|
|
927
942
|
let promptContext = {};
|
|
928
943
|
// Set once the panel fails to answer, so a closed panel does not cost a
|
|
@@ -974,6 +989,10 @@ export function createFramerAgentToolRuntime({
|
|
|
974
989
|
|
|
975
990
|
const client = mcpClient || createFramerMcpClient({ getAccessToken: tokenGetter });
|
|
976
991
|
|
|
992
|
+
function propertyErrorKey(error) {
|
|
993
|
+
return `${error.nodeId}.${error.property}`;
|
|
994
|
+
}
|
|
995
|
+
|
|
977
996
|
function lingeringPropertyErrors() {
|
|
978
997
|
let open = [];
|
|
979
998
|
for (const receipt of mutationReceipts) {
|
|
@@ -1181,8 +1200,8 @@ export function createFramerAgentToolRuntime({
|
|
|
1181
1200
|
// Records the layers the plan said to leave as they are. Boxes come from the
|
|
1182
1201
|
// editor where it measured them, otherwise from Framer MCP where that page is
|
|
1183
1202
|
// laid out; each records its source so before and after compare like with like.
|
|
1184
|
-
async function snapshotLayers(roots) {
|
|
1185
|
-
const snapshot = outputOf(await client.exec(snapshotProgram(roots))).result;
|
|
1203
|
+
async function snapshotLayers(roots, options) {
|
|
1204
|
+
const snapshot = outputOf(await client.exec(snapshotProgram(roots, options))).result;
|
|
1186
1205
|
if (!snapshot || typeof snapshot !== 'object') return null;
|
|
1187
1206
|
for (const entry of Object.values(snapshot)) if (entry?.rect) entry.rect.source = 'mcp';
|
|
1188
1207
|
const layers = await measureInEditor(Object.keys(snapshot));
|
|
@@ -1328,6 +1347,7 @@ export function createFramerAgentToolRuntime({
|
|
|
1328
1347
|
await snapshotUnchanged();
|
|
1329
1348
|
const result = await client.exec(wrapProgram(program));
|
|
1330
1349
|
const applied = applyReceipt(result, screenshotNodeIds);
|
|
1350
|
+
requirements = renameRequirementNodes(requirements, applied.receipt.renamedIds);
|
|
1331
1351
|
const programError = programErrorOf(applied.output, wrappedLineOffset(program));
|
|
1332
1352
|
// Layers created by this program only get their real ids once it
|
|
1333
1353
|
// finishes, so screenshots are taken in a second call.
|
|
@@ -1417,7 +1437,8 @@ export function createFramerAgentToolRuntime({
|
|
|
1417
1437
|
const measures = measureChecks(checks);
|
|
1418
1438
|
const layers = await measureInEditor(measures.flatMap((check) => [check.nodeId, check.otherNodeId]));
|
|
1419
1439
|
const fromEditor = measures.map((check) => editorMeasurement(check, layers));
|
|
1420
|
-
const
|
|
1440
|
+
const childNodeIds = [...new Set(checks.filter((check) => check.property === 'children').map((check) => check.nodeId))];
|
|
1441
|
+
const result = await client.exec(verifyProgram(checkNodeIds(checks), measures.filter((_, index) => !fromEditor[index]), childNodeIds));
|
|
1421
1442
|
const output = outputOf(result);
|
|
1422
1443
|
if (output.error) throw toolError('FRAMER_PROGRAM_FAILED', String(output.error).split('\n')[0].slice(0, 300));
|
|
1423
1444
|
const { nodes, measured: fromMcp } = output.result && typeof output.result === 'object' ? output.result : {};
|
|
@@ -1615,6 +1636,7 @@ export function createFramerAgentToolRuntime({
|
|
|
1615
1636
|
const missing = verification().missingChecks;
|
|
1616
1637
|
const propertyErrors = lingeringPropertyErrors();
|
|
1617
1638
|
const facts = [];
|
|
1639
|
+
for (const error of propertyErrors) weighedPropertyErrors.add(propertyErrorKey(error));
|
|
1618
1640
|
if (propertyErrors.length) {
|
|
1619
1641
|
facts.push(`Framer rejected these properties, so they are not set: ${propertyErrors.map((error) => `${error.nodeId}.${error.property}: ${error.message}`).join('; ')}.`);
|
|
1620
1642
|
}
|
|
@@ -1633,10 +1655,10 @@ export function createFramerAgentToolRuntime({
|
|
|
1633
1655
|
}
|
|
1634
1656
|
const unchecked = uncheckedRequirements();
|
|
1635
1657
|
if (unchecked.length) {
|
|
1636
|
-
facts.push(`These requirements from your plan have not been checked since your last change, so they are not confirmed either way: ${unchecked.map((item) => item.text).join('; ')}.`);
|
|
1658
|
+
facts.push(`These requirements from your plan have not been checked since your last change, so they are not confirmed either way: ${unchecked.map((item) => `${item.id}: ${item.text}`).join('; ')}.`);
|
|
1637
1659
|
}
|
|
1638
1660
|
if (unchangedChanges.length) {
|
|
1639
|
-
facts.push(`These layers you planned to leave as they were now read differently: ${unchangedChanges.join('; ')}.`);
|
|
1661
|
+
facts.push(`These layers you planned to leave as they were now read differently: ${unchangedChanges.map((change) => change.text).join('; ')}.`);
|
|
1640
1662
|
}
|
|
1641
1663
|
if (!facts.length) return null;
|
|
1642
1664
|
return [
|
|
@@ -1700,8 +1722,8 @@ export function createFramerAgentToolRuntime({
|
|
|
1700
1722
|
if (mutationCalls > 0 && Object.keys(unchangedBefore).length) {
|
|
1701
1723
|
try {
|
|
1702
1724
|
await ensureProject();
|
|
1703
|
-
const after = await snapshotLayers(Object.keys(unchangedBefore)
|
|
1704
|
-
unchangedChanges = after ? unchangedViolations(unchangedBefore, after) : [];
|
|
1725
|
+
const after = await snapshotLayers(Object.keys(unchangedBefore), { walk: false });
|
|
1726
|
+
unchangedChanges = after ? unchangedViolations(unchangedBefore, after, known) : [];
|
|
1705
1727
|
readCalls += 1;
|
|
1706
1728
|
} catch {
|
|
1707
1729
|
unchangedChanges = [];
|
|
@@ -1739,7 +1761,9 @@ export function createFramerAgentToolRuntime({
|
|
|
1739
1761
|
output: 'No layer changed for a summary that claimed a change.',
|
|
1740
1762
|
});
|
|
1741
1763
|
}
|
|
1742
|
-
|
|
1764
|
+
// A rejected property the model was shown and then finished without is
|
|
1765
|
+
// its judgment, such as reaching the goal another way.
|
|
1766
|
+
const propertyErrors = lingeringPropertyErrors().filter((error) => !weighedPropertyErrors.has(propertyErrorKey(error)));
|
|
1743
1767
|
if (propertyErrors.length) {
|
|
1744
1768
|
checks.push({
|
|
1745
1769
|
command: PROPERTY_ERROR_CHECK,
|
|
@@ -1774,11 +1798,14 @@ export function createFramerAgentToolRuntime({
|
|
|
1774
1798
|
.slice(0, 2000),
|
|
1775
1799
|
});
|
|
1776
1800
|
}
|
|
1777
|
-
|
|
1801
|
+
// A layer the model edited, or one that follows its edit, is a change it
|
|
1802
|
+
// made and weighed; only changes nothing in the run explains fail it.
|
|
1803
|
+
const unexplained = unchangedChanges.filter((change) => !change.edited);
|
|
1804
|
+
if (unexplained.length) {
|
|
1778
1805
|
checks.push({
|
|
1779
1806
|
command: UNCHANGED_CHECK,
|
|
1780
1807
|
status: 'failed',
|
|
1781
|
-
output:
|
|
1808
|
+
output: unexplained.map((change) => change.text).join('; ').slice(0, 2000),
|
|
1782
1809
|
});
|
|
1783
1810
|
}
|
|
1784
1811
|
// The model's own plan sets the evidence it owes; a run that changed the
|