@gakim-digital/dexter-bridge 0.13.4 → 0.13.5

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.5",
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": {
@@ -140,6 +140,13 @@ function hasContent(value) {
140
140
  // descendant cannot satisfy a claim about its parent. A dotted name such as
141
141
  // `link.href` or `hoverEffect.opacity` reads the field inside the property's value.
142
142
  function propertyValues(node, property) {
143
+ // Child layers are named by id, name, type, and text, not their settings.
144
+ if (property === 'children') {
145
+ const children = Array.isArray(node.children) ? node.children.filter(isRecord) : [];
146
+ return children.length
147
+ ? [children.map((child) => ({ id: child.id, name: child.name ?? null, type: child.type, ...(child.text ? { text: child.text } : {}) }))]
148
+ : [];
149
+ }
143
150
  const values = keyValues(node, property);
144
151
  const [head, ...path] = property.split('.');
145
152
  if (values.length > 0 || path.length === 0) return values;
@@ -305,27 +312,31 @@ export function summarizeRequirements(requirements, checkResults) {
305
312
  }
306
313
 
307
314
  // 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) {
315
+ // maps a layer id to { name, origin, rect, props } where props is the
316
+ // serialized layer without children, origin is the layer a breakpoint or
317
+ // variant copy follows, and rect is null when Dexter could not measure the
318
+ // layer, so only its settings are compared. Sizes are compared only when both
319
+ // readings came from the same source (the user's editor or Framer MCP), and
320
+ // only size counts, not position: a section that grows moves every section
321
+ // below it.
322
+ // A change is edited when the run edited the layer, or the layer it follows;
323
+ // any other change happened without the model touching the layer.
324
+ export function unchangedViolations(before, after, edited = new Set(), tolerance = 1) {
313
325
  const changes = [];
314
326
  for (const [id, earlier] of Object.entries(before || {})) {
315
327
  const later = after?.[id];
316
328
  const name = earlier.name || id;
317
- if (!later) {
318
- changes.push(`${name} was removed`);
319
- continue;
320
- }
329
+ const source = edited.has(id) ? 'self' : earlier.origin && edited.has(earlier.origin) ? 'origin' : null;
330
+ const note = source === 'self' ? ' (you edited it)' : source === 'origin' ? ` (it follows your edit of ${earlier.origin})` : '';
321
331
  const a = earlier.rect;
322
- const b = later.rect;
332
+ const b = later?.rect;
323
333
  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
- }
334
+ let text = null;
335
+ if (!later) text = `${name} was removed`;
336
+ else if (comparable && ['width', 'height'].some((key) => Math.abs(a[key] - b[key]) > tolerance)) {
337
+ text = `${name} changed size (${a.width}×${a.height} → ${b.width}×${b.height})`;
338
+ } else if (earlier.props !== later.props) text = `${name} has different settings`;
339
+ if (text) changes.push({ text: `${text}${note}`, edited: Boolean(source) });
329
340
  if (changes.length >= 20) break;
330
341
  }
331
342
  return changes;
@@ -76,7 +76,7 @@ const CHECK_SCHEMA = {
76
76
  properties: {
77
77
  requirementId: { type: 'string' },
78
78
  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.' },
79
+ 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
80
  contains: { type: 'string', description: 'Text the property value must contain, such as #151515 or /contact.' },
81
81
  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
82
  measure: { type: 'string', enum: MEASURES, description: 'width, height, gap (to otherNodeId), or insideParent (the layer stays inside its parent).' },
@@ -789,10 +789,15 @@ function helpersUsed(program) {
789
789
 
790
790
  // Reads what framer_verify needs in one program: the layers whose properties
791
791
  // are checked, and each measure in order.
792
- export function verifyProgram(propertyNodeIds, measures) {
792
+ // Layers whose checks name children are read one level deep so their
793
+ // child layers can be checked.
794
+ export function verifyProgram(propertyNodeIds, measures, childNodeIds = []) {
793
795
  return `${MEASURE_PRELUDE}
794
796
  const tree = api.tree;
795
- const nodes = await tree.serializeNodes(${JSON.stringify(propertyNodeIds)}, { depth: 0 });
797
+ const withChildren = new Set(${JSON.stringify(childNodeIds)});
798
+ const flat = await tree.serializeNodes(${JSON.stringify(propertyNodeIds)}.filter((id) => !withChildren.has(id)), { depth: 0 });
799
+ const nested = withChildren.size ? await tree.serializeNodes([...withChildren], { depth: 1 }) : [];
800
+ const nodes = [...(flat || []), ...(nested || [])];
796
801
  const measured = [];
797
802
  for (const check of ${JSON.stringify(measures)}) {
798
803
  try {
@@ -824,8 +829,9 @@ return { nodes, measured };`;
824
829
  }
825
830
 
826
831
  // 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.
832
+ // when Framer has laid it out, a fingerprint of its settings, and the layer a
833
+ // breakpoint or variant copy follows, for every layer inside them. The read
834
+ // after the run passes walk: false to read back exactly the recorded layers.
829
835
  // Layers name colour styles only as var(--token-id). Reads carry the style's
830
836
  // name and values next to them, so a model can tell a dark page colour from
831
837
  // a light surface without guessing from the id.
@@ -851,14 +857,16 @@ export function describeColorStyle(style) {
851
857
  return `${style?.name || 'Unnamed colour style'}${values ? ` (${values})` : ''}`;
852
858
  }
853
859
 
854
- export function snapshotProgram(rootIds) {
860
+ export function snapshotProgram(rootIds, { walk = true } = {}) {
855
861
  return `${LAYOUT_CHECK}
856
862
  const tree = api.tree;
857
863
  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
864
  const ids = new Set();
859
865
  for (const root of ${JSON.stringify(rootIds)}) {
866
+ if (${walk ? 'true' : 'false'} && ids.size >= ${UNCHANGED_LAYER_LIMIT}) break;
860
867
  if (!tree.get(root)) continue;
861
868
  ids.add(root);
869
+ if (!${walk ? 'true' : 'false'}) continue;
862
870
  for (const node of tree.walk(root)) {
863
871
  if (ids.size >= ${UNCHANGED_LAYER_LIMIT}) break;
864
872
  if (node && node.id) ids.add(node.id);
@@ -871,7 +879,7 @@ for (const id of ids) {
871
879
  const rect = (await __dexterLaidOut(id)) ? await __dexterRect(id) : null;
872
880
  let props = '';
873
881
  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 };
882
+ 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 };
875
883
  }
876
884
  return out;`;
877
885
  }
@@ -923,6 +931,7 @@ export function createFramerAgentToolRuntime({
923
931
  const unchangedBefore = {};
924
932
  let unchangedPending = [];
925
933
  let unchangedChanges = [];
934
+ const weighedPropertyErrors = new Set();
926
935
  const colorStyles = new Map();
927
936
  let promptContext = {};
928
937
  // Set once the panel fails to answer, so a closed panel does not cost a
@@ -974,6 +983,10 @@ export function createFramerAgentToolRuntime({
974
983
 
975
984
  const client = mcpClient || createFramerMcpClient({ getAccessToken: tokenGetter });
976
985
 
986
+ function propertyErrorKey(error) {
987
+ return `${error.nodeId}.${error.property}`;
988
+ }
989
+
977
990
  function lingeringPropertyErrors() {
978
991
  let open = [];
979
992
  for (const receipt of mutationReceipts) {
@@ -1181,8 +1194,8 @@ export function createFramerAgentToolRuntime({
1181
1194
  // Records the layers the plan said to leave as they are. Boxes come from the
1182
1195
  // editor where it measured them, otherwise from Framer MCP where that page is
1183
1196
  // 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;
1197
+ async function snapshotLayers(roots, options) {
1198
+ const snapshot = outputOf(await client.exec(snapshotProgram(roots, options))).result;
1186
1199
  if (!snapshot || typeof snapshot !== 'object') return null;
1187
1200
  for (const entry of Object.values(snapshot)) if (entry?.rect) entry.rect.source = 'mcp';
1188
1201
  const layers = await measureInEditor(Object.keys(snapshot));
@@ -1417,7 +1430,8 @@ export function createFramerAgentToolRuntime({
1417
1430
  const measures = measureChecks(checks);
1418
1431
  const layers = await measureInEditor(measures.flatMap((check) => [check.nodeId, check.otherNodeId]));
1419
1432
  const fromEditor = measures.map((check) => editorMeasurement(check, layers));
1420
- const result = await client.exec(verifyProgram(checkNodeIds(checks), measures.filter((_, index) => !fromEditor[index])));
1433
+ const childNodeIds = [...new Set(checks.filter((check) => check.property === 'children').map((check) => check.nodeId))];
1434
+ const result = await client.exec(verifyProgram(checkNodeIds(checks), measures.filter((_, index) => !fromEditor[index]), childNodeIds));
1421
1435
  const output = outputOf(result);
1422
1436
  if (output.error) throw toolError('FRAMER_PROGRAM_FAILED', String(output.error).split('\n')[0].slice(0, 300));
1423
1437
  const { nodes, measured: fromMcp } = output.result && typeof output.result === 'object' ? output.result : {};
@@ -1615,6 +1629,7 @@ export function createFramerAgentToolRuntime({
1615
1629
  const missing = verification().missingChecks;
1616
1630
  const propertyErrors = lingeringPropertyErrors();
1617
1631
  const facts = [];
1632
+ for (const error of propertyErrors) weighedPropertyErrors.add(propertyErrorKey(error));
1618
1633
  if (propertyErrors.length) {
1619
1634
  facts.push(`Framer rejected these properties, so they are not set: ${propertyErrors.map((error) => `${error.nodeId}.${error.property}: ${error.message}`).join('; ')}.`);
1620
1635
  }
@@ -1636,7 +1651,7 @@ export function createFramerAgentToolRuntime({
1636
1651
  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('; ')}.`);
1637
1652
  }
1638
1653
  if (unchangedChanges.length) {
1639
- facts.push(`These layers you planned to leave as they were now read differently: ${unchangedChanges.join('; ')}.`);
1654
+ facts.push(`These layers you planned to leave as they were now read differently: ${unchangedChanges.map((change) => change.text).join('; ')}.`);
1640
1655
  }
1641
1656
  if (!facts.length) return null;
1642
1657
  return [
@@ -1700,8 +1715,8 @@ export function createFramerAgentToolRuntime({
1700
1715
  if (mutationCalls > 0 && Object.keys(unchangedBefore).length) {
1701
1716
  try {
1702
1717
  await ensureProject();
1703
- const after = await snapshotLayers(Object.keys(unchangedBefore).slice(0, UNCHANGED_LAYER_LIMIT));
1704
- unchangedChanges = after ? unchangedViolations(unchangedBefore, after) : [];
1718
+ const after = await snapshotLayers(Object.keys(unchangedBefore), { walk: false });
1719
+ unchangedChanges = after ? unchangedViolations(unchangedBefore, after, known) : [];
1705
1720
  readCalls += 1;
1706
1721
  } catch {
1707
1722
  unchangedChanges = [];
@@ -1739,7 +1754,9 @@ export function createFramerAgentToolRuntime({
1739
1754
  output: 'No layer changed for a summary that claimed a change.',
1740
1755
  });
1741
1756
  }
1742
- const propertyErrors = lingeringPropertyErrors();
1757
+ // A rejected property the model was shown and then finished without is
1758
+ // its judgment, such as reaching the goal another way.
1759
+ const propertyErrors = lingeringPropertyErrors().filter((error) => !weighedPropertyErrors.has(propertyErrorKey(error)));
1743
1760
  if (propertyErrors.length) {
1744
1761
  checks.push({
1745
1762
  command: PROPERTY_ERROR_CHECK,
@@ -1774,11 +1791,14 @@ export function createFramerAgentToolRuntime({
1774
1791
  .slice(0, 2000),
1775
1792
  });
1776
1793
  }
1777
- if (unchangedChanges.length) {
1794
+ // A layer the model edited, or one that follows its edit, is a change it
1795
+ // made and weighed; only changes nothing in the run explains fail it.
1796
+ const unexplained = unchangedChanges.filter((change) => !change.edited);
1797
+ if (unexplained.length) {
1778
1798
  checks.push({
1779
1799
  command: UNCHANGED_CHECK,
1780
1800
  status: 'failed',
1781
- output: unchangedChanges.join('; ').slice(0, 2000),
1801
+ output: unexplained.map((change) => change.text).join('; ').slice(0, 2000),
1782
1802
  });
1783
1803
  }
1784
1804
  // The model's own plan sets the evidence it owes; a run that changed the