@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gakim-digital/dexter-bridge",
3
- "version": "0.13.4",
3
+ "version": "0.13.6",
4
4
  "description": "Local bridge for InstaWebAI and Dexter — runs Codex, Claude Code, or OpenCode on your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 the serialized
309
- // layer without children and rect is null when Dexter could not measure the
310
- // layer, so only its settings are compared. Boxes are compared only when both
311
- // readings came from the same source (the user's editor or Framer MCP).
312
- export function unchangedViolations(before, after, tolerance = 1) {
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
- if (!later) {
318
- changes.push(`${name} was removed`);
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.rect;
353
+ const b = later?.rect;
323
354
  const comparable = a && b && (a.source || null) === (b.source || null);
324
- if (comparable && ['x', 'y', 'width', 'height'].some((key) => Math.abs(a[key] - b[key]) > tolerance)) {
325
- changes.push(`${name} moved or resized (${a.width}×${a.height} at ${a.x},${a.y} → ${b.width}×${b.height} at ${b.x},${b.y})`);
326
- } else if (earlier.props !== later.props) {
327
- changes.push(`${name} has different settings`);
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;
@@ -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
- export function verifyProgram(propertyNodeIds, measures) {
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 nodes = await tree.serializeNodes(${JSON.stringify(propertyNodeIds)}, { depth: 0 });
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, and a fingerprint of its settings, for every
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
- out[id] = { name: node.name || null, rect: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null, props };
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 result = await client.exec(verifyProgram(checkNodeIds(checks), measures.filter((_, index) => !fromEditor[index])));
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).slice(0, UNCHANGED_LAYER_LIMIT));
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
- const propertyErrors = lingeringPropertyErrors();
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
- if (unchangedChanges.length) {
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: unchangedChanges.join('; ').slice(0, 2000),
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