@gakim-digital/dexter-bridge 0.11.23 → 0.12.0
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/README.md +15 -26
- package/package.json +1 -2
- package/src/agent.js +25 -35
- package/src/agentOutput.js +1 -1
- package/src/api.js +0 -35
- package/src/config.js +3 -2
- package/src/framerBehaviorContract.js +1 -90
- package/src/framerMcpClient.js +214 -0
- package/src/framerTools.js +1253 -0
- package/src/outcomeWorkspace.js +4 -0
- package/src/protocol.js +46 -22
- package/src/providers/codexAppServer.js +2 -5
- package/src/framerAgentTools.js +0 -3298
- package/src/googleFontsFallback.js +0 -196
|
@@ -0,0 +1,1253 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import jsTokens from 'js-tokens';
|
|
3
|
+
import { createFramerMcpClient } from './framerMcpClient.js';
|
|
4
|
+
import {
|
|
5
|
+
evidenceNodeIds,
|
|
6
|
+
mergeBehaviorPlans,
|
|
7
|
+
normalizeBehaviorEvidence,
|
|
8
|
+
normalizeBehaviorPlan,
|
|
9
|
+
summarizeBehaviorVerification,
|
|
10
|
+
verifyBehaviorChecks,
|
|
11
|
+
} from './framerBehaviorContract.js';
|
|
12
|
+
|
|
13
|
+
export const UNAPPLIED_CHANGE_CHECK = 'Harness change check';
|
|
14
|
+
export const PROPERTY_ERROR_CHECK = 'Unapplied properties';
|
|
15
|
+
export const CLAIMED_TARGET_CHECK = 'Claimed targets';
|
|
16
|
+
export const VERIFICATION_CHECK = 'Harness-owned final verification';
|
|
17
|
+
|
|
18
|
+
const LITERAL_TOKEN_TYPES = new Set(['StringLiteral', 'NoSubstitutionTemplate', 'TemplateHead', 'TemplateMiddle', 'TemplateTail']);
|
|
19
|
+
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/;
|
|
20
|
+
const READ_MUTATING_PATTERN = /\b(?:tree\.(?:insert|patch|remove|move|duplicate|replaceText|patchFormDestinations|createComponentFromFrame|makeExternalComponentLocal|createBranch|renameBranch|joinBranch|leaveBranch|switchBranch|mergeBranch|deleteBranch)|api\.files\.(?:create|write|applyPatch|delete)|api\.publishing\.(?:publish|deployToProduction)|api\.store)\b/;
|
|
21
|
+
const PUBLISH_PATTERN = /\b(?:api\.publishing\.(?:publish|deployToProduction)|tree\.(?:mergeBranch|deleteBranch|switchBranch))\b/;
|
|
22
|
+
// Node properties and the project APIs (api.fonts, api.images, api.files) are
|
|
23
|
+
// preloaded because guessed property names and fonts Framer cannot load are
|
|
24
|
+
// the usual rejected properties, and each repair costs a model turn plus the
|
|
25
|
+
// doc reads before it.
|
|
26
|
+
const GUIDANCE_PATHS = [
|
|
27
|
+
'references/workflow/part-1.md',
|
|
28
|
+
'references/script/tree-api/part-1.md',
|
|
29
|
+
'references/script/node-properties/part-1.md',
|
|
30
|
+
'references/script/node-properties/part-2.md',
|
|
31
|
+
'references/script/project-apis/part-1.md',
|
|
32
|
+
];
|
|
33
|
+
const CONTEXT_PATH = 'context.md';
|
|
34
|
+
const PRELOADED_PATHS = new Set([CONTEXT_PATH, ...GUIDANCE_PATHS]);
|
|
35
|
+
// Lines that only fit a client reading docs itself, or that name a session,
|
|
36
|
+
// would steer the model into extra turns or change the cached prompt per run.
|
|
37
|
+
const STRIP_GUIDANCE = /one (?:doc|document)(?: or part)? per (?:call|turn)|one at a time with read_docs|fetch several parts|Pass session|@framer\/agent setup|project auth|session new/i;
|
|
38
|
+
const PRELOADED_GUIDANCE_NOTE = `${CONTEXT_PATH}, ${GUIDANCE_PATHS.join(', ')} and the README are already included below. Do not read them again. Use framer_instructions only when a specific API question blocks your next edit, not to read general guidance before you start, and pass every path you need in one call. This run has no skills catalog and no Skill tool; skip the README's skills gate.`;
|
|
39
|
+
const MAX_RESULT_CHARS = 6_000;
|
|
40
|
+
// Users judge progress by the canvas, so a run that is still only reading
|
|
41
|
+
// after this long is told to start building.
|
|
42
|
+
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.';
|
|
44
|
+
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
|
+
const ACTIVE_PAGE_OUTLINE_DEPTH = 2;
|
|
46
|
+
const FONT_KEYS = new Set(['font', 'fontFamily', 'family']);
|
|
47
|
+
const PROJECT_MISMATCH_PATTERN = /permission|forbidden|not found|does not have access|no access|can't open|cannot open/i;
|
|
48
|
+
|
|
49
|
+
// Item shapes the behavior contract (framerBehaviorContract.js) accepts.
|
|
50
|
+
const BEHAVIOR_SCHEMA = {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
id: { type: 'string', description: 'Short unique id, such as "whatsapp-links".' },
|
|
54
|
+
description: { type: 'string', description: 'What the user should experience.' },
|
|
55
|
+
targetNodeIds: { type: 'array', items: { type: 'string' }, description: 'Every node that should carry the behavior.' },
|
|
56
|
+
},
|
|
57
|
+
required: ['id', 'description'],
|
|
58
|
+
additionalProperties: false,
|
|
59
|
+
};
|
|
60
|
+
const BEHAVIOR_CHECK_SCHEMA = {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
behaviorId: { type: 'string' },
|
|
64
|
+
nodeId: { type: 'string' },
|
|
65
|
+
property: { type: 'string', description: 'The Framer property that carries the behavior, such as link.href.' },
|
|
66
|
+
contains: { type: 'string', description: 'Text the property value must contain.' },
|
|
67
|
+
},
|
|
68
|
+
required: ['behaviorId', 'nodeId', 'property'],
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const TOOL_DEFINITIONS = [
|
|
73
|
+
{
|
|
74
|
+
name: 'framer_read',
|
|
75
|
+
description: 'Read the open Framer project by running a program. Use api.tree.getNode, getNodes, serialize, and serializeNodes. Stock image search is api.images.search({ query }), one call per query. Fonts are api.fonts.get and api.fonts.list({ query }). There is no framer global. This tool cannot change the project.',
|
|
76
|
+
inputSchema: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
program: { type: 'string', description: 'JavaScript that returns the data you need. It runs inside Framer with api.tree, api.fonts, and api.images.' },
|
|
80
|
+
},
|
|
81
|
+
required: ['program'],
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'framer_edit',
|
|
87
|
+
description: 'Change the open Framer project by running a program. Use api.tree.insert, patch, remove, move, and replaceText. Place an SVG with tree.insert("SVGNode", id, { svg, intrinsicWidth, intrinsicHeight }). Write code files with api.files.create, write, or applyPatch. Search stock images with api.images.search({ query }). Place an image by setting fill to await api.tree.assetReference(url) with a public image URL. Load fonts with api.fonts. Pass screenshotNodeIds for the nodes you want to see after the edit. Include plan on your first framer_edit.',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
program: { type: 'string' },
|
|
92
|
+
reason: { type: 'string', description: 'The concrete change this program makes.' },
|
|
93
|
+
screenshotNodeIds: { type: 'array', items: { type: 'string' }, maxItems: 4 },
|
|
94
|
+
plan: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
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.',
|
|
97
|
+
properties: {
|
|
98
|
+
summary: { type: 'string' },
|
|
99
|
+
domains: { type: 'array', items: { type: 'string' } },
|
|
100
|
+
verification: { type: 'array', items: { type: 'string' } },
|
|
101
|
+
behaviors: { type: 'array', items: BEHAVIOR_SCHEMA },
|
|
102
|
+
},
|
|
103
|
+
required: ['summary', 'domains', 'verification'],
|
|
104
|
+
additionalProperties: false,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
required: ['program'],
|
|
108
|
+
additionalProperties: false,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'framer_instructions',
|
|
113
|
+
description: 'Read Framer MCP documents by path, such as references/script/tree-api/part-2.md. Pass every path you need in one call; each call costs a turn.',
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
paths: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 6 },
|
|
118
|
+
},
|
|
119
|
+
required: ['paths'],
|
|
120
|
+
additionalProperties: false,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'framer_view_image',
|
|
125
|
+
description: 'Show one or more image URLs to yourself so you can judge them. Use public attachment URLs and image URLs already in the project.',
|
|
126
|
+
inputSchema: {
|
|
127
|
+
type: 'object',
|
|
128
|
+
properties: {
|
|
129
|
+
urls: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 6 },
|
|
130
|
+
},
|
|
131
|
+
required: ['urls'],
|
|
132
|
+
additionalProperties: false,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'framer_verify_interactions',
|
|
137
|
+
description: 'Check that each declared behavior sits on the node properties you name. This is structural evidence, not runtime playback.',
|
|
138
|
+
inputSchema: {
|
|
139
|
+
type: 'object',
|
|
140
|
+
properties: {
|
|
141
|
+
checks: { type: 'array', items: BEHAVIOR_CHECK_SCHEMA },
|
|
142
|
+
behaviors: { type: 'array', items: BEHAVIOR_SCHEMA },
|
|
143
|
+
},
|
|
144
|
+
required: ['checks'],
|
|
145
|
+
additionalProperties: false,
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: 'framer_read_website',
|
|
150
|
+
description: 'Read one page of a website the user named: exact copy per section, fonts and text styles, colours, image URLs (including CSS backgrounds), and desktop and phone screenshots. Use it before recreating or migrating that page. Only sites the user named can be read.',
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: 'object',
|
|
153
|
+
properties: {
|
|
154
|
+
url: { type: 'string', minLength: 1, maxLength: 2048 },
|
|
155
|
+
screenshotTop: {
|
|
156
|
+
type: 'integer',
|
|
157
|
+
minimum: 0,
|
|
158
|
+
description: 'Desktop page offset in pixels for the screenshots. Use the value the previous result suggests to see further down.',
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
required: ['url'],
|
|
162
|
+
additionalProperties: false,
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
export const FRAMER_AGENT_TOOL_DEFINITIONS = TOOL_DEFINITIONS;
|
|
168
|
+
|
|
169
|
+
function toolError(code, message, statusCode = 400) {
|
|
170
|
+
return Object.assign(new Error(message), { code, statusCode });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function codeOutsideLiterals(code) {
|
|
174
|
+
let visible = '';
|
|
175
|
+
for (const token of jsTokens(String(code || ''))) {
|
|
176
|
+
visible += LITERAL_TOKEN_TYPES.has(token.type) ? ' ' : token.value;
|
|
177
|
+
}
|
|
178
|
+
return visible;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Text blocks inside a layer have virtual ids such as "v:dD8FHq_BL:0"; they
|
|
182
|
+
// belong to the layer named in the middle.
|
|
183
|
+
function layerIdOf(id) {
|
|
184
|
+
const text = String(id || '');
|
|
185
|
+
return text.startsWith('v:') ? text.split(':')[1] || '' : text;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function validateProgram(program, { write = false, allowPublishing = false } = {}) {
|
|
189
|
+
const code = String(program || '').trim();
|
|
190
|
+
if (!code) {
|
|
191
|
+
throw toolError('FRAMER_AGENT_CODE_REQUIRED', 'A Framer program is required.');
|
|
192
|
+
}
|
|
193
|
+
const visible = codeOutsideLiterals(code);
|
|
194
|
+
if (UNSAFE_EXEC_PATTERN.test(visible)) {
|
|
195
|
+
throw toolError(
|
|
196
|
+
'FRAMER_AGENT_CODE_UNSAFE',
|
|
197
|
+
'This program uses a capability Dexter does not allow in Framer MCP.',
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (!write && READ_MUTATING_PATTERN.test(visible)) {
|
|
201
|
+
throw toolError(
|
|
202
|
+
'FRAMER_AGENT_READ_ONLY_VIOLATION',
|
|
203
|
+
'Use framer_edit to change the project.',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (!allowPublishing && PUBLISH_PATTERN.test(visible)) {
|
|
207
|
+
throw toolError(
|
|
208
|
+
'FRAMER_AGENT_PUBLISH_NOT_AUTHORIZED',
|
|
209
|
+
'Publishing and branch merge, delete, or switch are not authorized for this run.',
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return code;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function stripGuidance(text) {
|
|
216
|
+
return String(text || '')
|
|
217
|
+
.split('\n')
|
|
218
|
+
.filter((line) => !STRIP_GUIDANCE.test(line))
|
|
219
|
+
.join('\n')
|
|
220
|
+
.trim();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// "`color` is not a supported property." → color;
|
|
224
|
+
// "Cannot apply `{ fill: "https://…" }`: …" → fill.
|
|
225
|
+
function propertyNameOf(message) {
|
|
226
|
+
const text = String(message || '');
|
|
227
|
+
return text.match(/`\{\s*"?([A-Za-z0-9_.$-]+)"?\s*:/)?.[1]
|
|
228
|
+
|| text.match(/`([A-Za-z0-9_.$-]+)`/)?.[1]
|
|
229
|
+
|| '';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Framer reports values that failed to apply as
|
|
233
|
+
// { "`color` is not a supported property.": ["node-id", …] }. Node ids are the
|
|
234
|
+
// ones the program used, so ids of nodes it inserted map through renamedIds.
|
|
235
|
+
export function propertyErrorsOf(output, renamedIds = {}) {
|
|
236
|
+
const raw = output?.tree?.errors ?? output?.errors;
|
|
237
|
+
const errors = [];
|
|
238
|
+
const push = (nodeId, message, property) => {
|
|
239
|
+
const id = String(nodeId || '').trim();
|
|
240
|
+
if (!id) return;
|
|
241
|
+
const text = String(message || '').slice(0, 500);
|
|
242
|
+
errors.push({
|
|
243
|
+
nodeId: String(renamedIds[id] || id).slice(0, 200),
|
|
244
|
+
property: String(property || propertyNameOf(text)).slice(0, 120),
|
|
245
|
+
message: text,
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
if (Array.isArray(raw)) {
|
|
249
|
+
for (const error of raw) {
|
|
250
|
+
if (!error || typeof error !== 'object') continue;
|
|
251
|
+
push(error.nodeId || error.id, error.message || error.error, error.property || error.attribute);
|
|
252
|
+
}
|
|
253
|
+
} else if (raw && typeof raw === 'object') {
|
|
254
|
+
for (const [message, nodeIds] of Object.entries(raw)) {
|
|
255
|
+
for (const nodeId of Array.isArray(nodeIds) ? nodeIds : [nodeIds]) {
|
|
256
|
+
push(typeof nodeId === 'object' ? nodeId?.id : nodeId, message);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return errors.slice(0, 50);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Framer's design linter reports { "<warning>": [node ids or details] } under
|
|
264
|
+
// tree.linter.warnings. Each warning keeps up to five affected entries.
|
|
265
|
+
export function linterWarningsOf(output) {
|
|
266
|
+
const raw = output?.tree?.linter?.warnings ?? output?.linter?.warnings;
|
|
267
|
+
if (Array.isArray(raw)) return raw.slice(0, 20);
|
|
268
|
+
if (!raw || typeof raw !== 'object') return [];
|
|
269
|
+
return Object.entries(raw).slice(0, 20).map(([message, entries]) => {
|
|
270
|
+
const list = Array.isArray(entries) ? entries : [entries];
|
|
271
|
+
return {
|
|
272
|
+
message: String(message).slice(0, 500),
|
|
273
|
+
nodes: list.slice(0, 5).map((entry) => (
|
|
274
|
+
entry && typeof entry === 'object'
|
|
275
|
+
? {
|
|
276
|
+
id: String(entry.id || '').slice(0, 200),
|
|
277
|
+
...(entry.text ? { text: String(entry.text).slice(0, 120) } : {}),
|
|
278
|
+
...(entry.backgroundNodeIds ? { backgroundNodeIds: entry.backgroundNodeIds } : {}),
|
|
279
|
+
}
|
|
280
|
+
: String(entry).slice(0, 200)
|
|
281
|
+
)),
|
|
282
|
+
count: list.length,
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Font families named anywhere in serialized nodes, so the model can reuse
|
|
288
|
+
// what the page already loads instead of guessing a family Framer rejects.
|
|
289
|
+
export function fontFamiliesIn(value, found = new Set()) {
|
|
290
|
+
if (Array.isArray(value)) {
|
|
291
|
+
for (const entry of value) fontFamiliesIn(entry, found);
|
|
292
|
+
} else if (value && typeof value === 'object') {
|
|
293
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
294
|
+
if (FONT_KEYS.has(key)) {
|
|
295
|
+
const family = typeof entry === 'string' ? entry : entry?.family || entry?.fontFamily;
|
|
296
|
+
if (typeof family === 'string' && family.trim()) found.add(family.trim());
|
|
297
|
+
}
|
|
298
|
+
if (entry && typeof entry === 'object') fontFamiliesIn(entry, found);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return [...found].slice(0, 20);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function isFontError(error) {
|
|
305
|
+
return /font/i.test(`${error?.property || ''} ${error?.message || ''}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function boundedResult(value) {
|
|
309
|
+
if (value === undefined) return undefined;
|
|
310
|
+
let text;
|
|
311
|
+
try {
|
|
312
|
+
text = JSON.stringify(value);
|
|
313
|
+
} catch {
|
|
314
|
+
return String(value).slice(0, MAX_RESULT_CHARS);
|
|
315
|
+
}
|
|
316
|
+
if (text === undefined || text.length <= MAX_RESULT_CHARS) return value;
|
|
317
|
+
return `${text.slice(0, MAX_RESULT_CHARS)}… (truncated; return less data from the program)`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function outputOf(result) {
|
|
321
|
+
const structured = result?.structured || {};
|
|
322
|
+
return structured.output && typeof structured.output === 'object' ? structured.output : structured;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function imagesFromExec(result, output) {
|
|
326
|
+
const listed = Array.isArray(output?.images) ? output.images : [];
|
|
327
|
+
const fromOutput = listed.flatMap((image) => {
|
|
328
|
+
const data = image?.data || image?.base64;
|
|
329
|
+
if (typeof data !== 'string' || !data) return [];
|
|
330
|
+
return [{ mimeType: image.mimeType || image.mediaType || 'image/png', data }];
|
|
331
|
+
});
|
|
332
|
+
return [...fromOutput, ...(result?.images || [])];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// The edit program runs with a recording view of `api`. Every layer an edit
|
|
336
|
+
// call changes is noted as it happens, including ids computed in loops, and
|
|
337
|
+
// so is every code file written. A call that edits an existing layer in place
|
|
338
|
+
// counts only when the layer reads differently right after the call than right
|
|
339
|
+
// before it; Framer's own change count leaves out text edits. Calls that throw
|
|
340
|
+
// are noted even when the program catches the error. The real `api` object is
|
|
341
|
+
// never modified.
|
|
342
|
+
const RECORDING_PRELUDE = `
|
|
343
|
+
const __dexterTouched = [];
|
|
344
|
+
const __dexterFiles = [];
|
|
345
|
+
const __dexterFailed = [];
|
|
346
|
+
const __dexterUnchanged = [];
|
|
347
|
+
const __dexterIdOf = (value) => typeof value === 'string' ? value : (value && typeof value.id === 'string' ? value.id : null);
|
|
348
|
+
// Text blocks have virtual ids such as "v:dD8FHq_BL:1:0"; they belong to the layer in the middle.
|
|
349
|
+
const __dexterLayerOf = (value) => { const id = __dexterIdOf(value); return id && id.startsWith('v:') ? id.split(':')[1] || null : id; };
|
|
350
|
+
const __dexterNote = (...values) => { for (const value of values) { const id = __dexterIdOf(value); if (id) __dexterTouched.push(id); } };
|
|
351
|
+
const __dexterStateOf = (tree, id) => {
|
|
352
|
+
if (!id) return null;
|
|
353
|
+
try {
|
|
354
|
+
const node = tree.get(id);
|
|
355
|
+
if (!node || typeof node.then === 'function') return null;
|
|
356
|
+
return JSON.stringify(node);
|
|
357
|
+
} catch { return null; }
|
|
358
|
+
};
|
|
359
|
+
const __dexterCompared = new Set(['patch', 'replaceText', 'remove']);
|
|
360
|
+
const __dexterTreeRecorders = {
|
|
361
|
+
insert: (args, result) => __dexterNote(args[1], result),
|
|
362
|
+
move: (args) => __dexterNote(args[0]),
|
|
363
|
+
duplicate: (args, result) => __dexterNote(args[0], args[1], result),
|
|
364
|
+
patchFormDestinations: (args) => __dexterNote(args[0]),
|
|
365
|
+
createComponentFromFrame: (args, result) => __dexterNote(args[0], result),
|
|
366
|
+
makeExternalComponentLocal: (args, result) => __dexterNote(args[0], result),
|
|
367
|
+
};
|
|
368
|
+
const __dexterTreeTarget = { insert: 1 };
|
|
369
|
+
const __dexterFileRecorders = Object.fromEntries(['create', 'write', 'applyPatch', 'delete'].map((op) => [op, (args, result) => {
|
|
370
|
+
__dexterFiles.push({ op, path: typeof args[0] === 'string' ? args[0] : '', diagnostics: result && Array.isArray(result.diagnostics) ? result.diagnostics : [] });
|
|
371
|
+
}]));
|
|
372
|
+
// The proxy sits on an empty stand-in that forwards to the real object, so
|
|
373
|
+
// methods can be wrapped even when Framer freezes its API objects.
|
|
374
|
+
const __dexterRecording = (object, recorders, children = {}, compared = new Set(), targets = {}) => new Proxy({}, {
|
|
375
|
+
has: (_, key) => Reflect.has(object, key),
|
|
376
|
+
ownKeys: () => Reflect.ownKeys(object),
|
|
377
|
+
getOwnPropertyDescriptor: (_, key) => {
|
|
378
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(object, key);
|
|
379
|
+
return descriptor ? { ...descriptor, configurable: true } : undefined;
|
|
380
|
+
},
|
|
381
|
+
get(_, key) {
|
|
382
|
+
const value = Reflect.get(object, key);
|
|
383
|
+
if (children[key] && value && typeof value === 'object') return children[key](value);
|
|
384
|
+
if (typeof value !== 'function') return value;
|
|
385
|
+
const recorder = recorders[key];
|
|
386
|
+
const checked = compared.has(key);
|
|
387
|
+
if (!recorder && !checked) return (...args) => value.apply(object, args);
|
|
388
|
+
return (...args) => {
|
|
389
|
+
const fail = (error) => {
|
|
390
|
+
if (__dexterFailed.length < 50) {
|
|
391
|
+
__dexterFailed.push({ call: String(key), target: __dexterIdOf(args[targets[key] || 0]) || '', message: String((error && error.message) || error).slice(0, 300) });
|
|
392
|
+
}
|
|
393
|
+
throw error;
|
|
394
|
+
};
|
|
395
|
+
const layer = checked ? __dexterLayerOf(args[0]) : null;
|
|
396
|
+
const before = checked ? __dexterStateOf(object, layer) : null;
|
|
397
|
+
const settle = (result) => {
|
|
398
|
+
if (!checked) { recorder(args, result); return result; }
|
|
399
|
+
const after = __dexterStateOf(object, layer);
|
|
400
|
+
// Without a readable layer, the call's own answer is all there is.
|
|
401
|
+
const changed = before !== null || after !== null ? before !== after : result !== false;
|
|
402
|
+
if (changed) __dexterNote(layer);
|
|
403
|
+
else if (__dexterUnchanged.length < 50) __dexterUnchanged.push({ call: String(key), target: __dexterIdOf(args[0]) || '' });
|
|
404
|
+
return result;
|
|
405
|
+
};
|
|
406
|
+
let result;
|
|
407
|
+
try { result = value.apply(object, args); } catch (error) { fail(error); }
|
|
408
|
+
if (result && typeof result.then === 'function') return result.then(settle, fail);
|
|
409
|
+
return settle(result);
|
|
410
|
+
};
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
const __dexterApi = __dexterRecording(api, {}, {
|
|
414
|
+
tree: (tree) => __dexterRecording(tree, __dexterTreeRecorders, {}, __dexterCompared, __dexterTreeTarget),
|
|
415
|
+
files: (files) => __dexterRecording(files, __dexterFileRecorders),
|
|
416
|
+
});
|
|
417
|
+
`;
|
|
418
|
+
|
|
419
|
+
function wrapperParts(program) {
|
|
420
|
+
const source = String(program || '');
|
|
421
|
+
// Framer shows a leading "// @step" comment as progress, so it stays first.
|
|
422
|
+
// The line it leaves behind keeps the program's own line numbers intact.
|
|
423
|
+
const step = source.match(/^\s*(\/\/\s*@step[^\n]*)/)?.[1] || '';
|
|
424
|
+
const body = step ? source.replace(step, '') : source;
|
|
425
|
+
// A program that throws still keeps the changes it made before the throw,
|
|
426
|
+
// so the error is caught here and returned with the record of those changes.
|
|
427
|
+
const prefix = `${step}
|
|
428
|
+
${RECORDING_PRELUDE}
|
|
429
|
+
let __dexterResult;
|
|
430
|
+
let __dexterError = null;
|
|
431
|
+
try {
|
|
432
|
+
__dexterResult = await (async (api) => {
|
|
433
|
+
`;
|
|
434
|
+
const suffix = `
|
|
435
|
+
})(__dexterApi);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
__dexterError = { name: String((error && error.name) || 'Error'), message: String((error && error.message) || error) };
|
|
438
|
+
}
|
|
439
|
+
return { result: __dexterResult, dexterError: __dexterError, dexterFiles: __dexterFiles, dexterTouched: __dexterTouched, dexterFailed: __dexterFailed, dexterUnchanged: __dexterUnchanged };
|
|
440
|
+
`;
|
|
441
|
+
return { prefix, body, suffix };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function wrapProgram(program) {
|
|
445
|
+
const { prefix, body, suffix } = wrapperParts(program);
|
|
446
|
+
return `${prefix}${body}${suffix}`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Lines the wrapper adds before the model's program; Framer's compile errors
|
|
450
|
+
// count from the top of the wrapped program.
|
|
451
|
+
export function wrappedLineOffset(program) {
|
|
452
|
+
return wrapperParts(program).prefix.split('\n').length - 1;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Framer reports a failed program as { error, errorType } with a long stack.
|
|
456
|
+
// The model gets the first line, with line numbers in its own program.
|
|
457
|
+
export function programErrorOf(output, lineOffset = 0) {
|
|
458
|
+
const inner = output?.result && typeof output.result === 'object' ? output.result : {};
|
|
459
|
+
const caught = inner.dexterError;
|
|
460
|
+
const raw = caught ? `${caught.name}: ${caught.message}` : output?.error;
|
|
461
|
+
if (!raw) return null;
|
|
462
|
+
let message = String(raw).split('\n')[0].slice(0, 600);
|
|
463
|
+
if (lineOffset > 0) {
|
|
464
|
+
message = message.replace(/Agent script line (\d+)/, (_, line) => `Program line ${Math.max(1, Number(line) - lineOffset)}`);
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
type: caught ? 'script_error' : String(output?.errorType || 'script_error'),
|
|
468
|
+
message,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function screenshotProgram(ids) {
|
|
473
|
+
return `const taken = []
|
|
474
|
+
for (const id of ${JSON.stringify(ids)}) {
|
|
475
|
+
try { await api.tree.screenshot(id); taken.push(id) } catch {}
|
|
476
|
+
}
|
|
477
|
+
return taken`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function claimedTargetProgram(claimed, touched) {
|
|
481
|
+
return `
|
|
482
|
+
const { tree } = api;
|
|
483
|
+
const touched = new Set(${JSON.stringify(touched.slice(0, 500))});
|
|
484
|
+
const verdicts = {};
|
|
485
|
+
for (const id of ${JSON.stringify(claimed.slice(0, 200))}) {
|
|
486
|
+
const node = tree.get(id);
|
|
487
|
+
if (!node) { verdicts[id] = 'missing'; continue; }
|
|
488
|
+
const ancestors = (await tree.getAncestorsFor(id)) || [];
|
|
489
|
+
if (ancestors.some((ancestor) => touched.has(ancestor.id))) { verdicts[id] = 'related'; continue; }
|
|
490
|
+
let related = false;
|
|
491
|
+
for (const descendant of tree.walk(id)) {
|
|
492
|
+
if (touched.has(descendant.id)) { related = true; break; }
|
|
493
|
+
}
|
|
494
|
+
verdicts[id] = related ? 'related' : 'untouched';
|
|
495
|
+
}
|
|
496
|
+
return verdicts;
|
|
497
|
+
`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function mismatchMessage(name) {
|
|
501
|
+
const account = name ? ` (${name})` : '';
|
|
502
|
+
return `Your connected Framer account${account} can't open this project — connect the account that owns it.`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function createFramerAgentToolRuntime({
|
|
506
|
+
assignment,
|
|
507
|
+
onActivity,
|
|
508
|
+
authorizeProject,
|
|
509
|
+
readWebsite,
|
|
510
|
+
mcpClient,
|
|
511
|
+
getAccessToken,
|
|
512
|
+
trace,
|
|
513
|
+
} = {}) {
|
|
514
|
+
const context = assignment?.context && typeof assignment.context === 'object' ? assignment.context : {};
|
|
515
|
+
const projectId = String(context.framerProjectId || '').trim();
|
|
516
|
+
const projectUrl = String(context.framerProjectUrl || '').trim();
|
|
517
|
+
const activePageId = String(context.activePageId || '').trim();
|
|
518
|
+
const allowPublishing = context.permissions?.publish === true;
|
|
519
|
+
const allowedTools = Array.isArray(assignment?.toolProtocol?.tools)
|
|
520
|
+
? new Set(assignment.toolProtocol.tools)
|
|
521
|
+
: null;
|
|
522
|
+
const definitions = TOOL_DEFINITIONS.filter((tool) => !allowedTools || allowedTools.has(tool.name));
|
|
523
|
+
const reportActivity = typeof onActivity === 'function' ? onActivity : () => undefined;
|
|
524
|
+
|
|
525
|
+
let framerUserName = null;
|
|
526
|
+
let cachedToken = null;
|
|
527
|
+
let opened = false;
|
|
528
|
+
let branchChange = null;
|
|
529
|
+
let mutationCalls = 0;
|
|
530
|
+
let readCalls = 0;
|
|
531
|
+
let toolCallCount = 0;
|
|
532
|
+
let plan = null;
|
|
533
|
+
let behaviorPlan = null;
|
|
534
|
+
let behaviorResults = [];
|
|
535
|
+
let lastMutationAt = 0;
|
|
536
|
+
let lastReadAfterMutationAt = 0;
|
|
537
|
+
let lastVisualAt = 0;
|
|
538
|
+
let firstToolAt = null;
|
|
539
|
+
let guidance = { content: '', inventory: '', loaded: false, files: [] };
|
|
540
|
+
const mutationReceipts = [];
|
|
541
|
+
// Layer id → result of the latest edit call on it ('changed' or 'failed').
|
|
542
|
+
const editOutcomes = new Map();
|
|
543
|
+
const toolCalls = [];
|
|
544
|
+
let serial = Promise.resolve();
|
|
545
|
+
let server = null;
|
|
546
|
+
let gateway = null;
|
|
547
|
+
let closed = false;
|
|
548
|
+
const abortController = new AbortController();
|
|
549
|
+
let pendingRepair = null;
|
|
550
|
+
|
|
551
|
+
// Access tokens last minutes; the API refreshes them. A token is reused
|
|
552
|
+
// until a minute before it expires, and replaced at once when Framer
|
|
553
|
+
// refuses it.
|
|
554
|
+
const tokenGetter = getAccessToken || (async ({ force } = {}) => {
|
|
555
|
+
if (!force && cachedToken && cachedToken.expiresAt - Date.now() > 60_000) {
|
|
556
|
+
return cachedToken.accessToken;
|
|
557
|
+
}
|
|
558
|
+
if (typeof authorizeProject !== 'function') {
|
|
559
|
+
throw toolError('FRAMER_ACCOUNT_CONNECTION_REQUIRED', 'Connect your Framer account in Dexter before starting.', 409);
|
|
560
|
+
}
|
|
561
|
+
const authorization = await authorizeProject({ forceRefresh: Boolean(force), signal: abortController.signal });
|
|
562
|
+
if (authorization?.status !== 'ready' || !authorization.accessToken) {
|
|
563
|
+
cachedToken = null;
|
|
564
|
+
throw toolError('FRAMER_ACCOUNT_CONNECTION_REQUIRED', 'Connect your Framer account in Dexter before starting.', 409);
|
|
565
|
+
}
|
|
566
|
+
framerUserName = authorization.framerUserName || framerUserName;
|
|
567
|
+
const expiresAt = Date.parse(authorization.expiresAt || '');
|
|
568
|
+
cachedToken = {
|
|
569
|
+
accessToken: authorization.accessToken,
|
|
570
|
+
expiresAt: Number.isFinite(expiresAt) ? expiresAt : Date.now() + 5 * 60_000,
|
|
571
|
+
};
|
|
572
|
+
return authorization.accessToken;
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const client = mcpClient || createFramerMcpClient({ getAccessToken: tokenGetter });
|
|
576
|
+
|
|
577
|
+
function lingeringPropertyErrors() {
|
|
578
|
+
let open = [];
|
|
579
|
+
for (const receipt of mutationReceipts) {
|
|
580
|
+
const touched = new Set(receipt.touchedNodeIds || []);
|
|
581
|
+
open = open.filter((error) => !touched.has(error.nodeId));
|
|
582
|
+
open.push(...(receipt.propertyErrors || []));
|
|
583
|
+
}
|
|
584
|
+
return open;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function touchedIds() {
|
|
588
|
+
const ids = new Set();
|
|
589
|
+
for (const receipt of mutationReceipts) {
|
|
590
|
+
for (const id of receipt.touchedNodeIds || []) ids.add(id);
|
|
591
|
+
for (const [from, to] of Object.entries(receipt.renamedIds || {})) {
|
|
592
|
+
if (from) ids.add(from);
|
|
593
|
+
if (to) ids.add(to);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return ids;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function ensureProject() {
|
|
600
|
+
if (opened) return;
|
|
601
|
+
if (!projectId) {
|
|
602
|
+
throw toolError('FRAMER_HARNESS_PROJECT_REQUIRED', 'This run does not identify a Framer project.');
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
await client.openProject(projectUrl || projectId);
|
|
606
|
+
opened = true;
|
|
607
|
+
} catch (error) {
|
|
608
|
+
if (error?.code === 'FRAMER_ACCOUNT_CONNECTION_REQUIRED') throw error;
|
|
609
|
+
if (PROJECT_MISMATCH_PATTERN.test(String(error?.message || error))) {
|
|
610
|
+
throw toolError('FRAMER_ACCOUNT_PROJECT_MISMATCH', mismatchMessage(framerUserName), 409);
|
|
611
|
+
}
|
|
612
|
+
throw error;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function recordCall(name, category, status, extra = {}) {
|
|
617
|
+
toolCallCount += 1;
|
|
618
|
+
toolCalls.push({ name, category, status, durationMs: extra.durationMs || 0, ...extra });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function applyReceipt(result, screenshotNodeIds) {
|
|
622
|
+
const output = outputOf(result);
|
|
623
|
+
const tree = output.tree && typeof output.tree === 'object' ? output.tree : {};
|
|
624
|
+
const changedNodeCount = Number(tree.changedNodeCount || output.changedNodeCount || 0) || 0;
|
|
625
|
+
const inner = output.result && typeof output.result === 'object' ? output.result : {};
|
|
626
|
+
const recordedFiles = [
|
|
627
|
+
...(Array.isArray(output.dexterFiles) ? output.dexterFiles : []),
|
|
628
|
+
...(Array.isArray(inner.dexterFiles) ? inner.dexterFiles : []),
|
|
629
|
+
];
|
|
630
|
+
const codeFiles = recordedFiles.map((file) => ({
|
|
631
|
+
op: String(file.op || 'write').slice(0, 40),
|
|
632
|
+
path: String(file.path || '').slice(0, 500),
|
|
633
|
+
diagnostics: Array.isArray(file.diagnostics) ? file.diagnostics.slice(0, 20) : [],
|
|
634
|
+
}));
|
|
635
|
+
const renamedIds = tree.renamedIds && typeof tree.renamedIds === 'object' ? tree.renamedIds : {};
|
|
636
|
+
const resolve = (ids) => [...new Set(ids.map((id) => renamedIds[String(id)] || String(id)))].slice(0, 500);
|
|
637
|
+
// Only the recording wrapper's lists count: they hold what the program
|
|
638
|
+
// actually did inside Framer.
|
|
639
|
+
const calls = (list) => (Array.isArray(list) ? list : [])
|
|
640
|
+
.filter((call) => call && typeof call === 'object')
|
|
641
|
+
.slice(0, 50)
|
|
642
|
+
.map((call) => ({
|
|
643
|
+
call: String(call.call || '').slice(0, 60),
|
|
644
|
+
target: String(renamedIds[call.target] || call.target || '').slice(0, 200),
|
|
645
|
+
...(call.message ? { message: String(call.message).slice(0, 300) } : {}),
|
|
646
|
+
}));
|
|
647
|
+
const failedCalls = calls(inner.dexterFailed);
|
|
648
|
+
const receipt = {
|
|
649
|
+
changedNodeCount,
|
|
650
|
+
renamedIds,
|
|
651
|
+
propertyErrors: propertyErrorsOf(output, renamedIds),
|
|
652
|
+
linterWarnings: linterWarningsOf(output),
|
|
653
|
+
codeFiles,
|
|
654
|
+
touchedNodeIds: resolve(Array.isArray(inner.dexterTouched) ? inner.dexterTouched : []),
|
|
655
|
+
failedCalls,
|
|
656
|
+
unchangedCalls: calls(inner.dexterUnchanged),
|
|
657
|
+
branchChange: output.branchChange || result.structured.branchChange || null,
|
|
658
|
+
};
|
|
659
|
+
for (const call of failedCalls) {
|
|
660
|
+
const id = layerIdOf(call.target);
|
|
661
|
+
if (id) editOutcomes.set(id, 'failed');
|
|
662
|
+
}
|
|
663
|
+
for (const id of receipt.touchedNodeIds) editOutcomes.set(layerIdOf(id), 'changed');
|
|
664
|
+
if (receipt.branchChange?.url) {
|
|
665
|
+
branchChange = {
|
|
666
|
+
title: String(receipt.branchChange.title || 'a Framer branch'),
|
|
667
|
+
id: String(receipt.branchChange.id || ''),
|
|
668
|
+
url: String(receipt.branchChange.url),
|
|
669
|
+
};
|
|
670
|
+
reportActivity({
|
|
671
|
+
kind: 'status_update',
|
|
672
|
+
source: 'framer-mcp',
|
|
673
|
+
message: `Edits are on branch ${branchChange.title}.`,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
// A layer that reads differently after the edit is the evidence; Framer's
|
|
677
|
+
// count only adds changes the wrapper cannot see.
|
|
678
|
+
const changed = receipt.touchedNodeIds.length > 0 || codeFiles.length > 0 || changedNodeCount > 0;
|
|
679
|
+
if (changed) {
|
|
680
|
+
mutationCalls += 1;
|
|
681
|
+
lastMutationAt = Date.now();
|
|
682
|
+
mutationReceipts.push(receipt);
|
|
683
|
+
}
|
|
684
|
+
const images = imagesFromExec(result, output);
|
|
685
|
+
if (images.length > 0 || (screenshotNodeIds || []).length > 0 && images.length > 0) {
|
|
686
|
+
lastVisualAt = Date.now();
|
|
687
|
+
}
|
|
688
|
+
return { receipt, images, changed, output };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function recordPlan(rawPlan) {
|
|
692
|
+
const nextBehaviors = normalizeBehaviorPlan({ behaviors: rawPlan.behaviors || [] });
|
|
693
|
+
plan = {
|
|
694
|
+
summary: String(rawPlan.summary || '').slice(0, 1000),
|
|
695
|
+
domains: Array.isArray(rawPlan.domains) ? rawPlan.domains.map(String).slice(0, 12) : [],
|
|
696
|
+
verification: Array.isArray(rawPlan.verification) ? rawPlan.verification.map(String).slice(0, 12) : [],
|
|
697
|
+
};
|
|
698
|
+
behaviorPlan = mergeBehaviorPlans(behaviorPlan, nextBehaviors);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function firstEditNudge() {
|
|
702
|
+
if (mutationCalls > 0 || !definitions.some((tool) => tool.name === 'framer_edit')) return null;
|
|
703
|
+
return Date.now() - firstToolAt >= FIRST_EDIT_NUDGE_MS ? FIRST_EDIT_NUDGE : null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function verification() {
|
|
707
|
+
const required = new Set(plan?.verification || []);
|
|
708
|
+
const missingChecks = [];
|
|
709
|
+
if (mutationCalls > 0 && lastReadAfterMutationAt < lastMutationAt) missingChecks.push('structural');
|
|
710
|
+
if (required.has('visual') && mutationCalls > 0 && lastVisualAt < lastMutationAt) missingChecks.push('visual');
|
|
711
|
+
if (required.has('interactions') && behaviorPlan) {
|
|
712
|
+
const summary = summarizeBehaviorVerification(behaviorPlan, behaviorResults);
|
|
713
|
+
if (summary.status !== 'verified') missingChecks.push('interactions');
|
|
714
|
+
}
|
|
715
|
+
return {
|
|
716
|
+
visualRequired: required.has('visual'),
|
|
717
|
+
visualCompleted: required.has('visual') && mutationCalls > 0 && lastVisualAt >= lastMutationAt,
|
|
718
|
+
completed: missingChecks.length === 0,
|
|
719
|
+
missingChecks,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// The structural read is the harness's job at the end of the run, so the
|
|
724
|
+
// model is only told about evidence it must produce itself.
|
|
725
|
+
function evidenceStillDue() {
|
|
726
|
+
if (mutationCalls === 0) return [];
|
|
727
|
+
return verification().missingChecks.filter((check) => check !== 'structural');
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function invokeUnsafe(name, args = {}) {
|
|
731
|
+
if (closed) throw toolError('FRAMER_AGENT_RUNTIME_CLOSED', 'This Framer run has finished.');
|
|
732
|
+
if (!definitions.some((tool) => tool.name === name)) {
|
|
733
|
+
throw toolError('FRAMER_AGENT_TOOL_UNKNOWN', `Unknown Framer tool ${name}.`);
|
|
734
|
+
}
|
|
735
|
+
const startedAt = Date.now();
|
|
736
|
+
if (firstToolAt === null) firstToolAt = startedAt;
|
|
737
|
+
reportActivity({ kind: 'tool_active', source: 'framer-mcp', name, phase: 'started' });
|
|
738
|
+
// Doc paths are kept on the call record so runs show which documents
|
|
739
|
+
// models keep asking for; those belong in the preloaded guidance.
|
|
740
|
+
const callExtra = {};
|
|
741
|
+
try {
|
|
742
|
+
let payload;
|
|
743
|
+
let category = 'read';
|
|
744
|
+
if (name === 'framer_read_website') {
|
|
745
|
+
if (typeof readWebsite !== 'function') {
|
|
746
|
+
throw toolError('FRAMER_READ_WEBSITE_UNAVAILABLE', 'Website reading is unavailable for this run.');
|
|
747
|
+
}
|
|
748
|
+
const website = await readWebsite({
|
|
749
|
+
url: String(args.url || '').trim(),
|
|
750
|
+
screenshotTop: Number.isInteger(args.screenshotTop) ? args.screenshotTop : undefined,
|
|
751
|
+
});
|
|
752
|
+
// Screenshots go to the model as images; inside the JSON text they
|
|
753
|
+
// would blow past the model's tool-result limit.
|
|
754
|
+
const images = (Array.isArray(website?.images) ? website.images : [])
|
|
755
|
+
.filter((image) => typeof image?.data === 'string' && image.data)
|
|
756
|
+
.map((image) => ({ mimeType: image.mimeType || 'image/jpeg', data: image.data }));
|
|
757
|
+
payload = {
|
|
758
|
+
result: website?.result ?? {},
|
|
759
|
+
...(images.length ? { modelImages: images } : {}),
|
|
760
|
+
};
|
|
761
|
+
if (images.length) lastVisualAt = Date.now();
|
|
762
|
+
readCalls += 1;
|
|
763
|
+
} else if (name === 'framer_instructions') {
|
|
764
|
+
const paths = [...new Set((Array.isArray(args.paths) ? args.paths : []).map(String))].slice(0, 6);
|
|
765
|
+
callExtra.paths = paths;
|
|
766
|
+
await ensureProject();
|
|
767
|
+
// One wrong path must not cost the pages that exist.
|
|
768
|
+
const pages = await readDocsTolerant(paths);
|
|
769
|
+
const found = new Set(pages.map((page) => page.path));
|
|
770
|
+
const missing = paths.filter((docPath) => !found.has(docPath));
|
|
771
|
+
const preloaded = paths.filter((docPath) => PRELOADED_PATHS.has(docPath));
|
|
772
|
+
payload = {
|
|
773
|
+
pages: pages.map((page) => ({ path: page.path, content: stripGuidance(page.content) })),
|
|
774
|
+
...(missing.length ? { missing } : {}),
|
|
775
|
+
...(preloaded.length ? { note: `${preloaded.join(', ')} ${preloaded.length === 1 ? 'is' : 'are'} already in your instructions; do not read preloaded documents again.` } : {}),
|
|
776
|
+
};
|
|
777
|
+
} else if (name === 'framer_view_image') {
|
|
778
|
+
await ensureProject();
|
|
779
|
+
const result = await client.viewImage(args.urls);
|
|
780
|
+
const images = imagesFromExec(result, outputOf(result));
|
|
781
|
+
payload = { viewed: (args.urls || []).length, ...(images.length ? { modelImages: images } : {}) };
|
|
782
|
+
if (images.length) lastVisualAt = Date.now();
|
|
783
|
+
} else if (name === 'framer_read') {
|
|
784
|
+
const program = validateProgram(args.program, { write: false, allowPublishing });
|
|
785
|
+
await ensureProject();
|
|
786
|
+
const result = await client.exec(program);
|
|
787
|
+
const applied = applyReceipt(result, []);
|
|
788
|
+
if (applied.changed) {
|
|
789
|
+
throw toolError(
|
|
790
|
+
'FRAMER_AGENT_READ_ONLY_VIOLATION',
|
|
791
|
+
'A read changed the Framer project. Use framer_edit for changes.',
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
const readError = programErrorOf(applied.output);
|
|
795
|
+
if (readError) {
|
|
796
|
+
throw toolError('FRAMER_PROGRAM_FAILED', `${readError.message} (${readError.type})`);
|
|
797
|
+
}
|
|
798
|
+
readCalls += 1;
|
|
799
|
+
if (mutationCalls > 0) lastReadAfterMutationAt = Date.now();
|
|
800
|
+
const due = evidenceStillDue();
|
|
801
|
+
payload = {
|
|
802
|
+
result: boundedResult(applied.output.result ?? null),
|
|
803
|
+
...(applied.images.length ? { modelImages: applied.images } : {}),
|
|
804
|
+
...(due.length ? { evidenceStillDue: due } : {}),
|
|
805
|
+
};
|
|
806
|
+
} else if (name === 'framer_edit') {
|
|
807
|
+
category = 'mutation';
|
|
808
|
+
const program = validateProgram(args.program, { write: true, allowPublishing });
|
|
809
|
+
if (args.plan && typeof args.plan === 'object') recordPlan(args.plan);
|
|
810
|
+
const screenshotNodeIds = Array.isArray(args.screenshotNodeIds) ? args.screenshotNodeIds : [];
|
|
811
|
+
await ensureProject();
|
|
812
|
+
const result = await client.exec(wrapProgram(program));
|
|
813
|
+
const applied = applyReceipt(result, screenshotNodeIds);
|
|
814
|
+
const programError = programErrorOf(applied.output, wrappedLineOffset(program));
|
|
815
|
+
// Layers created by this program only get their real ids once it
|
|
816
|
+
// finishes, so screenshots are taken in a second call.
|
|
817
|
+
let screenshotWarning = null;
|
|
818
|
+
const shotIds = [...new Set(screenshotNodeIds.map((id) => applied.receipt.renamedIds[id] || id))]
|
|
819
|
+
.map((id) => String(id).slice(0, 200))
|
|
820
|
+
.filter(Boolean)
|
|
821
|
+
.slice(0, 4);
|
|
822
|
+
if (shotIds.length) {
|
|
823
|
+
try {
|
|
824
|
+
const shot = await client.exec(screenshotProgram(shotIds));
|
|
825
|
+
applied.images.push(...imagesFromExec(shot, outputOf(shot)));
|
|
826
|
+
const taken = outputOf(shot).result;
|
|
827
|
+
const missing = shotIds.filter((id) => !Array.isArray(taken) || !taken.includes(id));
|
|
828
|
+
if (missing.length) screenshotWarning = `No screenshot for ${missing.join(', ')}.`;
|
|
829
|
+
} catch (error) {
|
|
830
|
+
screenshotWarning = `Screenshots failed: ${String(error?.message || error).split('\n')[0].slice(0, 200)}`;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
if (applied.images.length) lastVisualAt = Date.now();
|
|
834
|
+
const due = evidenceStillDue();
|
|
835
|
+
const inner = applied.output.result && typeof applied.output.result === 'object'
|
|
836
|
+
? applied.output.result
|
|
837
|
+
: {};
|
|
838
|
+
const programResult = boundedResult(inner.result);
|
|
839
|
+
payload = {
|
|
840
|
+
...(programError
|
|
841
|
+
? {
|
|
842
|
+
programError: {
|
|
843
|
+
...programError,
|
|
844
|
+
note: applied.changed
|
|
845
|
+
? 'The program stopped early. Changes made before the error were kept; read the project before retrying so they are not applied twice.'
|
|
846
|
+
: 'The program stopped before changing anything.',
|
|
847
|
+
},
|
|
848
|
+
}
|
|
849
|
+
: {}),
|
|
850
|
+
...(screenshotWarning ? { screenshotWarning } : {}),
|
|
851
|
+
...(programResult !== undefined ? { result: programResult } : {}),
|
|
852
|
+
changedNodeCount: applied.receipt.changedNodeCount,
|
|
853
|
+
// Framer gives inserted nodes new ids; later programs must use them.
|
|
854
|
+
renamedIds: applied.receipt.renamedIds,
|
|
855
|
+
touchedNodeIds: applied.receipt.touchedNodeIds,
|
|
856
|
+
...(applied.receipt.unchangedCalls.length
|
|
857
|
+
? {
|
|
858
|
+
unchangedCalls: applied.receipt.unchangedCalls,
|
|
859
|
+
unchangedCallsNote: 'These calls ran but left the layer exactly as it was.',
|
|
860
|
+
}
|
|
861
|
+
: {}),
|
|
862
|
+
...(applied.receipt.failedCalls.length
|
|
863
|
+
? {
|
|
864
|
+
failedCalls: applied.receipt.failedCalls,
|
|
865
|
+
failedCallsNote: 'These calls threw and changed nothing, even where your program caught the error. Fix them in your next framer_edit, or say in your summary what was not changed.',
|
|
866
|
+
}
|
|
867
|
+
: {}),
|
|
868
|
+
propertyErrors: applied.receipt.propertyErrors,
|
|
869
|
+
...(applied.receipt.propertyErrors.some(isFontError) ? { propertyErrorHint: FONT_ERROR_HINT } : {}),
|
|
870
|
+
...(applied.receipt.linterWarnings.length ? { linterWarnings: applied.receipt.linterWarnings } : {}),
|
|
871
|
+
codeFiles: applied.receipt.codeFiles,
|
|
872
|
+
...(applied.receipt.branchChange ? { branchChange: applied.receipt.branchChange } : {}),
|
|
873
|
+
...(applied.images.length ? { modelImages: applied.images } : {}),
|
|
874
|
+
...(due.length ? { evidenceStillDue: due } : {}),
|
|
875
|
+
};
|
|
876
|
+
} else if (name === 'framer_verify_interactions') {
|
|
877
|
+
behaviorPlan = mergeBehaviorPlans(behaviorPlan, normalizeBehaviorPlan({ behaviors: args.behaviors || [] }));
|
|
878
|
+
const checks = normalizeBehaviorEvidence(args.checks, behaviorPlan);
|
|
879
|
+
const nodeIds = evidenceNodeIds(checks);
|
|
880
|
+
await ensureProject();
|
|
881
|
+
// Serialized nodes are plain data; live node objects can crash
|
|
882
|
+
// Framer's worker when returned.
|
|
883
|
+
const result = await client.exec(
|
|
884
|
+
`return await api.tree.serializeNodes(${JSON.stringify(nodeIds)}, { depth: 0 });`,
|
|
885
|
+
);
|
|
886
|
+
const output = outputOf(result);
|
|
887
|
+
const nodes = output.result || output.nodes || output;
|
|
888
|
+
const list = Array.isArray(nodes) ? nodes : nodes?.nodes;
|
|
889
|
+
const nodeMap = new Map();
|
|
890
|
+
if (Array.isArray(list)) {
|
|
891
|
+
for (const node of list) {
|
|
892
|
+
if (node?.id) nodeMap.set(node.id, node);
|
|
893
|
+
}
|
|
894
|
+
} else if (nodes && typeof nodes === 'object') {
|
|
895
|
+
for (const [id, node] of Object.entries(nodes)) nodeMap.set(id, node);
|
|
896
|
+
}
|
|
897
|
+
behaviorResults = verifyBehaviorChecks({ checks, nodes: nodeMap });
|
|
898
|
+
readCalls += 1;
|
|
899
|
+
if (mutationCalls > 0) lastReadAfterMutationAt = Date.now();
|
|
900
|
+
payload = summarizeBehaviorVerification(behaviorPlan, behaviorResults);
|
|
901
|
+
} else {
|
|
902
|
+
throw toolError('FRAMER_AGENT_TOOL_UNKNOWN', `Unknown Framer tool ${name}.`);
|
|
903
|
+
}
|
|
904
|
+
recordCall(name, category, 'succeeded', { ...callExtra, durationMs: Date.now() - startedAt });
|
|
905
|
+
const nudge = category === 'read' ? firstEditNudge() : null;
|
|
906
|
+
if (nudge) payload = { ...payload, nextStep: nudge };
|
|
907
|
+
reportActivity({
|
|
908
|
+
kind: 'tool_active',
|
|
909
|
+
source: 'framer-mcp',
|
|
910
|
+
name,
|
|
911
|
+
phase: 'completed',
|
|
912
|
+
durationMs: Date.now() - startedAt,
|
|
913
|
+
});
|
|
914
|
+
return payload;
|
|
915
|
+
} catch (error) {
|
|
916
|
+
recordCall(name, name === 'framer_edit' ? 'mutation' : 'read', 'failed', {
|
|
917
|
+
...callExtra,
|
|
918
|
+
errorCode: error?.code,
|
|
919
|
+
durationMs: Date.now() - startedAt,
|
|
920
|
+
});
|
|
921
|
+
reportActivity({
|
|
922
|
+
kind: 'tool_active',
|
|
923
|
+
source: 'framer-mcp',
|
|
924
|
+
name,
|
|
925
|
+
phase: 'failed',
|
|
926
|
+
message: error?.message,
|
|
927
|
+
durationMs: Date.now() - startedAt,
|
|
928
|
+
});
|
|
929
|
+
throw error;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function invoke(name, args = {}) {
|
|
934
|
+
const operation = serial.then(() => invokeUnsafe(name, args));
|
|
935
|
+
serial = operation.catch(() => undefined);
|
|
936
|
+
return operation;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Each path is read on its own so one missing document does not lose the
|
|
940
|
+
// others.
|
|
941
|
+
async function readDocsTolerant(paths) {
|
|
942
|
+
const pages = await Promise.all(paths.map(async (docPath) => {
|
|
943
|
+
try {
|
|
944
|
+
const [page] = await client.readDocs([docPath]);
|
|
945
|
+
if (page && String(page.content || '').trim()) return page;
|
|
946
|
+
trace?.warn?.('framer_doc_empty', { path: docPath });
|
|
947
|
+
} catch (error) {
|
|
948
|
+
trace?.warn?.('framer_doc_failed', { path: docPath, message: String(error?.message || error).slice(0, 300) });
|
|
949
|
+
}
|
|
950
|
+
return null;
|
|
951
|
+
}));
|
|
952
|
+
return pages.filter(Boolean);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// The active page's top levels, so the model knows what is on the page
|
|
956
|
+
// without spending a turn reading it.
|
|
957
|
+
async function readActivePageOutline() {
|
|
958
|
+
if (!activePageId) return '';
|
|
959
|
+
const result = await client.exec(
|
|
960
|
+
`return await api.tree.serializeNodes(${JSON.stringify([activePageId])}, { depth: ${ACTIVE_PAGE_OUTLINE_DEPTH} });`,
|
|
961
|
+
);
|
|
962
|
+
const output = outputOf(result);
|
|
963
|
+
if (output.error || output.result == null) return '';
|
|
964
|
+
const outline = boundedResult(output.result);
|
|
965
|
+
const fonts = fontFamiliesIn(output.result);
|
|
966
|
+
return [
|
|
967
|
+
`Active page ${activePageId}, read when this run started (top ${ACTIVE_PAGE_OUTLINE_DEPTH} levels):\n${typeof outline === 'string' ? outline : JSON.stringify(outline)}`,
|
|
968
|
+
fonts.length ? `Fonts this page already uses, safe to set directly: ${fonts.join(', ')}.` : '',
|
|
969
|
+
].filter(Boolean).join('\n');
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function loadProjectGuidance() {
|
|
973
|
+
await ensureProject();
|
|
974
|
+
// Guidance and the page outline are helpful and must not block the run.
|
|
975
|
+
const [pages, contextPages, activePage] = await Promise.all([
|
|
976
|
+
readDocsTolerant(GUIDANCE_PATHS),
|
|
977
|
+
readDocsTolerant([CONTEXT_PATH]),
|
|
978
|
+
readActivePageOutline().catch(() => ''),
|
|
979
|
+
]);
|
|
980
|
+
const inventory = [
|
|
981
|
+
stripGuidance(contextPages.map((page) => page.content).join('\n\n')),
|
|
982
|
+
activePage,
|
|
983
|
+
].filter(Boolean).join('\n\n');
|
|
984
|
+
const readme = stripGuidance(client.readme());
|
|
985
|
+
const content = [
|
|
986
|
+
PRELOADED_GUIDANCE_NOTE,
|
|
987
|
+
readme,
|
|
988
|
+
...pages.map((page) => stripGuidance(page.content)),
|
|
989
|
+
].filter(Boolean).join('\n\n');
|
|
990
|
+
guidance = {
|
|
991
|
+
content,
|
|
992
|
+
inventory,
|
|
993
|
+
loaded: Boolean(content || inventory),
|
|
994
|
+
files: [
|
|
995
|
+
readme ? 'README' : null,
|
|
996
|
+
...pages.map((page) => page.path),
|
|
997
|
+
contextPages.length ? CONTEXT_PATH : null,
|
|
998
|
+
activePage ? 'active-page' : null,
|
|
999
|
+
].filter(Boolean),
|
|
1000
|
+
};
|
|
1001
|
+
return { content: guidance.content, inventory: guidance.inventory };
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function verificationRepairText() {
|
|
1005
|
+
if (mutationCalls === 0) return null;
|
|
1006
|
+
const missing = verification().missingChecks;
|
|
1007
|
+
const propertyErrors = lingeringPropertyErrors();
|
|
1008
|
+
const lines = [];
|
|
1009
|
+
if (propertyErrors.length) {
|
|
1010
|
+
lines.push(`Framer did not apply every property: ${propertyErrors.map((error) => `${error.nodeId}.${error.property}: ${error.message}`).join('; ')}. Repair those properties with framer_edit.`);
|
|
1011
|
+
}
|
|
1012
|
+
if (pendingRepair?.claimed?.length) {
|
|
1013
|
+
lines.push(`These claimed node ids were not touched by an edit: ${pendingRepair.claimed.join(', ')}. Edit those nodes or remove them from changedNodeIds.`);
|
|
1014
|
+
}
|
|
1015
|
+
if (pendingRepair?.failed?.length) {
|
|
1016
|
+
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.`);
|
|
1017
|
+
}
|
|
1018
|
+
for (const check of missing) {
|
|
1019
|
+
if (check === 'structural') lines.push('perform a focused live read after the latest mutation');
|
|
1020
|
+
else if (check === 'visual') lines.push('capture and inspect a screenshot of the affected canvas after the latest mutation');
|
|
1021
|
+
else if (check === 'interactions') lines.push('confirm declared behaviors with framer_verify_interactions');
|
|
1022
|
+
}
|
|
1023
|
+
if (!lines.length) return null;
|
|
1024
|
+
return lines.join(' ');
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// The read after the last change is a mechanical check, so the harness
|
|
1028
|
+
// performs it instead of sending the model back for another turn.
|
|
1029
|
+
async function completeStructuralRead() {
|
|
1030
|
+
if (mutationCalls === 0 || lastReadAfterMutationAt >= lastMutationAt) return;
|
|
1031
|
+
const touched = mutationReceipts.at(-1)?.touchedNodeIds || [];
|
|
1032
|
+
const ids = (touched.length ? touched : activePageId ? [activePageId] : []).slice(0, 50);
|
|
1033
|
+
if (!ids.length) return;
|
|
1034
|
+
const startedAt = Date.now();
|
|
1035
|
+
try {
|
|
1036
|
+
await ensureProject();
|
|
1037
|
+
const result = await client.exec(
|
|
1038
|
+
`return (await api.tree.serializeNodes(${JSON.stringify(ids)}, { depth: 0 })) != null;`,
|
|
1039
|
+
);
|
|
1040
|
+
if (outputOf(result).error) throw new Error(String(outputOf(result).error));
|
|
1041
|
+
readCalls += 1;
|
|
1042
|
+
lastReadAfterMutationAt = Date.now();
|
|
1043
|
+
recordCall('framer_read', 'read', 'succeeded', { harness: true, durationMs: Date.now() - startedAt });
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
recordCall('framer_read', 'read', 'failed', { harness: true, errorCode: error?.code, durationMs: Date.now() - startedAt });
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
async function assessCompletion(completion) {
|
|
1050
|
+
await completeStructuralRead();
|
|
1051
|
+
const claimed = Array.isArray(completion?.changedNodeIds)
|
|
1052
|
+
? completion.changedNodeIds.filter((id) => typeof id === 'string' && id.trim())
|
|
1053
|
+
: [];
|
|
1054
|
+
const known = touchedIds();
|
|
1055
|
+
// A layer whose latest edit threw is not changed by that edit, even when
|
|
1056
|
+
// an earlier edit in the run touched it.
|
|
1057
|
+
const failed = claimed.filter((id) => editOutcomes.get(layerIdOf(id)) === 'failed');
|
|
1058
|
+
let missing = claimed.filter((id) => !failed.includes(id) && !known.has(id));
|
|
1059
|
+
if (missing.length) {
|
|
1060
|
+
// A claimed layer also counts when an edit changed a layer inside it or
|
|
1061
|
+
// around it, such as a card whose title was rewritten.
|
|
1062
|
+
try {
|
|
1063
|
+
await ensureProject();
|
|
1064
|
+
const result = await client.exec(claimedTargetProgram(missing, [...known]));
|
|
1065
|
+
const verdicts = outputOf(result).result;
|
|
1066
|
+
if (verdicts && typeof verdicts === 'object') {
|
|
1067
|
+
missing = missing.filter((id) => verdicts[id] !== 'related');
|
|
1068
|
+
}
|
|
1069
|
+
readCalls += 1;
|
|
1070
|
+
} catch {
|
|
1071
|
+
// Without a read the claim cannot be checked either way, so it is
|
|
1072
|
+
// not held against the run; the change-count check still applies.
|
|
1073
|
+
missing = [];
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
pendingRepair = { claimed: missing, failed };
|
|
1077
|
+
return pendingRepair;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function unappliedChangePrompt(completion) {
|
|
1081
|
+
if (completion?.projectChanged !== true || mutationCalls > 0) return null;
|
|
1082
|
+
return [
|
|
1083
|
+
'Your summary says you changed the Framer project, but no layer reads differently after your edits.',
|
|
1084
|
+
'Continue the same task: find every target the user meant, apply the change with framer_edit, verify it, then return the required JSON outcome.',
|
|
1085
|
+
'If you cannot find or change a target, name it in the summary and set projectChanged to false.',
|
|
1086
|
+
].join(' ');
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function unblockedFailurePrompt(completion) {
|
|
1090
|
+
if (completion?.status !== 'failed' || mutationCalls > 0) return null;
|
|
1091
|
+
const lastEdit = [...toolCalls].reverse().find((call) => call.category === 'mutation');
|
|
1092
|
+
if (lastEdit?.status === 'failed') return null;
|
|
1093
|
+
return [
|
|
1094
|
+
'You returned failed without changing the Framer project, and no tool refused the work.',
|
|
1095
|
+
'Continue the same task from the current project state: plan it, apply the change, verify it, then return the required JSON outcome.',
|
|
1096
|
+
].join(' ');
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function failedChecks(completion) {
|
|
1100
|
+
const checks = [];
|
|
1101
|
+
if (completion?.projectChanged === true && mutationCalls === 0) {
|
|
1102
|
+
checks.push({
|
|
1103
|
+
command: UNAPPLIED_CHANGE_CHECK,
|
|
1104
|
+
status: 'failed',
|
|
1105
|
+
output: 'No layer changed for a summary that claimed a change.',
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
const propertyErrors = lingeringPropertyErrors();
|
|
1109
|
+
if (propertyErrors.length) {
|
|
1110
|
+
checks.push({
|
|
1111
|
+
command: PROPERTY_ERROR_CHECK,
|
|
1112
|
+
status: 'failed',
|
|
1113
|
+
output: propertyErrors.map((error) => `${error.nodeId}.${error.property}`).join(', '),
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
const unbacked = [...(pendingRepair?.claimed || []), ...(pendingRepair?.failed || [])];
|
|
1117
|
+
if (unbacked.length) {
|
|
1118
|
+
checks.push({
|
|
1119
|
+
command: CLAIMED_TARGET_CHECK,
|
|
1120
|
+
status: 'failed',
|
|
1121
|
+
output: unbacked.join(', '),
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
// The model's own plan sets the evidence it owes; a run that changed the
|
|
1125
|
+
// project and skipped it is not accepted as done.
|
|
1126
|
+
const state = verification();
|
|
1127
|
+
if (mutationCalls > 0 && !state.completed) {
|
|
1128
|
+
const missing = state.missingChecks.map((check) => {
|
|
1129
|
+
if (check === 'structural') return 'a read after the last change';
|
|
1130
|
+
if (check === 'visual') return 'a screenshot after the last change';
|
|
1131
|
+
return `${check} verification after the last change`;
|
|
1132
|
+
});
|
|
1133
|
+
checks.push({
|
|
1134
|
+
command: VERIFICATION_CHECK,
|
|
1135
|
+
status: 'failed',
|
|
1136
|
+
output: `The run ended before ${missing.join(' and ')}.`,
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
return checks;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function finalizeCompletion(completion) {
|
|
1143
|
+
if (completion?.status !== 'completed') return completion;
|
|
1144
|
+
const extra = failedChecks(completion);
|
|
1145
|
+
if (!extra.length) return completion;
|
|
1146
|
+
const unapplied = extra.some((check) => check.command === UNAPPLIED_CHANGE_CHECK);
|
|
1147
|
+
return {
|
|
1148
|
+
...completion,
|
|
1149
|
+
status: 'failed',
|
|
1150
|
+
projectChanged: unapplied ? false : completion.projectChanged,
|
|
1151
|
+
summary: unapplied
|
|
1152
|
+
? 'I didn’t change anything in Framer this time, even though I was about to say I had. Please try again, or select the element you want changed.'
|
|
1153
|
+
: completion.summary,
|
|
1154
|
+
checks: [...(completion.checks || []), ...extra],
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
async function startGateway() {
|
|
1159
|
+
if (gateway) return gateway;
|
|
1160
|
+
const { createServer } = await import('node:http');
|
|
1161
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
1162
|
+
server = createServer((request, response) => {
|
|
1163
|
+
const fail = (status, code, message) => {
|
|
1164
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
1165
|
+
response.end(JSON.stringify({ ok: false, error: { code, message } }));
|
|
1166
|
+
};
|
|
1167
|
+
if (request.method !== 'POST' || request.url !== '/tool') {
|
|
1168
|
+
fail(404, 'FRAMER_MCP_GATEWAY_NOT_FOUND', 'Framer tool endpoint not found.');
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (request.headers.authorization !== `Bearer ${token}`) {
|
|
1172
|
+
fail(401, 'FRAMER_MCP_GATEWAY_UNAUTHORIZED', 'Invalid Framer tool token.');
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
let body = '';
|
|
1176
|
+
request.on('data', (chunk) => {
|
|
1177
|
+
body += chunk.toString('utf8');
|
|
1178
|
+
if (body.length > 512 * 1024) request.destroy();
|
|
1179
|
+
});
|
|
1180
|
+
request.on('end', async () => {
|
|
1181
|
+
try {
|
|
1182
|
+
const payload = JSON.parse(body || '{}');
|
|
1183
|
+
const result = await invoke(String(payload.name || ''), payload.arguments || {});
|
|
1184
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
1185
|
+
response.end(JSON.stringify({ ok: true, result }));
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
fail(Number(error?.statusCode) || 400, error?.code || 'FRAMER_MCP_TOOL_FAILED', error?.message || 'Framer tool failed.');
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
});
|
|
1191
|
+
await new Promise((resolve, reject) => {
|
|
1192
|
+
server.once('error', reject);
|
|
1193
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
1194
|
+
});
|
|
1195
|
+
const address = server.address();
|
|
1196
|
+
gateway = { url: `http://127.0.0.1:${address.port}/tool`, token };
|
|
1197
|
+
return gateway;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
return {
|
|
1201
|
+
definitions,
|
|
1202
|
+
toolNames: definitions.map((definition) => definition.name),
|
|
1203
|
+
preflight: async () => {
|
|
1204
|
+
await ensureProject();
|
|
1205
|
+
return { unresolvedTargets: [] };
|
|
1206
|
+
},
|
|
1207
|
+
loadProjectGuidance,
|
|
1208
|
+
configurePromptContext() {},
|
|
1209
|
+
invoke,
|
|
1210
|
+
startGateway,
|
|
1211
|
+
synchronize: async () => ({ synchronized: true, sourceHash: null }),
|
|
1212
|
+
inspect: async () => ({ synchronized: true, sourceHash: null }),
|
|
1213
|
+
assessCompletion,
|
|
1214
|
+
finalizeCompletion,
|
|
1215
|
+
verificationRepairPrompt: () => verificationRepairText(),
|
|
1216
|
+
unappliedChangePrompt,
|
|
1217
|
+
unblockedFailurePrompt,
|
|
1218
|
+
sourceHash: () => null,
|
|
1219
|
+
hasPendingChanges: () => false,
|
|
1220
|
+
directWorkspace: true,
|
|
1221
|
+
summary: () => ({
|
|
1222
|
+
projectId,
|
|
1223
|
+
projectUrl,
|
|
1224
|
+
mutationCalls,
|
|
1225
|
+
readCalls,
|
|
1226
|
+
toolCallCount,
|
|
1227
|
+
toolCalls: toolCalls.map((call) => ({ ...call })),
|
|
1228
|
+
mutationReceipts: mutationReceipts.map((receipt) => ({ ...receipt })),
|
|
1229
|
+
branch: branchChange,
|
|
1230
|
+
verification: verification(),
|
|
1231
|
+
framerGuidanceLoaded: guidance.loaded,
|
|
1232
|
+
guidance: {
|
|
1233
|
+
loaded: guidance.loaded,
|
|
1234
|
+
files: [...guidance.files],
|
|
1235
|
+
},
|
|
1236
|
+
}),
|
|
1237
|
+
fatalInfrastructureError: () => null,
|
|
1238
|
+
waitForFatalInfrastructure: () => new Promise(() => undefined),
|
|
1239
|
+
close: async ({ waitForPending = true } = {}) => {
|
|
1240
|
+
closed = true;
|
|
1241
|
+
abortController.abort();
|
|
1242
|
+
if (waitForPending) await serial.catch(() => undefined);
|
|
1243
|
+
await client.close?.().catch(() => undefined);
|
|
1244
|
+
if (server) {
|
|
1245
|
+
if (!waitForPending) server.closeAllConnections?.();
|
|
1246
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
1247
|
+
server = null;
|
|
1248
|
+
}
|
|
1249
|
+
},
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
export function __resetFramerAgentToolRuntimeForTests() {}
|