@gakim-digital/dexter-bridge 0.13.0 → 0.13.2

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.
@@ -2,18 +2,23 @@ import crypto from 'node:crypto';
2
2
  import jsTokens from 'js-tokens';
3
3
  import { createFramerMcpClient } from './framerMcpClient.js';
4
4
  import {
5
- evidenceNodeIds,
6
- mergeBehaviorPlans,
7
- normalizeBehaviorEvidence,
8
- normalizeBehaviorPlan,
9
- summarizeBehaviorVerification,
10
- verifyBehaviorChecks,
11
- } from './framerBehaviorContract.js';
5
+ MEASURES,
6
+ checkNodeIds,
7
+ measureChecks,
8
+ mergeRequirements,
9
+ normalizeChecks,
10
+ normalizeRequirements,
11
+ summarizeRequirements,
12
+ unchangedViolations,
13
+ verifyChecks,
14
+ } from './framerRequirementContract.js';
12
15
 
13
16
  export const UNAPPLIED_CHANGE_CHECK = 'Harness change check';
14
17
  export const PROPERTY_ERROR_CHECK = 'Unapplied properties';
15
18
  export const CLAIMED_TARGET_CHECK = 'Claimed targets';
16
19
  export const VERIFICATION_CHECK = 'Harness-owned final verification';
20
+ export const REQUIREMENTS_CHECK = 'Requirements';
21
+ export const UNCHANGED_CHECK = 'Unchanged layers';
17
22
 
18
23
  const LITERAL_TOKEN_TYPES = new Set(['StringLiteral', 'NoSubstitutionTemplate', 'TemplateHead', 'TemplateMiddle', 'TemplateTail']);
19
24
  const UNSAFE_EXEC_PATTERN = /\b(?:process|globalThis|global|require|import\s*\(|child_process|worker_threads|fs|os|net|tls|dgram|http|https|fetch|WebSocket|eval|Function)\b/;
@@ -40,39 +45,61 @@ const MAX_RESULT_CHARS = 6_000;
40
45
  // Users judge progress by the canvas, so a run that is still only reading
41
46
  // after this long is told to start building.
42
47
  const FIRST_EDIT_NUDGE_MS = 60_000;
43
- const FIRST_EDIT_NUDGE = 'Nothing is on the canvas yet. You know enough to start: make your first framer_edit now with a small first section, such as the navigation and the hero headline, then build the rest two sections per edit. Read more only when a specific detail blocks the next edit.';
48
+ const FIRST_EDIT_NUDGE = 'If this request changes the canvas, nothing is on it yet and you know enough to start: make your first framer_edit now with a small first section, such as the navigation and the hero headline, then build the rest two sections per edit. Read more only when a specific detail blocks the next edit. If the user asked only for an answer or an analysis, keep to that.';
44
49
  const FONT_ERROR_HINT = 'A font Framer rejected is not available to this project. Follow the preloaded project APIs guidance: resolve the family through api.fonts before setting it, or use a family the project already uses. Fix it in your next framer_edit, not at the end.';
45
50
  const ACTIVE_PAGE_OUTLINE_DEPTH = 2;
46
51
  // Framer returns the previous layout for about 50ms after an edit.
47
52
  const LAYOUT_SETTLE_MS = 150;
53
+ const UNCHANGED_LAYER_LIMIT = 600;
48
54
  const FONT_KEYS = new Set(['font', 'fontFamily', 'family']);
49
55
  const PROJECT_MISMATCH_PATTERN = /permission|forbidden|not found|does not have access|no access|can't open|cannot open/i;
50
56
 
51
- const MEASURE_TOOL_NOTE = 'For exact spacing or sizes, await dexter.measure(ids) returns each layer\'s x, y, width, and height inside its breakpoint frame, and await dexter.gap(a, b) returns the vertical and horizontal space between two layers (negative when they share that range) and whether they overlap. Both measure the rendered layout, including edits made earlier in the same program.';
57
+ const MEASURE_TOOL_NOTE = 'For exact spacing or sizes, await dexter.measure(ids) returns each layer\'s x, y, width, and height inside its breakpoint frame, and await dexter.gap(a, b) returns the vertical and horizontal space between two layers (negative when they share that range) and whether they overlap. Both measure the rendered layout, including edits made earlier in the same program. Framer lays out only some pages for Dexter; elsewhere a layer comes back with measured: false, and then there are no numbers to act on, so judge it from a screenshot.';
52
58
 
53
- // Item shapes the behavior contract (framerBehaviorContract.js) accepts.
54
- const BEHAVIOR_SCHEMA = {
59
+ // Item shapes the requirement contract (framerRequirementContract.js) accepts.
60
+ const REQUIREMENT_SCHEMA = {
55
61
  type: 'object',
56
62
  properties: {
57
- id: { type: 'string', description: 'Short unique id, such as "whatsapp-links".' },
58
- description: { type: 'string', description: 'What the user should experience.' },
59
- targetNodeIds: { type: 'array', items: { type: 'string' }, description: 'Every node that should carry the behavior.' },
63
+ id: { type: 'string', description: 'Short unique id, such as "button-height".' },
64
+ text: { type: 'string', description: 'What must be true, in the user\'s terms, such as "Send Enquiry button is about 52px tall".' },
65
+ nodeIds: { type: 'array', items: { type: 'string' }, description: 'Every layer that must meet it, when it applies to several.' },
60
66
  },
61
- required: ['id', 'description'],
67
+ required: ['id', 'text'],
62
68
  additionalProperties: false,
63
69
  };
64
- const BEHAVIOR_CHECK_SCHEMA = {
70
+ const CHECK_SCHEMA = {
65
71
  type: 'object',
66
72
  properties: {
67
- behaviorId: { type: 'string' },
73
+ requirementId: { type: 'string' },
68
74
  nodeId: { type: 'string' },
69
- property: { type: 'string', description: 'The Framer property that carries the behavior, such as link.href.' },
70
- contains: { type: 'string', description: 'Text the property value must contain.' },
75
+ property: { type: 'string', description: 'A Framer property to read, such as fill, link.href, or text. Use either property or measure.' },
76
+ contains: { type: 'string', description: 'Text the property value must contain, such as #151515 or /contact.' },
77
+ 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.' },
78
+ measure: { type: 'string', enum: MEASURES, description: 'width, height, gap (to otherNodeId), or insideParent (the layer stays inside its parent).' },
79
+ otherNodeId: { type: 'string' },
80
+ expected: { type: 'number', description: 'Expected pixels for width, height, or gap.' },
81
+ tolerance: { type: 'number', description: 'Allowed difference in pixels; defaults to 2.' },
71
82
  },
72
- required: ['behaviorId', 'nodeId', 'property'],
83
+ required: ['requirementId', 'nodeId'],
73
84
  additionalProperties: false,
74
85
  };
75
86
 
87
+ // Offered only when the Dexter panel says it can measure: it reads the page
88
+ // open in the user's editor, which Framer lays out, unlike most pages Dexter
89
+ // reads through Framer MCP.
90
+ const FRAMER_MEASURE_DEFINITION = {
91
+ name: 'framer_measure',
92
+ description: 'Measure layers on the page open in the user\'s Framer editor: each layer\'s x, y, width, and height inside its breakpoint frame, and its box inside its parent. It reads the editor\'s own layout, on every breakpoint of that page, including your latest edits. Layers on other pages come back measured: false. Use it when dexter.measure returns measured: false.',
93
+ inputSchema: {
94
+ type: 'object',
95
+ properties: {
96
+ nodeIds: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 100 },
97
+ },
98
+ required: ['nodeIds'],
99
+ additionalProperties: false,
100
+ },
101
+ };
102
+
76
103
  const TOOL_DEFINITIONS = [
77
104
  {
78
105
  name: 'framer_read',
@@ -97,14 +124,14 @@ const TOOL_DEFINITIONS = [
97
124
  screenshotNodeIds: { type: 'array', items: { type: 'string' }, maxItems: 4 },
98
125
  plan: {
99
126
  type: 'object',
100
- description: 'Your task plan, on the first framer_edit: a summary, the task domains, and the evidence you will produce (for example structural, visual, interactions). For interactive or animated results, list each behavior with every node that should carry it.',
127
+ description: 'Your task plan, on the first framer_edit. requirements lists every concrete, checkable thing the request asks for: values, colours, sizes, spacing, text, links, order, visibility, and behaviors, each with the layers it applies to. unchanged lists layers the user wants left exactly as they are, such as other breakpoints when they named one; Dexter records them before this edit and compares them at the end. verification lists other evidence you will produce, such as visual.',
101
128
  properties: {
102
129
  summary: { type: 'string' },
103
- domains: { type: 'array', items: { type: 'string' } },
130
+ requirements: { type: 'array', items: REQUIREMENT_SCHEMA },
131
+ unchanged: { type: 'array', items: { type: 'string' }, maxItems: 20 },
104
132
  verification: { type: 'array', items: { type: 'string' } },
105
- behaviors: { type: 'array', items: BEHAVIOR_SCHEMA },
106
133
  },
107
- required: ['summary', 'domains', 'verification'],
134
+ required: ['summary', 'requirements'],
108
135
  additionalProperties: false,
109
136
  },
110
137
  },
@@ -137,13 +164,13 @@ const TOOL_DEFINITIONS = [
137
164
  },
138
165
  },
139
166
  {
140
- name: 'framer_verify_interactions',
141
- description: 'Check that each declared behavior sits on the node properties you name. This is structural evidence, not runtime playback.',
167
+ name: 'framer_verify',
168
+ description: 'Check your plan\'s requirements against the live project after your last change. Each check names a requirement and a layer, and either a property to read (optionally with text its value must contain, or absent when it must not be set) or a measure: width, height, gap to another layer, or insideParent. Dexter reads Framer and returns which requirements are met. A requirement counts only when every layer it names passes. A measure Dexter cannot take on this page comes back unmeasured: that is not a failure, and there is nothing to fix; judge it from a screenshot when it matters.',
142
169
  inputSchema: {
143
170
  type: 'object',
144
171
  properties: {
145
- checks: { type: 'array', items: BEHAVIOR_CHECK_SCHEMA },
146
- behaviors: { type: 'array', items: BEHAVIOR_SCHEMA },
172
+ checks: { type: 'array', items: CHECK_SCHEMA },
173
+ requirements: { type: 'array', items: REQUIREMENT_SCHEMA, description: 'Requirements to add to your plan before checking.' },
147
174
  },
148
175
  required: ['checks'],
149
176
  additionalProperties: false,
@@ -352,10 +379,98 @@ function outputOf(result) {
352
379
  return structured.output && typeof structured.output === 'object' ? structured.output : structured;
353
380
  }
354
381
 
382
+ const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
383
+ const DATA_URL_PATTERN = /^data:(image\/[a-z0-9.+-]+);base64,(.+)$/is;
384
+ const IMAGE_FETCH_TIMEOUT_MS = 15_000;
385
+ const MAX_MODEL_IMAGE_BYTES = 8 * 1024 * 1024;
386
+
387
+ // Models accept images only as inline base64 data. Framer returns screenshots
388
+ // as links, and a program may hand back a data URL; both become plain base64,
389
+ // and anything else is dropped rather than sent on.
390
+ async function modelReadyImage(image, fetchImpl = fetch) {
391
+ const raw = typeof image?.data === 'string' ? image.data.trim() : '';
392
+ const mimeType = image?.mimeType || 'image/png';
393
+ if (!raw) return null;
394
+ const dataUrl = raw.match(DATA_URL_PATTERN);
395
+ if (dataUrl) return { mimeType: dataUrl[1], data: dataUrl[2].replace(/\s+/g, '') };
396
+ if (/^https:\/\//i.test(raw)) {
397
+ try {
398
+ const response = await fetchImpl(raw, { signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS) });
399
+ const type = String(response.headers.get('content-type') || '').split(';')[0].trim();
400
+ if (!response.ok || !type.startsWith('image/')) return null;
401
+ const bytes = Buffer.from(await response.arrayBuffer());
402
+ if (!bytes.length || bytes.length > MAX_MODEL_IMAGE_BYTES) return null;
403
+ return { mimeType: type, data: bytes.toString('base64') };
404
+ } catch {
405
+ return null;
406
+ }
407
+ }
408
+ const compact = raw.replace(/\s+/g, '');
409
+ if (!BASE64_PATTERN.test(compact)) return null;
410
+ const type = imageTypeOf(Buffer.from(compact.slice(0, 32), 'base64'));
411
+ return type ? { mimeType: type, data: compact } : null;
412
+ }
413
+
414
+ function imageTypeOf(bytes) {
415
+ if (bytes.length >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png';
416
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
417
+ if (bytes.length >= 4 && bytes.toString('ascii', 0, 4) === 'GIF8') return 'image/gif';
418
+ if (bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return 'image/webp';
419
+ return null;
420
+ }
421
+
422
+ // Width and height from a PNG or JPEG header.
423
+ export function imageSize(bytes) {
424
+ if (bytes.length >= 24 && bytes[0] === 0x89 && bytes.toString('ascii', 1, 4) === 'PNG') {
425
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
426
+ }
427
+ if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
428
+ let offset = 2;
429
+ while (offset + 9 < bytes.length) {
430
+ if (bytes[offset] !== 0xff) { offset += 1; continue; }
431
+ const marker = bytes[offset + 1];
432
+ const length = bytes.readUInt16BE(offset + 2);
433
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
434
+ return { width: bytes.readUInt16BE(offset + 7), height: bytes.readUInt16BE(offset + 5) };
435
+ }
436
+ offset += 2 + length;
437
+ }
438
+ }
439
+ return null;
440
+ }
441
+
442
+ // What the editor's measurement of one layer says for a measure check:
443
+ // { value }, { inside, overflow }, or null when the editor did not measure it.
444
+ export function editorMeasurement(check, layers) {
445
+ const layer = layers?.[check.nodeId];
446
+ if (!layer?.measured) return null;
447
+ if (check.measure === 'width' || check.measure === 'height') return { value: layer[check.measure] };
448
+ if (check.measure === 'insideParent') {
449
+ const rect = layer.rect;
450
+ const parent = layer.parent;
451
+ if (!rect || !parent) return null;
452
+ const overflow = Math.max(0, -rect.x, -rect.y, rect.x + rect.width - parent.width, rect.y + rect.height - parent.height);
453
+ return { inside: overflow <= check.tolerance, overflow: Math.round(overflow) };
454
+ }
455
+ if (check.measure === 'gap') {
456
+ const other = layers?.[check.otherNodeId];
457
+ if (!other?.measured || other.frameId !== layer.frameId) return null;
458
+ const vertical = Math.max(other.y - (layer.y + layer.height), layer.y - (other.y + other.height));
459
+ const horizontal = Math.max(other.x - (layer.x + layer.width), layer.x - (other.x + other.width));
460
+ return { value: vertical >= 0 ? vertical : horizontal >= 0 ? horizontal : Math.max(vertical, horizontal) };
461
+ }
462
+ return null;
463
+ }
464
+
465
+ export async function modelReadyImages(images, fetchImpl = fetch) {
466
+ const ready = await Promise.all((images || []).map((image) => modelReadyImage(image, fetchImpl)));
467
+ return ready.filter(Boolean);
468
+ }
469
+
355
470
  function imagesFromExec(result, output) {
356
471
  const listed = Array.isArray(output?.images) ? output.images : [];
357
472
  const fromOutput = listed.flatMap((image) => {
358
- const data = image?.data || image?.base64;
473
+ const data = image?.data || image?.base64 || image?.url;
359
474
  if (typeof data !== 'string' || !data) return [];
360
475
  return [{ mimeType: image.mimeType || image.mediaType || 'image/png', data }];
361
476
  });
@@ -450,12 +565,46 @@ const __dexterApi = __dexterRecording(api, {}, {
450
565
  });
451
566
  `;
452
567
 
568
+ // Framer MCP lays out only some pages. On the others getRect returns
569
+ // placeholder boxes: a layer sized by its parent or content reads 200px and
570
+ // can sit centred on its own origin (x = -width/2, y = -height/2), and the
571
+ // numbers change between reads. A reading counts only when the layer and every
572
+ // layer above it, up to its breakpoint, look laid out.
573
+ const LAYOUT_CHECK = `
574
+ const __dexterRelative = (value) => value == null || !/px$/.test(String(value));
575
+ const __dexterUnlaid = (node, rect) => {
576
+ if (!rect) return true;
577
+ if ((rect.width > 0 || rect.height > 0) && Math.abs(rect.x + rect.width / 2) < 0.5 && Math.abs(rect.y + rect.height / 2) < 0.5) return true;
578
+ if (__dexterRelative(node && node.width) && Math.abs(rect.width - 200) < 0.01) return true;
579
+ if (__dexterRelative(node && node.height) && Math.abs(rect.height - 200) < 0.01) return true;
580
+ return false;
581
+ };
582
+ const __dexterRects = new Map();
583
+ const __dexterRect = async (id) => {
584
+ if (!__dexterRects.has(id)) __dexterRects.set(id, await api.tree.getRect(id));
585
+ return __dexterRects.get(id);
586
+ };
587
+ const __dexterLaidOut = async (id) => {
588
+ const node = api.tree.get(id);
589
+ if (!node) return false;
590
+ const frame = await api.tree.getGroundNodeFor(id);
591
+ if (__dexterUnlaid(node, await __dexterRect(id))) return false;
592
+ if (frame && frame.id === id) return true;
593
+ for (const ancestor of (await api.tree.getAncestorsFor(id)) || []) {
594
+ if (frame && ancestor.id === frame.id) break;
595
+ if (__dexterUnlaid(ancestor, await __dexterRect(ancestor.id))) return false;
596
+ }
597
+ return true;
598
+ };
599
+ const __dexterUnmeasuredNote = 'Framer has not laid out this page for Dexter, so this layer\\'s size and position cannot be read here. Judge it by eye instead.';
600
+ `;
601
+
453
602
  // Programs get `dexter.measure` and `dexter.gap` for exact spacing and sizes.
454
603
  // Framer's getRect is relative to the parent, so positions are summed up to
455
604
  // the breakpoint (or variant) frame, which keeps desktop and phone apart.
456
605
  // Layout settles shortly after an edit; measuring straight away returns the
457
606
  // old layout, so each call waits first.
458
- const MEASURE_PRELUDE = `
607
+ const MEASURE_PRELUDE = `${LAYOUT_CHECK}
459
608
  // Layers inserted by this program are known to Framer only by their real ids
460
609
  // until the program ends; the edit recorder maps the program's own ids.
461
610
  const __dexterInsertedIds = {};
@@ -465,13 +614,14 @@ const dexter = (() => {
465
614
  const id = typeof value === 'string' ? value : (value && typeof value.id === 'string' ? value.id : null);
466
615
  return (id && __dexterInsertedIds[id]) || id;
467
616
  };
468
- const settle = () => new Promise((resolve) => setTimeout(resolve, ${LAYOUT_SETTLE_MS}));
617
+ const settle = () => new Promise((resolve) => setTimeout(resolve, ${LAYOUT_SETTLE_MS})).then(() => __dexterRects.clear());
469
618
  const one = async (value) => {
470
619
  const id = idOf(value);
471
620
  const node = id ? tree.get(id) : null;
472
621
  if (!node) return { id, measured: false, note: 'Framer has no layer with this id.' };
473
622
  const rect = await tree.getRect(id);
474
623
  if (!rect) return { id, measured: false };
624
+ if (!(await __dexterLaidOut(id))) return { id, name: node.name || null, measured: false, unmeasured: true, note: __dexterUnmeasuredNote };
475
625
  const frame = await tree.getGroundNodeFor(id);
476
626
  let x = rect.x;
477
627
  let y = rect.y;
@@ -497,7 +647,10 @@ const dexter = (() => {
497
647
  async gap(first, second) {
498
648
  await settle();
499
649
  const [a, b] = await measureAll([first, second]);
500
- if (a.measured === false || b.measured === false) return { a, b, measured: false };
650
+ if (a.measured === false || b.measured === false) {
651
+ const unmeasured = a.unmeasured === true || b.unmeasured === true;
652
+ return { a, b, measured: false, ...(unmeasured ? { unmeasured: true, note: __dexterUnmeasuredNote } : {}) };
653
+ }
501
654
  if (a.frame?.id !== b.frame?.id) return { a, b, measured: false, note: 'These layers are in different breakpoints or frames.' };
502
655
  const vertical = Math.max(b.y - (a.y + a.height), a.y - (b.y + b.height));
503
656
  const horizontal = Math.max(b.x - (a.x + a.width), a.x - (b.x + b.width));
@@ -624,6 +777,76 @@ return verdicts;
624
777
  `;
625
778
  }
626
779
 
780
+ // Which Dexter helpers a program uses, kept on the call record so runs show
781
+ // whether models measure.
782
+ function helpersUsed(program) {
783
+ return ['measure', 'gap'].filter((name) => program.includes(`dexter.${name}`));
784
+ }
785
+
786
+ // Reads what framer_verify needs in one program: the layers whose properties
787
+ // are checked, and each measure in order.
788
+ export function verifyProgram(propertyNodeIds, measures) {
789
+ return `${MEASURE_PRELUDE}
790
+ const tree = api.tree;
791
+ const nodes = await tree.serializeNodes(${JSON.stringify(propertyNodeIds)}, { depth: 0 });
792
+ const measured = [];
793
+ for (const check of ${JSON.stringify(measures)}) {
794
+ try {
795
+ if (check.measure === 'insideParent') {
796
+ const rect = await tree.getRect(check.nodeId);
797
+ const parent = await tree.getParentNodeFor(check.nodeId);
798
+ const box = parent ? await tree.getRect(parent.id) : null;
799
+ if (!rect || !box) { measured.push({ error: check.nodeId + ' or its parent could not be measured' }); continue; }
800
+ if (!(await __dexterLaidOut(check.nodeId)) || !(await __dexterLaidOut(parent.id))) { measured.push({ unmeasured: true, note: __dexterUnmeasuredNote }); continue; }
801
+ const overflow = Math.max(0, -rect.x, -rect.y, rect.x + rect.width - box.width, rect.y + rect.height - box.height);
802
+ measured.push({ inside: overflow <= check.tolerance, overflow: Math.round(overflow) });
803
+ } else if (check.measure === 'gap') {
804
+ const space = await dexter.gap(check.nodeId, check.otherNodeId);
805
+ if (space.unmeasured) { measured.push({ unmeasured: true, note: space.note }); continue; }
806
+ if (space.measured === false) { measured.push({ error: space.note || 'these layers could not be measured' }); continue; }
807
+ const value = space.vertical >= 0 ? space.vertical : space.horizontal >= 0 ? space.horizontal : Math.max(space.vertical, space.horizontal);
808
+ measured.push({ value });
809
+ } else {
810
+ const [box] = await dexter.measure([check.nodeId]);
811
+ if (box.unmeasured) { measured.push({ unmeasured: true, note: box.note }); continue; }
812
+ if (box.measured === false) { measured.push({ error: box.note || check.nodeId + ' could not be measured' }); continue; }
813
+ measured.push({ value: box[check.measure] });
814
+ }
815
+ } catch (error) {
816
+ measured.push({ error: String((error && error.message) || error).slice(0, 200) });
817
+ }
818
+ }
819
+ return { nodes, measured };`;
820
+ }
821
+
822
+ // Records layers the user wants left as they are: each layer's measured box,
823
+ // when Framer has laid it out, and a fingerprint of its settings, for every
824
+ // layer inside them.
825
+ export function snapshotProgram(rootIds) {
826
+ return `${LAYOUT_CHECK}
827
+ const tree = api.tree;
828
+ 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); };
829
+ const ids = new Set();
830
+ for (const root of ${JSON.stringify(rootIds)}) {
831
+ if (!tree.get(root)) continue;
832
+ ids.add(root);
833
+ for (const node of tree.walk(root)) {
834
+ if (ids.size >= ${UNCHANGED_LAYER_LIMIT}) break;
835
+ if (node && node.id) ids.add(node.id);
836
+ }
837
+ }
838
+ const out = {};
839
+ for (const id of ids) {
840
+ const node = tree.get(id);
841
+ if (!node) continue;
842
+ const rect = (await __dexterLaidOut(id)) ? await __dexterRect(id) : null;
843
+ let props = '';
844
+ try { const { children, ...rest } = JSON.parse(JSON.stringify(node)); props = fingerprint(JSON.stringify(rest)); } catch {}
845
+ out[id] = { name: node.name || null, rect: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null, props };
846
+ }
847
+ return out;`;
848
+ }
849
+
627
850
  function mismatchMessage(name) {
628
851
  const account = name ? ` (${name})` : '';
629
852
  return `Your connected Framer account${account} can't open this project — connect the account that owns it.`;
@@ -634,9 +857,11 @@ export function createFramerAgentToolRuntime({
634
857
  onActivity,
635
858
  authorizeProject,
636
859
  readWebsite,
860
+ callPanelTool,
637
861
  mcpClient,
638
862
  getAccessToken,
639
863
  trace,
864
+ fetchImpl = fetch,
640
865
  } = {}) {
641
866
  const context = assignment?.context && typeof assignment.context === 'object' ? assignment.context : {};
642
867
  const projectId = String(context.framerProjectId || '').trim();
@@ -646,7 +871,13 @@ export function createFramerAgentToolRuntime({
646
871
  const allowedTools = Array.isArray(assignment?.toolProtocol?.tools)
647
872
  ? new Set(assignment.toolProtocol.tools)
648
873
  : null;
649
- const definitions = TOOL_DEFINITIONS.filter((tool) => !allowedTools || allowedTools.has(tool.name));
874
+ const editorMeasures = typeof callPanelTool === 'function'
875
+ && Array.isArray(context.panelTools)
876
+ && context.panelTools.includes('framer_measure');
877
+ const definitions = [
878
+ ...TOOL_DEFINITIONS.filter((tool) => !allowedTools || allowedTools.has(tool.name)),
879
+ ...(editorMeasures ? [FRAMER_MEASURE_DEFINITION] : []),
880
+ ];
650
881
  const reportActivity = typeof onActivity === 'function' ? onActivity : () => undefined;
651
882
 
652
883
  let framerUserName = null;
@@ -657,8 +888,16 @@ export function createFramerAgentToolRuntime({
657
888
  let readCalls = 0;
658
889
  let toolCallCount = 0;
659
890
  let plan = null;
660
- let behaviorPlan = null;
661
- let behaviorResults = [];
891
+ let requirements = [];
892
+ // Latest check result per requirement, each stamped with when it was read.
893
+ let checkResults = [];
894
+ const unchangedBefore = {};
895
+ let unchangedPending = [];
896
+ let unchangedChanges = [];
897
+ let promptContext = {};
898
+ // Set once the panel fails to answer, so a closed panel does not cost a
899
+ // wait on every later measure.
900
+ let editorMeasureUnavailable = false;
662
901
  let lastMutationAt = 0;
663
902
  let lastReadAfterMutationAt = 0;
664
903
  let lastVisualAt = 0;
@@ -814,6 +1053,7 @@ export function createFramerAgentToolRuntime({
814
1053
  }));
815
1054
  const failedCalls = calls(inner.dexterFailed);
816
1055
  const receipt = {
1056
+ kind: 'framer-mcp-edit',
817
1057
  changedNodeCount,
818
1058
  renamedIds,
819
1059
  propertyErrors: propertyErrorsOf(output, renamedIds),
@@ -857,13 +1097,89 @@ export function createFramerAgentToolRuntime({
857
1097
  }
858
1098
 
859
1099
  function recordPlan(rawPlan) {
860
- const nextBehaviors = normalizeBehaviorPlan({ behaviors: rawPlan.behaviors || [] });
1100
+ requirements = mergeRequirements(requirements, normalizeRequirements(rawPlan.requirements));
861
1101
  plan = {
862
1102
  summary: String(rawPlan.summary || '').slice(0, 1000),
863
- domains: Array.isArray(rawPlan.domains) ? rawPlan.domains.map(String).slice(0, 12) : [],
864
1103
  verification: Array.isArray(rawPlan.verification) ? rawPlan.verification.map(String).slice(0, 12) : [],
865
1104
  };
866
- behaviorPlan = mergeBehaviorPlans(behaviorPlan, nextBehaviors);
1105
+ const unchanged = Array.isArray(rawPlan.unchanged) ? rawPlan.unchanged.map(String).filter(Boolean).slice(0, 20) : [];
1106
+ unchangedPending = [...new Set([...unchangedPending, ...unchanged])]
1107
+ .filter((id) => !Object.prototype.hasOwnProperty.call(unchangedBefore, id));
1108
+ }
1109
+
1110
+ // Layers the plan says must stay as they are are recorded before the edit
1111
+ // that could change them.
1112
+ // Measures layers in the user's editor through the Dexter panel. Returns a
1113
+ // map of layer id to its measurement, or null when the panel cannot answer.
1114
+ async function measureInEditor(nodeIds) {
1115
+ const ids = [...new Set(nodeIds.filter(Boolean))].slice(0, UNCHANGED_LAYER_LIMIT);
1116
+ if (!editorMeasures || editorMeasureUnavailable || !ids.length) return null;
1117
+ try {
1118
+ const payload = await callPanelTool({
1119
+ callId: crypto.randomUUID(),
1120
+ name: 'framer_measure',
1121
+ arguments: { nodeIds: ids },
1122
+ });
1123
+ const result = payload?.ok === false ? null : payload?.result ?? payload;
1124
+ if (!result || result.ok === false || !result.layers || typeof result.layers !== 'object') {
1125
+ editorMeasureUnavailable = true;
1126
+ return null;
1127
+ }
1128
+ return result.layers;
1129
+ } catch (error) {
1130
+ editorMeasureUnavailable = true;
1131
+ trace?.warn?.('framer_editor_measure_failed', { message: String(error?.message || error).slice(0, 300) });
1132
+ return null;
1133
+ }
1134
+ }
1135
+
1136
+ // A layer's rendered size from a Framer screenshot, which Framer renders
1137
+ // correctly on every page. It gives no position.
1138
+ async function screenshotSize(nodeId) {
1139
+ try {
1140
+ const result = await client.exec(`return await api.tree.screenshot(${JSON.stringify(nodeId)});`);
1141
+ const url = outputOf(result).result;
1142
+ if (typeof url !== 'string' || !/^https:\/\//.test(url)) return null;
1143
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS) });
1144
+ if (!response.ok) return null;
1145
+ return imageSize(Buffer.from(await response.arrayBuffer()));
1146
+ } catch {
1147
+ return null;
1148
+ }
1149
+ }
1150
+
1151
+ // Records the layers the plan said to leave as they are. Boxes come from the
1152
+ // editor where it measured them, otherwise from Framer MCP where that page is
1153
+ // laid out; each records its source so before and after compare like with like.
1154
+ async function snapshotLayers(roots) {
1155
+ const snapshot = outputOf(await client.exec(snapshotProgram(roots))).result;
1156
+ if (!snapshot || typeof snapshot !== 'object') return null;
1157
+ for (const entry of Object.values(snapshot)) if (entry?.rect) entry.rect.source = 'mcp';
1158
+ const layers = await measureInEditor(Object.keys(snapshot));
1159
+ for (const [id, layer] of Object.entries(layers || {})) {
1160
+ if (layer?.measured && layer.rect && snapshot[id]) snapshot[id].rect = { ...layer.rect, source: 'editor' };
1161
+ }
1162
+ return snapshot;
1163
+ }
1164
+
1165
+ async function snapshotUnchanged() {
1166
+ if (!unchangedPending.length) return;
1167
+ const roots = unchangedPending;
1168
+ unchangedPending = [];
1169
+ try {
1170
+ const snapshot = await snapshotLayers(roots);
1171
+ if (snapshot) Object.assign(unchangedBefore, snapshot);
1172
+ } catch (error) {
1173
+ trace?.warn?.('framer_unchanged_snapshot_failed', { message: String(error?.message || error).slice(0, 300) });
1174
+ }
1175
+ }
1176
+
1177
+ // Only checks read after the last change count.
1178
+ function requirementStatus() {
1179
+ return summarizeRequirements(
1180
+ requirements,
1181
+ checkResults.filter((result) => result.at >= lastMutationAt),
1182
+ );
867
1183
  }
868
1184
 
869
1185
  function firstEditNudge() {
@@ -876,9 +1192,8 @@ export function createFramerAgentToolRuntime({
876
1192
  const missingChecks = [];
877
1193
  if (mutationCalls > 0 && lastReadAfterMutationAt < lastMutationAt) missingChecks.push('structural');
878
1194
  if (required.has('visual') && mutationCalls > 0 && lastVisualAt < lastMutationAt) missingChecks.push('visual');
879
- if (required.has('interactions') && behaviorPlan) {
880
- const summary = summarizeBehaviorVerification(behaviorPlan, behaviorResults);
881
- if (summary.status !== 'verified') missingChecks.push('interactions');
1195
+ if (requirements.length && mutationCalls > 0 && requirementStatus().status !== 'verified') {
1196
+ missingChecks.push('requirements');
882
1197
  }
883
1198
  return {
884
1199
  visualRequired: required.has('visual'),
@@ -951,6 +1266,7 @@ export function createFramerAgentToolRuntime({
951
1266
  if (images.length) lastVisualAt = Date.now();
952
1267
  } else if (name === 'framer_read') {
953
1268
  const program = validateProgram(args.program, { write: false, allowPublishing });
1269
+ callExtra.helpers = helpersUsed(program);
954
1270
  await ensureProject();
955
1271
  const result = await client.exec(wrapReadProgram(program));
956
1272
  const applied = applyReceipt(result, []);
@@ -977,7 +1293,9 @@ export function createFramerAgentToolRuntime({
977
1293
  const program = validateProgram(args.program, { write: true, allowPublishing });
978
1294
  if (args.plan && typeof args.plan === 'object') recordPlan(args.plan);
979
1295
  const screenshotNodeIds = Array.isArray(args.screenshotNodeIds) ? args.screenshotNodeIds : [];
1296
+ callExtra.helpers = helpersUsed(program);
980
1297
  await ensureProject();
1298
+ await snapshotUnchanged();
981
1299
  const result = await client.exec(wrapProgram(program));
982
1300
  const applied = applyReceipt(result, screenshotNodeIds);
983
1301
  const programError = programErrorOf(applied.output, wrappedLineOffset(program));
@@ -1042,31 +1360,62 @@ export function createFramerAgentToolRuntime({
1042
1360
  ...(applied.images.length ? { modelImages: applied.images } : {}),
1043
1361
  ...(due.length ? { evidenceStillDue: due } : {}),
1044
1362
  };
1045
- } else if (name === 'framer_verify_interactions') {
1046
- behaviorPlan = mergeBehaviorPlans(behaviorPlan, normalizeBehaviorPlan({ behaviors: args.behaviors || [] }));
1047
- const checks = normalizeBehaviorEvidence(args.checks, behaviorPlan);
1048
- const nodeIds = evidenceNodeIds(checks);
1363
+ } else if (name === 'framer_measure') {
1364
+ const nodeIds = (Array.isArray(args.nodeIds) ? args.nodeIds : []).map((id) => String(id).slice(0, 200)).filter(Boolean).slice(0, 100);
1365
+ const layers = await measureInEditor(nodeIds);
1366
+ if (!layers) {
1367
+ throw toolError('FRAMER_MEASURE_UNAVAILABLE', 'The Dexter panel could not measure right now. Judge sizes and spacing from a screenshot.');
1368
+ }
1369
+ readCalls += 1;
1370
+ payload = {
1371
+ layers: Object.fromEntries(nodeIds.map((id) => {
1372
+ const layer = layers[id];
1373
+ if (!layer?.measured) {
1374
+ return [id, { measured: false, note: layer?.reason === 'not_found' ? 'Framer has no layer with this id.' : 'This layer is not on the page open in the user\'s editor, so it cannot be measured. Judge it from a screenshot.' }];
1375
+ }
1376
+ return [id, { x: layer.x, y: layer.y, width: layer.width, height: layer.height, frame: layer.frameId ? { id: layer.frameId, name: layer.frameName || null } : null }];
1377
+ })),
1378
+ };
1379
+ } else if (name === 'framer_verify') {
1380
+ requirements = mergeRequirements(requirements, normalizeRequirements(args.requirements));
1381
+ const checks = normalizeChecks(args.checks, requirements);
1049
1382
  await ensureProject();
1050
1383
  // Serialized nodes are plain data; live node objects can crash
1051
1384
  // Framer's worker when returned.
1052
- const result = await client.exec(
1053
- `return await api.tree.serializeNodes(${JSON.stringify(nodeIds)}, { depth: 0 });`,
1054
- );
1385
+ // Measures come from the user's editor where it can take them, then
1386
+ // from Framer MCP, then, for sizes, from a screenshot.
1387
+ const measures = measureChecks(checks);
1388
+ const layers = await measureInEditor(measures.flatMap((check) => [check.nodeId, check.otherNodeId]));
1389
+ const fromEditor = measures.map((check) => editorMeasurement(check, layers));
1390
+ const result = await client.exec(verifyProgram(checkNodeIds(checks), measures.filter((_, index) => !fromEditor[index])));
1055
1391
  const output = outputOf(result);
1056
- const nodes = output.result || output.nodes || output;
1057
- const list = Array.isArray(nodes) ? nodes : nodes?.nodes;
1058
- const nodeMap = new Map();
1059
- if (Array.isArray(list)) {
1060
- for (const node of list) {
1061
- if (node?.id) nodeMap.set(node.id, node);
1392
+ if (output.error) throw toolError('FRAMER_PROGRAM_FAILED', String(output.error).split('\n')[0].slice(0, 300));
1393
+ const { nodes, measured: fromMcp } = output.result && typeof output.result === 'object' ? output.result : {};
1394
+ let mcpIndex = 0;
1395
+ const measured = [];
1396
+ for (const [index, check] of measures.entries()) {
1397
+ let entry = fromEditor[index] || (Array.isArray(fromMcp) ? fromMcp[mcpIndex++] : null);
1398
+ if (entry?.unmeasured && (check.measure === 'width' || check.measure === 'height')) {
1399
+ const size = await screenshotSize(check.nodeId);
1400
+ if (size) entry = { value: size[check.measure] };
1062
1401
  }
1063
- } else if (nodes && typeof nodes === 'object') {
1064
- for (const [id, node] of Object.entries(nodes)) nodeMap.set(id, node);
1402
+ measured.push(entry);
1403
+ }
1404
+ const nodeMap = new Map();
1405
+ for (const node of Array.isArray(nodes) ? nodes : []) {
1406
+ if (node?.id) nodeMap.set(node.id, node);
1065
1407
  }
1066
- behaviorResults = verifyBehaviorChecks({ checks, nodes: nodeMap });
1408
+ const at = Date.now();
1409
+ const results = verifyChecks({ checks, nodes: nodeMap, measurements: Array.isArray(measured) ? measured : [] })
1410
+ .map((item) => ({ ...item, at }));
1411
+ const checkedRequirements = new Set(results.map((item) => item.requirementId));
1412
+ checkResults = [...checkResults.filter((item) => !checkedRequirements.has(item.requirementId)), ...results];
1067
1413
  readCalls += 1;
1068
- if (mutationCalls > 0) lastReadAfterMutationAt = Date.now();
1069
- payload = summarizeBehaviorVerification(behaviorPlan, behaviorResults);
1414
+ if (mutationCalls > 0) lastReadAfterMutationAt = at;
1415
+ payload = requirementStatus();
1416
+ if (payload.requirements.some((item) => item.verdict === 'unmeasured')) {
1417
+ payload = { ...payload, unmeasuredNote: 'Unmeasured requirements could not be measured on this page. They are not failures; judge them from a screenshot when they matter, and do not change layers because of them.' };
1418
+ }
1070
1419
  } else {
1071
1420
  throw toolError('FRAMER_AGENT_TOOL_UNKNOWN', `Unknown Framer tool ${name}.`);
1072
1421
  }
@@ -1074,6 +1423,11 @@ export function createFramerAgentToolRuntime({
1074
1423
  noteFramerAnswer(null);
1075
1424
  const nudge = category === 'read' ? firstEditNudge() : null;
1076
1425
  if (nudge) payload = { ...payload, nextStep: nudge };
1426
+ if (Array.isArray(payload?.modelImages)) {
1427
+ const { modelImages, ...rest } = payload;
1428
+ const ready = await modelReadyImages(modelImages);
1429
+ payload = ready.length ? { ...rest, modelImages: ready } : rest;
1430
+ }
1077
1431
  reportActivity({
1078
1432
  kind: 'tool_active',
1079
1433
  source: 'framer-mcp',
@@ -1175,27 +1529,52 @@ export function createFramerAgentToolRuntime({
1175
1529
  return { content: guidance.content, inventory: guidance.inventory };
1176
1530
  }
1177
1531
 
1532
+ function unmetRequirements() {
1533
+ if (!requirements.length || mutationCalls === 0) return [];
1534
+ return requirementStatus().requirements
1535
+ .filter((item) => !item.checked || (item.verdict !== 'verified' && item.verdict !== 'unmeasured'))
1536
+ .map((item) => ({
1537
+ id: item.requirementId,
1538
+ text: item.text,
1539
+ reason: item.contradictions[0]
1540
+ || item.unknowns[0]
1541
+ || (item.uncheckedNodeIds.length ? `not checked on ${item.uncheckedNodeIds.join(', ')}` : 'not checked after the last change'),
1542
+ }));
1543
+ }
1544
+
1545
+ // What Dexter's own reads found after the model said it was done. These are
1546
+ // facts for the model to weigh against the user's request, not orders: it
1547
+ // decides whether to change something or to tell the user plainly.
1178
1548
  function verificationRepairText() {
1179
1549
  if (mutationCalls === 0) return null;
1180
1550
  const missing = verification().missingChecks;
1181
1551
  const propertyErrors = lingeringPropertyErrors();
1182
- const lines = [];
1552
+ const facts = [];
1183
1553
  if (propertyErrors.length) {
1184
- lines.push(`Framer did not apply every property: ${propertyErrors.map((error) => `${error.nodeId}.${error.property}: ${error.message}`).join('; ')}. Repair those properties with framer_edit.`);
1554
+ facts.push(`Framer rejected these properties, so they are not set: ${propertyErrors.map((error) => `${error.nodeId}.${error.property}: ${error.message}`).join('; ')}.`);
1185
1555
  }
1186
1556
  if (pendingRepair?.claimed?.length) {
1187
- lines.push(`These claimed node ids were not touched by an edit: ${pendingRepair.claimed.join(', ')}. Edit those nodes or remove them from changedNodeIds.`);
1557
+ facts.push(`Your changedNodeIds lists layers no edit touched: ${pendingRepair.claimed.join(', ')}.`);
1188
1558
  }
1189
1559
  if (pendingRepair?.failed?.length) {
1190
- lines.push(`Your latest edit of these claimed node ids failed, so it changed nothing: ${pendingRepair.failed.join(', ')}. Apply that change with framer_edit, or remove them from changedNodeIds and say in the summary what was not changed.`);
1560
+ facts.push(`Your latest edit of these layers failed, so it changed nothing: ${pendingRepair.failed.join(', ')}.`);
1561
+ }
1562
+ if (missing.includes('visual')) {
1563
+ facts.push('Your plan declared visual verification, and there is no screenshot after your last change.');
1191
1564
  }
1192
- for (const check of missing) {
1193
- if (check === 'structural') lines.push('perform a focused live read after the latest mutation');
1194
- else if (check === 'visual') lines.push('capture and inspect a screenshot of the affected canvas after the latest mutation');
1195
- else if (check === 'interactions') lines.push('confirm declared behaviors with framer_verify_interactions');
1565
+ const unmet = unmetRequirements();
1566
+ if (unmet.length) {
1567
+ facts.push(`Framer shows these requirements from your plan as not met: ${unmet.map((item) => `${item.text} (${item.reason})`).join('; ')}.`);
1196
1568
  }
1197
- if (!lines.length) return null;
1198
- return lines.join(' ');
1569
+ if (unchangedChanges.length) {
1570
+ facts.push(`These layers you planned to leave as they were now read differently: ${unchangedChanges.join('; ')}.`);
1571
+ }
1572
+ if (!facts.length) return null;
1573
+ return [
1574
+ 'Before you finish, here is what Dexter read from Framer after your last change.',
1575
+ ...facts,
1576
+ 'Weigh this against what the user asked. Change what is genuinely wrong, or, if it cannot or should not be changed, keep it and tell the user plainly in your summary. Then return the JSON outcome again.',
1577
+ ].join(' ');
1199
1578
  }
1200
1579
 
1201
1580
  // The read after the last change is a mechanical check, so the harness
@@ -1249,6 +1628,16 @@ export function createFramerAgentToolRuntime({
1249
1628
  }
1250
1629
  }
1251
1630
  pendingRepair = { claimed: missing, failed };
1631
+ if (mutationCalls > 0 && Object.keys(unchangedBefore).length) {
1632
+ try {
1633
+ await ensureProject();
1634
+ const after = await snapshotLayers(Object.keys(unchangedBefore).slice(0, UNCHANGED_LAYER_LIMIT));
1635
+ unchangedChanges = after ? unchangedViolations(unchangedBefore, after) : [];
1636
+ readCalls += 1;
1637
+ } catch {
1638
+ unchangedChanges = [];
1639
+ }
1640
+ }
1252
1641
  return pendingRepair;
1253
1642
  }
1254
1643
 
@@ -1256,8 +1645,8 @@ export function createFramerAgentToolRuntime({
1256
1645
  if (completion?.projectChanged !== true || mutationCalls > 0) return null;
1257
1646
  return [
1258
1647
  'Your summary says you changed the Framer project, but no layer reads differently after your edits.',
1259
- 'Continue the same task: find every target the user meant, apply the change with framer_edit, verify it, then return the required JSON outcome.',
1260
- 'If you cannot find or change a target, name it in the summary and set projectChanged to false.',
1648
+ 'If the user wants that change, apply it with framer_edit and verify it.',
1649
+ 'If it cannot be done, or should not be, say so plainly in the summary and set projectChanged to false. Then return the JSON outcome again.',
1261
1650
  ].join(' ');
1262
1651
  }
1263
1652
 
@@ -1267,7 +1656,8 @@ export function createFramerAgentToolRuntime({
1267
1656
  if (lastEdit?.status === 'failed') return null;
1268
1657
  return [
1269
1658
  'You returned failed without changing the Framer project, and no tool refused the work.',
1270
- 'Continue the same task from the current project state: plan it, apply the change, verify it, then return the required JSON outcome.',
1659
+ 'If the request can be done, continue it from the current project state.',
1660
+ 'If it cannot be done, or the user said to leave it, return completed and explain plainly what is in the way.',
1271
1661
  ].join(' ');
1272
1662
  }
1273
1663
 
@@ -1296,11 +1686,27 @@ export function createFramerAgentToolRuntime({
1296
1686
  output: unbacked.join(', '),
1297
1687
  });
1298
1688
  }
1689
+ const unmet = unmetRequirements();
1690
+ if (unmet.length) {
1691
+ checks.push({
1692
+ command: REQUIREMENTS_CHECK,
1693
+ status: 'failed',
1694
+ output: unmet.map((item) => `${item.id}: ${item.reason}`).join('; ').slice(0, 2000),
1695
+ });
1696
+ }
1697
+ if (unchangedChanges.length) {
1698
+ checks.push({
1699
+ command: UNCHANGED_CHECK,
1700
+ status: 'failed',
1701
+ output: unchangedChanges.join('; ').slice(0, 2000),
1702
+ });
1703
+ }
1299
1704
  // The model's own plan sets the evidence it owes; a run that changed the
1300
1705
  // project and skipped it is not accepted as done.
1301
1706
  const state = verification();
1302
- if (mutationCalls > 0 && !state.completed) {
1303
- const missing = state.missingChecks.map((check) => {
1707
+ const missingEvidence = state.missingChecks.filter((check) => check !== 'requirements');
1708
+ if (mutationCalls > 0 && missingEvidence.length) {
1709
+ const missing = missingEvidence.map((check) => {
1304
1710
  if (check === 'structural') return 'a read after the last change';
1305
1711
  if (check === 'visual') return 'a screenshot after the last change';
1306
1712
  return `${check} verification after the last change`;
@@ -1381,7 +1787,9 @@ export function createFramerAgentToolRuntime({
1381
1787
  return { unresolvedTargets: [] };
1382
1788
  },
1383
1789
  loadProjectGuidance,
1384
- configurePromptContext() {},
1790
+ configurePromptContext(context = {}) {
1791
+ promptContext = { ...context };
1792
+ },
1385
1793
  invoke,
1386
1794
  startGateway,
1387
1795
  synchronize: async () => ({ synchronized: true, sourceHash: null }),
@@ -1404,6 +1812,15 @@ export function createFramerAgentToolRuntime({
1404
1812
  mutationReceipts: mutationReceipts.map((receipt) => ({ ...receipt })),
1405
1813
  branch: branchChange,
1406
1814
  verification: verification(),
1815
+ requirements: requirementStatus().requirements.map((item) => ({
1816
+ id: item.requirementId,
1817
+ verdict: item.verdict,
1818
+ checked: item.checked,
1819
+ })),
1820
+ unchangedLayers: Object.keys(unchangedBefore).length,
1821
+ unchangedChanges: unchangedChanges.length,
1822
+ doctrineLoaded: promptContext.doctrineLoaded === true,
1823
+ doctrineVersion: promptContext.doctrineVersion || null,
1407
1824
  framerGuidanceLoaded: guidance.loaded,
1408
1825
  guidance: {
1409
1826
  loaded: guidance.loaded,