@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.
- package/package.json +1 -1
- package/src/agent.js +46 -26
- package/src/api.js +1 -1
- package/src/config.js +2 -2
- package/src/framerRequirementContract.js +332 -0
- package/src/framerTools.js +492 -75
- package/src/protocol.js +16 -8
- package/src/framerBehaviorContract.js +0 -229
package/src/framerTools.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 = '
|
|
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
|
|
54
|
-
const
|
|
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 "
|
|
58
|
-
|
|
59
|
-
|
|
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', '
|
|
67
|
+
required: ['id', 'text'],
|
|
62
68
|
additionalProperties: false,
|
|
63
69
|
};
|
|
64
|
-
const
|
|
70
|
+
const CHECK_SCHEMA = {
|
|
65
71
|
type: 'object',
|
|
66
72
|
properties: {
|
|
67
|
-
|
|
73
|
+
requirementId: { type: 'string' },
|
|
68
74
|
nodeId: { type: 'string' },
|
|
69
|
-
property: { type: 'string', description: '
|
|
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: ['
|
|
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
|
|
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
|
-
|
|
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', '
|
|
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: '
|
|
141
|
-
description: 'Check
|
|
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:
|
|
146
|
-
|
|
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)
|
|
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
|
|
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
|
|
661
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
880
|
-
|
|
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 === '
|
|
1046
|
-
|
|
1047
|
-
const
|
|
1048
|
-
|
|
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
|
-
|
|
1053
|
-
|
|
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
|
-
|
|
1057
|
-
const
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
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 =
|
|
1069
|
-
payload =
|
|
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
|
|
1552
|
+
const facts = [];
|
|
1183
1553
|
if (propertyErrors.length) {
|
|
1184
|
-
|
|
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
|
-
|
|
1557
|
+
facts.push(`Your changedNodeIds lists layers no edit touched: ${pendingRepair.claimed.join(', ')}.`);
|
|
1188
1558
|
}
|
|
1189
1559
|
if (pendingRepair?.failed?.length) {
|
|
1190
|
-
|
|
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
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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 (
|
|
1198
|
-
|
|
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
|
-
'
|
|
1260
|
-
'If
|
|
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
|
-
'
|
|
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
|
-
|
|
1303
|
-
|
|
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,
|