@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
package/src/framerAgentTools.js
DELETED
|
@@ -1,3298 +0,0 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
4
|
-
import { createRequire } from 'node:module';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import jsTokens from 'js-tokens';
|
|
7
|
-
import { loadGoogleFontsCatalog, searchGoogleFonts } from './googleFontsFallback.js';
|
|
8
|
-
import {
|
|
9
|
-
canonicalIdMappings,
|
|
10
|
-
evidenceNodeIds,
|
|
11
|
-
mergeBehaviorPlans,
|
|
12
|
-
normalizeBehaviorEvidence,
|
|
13
|
-
normalizeBehaviorPlan,
|
|
14
|
-
summarizeBehaviorVerification,
|
|
15
|
-
summarizeFramerChanges,
|
|
16
|
-
verifyBehaviorChecks,
|
|
17
|
-
} from './framerBehaviorContract.js';
|
|
18
|
-
|
|
19
|
-
const require = createRequire(import.meta.url);
|
|
20
|
-
const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
21
|
-
const MAX_MODEL_RESULT_BYTES = 120 * 1024;
|
|
22
|
-
const MAX_CODE_FILE_CONTENT_CHARS = 200_000;
|
|
23
|
-
const MAX_SVG_MARKUP_CHARS = 200_000;
|
|
24
|
-
const MAX_SVG_ATTRIBUTES = 24;
|
|
25
|
-
const UNSAFE_SVG_PATTERN =
|
|
26
|
-
/<\s*(?:script|foreignObject|iframe)\b|\son[a-z]+\s*=|javascript:|(?:xlink:)?href\s*=\s*["']\s*(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
27
|
-
const MUTATION_TOOL_NAMES = new Set([
|
|
28
|
-
'framer_apply_changes',
|
|
29
|
-
'framer_add_svg',
|
|
30
|
-
'framer_update_code_file',
|
|
31
|
-
'framer_write',
|
|
32
|
-
]);
|
|
33
|
-
const MAX_SCREENSHOT_BYTES = 4 * 1024 * 1024;
|
|
34
|
-
const MAX_SCREENSHOTS_PER_CALL = 4;
|
|
35
|
-
const SCREENSHOT_FETCH_TIMEOUT_MS = 20_000;
|
|
36
|
-
const SESSION_SCREENSHOT_TIMEOUT_MS = 60_000;
|
|
37
|
-
const MAX_SCREENSHOT_FAILURES_REPORTED = 8;
|
|
38
|
-
const SCREENSHOT_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
|
39
|
-
const DEFAULT_COMMAND_TIMEOUT_MS = 5 * 60_000;
|
|
40
|
-
const SESSION_TIMEOUT_MS = 10 * 60_000;
|
|
41
|
-
const MAX_UNSAFE_WRITE_FAILURES = 2;
|
|
42
|
-
const MAX_LOW_LEVEL_WRITE_FAILURES = 3;
|
|
43
|
-
const MAX_GUIDANCE_BYTES = 180 * 1024;
|
|
44
|
-
const MAX_GUIDANCE_FILE_BYTES = 48 * 1024;
|
|
45
|
-
const PROJECT_ID_PATTERN = /^[A-Za-z0-9]{20}$/;
|
|
46
|
-
const PROJECT_SEGMENT_PATTERN =
|
|
47
|
-
/^(?:.+--)?([A-Za-z0-9]+)(?:-[A-Za-z0-9]+)?$/;
|
|
48
|
-
const FRAMER_PROJECT_HOST_PATTERN = /(^|\.)framer(?:local)?\.com$/i;
|
|
49
|
-
const PAGE_PATH_PATTERN = /^\/[^\0\r\n]{0,499}$/;
|
|
50
|
-
const UNSAFE_EXEC_PATTERN =
|
|
51
|
-
/\b(?:process|globalThis|global|require|import\s*\(|child_process|worker_threads|fs|os|net|tls|dgram|http|https|fetch|WebSocket|eval|Function)\b/;
|
|
52
|
-
const MUTATING_EXEC_PATTERN =
|
|
53
|
-
/\.(?:(?:set|add|delete|join|apply)(?:[A-Z_][A-Za-z0-9_]*)|(?:create|remove|update|insert|move|clone|duplicate|upload|publish|deploy|switch|merge|rename|leave)[A-Za-z0-9_]*)\s*\(/;
|
|
54
|
-
const CODE_FILE_SOURCE_WRITE_PATTERN =
|
|
55
|
-
/(?:\.setFileContent|\[\s*['"]setFileContent['"]\s*\])\s*\(/;
|
|
56
|
-
const TASK_DOMAINS = [
|
|
57
|
-
'visual',
|
|
58
|
-
'content',
|
|
59
|
-
'links',
|
|
60
|
-
'interactions',
|
|
61
|
-
'responsive',
|
|
62
|
-
'data',
|
|
63
|
-
'code',
|
|
64
|
-
'structure',
|
|
65
|
-
];
|
|
66
|
-
const VERIFICATION_CHECKS = [
|
|
67
|
-
'structural',
|
|
68
|
-
'visual',
|
|
69
|
-
'links',
|
|
70
|
-
'interactions',
|
|
71
|
-
'responsive',
|
|
72
|
-
'code',
|
|
73
|
-
'data',
|
|
74
|
-
];
|
|
75
|
-
const GENERIC_VERIFICATION_CHECKS = VERIFICATION_CHECKS.filter(
|
|
76
|
-
(check) => check !== 'interactions',
|
|
77
|
-
);
|
|
78
|
-
const EMPTY_SCHEMA = {
|
|
79
|
-
type: 'object',
|
|
80
|
-
properties: {},
|
|
81
|
-
additionalProperties: false,
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
export const FRAMER_AGENT_TOOL_DEFINITIONS = [
|
|
85
|
-
{
|
|
86
|
-
name: 'framer_instructions',
|
|
87
|
-
description:
|
|
88
|
-
'Read on-demand project guides by id from the "On-demand guides" catalog, or request targeted official API documentation when an exact method or type is unclear.',
|
|
89
|
-
inputSchema: {
|
|
90
|
-
type: 'object',
|
|
91
|
-
properties: {
|
|
92
|
-
guides: {
|
|
93
|
-
type: 'array',
|
|
94
|
-
maxItems: 6,
|
|
95
|
-
items: { type: 'string', minLength: 1, maxLength: 120 },
|
|
96
|
-
},
|
|
97
|
-
queries: {
|
|
98
|
-
type: 'array',
|
|
99
|
-
maxItems: 8,
|
|
100
|
-
items: { type: 'string', minLength: 1, maxLength: 120 },
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
additionalProperties: false,
|
|
104
|
-
},
|
|
105
|
-
},
|
|
106
|
-
{
|
|
107
|
-
name: 'framer_context',
|
|
108
|
-
description:
|
|
109
|
-
'Read current project context, including pages, components, styles, fonts, CMS collections, and the active branch.',
|
|
110
|
-
inputSchema: EMPTY_SCHEMA,
|
|
111
|
-
},
|
|
112
|
-
{
|
|
113
|
-
name: 'framer_read_project',
|
|
114
|
-
description:
|
|
115
|
-
'Run Framer Agent readProject queries: "screenshot", "font-search", and "implementation-guide-from-index" are the only query types. Read nodes and layout with framer_read instead.',
|
|
116
|
-
inputSchema: {
|
|
117
|
-
type: 'object',
|
|
118
|
-
properties: {
|
|
119
|
-
queries: {
|
|
120
|
-
type: 'array',
|
|
121
|
-
minItems: 1,
|
|
122
|
-
maxItems: 50,
|
|
123
|
-
items: { type: 'object', additionalProperties: true },
|
|
124
|
-
},
|
|
125
|
-
pagePath: { type: 'string', maxLength: 500, default: '/' },
|
|
126
|
-
verifies: {
|
|
127
|
-
type: 'array',
|
|
128
|
-
uniqueItems: true,
|
|
129
|
-
maxItems: 6,
|
|
130
|
-
items: { type: 'string', enum: GENERIC_VERIFICATION_CHECKS },
|
|
131
|
-
description:
|
|
132
|
-
'Verification evidence this read is intended to establish after mutations. Interaction verification requires framer_verify_interactions.',
|
|
133
|
-
},
|
|
134
|
-
},
|
|
135
|
-
required: ['queries'],
|
|
136
|
-
additionalProperties: false,
|
|
137
|
-
},
|
|
138
|
-
},
|
|
139
|
-
{
|
|
140
|
-
name: 'framer_read_website',
|
|
141
|
-
description:
|
|
142
|
-
'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.',
|
|
143
|
-
inputSchema: {
|
|
144
|
-
type: 'object',
|
|
145
|
-
properties: {
|
|
146
|
-
url: { type: 'string', minLength: 1, maxLength: 2048 },
|
|
147
|
-
screenshotTop: {
|
|
148
|
-
type: 'integer',
|
|
149
|
-
minimum: 0,
|
|
150
|
-
description: 'Desktop page offset in pixels for the screenshots. Use the value the previous result suggests to see further down.',
|
|
151
|
-
},
|
|
152
|
-
},
|
|
153
|
-
required: ['url'],
|
|
154
|
-
additionalProperties: false,
|
|
155
|
-
},
|
|
156
|
-
},
|
|
157
|
-
{
|
|
158
|
-
name: 'framer_plan_task',
|
|
159
|
-
description:
|
|
160
|
-
'After inspecting the live Framer target and before the first mutation, declare your understanding of the task and the evidence you will produce. Dexter does not classify the request or choose how to build it; use any capability the Framer guidance documents.',
|
|
161
|
-
inputSchema: {
|
|
162
|
-
type: 'object',
|
|
163
|
-
properties: {
|
|
164
|
-
summary: {
|
|
165
|
-
type: 'string',
|
|
166
|
-
minLength: 10,
|
|
167
|
-
maxLength: 1000,
|
|
168
|
-
},
|
|
169
|
-
domains: {
|
|
170
|
-
type: 'array',
|
|
171
|
-
minItems: 1,
|
|
172
|
-
uniqueItems: true,
|
|
173
|
-
items: { type: 'string', enum: TASK_DOMAINS },
|
|
174
|
-
},
|
|
175
|
-
verification: {
|
|
176
|
-
type: 'array',
|
|
177
|
-
minItems: 1,
|
|
178
|
-
uniqueItems: true,
|
|
179
|
-
items: { type: 'string', enum: VERIFICATION_CHECKS },
|
|
180
|
-
description:
|
|
181
|
-
'The evidence you will produce before completion. Dexter enforces exactly this list and adds nothing to it. Include "visual" when the result must be judged by eye, such as layout or styling work or a build from a reference image, then inspect a screenshot after your final mutation.',
|
|
182
|
-
},
|
|
183
|
-
behaviors: {
|
|
184
|
-
type: 'array',
|
|
185
|
-
maxItems: 20,
|
|
186
|
-
description:
|
|
187
|
-
'Interactive or animated behaviors the result must have, such as links, hover or tap effects, overlays, variants, appear, loop, or ticker motion. Declaring behaviors adds interaction verification.',
|
|
188
|
-
items: {
|
|
189
|
-
type: 'object',
|
|
190
|
-
properties: {
|
|
191
|
-
id: { type: 'string', minLength: 1, maxLength: 120 },
|
|
192
|
-
description: {
|
|
193
|
-
type: 'string',
|
|
194
|
-
minLength: 5,
|
|
195
|
-
maxLength: 500,
|
|
196
|
-
description: 'What the visitor should experience.',
|
|
197
|
-
},
|
|
198
|
-
targetNodeIds: {
|
|
199
|
-
type: 'array',
|
|
200
|
-
maxItems: 50,
|
|
201
|
-
uniqueItems: true,
|
|
202
|
-
items: { type: 'string', minLength: 1, maxLength: 200 },
|
|
203
|
-
description:
|
|
204
|
-
'Every node that should carry this behavior once the work is done. Update the plan if they change, for example after replacing nodes.',
|
|
205
|
-
},
|
|
206
|
-
},
|
|
207
|
-
required: ['id', 'description'],
|
|
208
|
-
additionalProperties: false,
|
|
209
|
-
},
|
|
210
|
-
},
|
|
211
|
-
},
|
|
212
|
-
required: ['summary', 'domains', 'verification'],
|
|
213
|
-
additionalProperties: false,
|
|
214
|
-
},
|
|
215
|
-
},
|
|
216
|
-
{
|
|
217
|
-
name: 'framer_verify_interactions',
|
|
218
|
-
description:
|
|
219
|
-
'Confirm planned behaviors against the live project. For each target node, name the Framer property that carries the behavior (for example loopEffect, hoverEffect, appearEffect, link, or onTap, or a field inside one such as link.href or hoverEffect.opacity) and optionally a literal the value must contain (such as a route or SHOW_OVERLAY). Results: verified (the property is set and matches), contradicted (the property holds a different value), or unknown (the property was not found on the node). Unknown never blocks your work; it is reported as unverified. Checks accumulate across calls, so you can verify targets on several pages.',
|
|
220
|
-
inputSchema: {
|
|
221
|
-
type: 'object',
|
|
222
|
-
properties: {
|
|
223
|
-
pagePath: { type: 'string', maxLength: 500, default: '/' },
|
|
224
|
-
checks: {
|
|
225
|
-
type: 'array',
|
|
226
|
-
minItems: 1,
|
|
227
|
-
maxItems: 50,
|
|
228
|
-
items: {
|
|
229
|
-
type: 'object',
|
|
230
|
-
properties: {
|
|
231
|
-
behaviorId: { type: 'string', minLength: 1, maxLength: 120 },
|
|
232
|
-
nodeId: { type: 'string', minLength: 1, maxLength: 200 },
|
|
233
|
-
property: { type: 'string', minLength: 1, maxLength: 120 },
|
|
234
|
-
contains: { type: 'string', minLength: 1, maxLength: 500 },
|
|
235
|
-
},
|
|
236
|
-
required: ['behaviorId', 'nodeId', 'property'],
|
|
237
|
-
additionalProperties: false,
|
|
238
|
-
},
|
|
239
|
-
},
|
|
240
|
-
},
|
|
241
|
-
required: ['checks'],
|
|
242
|
-
additionalProperties: false,
|
|
243
|
-
},
|
|
244
|
-
},
|
|
245
|
-
{
|
|
246
|
-
name: 'framer_query_images',
|
|
247
|
-
description:
|
|
248
|
-
'Search Framer Agent’s approved image source for real-world imagery. Use this only when the design needs a concrete photo or editorial image and no suitable user attachment or existing project image is available.',
|
|
249
|
-
inputSchema: {
|
|
250
|
-
type: 'object',
|
|
251
|
-
properties: {
|
|
252
|
-
query: { type: 'string', minLength: 2, maxLength: 200 },
|
|
253
|
-
count: { type: 'integer', minimum: 1, maximum: 8, default: 4 },
|
|
254
|
-
orientation: {
|
|
255
|
-
type: 'string',
|
|
256
|
-
enum: ['landscape', 'portrait', 'squarish'],
|
|
257
|
-
},
|
|
258
|
-
},
|
|
259
|
-
required: ['query'],
|
|
260
|
-
additionalProperties: false,
|
|
261
|
-
},
|
|
262
|
-
},
|
|
263
|
-
{
|
|
264
|
-
name: 'framer_upload_attachment',
|
|
265
|
-
description:
|
|
266
|
-
'Upload one of the user attachments listed in context.referenceAssetFiles into this Framer project and return its framerusercontent.com URL. Use the returned url as an image fill in framer_apply_changes. This is the only way to place a user attachment in the project.',
|
|
267
|
-
inputSchema: {
|
|
268
|
-
type: 'object',
|
|
269
|
-
properties: {
|
|
270
|
-
attachmentId: { type: 'string', minLength: 1, maxLength: 180 },
|
|
271
|
-
},
|
|
272
|
-
required: ['attachmentId'],
|
|
273
|
-
additionalProperties: false,
|
|
274
|
-
},
|
|
275
|
-
},
|
|
276
|
-
{
|
|
277
|
-
name: 'framer_apply_changes',
|
|
278
|
-
description:
|
|
279
|
-
'Apply Framer Agent DSL changes directly to the connected project. Prefer this for page, layout, style, design-token, component, and CMS-on-canvas work. Pass screenshotNodeIds to get screenshots of those nodes taken after the change in the same result, instead of a separate read.',
|
|
280
|
-
inputSchema: {
|
|
281
|
-
type: 'object',
|
|
282
|
-
properties: {
|
|
283
|
-
changes: { type: 'string', minLength: 1, maxLength: 250_000 },
|
|
284
|
-
pagePath: { type: 'string', maxLength: 500, default: '/' },
|
|
285
|
-
screenshotNodeIds: {
|
|
286
|
-
type: 'array',
|
|
287
|
-
maxItems: MAX_SCREENSHOTS_PER_CALL,
|
|
288
|
-
uniqueItems: true,
|
|
289
|
-
items: { type: 'string', minLength: 1, maxLength: 200 },
|
|
290
|
-
description:
|
|
291
|
-
'Nodes (or component variants) to screenshot after the change is applied. The screenshots count as visual evidence after this mutation.',
|
|
292
|
-
},
|
|
293
|
-
},
|
|
294
|
-
required: ['changes'],
|
|
295
|
-
additionalProperties: false,
|
|
296
|
-
},
|
|
297
|
-
},
|
|
298
|
-
{
|
|
299
|
-
name: 'framer_add_svg',
|
|
300
|
-
description:
|
|
301
|
-
'Create an SVG layer from markup you write and place it inside a parent node. Use it for geometric and decorative artwork such as line work, starbursts, rings, sparkles, wireframe shapes, dividers, and simple marks. Use icon sets for icons, real text for text, and images for photos. Returns the new SVG node id; check it in a screenshot. To revise its markup later, use framer_write with setAttributes({ svg }) on that node.',
|
|
302
|
-
inputSchema: {
|
|
303
|
-
type: 'object',
|
|
304
|
-
properties: {
|
|
305
|
-
svg: {
|
|
306
|
-
type: 'string',
|
|
307
|
-
minLength: 1,
|
|
308
|
-
maxLength: MAX_SVG_MARKUP_CHARS,
|
|
309
|
-
description:
|
|
310
|
-
'One self-contained <svg> element with a viewBox. No scripts, event handlers, foreignObject, or external references.',
|
|
311
|
-
},
|
|
312
|
-
parentId: { type: 'string', minLength: 1, maxLength: 200 },
|
|
313
|
-
index: { type: 'integer', minimum: 0 },
|
|
314
|
-
name: { type: 'string', minLength: 1, maxLength: 120 },
|
|
315
|
-
attributes: {
|
|
316
|
-
type: 'object',
|
|
317
|
-
description:
|
|
318
|
-
'Optional Framer layer attributes for the SVG node, for example {"position":"absolute","top":"40px","left":"0px","width":"320px","height":"320px","rotation":12,"opacity":0.6}. Defaults to position relative at the SVG\'s own size.',
|
|
319
|
-
},
|
|
320
|
-
},
|
|
321
|
-
required: ['svg', 'parentId'],
|
|
322
|
-
additionalProperties: false,
|
|
323
|
-
},
|
|
324
|
-
},
|
|
325
|
-
{
|
|
326
|
-
name: 'framer_read_code_file',
|
|
327
|
-
description:
|
|
328
|
-
'Read the complete source of an existing Framer code file by its exact project path. Use this instead of generic JavaScript when inspecting a code component or override. Files up to 200,000 characters are returned without the general Framer result cap, together with completeness metadata, a source hash, and the version ID required for a safe update.',
|
|
329
|
-
inputSchema: {
|
|
330
|
-
type: 'object',
|
|
331
|
-
properties: {
|
|
332
|
-
path: { type: 'string', minLength: 1, maxLength: 1_000 },
|
|
333
|
-
},
|
|
334
|
-
required: ['path'],
|
|
335
|
-
additionalProperties: false,
|
|
336
|
-
},
|
|
337
|
-
},
|
|
338
|
-
{
|
|
339
|
-
name: 'framer_update_code_file',
|
|
340
|
-
description:
|
|
341
|
-
'Replace an existing Framer code file with complete updated source. Always read the file first and pass its exact version ID. Dexter resolves the CodeFile, rejects stale edits, saves through Framer, type-checks, and reads the saved source back.',
|
|
342
|
-
inputSchema: {
|
|
343
|
-
type: 'object',
|
|
344
|
-
properties: {
|
|
345
|
-
path: { type: 'string', minLength: 1, maxLength: 1_000 },
|
|
346
|
-
expectedVersionId: { type: 'string', minLength: 1, maxLength: 300 },
|
|
347
|
-
content: {
|
|
348
|
-
type: 'string',
|
|
349
|
-
maxLength: MAX_CODE_FILE_CONTENT_CHARS,
|
|
350
|
-
},
|
|
351
|
-
},
|
|
352
|
-
required: ['path', 'expectedVersionId', 'content'],
|
|
353
|
-
additionalProperties: false,
|
|
354
|
-
},
|
|
355
|
-
},
|
|
356
|
-
{
|
|
357
|
-
name: 'framer_read',
|
|
358
|
-
description:
|
|
359
|
-
'Execute read-only JavaScript with the official Framer Agent connection. Use it to read nodes and layout, for example with framer.agent.getNode, serialize, or serializeNodes; combine several reads in one script.',
|
|
360
|
-
inputSchema: {
|
|
361
|
-
type: 'object',
|
|
362
|
-
properties: {
|
|
363
|
-
code: { type: 'string', minLength: 1, maxLength: 200_000 },
|
|
364
|
-
verifies: {
|
|
365
|
-
type: 'array',
|
|
366
|
-
uniqueItems: true,
|
|
367
|
-
maxItems: 6,
|
|
368
|
-
items: { type: 'string', enum: GENERIC_VERIFICATION_CHECKS },
|
|
369
|
-
description:
|
|
370
|
-
'Verification evidence this read is intended to establish after mutations. Interaction verification requires framer_verify_interactions.',
|
|
371
|
-
},
|
|
372
|
-
},
|
|
373
|
-
required: ['code'],
|
|
374
|
-
additionalProperties: false,
|
|
375
|
-
},
|
|
376
|
-
},
|
|
377
|
-
{
|
|
378
|
-
name: 'framer_write',
|
|
379
|
-
description:
|
|
380
|
-
'Execute project-scoped JavaScript with the official Framer Agent connection for mutations not covered by dedicated tools or applyChanges, such as localization, redirects, assets, CMS, or branch operations. Use framer_update_code_file for existing code files. Publishing is disabled unless Dexter explicitly authorizes it. If a write is rejected as unsafe, do not retry JavaScript variants; switch to a dedicated tool, framer_apply_changes, or report the concrete blocker.',
|
|
381
|
-
inputSchema: {
|
|
382
|
-
type: 'object',
|
|
383
|
-
properties: {
|
|
384
|
-
code: { type: 'string', minLength: 1, maxLength: 200_000 },
|
|
385
|
-
reason: {
|
|
386
|
-
type: 'string',
|
|
387
|
-
minLength: 10,
|
|
388
|
-
maxLength: 500,
|
|
389
|
-
description:
|
|
390
|
-
'Explain the specific Framer capability needed and why framer_apply_changes cannot perform it.',
|
|
391
|
-
},
|
|
392
|
-
},
|
|
393
|
-
required: ['code', 'reason'],
|
|
394
|
-
additionalProperties: false,
|
|
395
|
-
},
|
|
396
|
-
},
|
|
397
|
-
];
|
|
398
|
-
|
|
399
|
-
const TOOL_BY_NAME = new Map(
|
|
400
|
-
FRAMER_AGENT_TOOL_DEFINITIONS.map((definition) => [
|
|
401
|
-
definition.name,
|
|
402
|
-
definition,
|
|
403
|
-
]),
|
|
404
|
-
);
|
|
405
|
-
const sessionsByProject = new Map();
|
|
406
|
-
let framerSetupPromise = null;
|
|
407
|
-
|
|
408
|
-
function isRecord(value) {
|
|
409
|
-
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function toolError(code, message, statusCode = 400) {
|
|
413
|
-
return Object.assign(new Error(message), { code, statusCode });
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function boundedText(value, maximum) {
|
|
417
|
-
const text = String(value || '');
|
|
418
|
-
if (Buffer.byteLength(text, 'utf8') <= maximum) return text;
|
|
419
|
-
return `${text.slice(0, maximum)}\n...[output clipped]`;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
function hasInstalledFramerSkill(env) {
|
|
423
|
-
const home = String(env?.HOME || '').trim();
|
|
424
|
-
if (!home) return false;
|
|
425
|
-
return [
|
|
426
|
-
path.join(home, '.agents', 'skills', 'framer', 'SKILL.md'),
|
|
427
|
-
path.join(home, '.claude', 'skills', 'framer', 'SKILL.md'),
|
|
428
|
-
].some((skillPath) => fs.existsSync(skillPath));
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
function framerSkillRoot(env) {
|
|
432
|
-
const home = String(env?.HOME || '').trim();
|
|
433
|
-
if (!home) return null;
|
|
434
|
-
return [
|
|
435
|
-
path.join(home, '.agents', 'skills', 'framer'),
|
|
436
|
-
path.join(home, '.claude', 'skills', 'framer'),
|
|
437
|
-
].find((root) => fs.existsSync(path.join(root, 'SKILL.md'))) || null;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
function isMissingFramerSkillError(error) {
|
|
441
|
-
return /could not find an installed framer skill|run [`'"]?@framer\/agent setup/i.test(
|
|
442
|
-
String(error?.message || ''),
|
|
443
|
-
);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
function installedFramerAgentVersion() {
|
|
447
|
-
try {
|
|
448
|
-
let current = path.dirname(resolveFramerAgentCli());
|
|
449
|
-
for (let depth = 0; depth < 8; depth += 1) {
|
|
450
|
-
const packagePath = path.join(current, 'package.json');
|
|
451
|
-
if (fs.existsSync(packagePath)) {
|
|
452
|
-
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
453
|
-
if (manifest?.name === '@framer/agent') {
|
|
454
|
-
return String(manifest.version || '').trim() || null;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
const parent = path.dirname(current);
|
|
458
|
-
if (parent === current) break;
|
|
459
|
-
current = parent;
|
|
460
|
-
}
|
|
461
|
-
} catch {
|
|
462
|
-
// Version telemetry must never block a run.
|
|
463
|
-
}
|
|
464
|
-
return null;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function readGuidanceFile(projectRoot, relativePath, remainingBytes, section) {
|
|
468
|
-
const normalized = relativePath.replaceAll('\\', '/');
|
|
469
|
-
if (
|
|
470
|
-
normalized.startsWith('/')
|
|
471
|
-
|| normalized.split('/').some((segment) => segment === '..')
|
|
472
|
-
) return null;
|
|
473
|
-
const absolutePath = path.resolve(projectRoot, normalized);
|
|
474
|
-
if (
|
|
475
|
-
absolutePath !== projectRoot
|
|
476
|
-
&& !absolutePath.startsWith(`${projectRoot}${path.sep}`)
|
|
477
|
-
) return null;
|
|
478
|
-
if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
|
|
479
|
-
return null;
|
|
480
|
-
}
|
|
481
|
-
const maximum = Math.min(MAX_GUIDANCE_FILE_BYTES, remainingBytes);
|
|
482
|
-
if (maximum <= 0) return null;
|
|
483
|
-
let content = fs.readFileSync(absolutePath, 'utf8');
|
|
484
|
-
if (section) {
|
|
485
|
-
content = markdownSection(content, section);
|
|
486
|
-
if (!content) return null;
|
|
487
|
-
}
|
|
488
|
-
return {
|
|
489
|
-
path: normalized,
|
|
490
|
-
content: boundedText(content, maximum),
|
|
491
|
-
};
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
const CORE_GUIDE_SECTIONS = [
|
|
495
|
-
{ file: 'how-projects-work', section: 'Scope Types' },
|
|
496
|
-
{ file: 'how-projects-work', section: 'Replicas' },
|
|
497
|
-
{ file: 'how-projects-work', section: 'Transforms' },
|
|
498
|
-
{ file: 'implementation-strategy', section: 'Determining Strategy' },
|
|
499
|
-
{ file: 'implementation-strategy', section: 'Edit Strategy' },
|
|
500
|
-
{ file: 'implementation-strategy', section: 'Update Loop' },
|
|
501
|
-
];
|
|
502
|
-
const ON_DEMAND_GUIDE_FILES = [
|
|
503
|
-
{ file: 'how-projects-work', sections: true },
|
|
504
|
-
{ file: 'implementation-strategy', sections: true },
|
|
505
|
-
{ file: 'core-examples', sections: false },
|
|
506
|
-
];
|
|
507
|
-
|
|
508
|
-
// Lists the topic guides that are not part of every prompt, keyed by
|
|
509
|
-
// "file#Section" (or "file" for a whole file), from the files on disk.
|
|
510
|
-
function onDemandGuideCatalog(projectRoot) {
|
|
511
|
-
const catalog = new Map();
|
|
512
|
-
for (const { file, sections } of ON_DEMAND_GUIDE_FILES) {
|
|
513
|
-
const relativePath = `prompt/${file}.md`;
|
|
514
|
-
const absolutePath = path.join(projectRoot, relativePath);
|
|
515
|
-
if (!fs.existsSync(absolutePath)) continue;
|
|
516
|
-
if (!sections) {
|
|
517
|
-
catalog.set(file, { root: projectRoot, path: relativePath });
|
|
518
|
-
continue;
|
|
519
|
-
}
|
|
520
|
-
const headings = fs.readFileSync(absolutePath, 'utf8')
|
|
521
|
-
.split('\n')
|
|
522
|
-
.filter((line) => line.startsWith('## '))
|
|
523
|
-
.map((line) => line.slice(3).trim())
|
|
524
|
-
.filter((heading) => !CORE_GUIDE_SECTIONS.some(
|
|
525
|
-
(core) => core.file === file && core.section === heading,
|
|
526
|
-
));
|
|
527
|
-
for (const heading of headings) {
|
|
528
|
-
catalog.set(`${file}#${heading}`, {
|
|
529
|
-
root: projectRoot,
|
|
530
|
-
path: relativePath,
|
|
531
|
-
section: heading,
|
|
532
|
-
});
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
return catalog;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// Returns a "## " or "### " section up to the next heading of the same or a
|
|
539
|
-
// higher level.
|
|
540
|
-
function markdownSection(markdown, heading) {
|
|
541
|
-
const lines = markdown.split('\n');
|
|
542
|
-
const headingLevel = (line) => /^(#{1,6}) /.exec(line)?.[1].length || 0;
|
|
543
|
-
const start = lines.findIndex(
|
|
544
|
-
(line) => headingLevel(line) >= 2
|
|
545
|
-
&& line.trim().replace(/^#+ /, '') === heading,
|
|
546
|
-
);
|
|
547
|
-
if (start < 0) return '';
|
|
548
|
-
const level = headingLevel(lines[start]);
|
|
549
|
-
const end = lines.findIndex(
|
|
550
|
-
(line, index) => index > start
|
|
551
|
-
&& headingLevel(line) > 0
|
|
552
|
-
&& headingLevel(line) <= level,
|
|
553
|
-
);
|
|
554
|
-
return lines.slice(start, end < 0 ? undefined : end).join('\n').trim();
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
function normalizeProjectId(value) {
|
|
558
|
-
const projectId = String(value || '').trim();
|
|
559
|
-
if (!PROJECT_ID_PATTERN.test(projectId)) {
|
|
560
|
-
throw toolError(
|
|
561
|
-
'FRAMER_AGENT_PROJECT_INVALID',
|
|
562
|
-
'The Framer project identifier is missing or invalid.',
|
|
563
|
-
);
|
|
564
|
-
}
|
|
565
|
-
return projectId;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
function projectIdFromUrl(url) {
|
|
569
|
-
const segments = url.pathname.split('/').filter(Boolean);
|
|
570
|
-
if (
|
|
571
|
-
segments.length !== 2
|
|
572
|
-
|| segments[0]?.toLowerCase() !== 'projects'
|
|
573
|
-
|| !segments[1]
|
|
574
|
-
) return '';
|
|
575
|
-
try {
|
|
576
|
-
const projectId = decodeURIComponent(segments[1])
|
|
577
|
-
.match(PROJECT_SEGMENT_PATTERN)?.[1] || '';
|
|
578
|
-
return PROJECT_ID_PATTERN.test(projectId) ? projectId : '';
|
|
579
|
-
} catch {
|
|
580
|
-
return '';
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
function normalizeProjectUrl(value, expectedProjectId) {
|
|
585
|
-
let url;
|
|
586
|
-
try {
|
|
587
|
-
url = new URL(String(value || '').trim());
|
|
588
|
-
} catch {
|
|
589
|
-
throw toolError(
|
|
590
|
-
'FRAMER_AGENT_PROJECT_URL_INVALID',
|
|
591
|
-
'The exact Framer project URL is missing or invalid.',
|
|
592
|
-
);
|
|
593
|
-
}
|
|
594
|
-
if (
|
|
595
|
-
url.protocol !== 'https:'
|
|
596
|
-
|| !FRAMER_PROJECT_HOST_PATTERN.test(url.hostname)
|
|
597
|
-
|| url.username
|
|
598
|
-
|| url.password
|
|
599
|
-
) {
|
|
600
|
-
throw toolError(
|
|
601
|
-
'FRAMER_AGENT_PROJECT_URL_INVALID',
|
|
602
|
-
'The exact Framer project URL is missing or invalid.',
|
|
603
|
-
);
|
|
604
|
-
}
|
|
605
|
-
const projectId = projectIdFromUrl(url);
|
|
606
|
-
if (!projectId || projectId !== expectedProjectId) {
|
|
607
|
-
throw toolError(
|
|
608
|
-
'FRAMER_AGENT_PROJECT_MISMATCH',
|
|
609
|
-
'The Framer project URL does not match the project selected in Dexter.',
|
|
610
|
-
409,
|
|
611
|
-
);
|
|
612
|
-
}
|
|
613
|
-
url.hash = '';
|
|
614
|
-
return url.toString();
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
function normalizePagePath(value) {
|
|
618
|
-
const pagePath = String(value || '/').trim() || '/';
|
|
619
|
-
if (!PAGE_PATH_PATTERN.test(pagePath)) {
|
|
620
|
-
throw toolError(
|
|
621
|
-
'FRAMER_AGENT_PAGE_PATH_INVALID',
|
|
622
|
-
'The Framer page path must start with "/" and contain no control characters.',
|
|
623
|
-
);
|
|
624
|
-
}
|
|
625
|
-
return pagePath;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
function normalizeCodeFilePath(value) {
|
|
629
|
-
const filePath = String(value || '').trim();
|
|
630
|
-
if (
|
|
631
|
-
!filePath
|
|
632
|
-
|| filePath.length > 1_000
|
|
633
|
-
|| /[\0\r\n]/.test(filePath)
|
|
634
|
-
) {
|
|
635
|
-
throw toolError(
|
|
636
|
-
'FRAMER_CODE_FILE_PATH_INVALID',
|
|
637
|
-
'A valid Framer code-file path is required.',
|
|
638
|
-
);
|
|
639
|
-
}
|
|
640
|
-
return filePath;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
function structuredResultError(result, fallbackCode, fallbackMessage) {
|
|
644
|
-
const error = isRecord(result?.error) ? result.error : {};
|
|
645
|
-
const code =
|
|
646
|
-
typeof error.code === 'string' && error.code.trim()
|
|
647
|
-
? error.code.trim().slice(0, 120)
|
|
648
|
-
: fallbackCode;
|
|
649
|
-
const message =
|
|
650
|
-
typeof error.message === 'string' && error.message.trim()
|
|
651
|
-
? error.message.trim().slice(0, 2_000)
|
|
652
|
-
: fallbackMessage;
|
|
653
|
-
const statusCode =
|
|
654
|
-
code === 'FRAMER_CODE_FILE_NOT_FOUND' || code === 'FRAMER_SVG_PARENT_NOT_FOUND'
|
|
655
|
-
? 404
|
|
656
|
-
: code === 'FRAMER_CODE_FILE_VERSION_CONFLICT'
|
|
657
|
-
? 409
|
|
658
|
-
: code === 'FRAMER_CODE_FILE_TOO_LARGE'
|
|
659
|
-
? 413
|
|
660
|
-
: 400;
|
|
661
|
-
return toolError(code, message, statusCode);
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
function uniqueStrings(values, maximum = 500) {
|
|
665
|
-
return Array.from(
|
|
666
|
-
new Set(
|
|
667
|
-
values
|
|
668
|
-
.filter((value) => typeof value === 'string')
|
|
669
|
-
.map((value) => value.trim())
|
|
670
|
-
.filter(Boolean),
|
|
671
|
-
),
|
|
672
|
-
).slice(0, maximum);
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
function normalizeVerificationValues(values, allowed) {
|
|
676
|
-
return uniqueStrings(Array.isArray(values) ? values : [])
|
|
677
|
-
.filter((value) => allowed.includes(value));
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
function normalizeTaskPlan(rawArguments) {
|
|
681
|
-
const summary = String(rawArguments.summary || '').trim().slice(0, 1000);
|
|
682
|
-
const domains = normalizeVerificationValues(
|
|
683
|
-
rawArguments.domains,
|
|
684
|
-
TASK_DOMAINS,
|
|
685
|
-
);
|
|
686
|
-
const requestedVerification = normalizeVerificationValues(
|
|
687
|
-
rawArguments.verification,
|
|
688
|
-
VERIFICATION_CHECKS,
|
|
689
|
-
);
|
|
690
|
-
if (summary.length < 10 || domains.length === 0) {
|
|
691
|
-
throw toolError(
|
|
692
|
-
'FRAMER_HARNESS_PLAN_INVALID',
|
|
693
|
-
'Describe the understood task and declare at least one task domain.',
|
|
694
|
-
);
|
|
695
|
-
}
|
|
696
|
-
// The model's declared verification is authoritative; domains describe the
|
|
697
|
-
// work and never add checks on their own.
|
|
698
|
-
const verification = new Set(['structural', ...requestedVerification]);
|
|
699
|
-
const behaviorPlan = normalizeBehaviorPlan(rawArguments);
|
|
700
|
-
if (behaviorPlan.behaviors.length > 0) {
|
|
701
|
-
if (!domains.includes('interactions')) domains.push('interactions');
|
|
702
|
-
verification.add('interactions');
|
|
703
|
-
}
|
|
704
|
-
return {
|
|
705
|
-
summary,
|
|
706
|
-
domains,
|
|
707
|
-
verification: [...verification],
|
|
708
|
-
...behaviorPlan,
|
|
709
|
-
};
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
function workingPreferencesFromAssignment(assignment) {
|
|
713
|
-
const context = isRecord(assignment?.context) ? assignment.context : {};
|
|
714
|
-
const grant = isRecord(context.contextGrant) ? context.contextGrant : {};
|
|
715
|
-
const supplied = isRecord(context.workingPreferences)
|
|
716
|
-
? context.workingPreferences
|
|
717
|
-
: {};
|
|
718
|
-
const selectedNodeIds = uniqueStrings([
|
|
719
|
-
...(Array.isArray(context.selectedNodeIds)
|
|
720
|
-
? context.selectedNodeIds
|
|
721
|
-
: []),
|
|
722
|
-
...(Array.isArray(grant.selectedNodeIds)
|
|
723
|
-
? grant.selectedNodeIds
|
|
724
|
-
: []),
|
|
725
|
-
]);
|
|
726
|
-
const selectionFirst =
|
|
727
|
-
supplied.focus === 'selection-first'
|
|
728
|
-
|| (
|
|
729
|
-
selectedNodeIds.length > 0
|
|
730
|
-
&& grant.writeScope === 'selection'
|
|
731
|
-
);
|
|
732
|
-
return {
|
|
733
|
-
mode: selectionFirst ? 'selection-first' : 'standard',
|
|
734
|
-
writeScope: String(
|
|
735
|
-
supplied.writeScope || grant.writeScope || 'none',
|
|
736
|
-
).trim() || 'none',
|
|
737
|
-
selectionFirst,
|
|
738
|
-
preferBatchedMutations: supplied.preferBatchedMutations !== false,
|
|
739
|
-
avoidRepeatedInspection: supplied.avoidRepeatedInspection !== false,
|
|
740
|
-
expandScopeOnlyWhenNeeded:
|
|
741
|
-
supplied.expandScopeOnlyWhenNeeded !== false,
|
|
742
|
-
};
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
function toolDefinitionsForPreferences(requestedTools, preferences) {
|
|
746
|
-
return FRAMER_AGENT_TOOL_DEFINITIONS
|
|
747
|
-
.filter((tool) => requestedTools.has(tool.name))
|
|
748
|
-
.map((tool) => {
|
|
749
|
-
if (!preferences.selectionFirst) return tool;
|
|
750
|
-
if (tool.name === 'framer_context') {
|
|
751
|
-
return {
|
|
752
|
-
...tool,
|
|
753
|
-
description:
|
|
754
|
-
'Read broad project context only when the selected subtree lacks a concrete dependency required for the request. Do not use this as the default first inspection.',
|
|
755
|
-
};
|
|
756
|
-
}
|
|
757
|
-
if (tool.name === 'framer_read_project') {
|
|
758
|
-
return {
|
|
759
|
-
...tool,
|
|
760
|
-
description:
|
|
761
|
-
'Run supported Framer readProject queries, especially screenshots. For node-tree inspection, prefer framer_read with framer.agent.getNode({ id }), getNodes({ ids }), serialize, or other focused node APIs. Batch related queries when practical.',
|
|
762
|
-
};
|
|
763
|
-
}
|
|
764
|
-
if (tool.name === 'framer_apply_changes') {
|
|
765
|
-
return {
|
|
766
|
-
...tool,
|
|
767
|
-
description:
|
|
768
|
-
'Apply related changes inside the authorized selection in coherent batches when practical. Read the returned diagnostics, verify the result, and make any further repair batches the work requires.',
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
if (tool.name === 'framer_read') {
|
|
772
|
-
return {
|
|
773
|
-
...tool,
|
|
774
|
-
description:
|
|
775
|
-
'Use focused JavaScript for node-tree inspection and Framer capabilities not covered by readProject. Start with the selected nodes, reuse prior evidence, and expand only when a concrete dependency requires it.',
|
|
776
|
-
};
|
|
777
|
-
}
|
|
778
|
-
if (tool.name === 'framer_write') {
|
|
779
|
-
return {
|
|
780
|
-
...tool,
|
|
781
|
-
description:
|
|
782
|
-
'Use project-scoped JavaScript only when framer_apply_changes cannot perform the required authorized capability. Explain that limitation in reason, prefer cohesive operations, and keep mutations within the authorized write scope. Do not retry JavaScript variants after a safety rejection.',
|
|
783
|
-
};
|
|
784
|
-
}
|
|
785
|
-
return tool;
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
function boundedStructuredValue(value, maximum = MAX_MODEL_RESULT_BYTES) {
|
|
790
|
-
let serialized;
|
|
791
|
-
try {
|
|
792
|
-
serialized = JSON.stringify(value);
|
|
793
|
-
} catch {
|
|
794
|
-
return {
|
|
795
|
-
truncated: true,
|
|
796
|
-
output: '[Framer result could not be serialized.]',
|
|
797
|
-
};
|
|
798
|
-
}
|
|
799
|
-
if (Buffer.byteLength(serialized, 'utf8') <= maximum) return value;
|
|
800
|
-
return {
|
|
801
|
-
truncated: true,
|
|
802
|
-
output: boundedText(serialized, maximum),
|
|
803
|
-
};
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
function authoritativeNodeIdsFromAssignment(assignment) {
|
|
807
|
-
const context = isRecord(assignment?.context) ? assignment.context : {};
|
|
808
|
-
const grant = isRecord(context.contextGrant) ? context.contextGrant : {};
|
|
809
|
-
return uniqueStrings([
|
|
810
|
-
...(Array.isArray(context.selectedNodeIds)
|
|
811
|
-
? context.selectedNodeIds
|
|
812
|
-
: []),
|
|
813
|
-
...(Array.isArray(grant.selectedNodeIds)
|
|
814
|
-
? grant.selectedNodeIds
|
|
815
|
-
: []),
|
|
816
|
-
...(Array.isArray(grant.contextNodeIds)
|
|
817
|
-
? grant.contextNodeIds
|
|
818
|
-
: []),
|
|
819
|
-
...(Array.isArray(grant.writableNodeIds)
|
|
820
|
-
? grant.writableNodeIds
|
|
821
|
-
: []),
|
|
822
|
-
]);
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
const LITERAL_TOKEN_TYPES = new Set([
|
|
826
|
-
'StringLiteral',
|
|
827
|
-
'NoSubstitutionTemplate',
|
|
828
|
-
'TemplateHead',
|
|
829
|
-
'TemplateMiddle',
|
|
830
|
-
'TemplateTail',
|
|
831
|
-
'SingleLineComment',
|
|
832
|
-
'MultiLineComment',
|
|
833
|
-
'RegularExpressionLiteral',
|
|
834
|
-
]);
|
|
835
|
-
|
|
836
|
-
// Blanks string, template, comment, and regex text so data such as image URLs
|
|
837
|
-
// or product copy is not mistaken for a forbidden global. Template
|
|
838
|
-
// substitutions stay visible because they are code.
|
|
839
|
-
function codeOutsideLiterals(code) {
|
|
840
|
-
let visible = '';
|
|
841
|
-
for (const token of jsTokens(code)) {
|
|
842
|
-
visible += LITERAL_TOKEN_TYPES.has(token.type) ? ' ' : token.value;
|
|
843
|
-
}
|
|
844
|
-
return visible;
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
function validateCode(value, { write = false, allowPublishing = false } = {}) {
|
|
848
|
-
const code = String(value || '').trim();
|
|
849
|
-
if (!code) {
|
|
850
|
-
throw toolError(
|
|
851
|
-
'FRAMER_AGENT_CODE_REQUIRED',
|
|
852
|
-
'Framer Agent code is required.',
|
|
853
|
-
);
|
|
854
|
-
}
|
|
855
|
-
if (UNSAFE_EXEC_PATTERN.test(codeOutsideLiterals(code))) {
|
|
856
|
-
throw toolError(
|
|
857
|
-
'FRAMER_AGENT_CODE_UNSAFE',
|
|
858
|
-
'Framer Agent code may use only the provided framer, state, and console globals.',
|
|
859
|
-
);
|
|
860
|
-
}
|
|
861
|
-
if (!write && MUTATING_EXEC_PATTERN.test(code)) {
|
|
862
|
-
throw toolError(
|
|
863
|
-
'FRAMER_AGENT_READ_ONLY_VIOLATION',
|
|
864
|
-
'Use framer_write or framer_apply_changes for project mutations.',
|
|
865
|
-
);
|
|
866
|
-
}
|
|
867
|
-
if (
|
|
868
|
-
!allowPublishing &&
|
|
869
|
-
/\.(?:publish|deploy)\s*\(/.test(code)
|
|
870
|
-
) {
|
|
871
|
-
throw toolError(
|
|
872
|
-
'FRAMER_AGENT_PUBLISH_NOT_AUTHORIZED',
|
|
873
|
-
'Publishing is not authorized for this Dexter run.',
|
|
874
|
-
);
|
|
875
|
-
}
|
|
876
|
-
return code;
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
function normalizeSvgMarkup(value) {
|
|
880
|
-
const svg = String(value || '').trim();
|
|
881
|
-
if (!/^<svg\b[\s\S]*<\/svg>$/i.test(svg) || svg.length > MAX_SVG_MARKUP_CHARS) {
|
|
882
|
-
throw toolError(
|
|
883
|
-
'FRAMER_SVG_INVALID',
|
|
884
|
-
`Provide one <svg>…</svg> element of at most ${MAX_SVG_MARKUP_CHARS} characters.`,
|
|
885
|
-
);
|
|
886
|
-
}
|
|
887
|
-
if (UNSAFE_SVG_PATTERN.test(svg)) {
|
|
888
|
-
throw toolError(
|
|
889
|
-
'FRAMER_SVG_UNSAFE',
|
|
890
|
-
'SVG markup must be self-contained: no scripts, event handlers, foreignObject, or external references.',
|
|
891
|
-
);
|
|
892
|
-
}
|
|
893
|
-
return svg;
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
function normalizeSvgAttributes(value) {
|
|
897
|
-
if (value === undefined) return {};
|
|
898
|
-
if (!isRecord(value) || Object.keys(value).length > MAX_SVG_ATTRIBUTES) {
|
|
899
|
-
throw toolError(
|
|
900
|
-
'FRAMER_SVG_ATTRIBUTES_INVALID',
|
|
901
|
-
`attributes must be an object with at most ${MAX_SVG_ATTRIBUTES} Framer layer attributes.`,
|
|
902
|
-
);
|
|
903
|
-
}
|
|
904
|
-
const { svg: _ignored, ...attributes } = value;
|
|
905
|
-
return attributes;
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
function extractJsonArray(value) {
|
|
909
|
-
const text = String(value || '').trim();
|
|
910
|
-
const start = text.indexOf('[');
|
|
911
|
-
const end = text.lastIndexOf(']');
|
|
912
|
-
if (start < 0 || end <= start) return [];
|
|
913
|
-
try {
|
|
914
|
-
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
915
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
916
|
-
} catch {
|
|
917
|
-
return [];
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
function abortError() {
|
|
922
|
-
return Object.assign(new Error('The Framer authorization was cancelled.'), {
|
|
923
|
-
name: 'AbortError',
|
|
924
|
-
code: 'ABORT_ERR',
|
|
925
|
-
});
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
function isFramerScreenshotUrl(value) {
|
|
929
|
-
try {
|
|
930
|
-
const url = new URL(String(value || ''));
|
|
931
|
-
return url.protocol === 'https:'
|
|
932
|
-
&& (url.hostname === 'framerusercontent.com'
|
|
933
|
-
|| url.hostname.endsWith('.framerusercontent.com'));
|
|
934
|
-
} catch {
|
|
935
|
-
return false;
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
function screenshotQueries(rawArguments) {
|
|
940
|
-
return (Array.isArray(rawArguments?.queries) ? rawArguments.queries : [])
|
|
941
|
-
.filter((query) => query?.type === 'screenshot')
|
|
942
|
-
.slice(0, MAX_SCREENSHOTS_PER_CALL);
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
// Screenshot results are URLs the model cannot open, so the bridge downloads
|
|
946
|
-
// them and hands the pixels to the model as image content. Failures carry a
|
|
947
|
-
// short reason so delivery problems are visible in run telemetry.
|
|
948
|
-
async function downloadScreenshotImage(url, fetchImpl) {
|
|
949
|
-
if (!isFramerScreenshotUrl(url)) return { error: 'untrusted_url' };
|
|
950
|
-
try {
|
|
951
|
-
const response = await fetchImpl(url, {
|
|
952
|
-
signal: AbortSignal.timeout(SCREENSHOT_FETCH_TIMEOUT_MS),
|
|
953
|
-
});
|
|
954
|
-
if (!response.ok) return { error: `http_${response.status}` };
|
|
955
|
-
const mimeType = String(response.headers.get('content-type') || '')
|
|
956
|
-
.split(';')[0]
|
|
957
|
-
.trim()
|
|
958
|
-
.toLowerCase();
|
|
959
|
-
if (!SCREENSHOT_MIME_TYPES.has(mimeType)) return { error: 'unsupported_type' };
|
|
960
|
-
const bytes = Buffer.from(await response.arrayBuffer());
|
|
961
|
-
if (!bytes.length) return { error: 'empty' };
|
|
962
|
-
if (bytes.length > MAX_SCREENSHOT_BYTES) return { error: 'too_large' };
|
|
963
|
-
return { image: { url, mimeType, data: bytes.toString('base64') } };
|
|
964
|
-
} catch (error) {
|
|
965
|
-
return {
|
|
966
|
-
error: String(error?.cause?.code || error?.name || 'fetch_failed').slice(0, 60),
|
|
967
|
-
};
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
function parseStructuredOutput(value) {
|
|
972
|
-
const text = String(value || '').trim();
|
|
973
|
-
if (!text) return null;
|
|
974
|
-
const candidates = [text];
|
|
975
|
-
const firstObject = text.indexOf('{');
|
|
976
|
-
const lastObject = text.lastIndexOf('}');
|
|
977
|
-
if (firstObject >= 0 && lastObject > firstObject) {
|
|
978
|
-
candidates.push(text.slice(firstObject, lastObject + 1));
|
|
979
|
-
}
|
|
980
|
-
const firstArray = text.indexOf('[');
|
|
981
|
-
const lastArray = text.lastIndexOf(']');
|
|
982
|
-
if (firstArray >= 0 && lastArray > firstArray) {
|
|
983
|
-
candidates.push(text.slice(firstArray, lastArray + 1));
|
|
984
|
-
}
|
|
985
|
-
for (const candidate of candidates) {
|
|
986
|
-
try {
|
|
987
|
-
return JSON.parse(candidate);
|
|
988
|
-
} catch {
|
|
989
|
-
// Keep the original text when the command intentionally prints prose.
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
return null;
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
function parseBranchChange(value) {
|
|
996
|
-
const match = String(value || '').match(
|
|
997
|
-
/\[FRAMER_BRANCH_CHANGE\]\s+title="([^"]*)"\s+id="([^"]*)"\s+url="([^"]*)"/,
|
|
998
|
-
);
|
|
999
|
-
return match
|
|
1000
|
-
? { title: match[1], id: match[2], url: match[3] }
|
|
1001
|
-
: null;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
export function resolveFramerAgentCli() {
|
|
1005
|
-
try {
|
|
1006
|
-
return require.resolve('@framer/agent');
|
|
1007
|
-
} catch {
|
|
1008
|
-
throw toolError(
|
|
1009
|
-
'FRAMER_AGENT_NOT_INSTALLED',
|
|
1010
|
-
'The official Framer Agent runtime is missing. Update Dexter Bridge and reconnect.',
|
|
1011
|
-
503,
|
|
1012
|
-
);
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
export function runFramerAgentCli(args, {
|
|
1017
|
-
cwd = process.cwd(),
|
|
1018
|
-
env = process.env,
|
|
1019
|
-
input,
|
|
1020
|
-
signal,
|
|
1021
|
-
timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS,
|
|
1022
|
-
onOutput,
|
|
1023
|
-
cliPath = resolveFramerAgentCli(),
|
|
1024
|
-
} = {}) {
|
|
1025
|
-
return new Promise((resolve, reject) => {
|
|
1026
|
-
const child = spawn(process.execPath, [cliPath, ...args], {
|
|
1027
|
-
cwd,
|
|
1028
|
-
env,
|
|
1029
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1030
|
-
windowsHide: true,
|
|
1031
|
-
signal,
|
|
1032
|
-
});
|
|
1033
|
-
let stdout = '';
|
|
1034
|
-
let stderr = '';
|
|
1035
|
-
let outputBytes = 0;
|
|
1036
|
-
let settled = false;
|
|
1037
|
-
let timer;
|
|
1038
|
-
const finish = (callback, value) => {
|
|
1039
|
-
if (settled) return;
|
|
1040
|
-
settled = true;
|
|
1041
|
-
clearTimeout(timer);
|
|
1042
|
-
callback(value);
|
|
1043
|
-
};
|
|
1044
|
-
const append = (channel, chunk) => {
|
|
1045
|
-
const text = chunk.toString('utf8');
|
|
1046
|
-
outputBytes += Buffer.byteLength(text, 'utf8');
|
|
1047
|
-
if (outputBytes > MAX_OUTPUT_BYTES) {
|
|
1048
|
-
child.kill('SIGTERM');
|
|
1049
|
-
finish(
|
|
1050
|
-
reject,
|
|
1051
|
-
toolError(
|
|
1052
|
-
'FRAMER_AGENT_OUTPUT_TOO_LARGE',
|
|
1053
|
-
'The Framer Agent command returned too much output.',
|
|
1054
|
-
),
|
|
1055
|
-
);
|
|
1056
|
-
return;
|
|
1057
|
-
}
|
|
1058
|
-
if (channel === 'stdout') stdout += text;
|
|
1059
|
-
else stderr += text;
|
|
1060
|
-
onOutput?.({ channel, text });
|
|
1061
|
-
};
|
|
1062
|
-
child.stdout.on('data', (chunk) => append('stdout', chunk));
|
|
1063
|
-
child.stderr.on('data', (chunk) => append('stderr', chunk));
|
|
1064
|
-
child.once('error', (error) => finish(reject, error));
|
|
1065
|
-
child.once('close', (code, signal) => {
|
|
1066
|
-
if (settled) return;
|
|
1067
|
-
if (code === 0) {
|
|
1068
|
-
finish(resolve, {
|
|
1069
|
-
stdout: boundedText(stdout, MAX_OUTPUT_BYTES),
|
|
1070
|
-
stderr: boundedText(stderr, MAX_OUTPUT_BYTES),
|
|
1071
|
-
code,
|
|
1072
|
-
signal,
|
|
1073
|
-
});
|
|
1074
|
-
return;
|
|
1075
|
-
}
|
|
1076
|
-
const message = boundedText(stderr || stdout, 8_000).trim();
|
|
1077
|
-
finish(
|
|
1078
|
-
reject,
|
|
1079
|
-
toolError(
|
|
1080
|
-
/authoriz|log in|login|api key/i.test(message)
|
|
1081
|
-
? 'FRAMER_AGENT_AUTHORIZATION_REQUIRED'
|
|
1082
|
-
: 'FRAMER_AGENT_COMMAND_FAILED',
|
|
1083
|
-
message || `Framer Agent exited with code ${code}.`,
|
|
1084
|
-
/authoriz|log in|login|api key/i.test(message) ? 409 : 500,
|
|
1085
|
-
),
|
|
1086
|
-
);
|
|
1087
|
-
});
|
|
1088
|
-
timer = setTimeout(() => {
|
|
1089
|
-
child.kill('SIGTERM');
|
|
1090
|
-
finish(
|
|
1091
|
-
reject,
|
|
1092
|
-
toolError(
|
|
1093
|
-
'FRAMER_AGENT_TIMEOUT',
|
|
1094
|
-
'The Framer Agent command timed out.',
|
|
1095
|
-
504,
|
|
1096
|
-
),
|
|
1097
|
-
);
|
|
1098
|
-
}, Math.max(1_000, Number(timeoutMs) || DEFAULT_COMMAND_TIMEOUT_MS));
|
|
1099
|
-
timer.unref?.();
|
|
1100
|
-
if (input !== undefined) child.stdin.end(String(input));
|
|
1101
|
-
else child.stdin.end();
|
|
1102
|
-
});
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
function sessionIdFromOutput(value) {
|
|
1106
|
-
return String(value || '')
|
|
1107
|
-
.split(/\r?\n/)
|
|
1108
|
-
.map((line) => line.trim())
|
|
1109
|
-
.find(
|
|
1110
|
-
(line) =>
|
|
1111
|
-
line &&
|
|
1112
|
-
!line.startsWith('[') &&
|
|
1113
|
-
!/^framer external agents/i.test(line) &&
|
|
1114
|
-
/^[A-Za-z0-9_-]{4,200}$/.test(line),
|
|
1115
|
-
) || null;
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
function isAuthorizationFailure(error) {
|
|
1119
|
-
return (
|
|
1120
|
-
error?.code === 'FRAMER_AGENT_AUTHORIZATION_REQUIRED'
|
|
1121
|
-
|| /authoriz|log in|login|api key|does not have access|session expired/i.test(
|
|
1122
|
-
String(error?.message || ''),
|
|
1123
|
-
)
|
|
1124
|
-
);
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
function projectConnectionError(error) {
|
|
1128
|
-
if (/project not found/i.test(String(error?.message || ''))) {
|
|
1129
|
-
return toolError(
|
|
1130
|
-
'FRAMER_AGENT_PROJECT_NOT_FOUND',
|
|
1131
|
-
'The project key passed Server API verification, but Framer Agent could not find the authoritative project ID. Reopen the project in Framer, reconnect it in Dexter, and try again.',
|
|
1132
|
-
409,
|
|
1133
|
-
);
|
|
1134
|
-
}
|
|
1135
|
-
if (isAuthorizationFailure(error)) {
|
|
1136
|
-
return toolError(
|
|
1137
|
-
'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
|
|
1138
|
-
'The project key passed Server API verification, but Framer Agent rejected it while creating the editing session. Reconnect the open project in Dexter with a current key from General → API Keys, then try again.',
|
|
1139
|
-
409,
|
|
1140
|
-
);
|
|
1141
|
-
}
|
|
1142
|
-
return error;
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
export function createFramerAgentToolRuntime({
|
|
1146
|
-
assignment,
|
|
1147
|
-
cwd,
|
|
1148
|
-
env = process.env,
|
|
1149
|
-
trace,
|
|
1150
|
-
onActivity,
|
|
1151
|
-
runCli = runFramerAgentCli,
|
|
1152
|
-
fetchImpl = fetch,
|
|
1153
|
-
authorizeProject,
|
|
1154
|
-
verifyProjectAuthorization,
|
|
1155
|
-
readWebsite,
|
|
1156
|
-
} = {}) {
|
|
1157
|
-
const projectId = normalizeProjectId(assignment?.context?.framerProjectId);
|
|
1158
|
-
const projectUrl = normalizeProjectUrl(
|
|
1159
|
-
assignment?.context?.framerProjectUrl,
|
|
1160
|
-
projectId,
|
|
1161
|
-
);
|
|
1162
|
-
const sessionCacheKey = projectId;
|
|
1163
|
-
const requestedTools = Array.isArray(assignment?.toolProtocol?.tools)
|
|
1164
|
-
? assignment.toolProtocol.tools
|
|
1165
|
-
: FRAMER_AGENT_TOOL_DEFINITIONS.map((tool) => tool.name);
|
|
1166
|
-
const allowedToolNames = new Set(
|
|
1167
|
-
requestedTools.filter((name) => TOOL_BY_NAME.has(name)),
|
|
1168
|
-
);
|
|
1169
|
-
const workingPreferences = workingPreferencesFromAssignment(assignment);
|
|
1170
|
-
const definitions = toolDefinitionsForPreferences(
|
|
1171
|
-
allowedToolNames,
|
|
1172
|
-
workingPreferences,
|
|
1173
|
-
);
|
|
1174
|
-
const exposedToolNames = new Set(
|
|
1175
|
-
definitions.map((definition) => definition.name),
|
|
1176
|
-
);
|
|
1177
|
-
const missingToolNames = uniqueStrings(requestedTools)
|
|
1178
|
-
.filter((name) => !exposedToolNames.has(name));
|
|
1179
|
-
if (missingToolNames.length > 0) {
|
|
1180
|
-
throw toolError(
|
|
1181
|
-
'FRAMER_HARNESS_TOOL_CATALOG_INCOMPLETE',
|
|
1182
|
-
`Dexter could not start because this bridge does not expose the required Framer tools: ${missingToolNames.join(', ')}.`,
|
|
1183
|
-
500,
|
|
1184
|
-
);
|
|
1185
|
-
}
|
|
1186
|
-
const allowPublishing = assignment?.context?.permissions?.publish === true;
|
|
1187
|
-
const activePagePath = normalizePagePath(
|
|
1188
|
-
assignment?.context?.activePagePath,
|
|
1189
|
-
);
|
|
1190
|
-
const authoritativeNodeIds =
|
|
1191
|
-
authoritativeNodeIdsFromAssignment(assignment);
|
|
1192
|
-
let sessionId = null;
|
|
1193
|
-
let sessionPromise = null;
|
|
1194
|
-
let nodeValidationPromise = null;
|
|
1195
|
-
let server = null;
|
|
1196
|
-
let gateway = null;
|
|
1197
|
-
let closed = false;
|
|
1198
|
-
let serial = Promise.resolve();
|
|
1199
|
-
let mutationCalls = 0;
|
|
1200
|
-
let applyChangesCalls = 0;
|
|
1201
|
-
let lowLevelWriteCalls = 0;
|
|
1202
|
-
let imageSearchCalls = 0;
|
|
1203
|
-
let readCalls = 0;
|
|
1204
|
-
let toolCallCount = 0;
|
|
1205
|
-
const toolCalls = [];
|
|
1206
|
-
let repeatedFailureFingerprint = null;
|
|
1207
|
-
let repeatedFailureCount = 0;
|
|
1208
|
-
let repeatedFailureStops = 0;
|
|
1209
|
-
let unsafeWriteFailures = 0;
|
|
1210
|
-
let lowLevelWriteFailures = 0;
|
|
1211
|
-
let lowLevelWriteDisabled = false;
|
|
1212
|
-
let lowLevelWriteBlockedCalls = 0;
|
|
1213
|
-
let instructionFallbacks = 0;
|
|
1214
|
-
let fontSearchFallbacks = 0;
|
|
1215
|
-
let instructionLookupUnavailable = false;
|
|
1216
|
-
let branchChange = null;
|
|
1217
|
-
let lastInspectionAt = null;
|
|
1218
|
-
let lastMutationAt = null;
|
|
1219
|
-
let screenshotCalls = 0;
|
|
1220
|
-
let interactionVerificationCalls = 0;
|
|
1221
|
-
// Latest result per behavior/node/property, with the operation sequence it
|
|
1222
|
-
// was observed at, so checks from several calls (or pages) accumulate.
|
|
1223
|
-
const interactionCheckResults = new Map();
|
|
1224
|
-
let interactionVerifiedAt = null;
|
|
1225
|
-
const mutationReceipts = [];
|
|
1226
|
-
let lastScreenshotSequence = 0;
|
|
1227
|
-
let lastScreenshotAttemptSequence = 0;
|
|
1228
|
-
let screenshotsViaDownload = 0;
|
|
1229
|
-
let screenshotsViaSession = 0;
|
|
1230
|
-
let screenshotsUndelivered = 0;
|
|
1231
|
-
const screenshotFailures = [];
|
|
1232
|
-
let taskPlan = null;
|
|
1233
|
-
const verificationEvidence = new Map();
|
|
1234
|
-
let guideCatalog = new Map();
|
|
1235
|
-
const guidesRead = [];
|
|
1236
|
-
let guidanceMetadata = {
|
|
1237
|
-
loaded: false,
|
|
1238
|
-
contentHash: null,
|
|
1239
|
-
projectContextHash: null,
|
|
1240
|
-
files: [],
|
|
1241
|
-
bytes: 0,
|
|
1242
|
-
};
|
|
1243
|
-
let promptMetadata = {
|
|
1244
|
-
promptBytes: 0,
|
|
1245
|
-
doctrineLoaded: false,
|
|
1246
|
-
doctrineVersion: null,
|
|
1247
|
-
};
|
|
1248
|
-
let framerSetupAttempts = 0;
|
|
1249
|
-
let framerSetupRecoveredSession = false;
|
|
1250
|
-
let framerSetupCompleted = hasInstalledFramerSkill(env);
|
|
1251
|
-
const framerAgentVersion = installedFramerAgentVersion();
|
|
1252
|
-
let operationSequence = 0;
|
|
1253
|
-
let lastMutationSequence = 0;
|
|
1254
|
-
let lastCodeMutationSequence = 0;
|
|
1255
|
-
let lastInspectionSequence = 0;
|
|
1256
|
-
const abortController = new AbortController();
|
|
1257
|
-
|
|
1258
|
-
const reportActivity = (event) => {
|
|
1259
|
-
try {
|
|
1260
|
-
void Promise.resolve(onActivity?.(event)).catch(() => undefined);
|
|
1261
|
-
} catch {
|
|
1262
|
-
// Observability must never interrupt a Framer operation.
|
|
1263
|
-
}
|
|
1264
|
-
};
|
|
1265
|
-
|
|
1266
|
-
const executeCli = async (args, options = {}) => {
|
|
1267
|
-
const result = await runCli(args, {
|
|
1268
|
-
cwd,
|
|
1269
|
-
env,
|
|
1270
|
-
signal: abortController.signal,
|
|
1271
|
-
...options,
|
|
1272
|
-
});
|
|
1273
|
-
const nextBranch = parseBranchChange(result.stdout);
|
|
1274
|
-
if (nextBranch) branchChange = nextBranch;
|
|
1275
|
-
return result;
|
|
1276
|
-
};
|
|
1277
|
-
|
|
1278
|
-
const ensureFramerSetup = async ({ force = false } = {}) => {
|
|
1279
|
-
const automaticSetupEnabled =
|
|
1280
|
-
runCli === runFramerAgentCli
|
|
1281
|
-
|| env.DEXTER_FRAMER_AUTO_SETUP === 'true';
|
|
1282
|
-
if (!force && !automaticSetupEnabled) return;
|
|
1283
|
-
if (!force && hasInstalledFramerSkill(env)) {
|
|
1284
|
-
framerSetupCompleted = true;
|
|
1285
|
-
return;
|
|
1286
|
-
}
|
|
1287
|
-
if (!framerSetupPromise) {
|
|
1288
|
-
framerSetupAttempts += 1;
|
|
1289
|
-
reportActivity({
|
|
1290
|
-
kind: 'status_update',
|
|
1291
|
-
source: 'framer-agent',
|
|
1292
|
-
message: 'Getting Framer ready.',
|
|
1293
|
-
});
|
|
1294
|
-
framerSetupPromise = executeCli(['setup'], {
|
|
1295
|
-
timeoutMs: 30_000,
|
|
1296
|
-
})
|
|
1297
|
-
.then(() => {
|
|
1298
|
-
if (!hasInstalledFramerSkill(env)) {
|
|
1299
|
-
throw toolError(
|
|
1300
|
-
'FRAMER_AGENT_SETUP_FAILED',
|
|
1301
|
-
'Framer setup completed without installing the required Framer skill.',
|
|
1302
|
-
503,
|
|
1303
|
-
);
|
|
1304
|
-
}
|
|
1305
|
-
framerSetupCompleted = true;
|
|
1306
|
-
})
|
|
1307
|
-
.catch((error) => {
|
|
1308
|
-
if (error?.code === 'FRAMER_AGENT_SETUP_FAILED') throw error;
|
|
1309
|
-
throw toolError(
|
|
1310
|
-
'FRAMER_AGENT_SETUP_FAILED',
|
|
1311
|
-
`Dexter could not prepare the Framer editing tools: ${String(error?.message || 'Framer setup failed.')}`,
|
|
1312
|
-
503,
|
|
1313
|
-
);
|
|
1314
|
-
})
|
|
1315
|
-
.finally(() => {
|
|
1316
|
-
framerSetupPromise = null;
|
|
1317
|
-
});
|
|
1318
|
-
}
|
|
1319
|
-
await framerSetupPromise;
|
|
1320
|
-
framerSetupCompleted = hasInstalledFramerSkill(env);
|
|
1321
|
-
};
|
|
1322
|
-
|
|
1323
|
-
const projectIsAuthorizedLocally = async () => {
|
|
1324
|
-
const listed = await executeCli(['project', 'list'], {
|
|
1325
|
-
timeoutMs: 30_000,
|
|
1326
|
-
}).catch(() => ({ stdout: '[]', stderr: '' }));
|
|
1327
|
-
return extractJsonArray(listed.stdout).some(
|
|
1328
|
-
(item) => isRecord(item) && item.projectId === projectId,
|
|
1329
|
-
);
|
|
1330
|
-
};
|
|
1331
|
-
|
|
1332
|
-
const resolveProjectAuthorization = async () => {
|
|
1333
|
-
if (typeof authorizeProject !== 'function') {
|
|
1334
|
-
throw toolError(
|
|
1335
|
-
'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
|
|
1336
|
-
'This Framer project needs to be connected before Dexter can use it.',
|
|
1337
|
-
409,
|
|
1338
|
-
);
|
|
1339
|
-
}
|
|
1340
|
-
let authorization = await authorizeProject({
|
|
1341
|
-
projectId,
|
|
1342
|
-
initiate: true,
|
|
1343
|
-
forceRefresh: false,
|
|
1344
|
-
signal: abortController.signal,
|
|
1345
|
-
});
|
|
1346
|
-
if (authorization?.framerProjectId !== projectId) {
|
|
1347
|
-
throw toolError(
|
|
1348
|
-
'FRAMER_AGENT_PROJECT_MISMATCH',
|
|
1349
|
-
'Dexter received authorization for a different Framer project.',
|
|
1350
|
-
409,
|
|
1351
|
-
);
|
|
1352
|
-
}
|
|
1353
|
-
if (authorization?.status === 'authorization_required') {
|
|
1354
|
-
reportActivity({
|
|
1355
|
-
kind: 'status_update',
|
|
1356
|
-
source: 'framer-agent',
|
|
1357
|
-
message:
|
|
1358
|
-
'This Framer project needs a project API key before Dexter can continue.',
|
|
1359
|
-
});
|
|
1360
|
-
throw toolError(
|
|
1361
|
-
'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
|
|
1362
|
-
'Connect this Framer project in Dexter by adding a project API key, then retry.',
|
|
1363
|
-
409,
|
|
1364
|
-
);
|
|
1365
|
-
}
|
|
1366
|
-
const apiKey = String(authorization?.apiKey || '').trim();
|
|
1367
|
-
if (authorization?.status !== 'ready' || !apiKey) {
|
|
1368
|
-
throw toolError(
|
|
1369
|
-
'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
|
|
1370
|
-
'Framer project authorization was not completed. Reconnect this project in Dexter and try again.',
|
|
1371
|
-
409,
|
|
1372
|
-
);
|
|
1373
|
-
}
|
|
1374
|
-
const authorizedProjectUrl = normalizeProjectUrl(
|
|
1375
|
-
authorization.framerProjectUrl,
|
|
1376
|
-
projectId,
|
|
1377
|
-
);
|
|
1378
|
-
return { ...authorization, apiKey, framerProjectUrl: authorizedProjectUrl };
|
|
1379
|
-
};
|
|
1380
|
-
|
|
1381
|
-
const installProjectAuthorization = async (authorization) => {
|
|
1382
|
-
await executeCli(['project', 'auth', projectId, authorization.apiKey], {
|
|
1383
|
-
timeoutMs: 30_000,
|
|
1384
|
-
});
|
|
1385
|
-
reportActivity({
|
|
1386
|
-
kind: 'status_update',
|
|
1387
|
-
source: 'framer-agent',
|
|
1388
|
-
message: 'This Framer project is authorized for the coding harness.',
|
|
1389
|
-
});
|
|
1390
|
-
};
|
|
1391
|
-
|
|
1392
|
-
const createSession = async () => {
|
|
1393
|
-
return executeCli(['session', 'new', projectId], {
|
|
1394
|
-
timeoutMs: SESSION_TIMEOUT_MS,
|
|
1395
|
-
});
|
|
1396
|
-
};
|
|
1397
|
-
|
|
1398
|
-
const reportRejectedAuthorization = async (authorization, error) => {
|
|
1399
|
-
if (
|
|
1400
|
-
typeof verifyProjectAuthorization !== 'function'
|
|
1401
|
-
|| !authorization?.credentialVersion
|
|
1402
|
-
) return;
|
|
1403
|
-
await verifyProjectAuthorization({
|
|
1404
|
-
status: 'rejected',
|
|
1405
|
-
framerProjectId: projectId,
|
|
1406
|
-
framerProjectUrl: projectUrl,
|
|
1407
|
-
credentialVersion: authorization.credentialVersion,
|
|
1408
|
-
errorCode: String(error?.code || 'FRAMER_AGENT_AUTHORIZATION_REJECTED')
|
|
1409
|
-
.slice(0, 120),
|
|
1410
|
-
signal: abortController.signal,
|
|
1411
|
-
}).catch(() => undefined);
|
|
1412
|
-
};
|
|
1413
|
-
|
|
1414
|
-
const verifyConnectedProject = async (activeSessionId, authorization) => {
|
|
1415
|
-
const result = await executeCli(['exec', '-s', activeSessionId], {
|
|
1416
|
-
input: [
|
|
1417
|
-
'const [project, branch] = await Promise.all([',
|
|
1418
|
-
' framer.getProjectInfo(),',
|
|
1419
|
-
' framer.agent.getActiveBranch(),',
|
|
1420
|
-
']);',
|
|
1421
|
-
'console.log(JSON.stringify({ projectName: project?.name ?? null, projectUrl: branch?.url ?? null }));',
|
|
1422
|
-
].join('\n'),
|
|
1423
|
-
timeoutMs: 30_000,
|
|
1424
|
-
});
|
|
1425
|
-
const observed = parseStructuredOutput(result.stdout);
|
|
1426
|
-
const observedProjectUrl = normalizeProjectUrl(
|
|
1427
|
-
observed?.projectUrl,
|
|
1428
|
-
projectId,
|
|
1429
|
-
);
|
|
1430
|
-
if (
|
|
1431
|
-
typeof verifyProjectAuthorization === 'function'
|
|
1432
|
-
&& authorization?.credentialVersion
|
|
1433
|
-
) {
|
|
1434
|
-
await verifyProjectAuthorization({
|
|
1435
|
-
status: 'verified',
|
|
1436
|
-
framerProjectId: projectId,
|
|
1437
|
-
framerProjectUrl: observedProjectUrl,
|
|
1438
|
-
credentialVersion: authorization.credentialVersion,
|
|
1439
|
-
signal: abortController.signal,
|
|
1440
|
-
});
|
|
1441
|
-
}
|
|
1442
|
-
};
|
|
1443
|
-
|
|
1444
|
-
const ensureSession = async () => {
|
|
1445
|
-
await ensureFramerSetup();
|
|
1446
|
-
if (sessionId) return sessionId;
|
|
1447
|
-
if (sessionPromise) return sessionPromise;
|
|
1448
|
-
sessionPromise = (async () => {
|
|
1449
|
-
reportActivity({
|
|
1450
|
-
kind: 'status_update',
|
|
1451
|
-
source: 'framer-agent',
|
|
1452
|
-
message: 'Connecting the coding harness to this Framer project.',
|
|
1453
|
-
});
|
|
1454
|
-
let authorization = null;
|
|
1455
|
-
if (typeof authorizeProject === 'function') {
|
|
1456
|
-
authorization = await resolveProjectAuthorization();
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
const cached = sessionsByProject.get(sessionCacheKey);
|
|
1460
|
-
if (
|
|
1461
|
-
cached
|
|
1462
|
-
&& (
|
|
1463
|
-
typeof authorizeProject !== 'function'
|
|
1464
|
-
|| (
|
|
1465
|
-
authorization?.verificationStatus === 'verified'
|
|
1466
|
-
&& cached.credentialVersion === authorization.credentialVersion
|
|
1467
|
-
)
|
|
1468
|
-
)
|
|
1469
|
-
) {
|
|
1470
|
-
sessionId = cached.sessionId;
|
|
1471
|
-
return sessionId;
|
|
1472
|
-
}
|
|
1473
|
-
if (cached) {
|
|
1474
|
-
await executeCli(['session', 'destroy', cached.sessionId], {
|
|
1475
|
-
timeoutMs: 10_000,
|
|
1476
|
-
}).catch(() => undefined);
|
|
1477
|
-
sessionsByProject.delete(sessionCacheKey);
|
|
1478
|
-
}
|
|
1479
|
-
if (!authorization && !(await projectIsAuthorizedLocally())) {
|
|
1480
|
-
throw toolError(
|
|
1481
|
-
'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
|
|
1482
|
-
'This Framer project needs to be connected before Dexter can use it.',
|
|
1483
|
-
409,
|
|
1484
|
-
);
|
|
1485
|
-
}
|
|
1486
|
-
let created;
|
|
1487
|
-
try {
|
|
1488
|
-
if (authorization) {
|
|
1489
|
-
await installProjectAuthorization(authorization);
|
|
1490
|
-
}
|
|
1491
|
-
try {
|
|
1492
|
-
created = await createSession();
|
|
1493
|
-
} catch (error) {
|
|
1494
|
-
if (!isMissingFramerSkillError(error)) throw error;
|
|
1495
|
-
await ensureFramerSetup({ force: true });
|
|
1496
|
-
created = await createSession();
|
|
1497
|
-
framerSetupRecoveredSession = true;
|
|
1498
|
-
}
|
|
1499
|
-
} catch (error) {
|
|
1500
|
-
if (isAuthorizationFailure(error)) {
|
|
1501
|
-
await reportRejectedAuthorization(authorization, error);
|
|
1502
|
-
}
|
|
1503
|
-
throw projectConnectionError(error);
|
|
1504
|
-
}
|
|
1505
|
-
sessionId = sessionIdFromOutput(created.stdout);
|
|
1506
|
-
if (!sessionId) {
|
|
1507
|
-
const refreshed = await executeCli(['session', 'list'], {
|
|
1508
|
-
timeoutMs: 10_000,
|
|
1509
|
-
}).catch(() => ({ stdout: '[]', stderr: '' }));
|
|
1510
|
-
const connected = extractJsonArray(refreshed.stdout).find(
|
|
1511
|
-
(item) => isRecord(item) && item.projectId === projectId && item.id,
|
|
1512
|
-
);
|
|
1513
|
-
sessionId = connected?.id ? String(connected.id) : null;
|
|
1514
|
-
}
|
|
1515
|
-
if (!sessionId) {
|
|
1516
|
-
throw toolError(
|
|
1517
|
-
'FRAMER_AGENT_SESSION_UNAVAILABLE',
|
|
1518
|
-
'Framer Agent connected but did not return a usable project session.',
|
|
1519
|
-
503,
|
|
1520
|
-
);
|
|
1521
|
-
}
|
|
1522
|
-
try {
|
|
1523
|
-
await verifyConnectedProject(sessionId, authorization);
|
|
1524
|
-
} catch (error) {
|
|
1525
|
-
await executeCli(['session', 'destroy', sessionId], {
|
|
1526
|
-
timeoutMs: 10_000,
|
|
1527
|
-
}).catch(() => undefined);
|
|
1528
|
-
sessionId = null;
|
|
1529
|
-
if (
|
|
1530
|
-
isAuthorizationFailure(error)
|
|
1531
|
-
|| error?.code === 'FRAMER_AGENT_PROJECT_MISMATCH'
|
|
1532
|
-
) {
|
|
1533
|
-
await reportRejectedAuthorization(authorization, error);
|
|
1534
|
-
}
|
|
1535
|
-
throw projectConnectionError(error);
|
|
1536
|
-
}
|
|
1537
|
-
sessionsByProject.set(sessionCacheKey, {
|
|
1538
|
-
sessionId,
|
|
1539
|
-
credentialVersion: authorization?.credentialVersion || null,
|
|
1540
|
-
});
|
|
1541
|
-
reportActivity({
|
|
1542
|
-
kind: 'status_update',
|
|
1543
|
-
source: 'framer-agent',
|
|
1544
|
-
message: 'The coding harness is connected to the Framer canvas.',
|
|
1545
|
-
});
|
|
1546
|
-
return sessionId;
|
|
1547
|
-
})().finally(() => {
|
|
1548
|
-
sessionPromise = null;
|
|
1549
|
-
});
|
|
1550
|
-
return sessionPromise;
|
|
1551
|
-
};
|
|
1552
|
-
|
|
1553
|
-
const loadProjectGuidance = async () => {
|
|
1554
|
-
await ensureSession();
|
|
1555
|
-
const skillRoot = framerSkillRoot(env);
|
|
1556
|
-
if (!skillRoot) return { content: '', ...guidanceMetadata };
|
|
1557
|
-
const projectRoot = path.join(skillRoot, 'projects', projectId);
|
|
1558
|
-
if (!fs.existsSync(projectRoot)) {
|
|
1559
|
-
return { content: '', ...guidanceMetadata };
|
|
1560
|
-
}
|
|
1561
|
-
// Every run gets the guidance an edit always needs: principles, command
|
|
1562
|
-
// syntax, tool APIs, scope and replica rules, and the edit loop. Topic
|
|
1563
|
-
// guides (CMS, components, creation strategy, examples, ...) are listed
|
|
1564
|
-
// in a catalog and read on demand through framer_instructions. The CLI,
|
|
1565
|
-
// session, and file-map parts of SKILL.md and index.md do not apply to
|
|
1566
|
-
// Dexter's tools, and index.md changes every session, so both are left
|
|
1567
|
-
// out; the guidance stays byte-identical across runs and can be cached.
|
|
1568
|
-
// The project inventory changes with the project and is returned apart.
|
|
1569
|
-
const requestedFiles = [
|
|
1570
|
-
{
|
|
1571
|
-
root: skillRoot,
|
|
1572
|
-
path: 'SKILL.md',
|
|
1573
|
-
section: 'Method Selection',
|
|
1574
|
-
label: 'SKILL.md#Method Selection',
|
|
1575
|
-
},
|
|
1576
|
-
{
|
|
1577
|
-
root: skillRoot,
|
|
1578
|
-
path: 'SKILL.md',
|
|
1579
|
-
section: 'Core Usage Principles',
|
|
1580
|
-
label: 'SKILL.md#Core Usage Principles',
|
|
1581
|
-
},
|
|
1582
|
-
{ root: projectRoot, path: 'prompt/overview.md' },
|
|
1583
|
-
{ root: projectRoot, path: 'prompt/core-principles.md' },
|
|
1584
|
-
{ root: projectRoot, path: 'prompt/guardrails.md' },
|
|
1585
|
-
{ root: projectRoot, path: 'prompt/critical-reminders.md' },
|
|
1586
|
-
{ root: projectRoot, path: 'prompt/design-rules.md' },
|
|
1587
|
-
{ root: projectRoot, path: 'prompt/updating-the-project.md' },
|
|
1588
|
-
{ root: projectRoot, path: 'prompt/tools.md' },
|
|
1589
|
-
...CORE_GUIDE_SECTIONS.map(({ file, section }) => ({
|
|
1590
|
-
root: projectRoot,
|
|
1591
|
-
path: `prompt/${file}.md`,
|
|
1592
|
-
section,
|
|
1593
|
-
label: `prompt/${file}.md#${section}`,
|
|
1594
|
-
})),
|
|
1595
|
-
];
|
|
1596
|
-
const files = [];
|
|
1597
|
-
let remainingBytes = MAX_GUIDANCE_BYTES;
|
|
1598
|
-
for (const requestedFile of requestedFiles) {
|
|
1599
|
-
const loaded = readGuidanceFile(
|
|
1600
|
-
requestedFile.root,
|
|
1601
|
-
requestedFile.path,
|
|
1602
|
-
remainingBytes,
|
|
1603
|
-
requestedFile.section,
|
|
1604
|
-
);
|
|
1605
|
-
if (!loaded) continue;
|
|
1606
|
-
files.push({
|
|
1607
|
-
...loaded,
|
|
1608
|
-
path: requestedFile.label || requestedFile.path,
|
|
1609
|
-
});
|
|
1610
|
-
remainingBytes -= Buffer.byteLength(loaded.content, 'utf8');
|
|
1611
|
-
if (remainingBytes <= 0) break;
|
|
1612
|
-
}
|
|
1613
|
-
guideCatalog = onDemandGuideCatalog(projectRoot);
|
|
1614
|
-
const catalogText = guideCatalog.size > 0
|
|
1615
|
-
? [
|
|
1616
|
-
'## On-demand guides',
|
|
1617
|
-
'',
|
|
1618
|
-
'Read a guide with framer_instructions({ guides: [id] }) before work that depends on it.',
|
|
1619
|
-
...[...guideCatalog.keys()].map((id) => `- ${id}`),
|
|
1620
|
-
].join('\n')
|
|
1621
|
-
: '';
|
|
1622
|
-
const content = [
|
|
1623
|
-
...files.map((file) => `## ${file.path}\n\n${file.content}`),
|
|
1624
|
-
...(catalogText ? [catalogText] : []),
|
|
1625
|
-
].join('\n\n');
|
|
1626
|
-
const inventory = readGuidanceFile(
|
|
1627
|
-
projectRoot,
|
|
1628
|
-
'project-inventory.md',
|
|
1629
|
-
MAX_GUIDANCE_FILE_BYTES,
|
|
1630
|
-
)?.content || '';
|
|
1631
|
-
guidanceMetadata = {
|
|
1632
|
-
loaded: Boolean(content),
|
|
1633
|
-
contentHash: content
|
|
1634
|
-
? crypto.createHash('sha256').update(content).digest('hex')
|
|
1635
|
-
: null,
|
|
1636
|
-
projectContextHash: inventory
|
|
1637
|
-
? crypto.createHash('sha256').update(inventory).digest('hex')
|
|
1638
|
-
: null,
|
|
1639
|
-
files: [
|
|
1640
|
-
...files.map((file) => file.path),
|
|
1641
|
-
...(inventory ? ['project/project-inventory.md'] : []),
|
|
1642
|
-
],
|
|
1643
|
-
bytes: Buffer.byteLength(content + inventory, 'utf8'),
|
|
1644
|
-
};
|
|
1645
|
-
return { content, inventory, ...guidanceMetadata };
|
|
1646
|
-
};
|
|
1647
|
-
|
|
1648
|
-
const configurePromptContext = ({
|
|
1649
|
-
doctrineLoaded = false,
|
|
1650
|
-
doctrineVersion = null,
|
|
1651
|
-
promptBytes = 0,
|
|
1652
|
-
} = {}) => {
|
|
1653
|
-
promptMetadata = {
|
|
1654
|
-
promptBytes: Math.max(0, Number(promptBytes) || 0),
|
|
1655
|
-
doctrineLoaded: doctrineLoaded === true,
|
|
1656
|
-
doctrineVersion:
|
|
1657
|
-
typeof doctrineVersion === 'string' && doctrineVersion.trim()
|
|
1658
|
-
? doctrineVersion.trim().slice(0, 160)
|
|
1659
|
-
: null,
|
|
1660
|
-
};
|
|
1661
|
-
};
|
|
1662
|
-
|
|
1663
|
-
const execCode = async (code, { timeoutMs } = {}) => {
|
|
1664
|
-
let activeSessionId = await ensureSession();
|
|
1665
|
-
try {
|
|
1666
|
-
return await executeCli(['exec', '-s', activeSessionId], {
|
|
1667
|
-
input: code,
|
|
1668
|
-
timeoutMs,
|
|
1669
|
-
});
|
|
1670
|
-
} catch (error) {
|
|
1671
|
-
if (
|
|
1672
|
-
error?.code !== 'FRAMER_AGENT_COMMAND_FAILED'
|
|
1673
|
-
|| !/session|connection|not found|closed|expired/i.test(
|
|
1674
|
-
String(error?.message || ''),
|
|
1675
|
-
)
|
|
1676
|
-
) {
|
|
1677
|
-
throw error;
|
|
1678
|
-
}
|
|
1679
|
-
sessionsByProject.delete(sessionCacheKey);
|
|
1680
|
-
sessionId = null;
|
|
1681
|
-
activeSessionId = await ensureSession();
|
|
1682
|
-
return executeCli(['exec', '-s', activeSessionId], {
|
|
1683
|
-
input: code,
|
|
1684
|
-
timeoutMs,
|
|
1685
|
-
});
|
|
1686
|
-
}
|
|
1687
|
-
};
|
|
1688
|
-
|
|
1689
|
-
// Captures a node through the Framer session itself. This needs no access
|
|
1690
|
-
// to the screenshot CDN, so it works where that download is blocked.
|
|
1691
|
-
const captureSessionScreenshot = async (nodeId) => {
|
|
1692
|
-
for (const scale of [1, 0.5]) {
|
|
1693
|
-
try {
|
|
1694
|
-
const result = await execCode(
|
|
1695
|
-
[
|
|
1696
|
-
`const shot = await framer.screenshot(${JSON.stringify(nodeId)}, { format: 'jpeg', quality: 80, scale: ${scale} });`,
|
|
1697
|
-
'console.log(JSON.stringify({ mimeType: shot.mimeType, data: Buffer.from(shot.data).toString("base64") }));',
|
|
1698
|
-
].join('\n'),
|
|
1699
|
-
{ timeoutMs: SESSION_SCREENSHOT_TIMEOUT_MS },
|
|
1700
|
-
);
|
|
1701
|
-
const parsed = parseStructuredOutput(result.stdout);
|
|
1702
|
-
const mimeType = String(parsed?.mimeType || '').toLowerCase();
|
|
1703
|
-
const data = typeof parsed?.data === 'string' ? parsed.data : '';
|
|
1704
|
-
if (!SCREENSHOT_MIME_TYPES.has(mimeType)) return { error: 'unsupported_type' };
|
|
1705
|
-
const bytes = Buffer.from(data, 'base64').length;
|
|
1706
|
-
if (!bytes) return { error: 'empty' };
|
|
1707
|
-
if (bytes > MAX_SCREENSHOT_BYTES) return { error: 'too_large' };
|
|
1708
|
-
return { image: { url: null, mimeType, data } };
|
|
1709
|
-
} catch (error) {
|
|
1710
|
-
if (error?.code === 'FRAMER_AGENT_OUTPUT_TOO_LARGE' && scale === 1) continue;
|
|
1711
|
-
return { error: String(error?.code || 'capture_failed').slice(0, 60) };
|
|
1712
|
-
}
|
|
1713
|
-
}
|
|
1714
|
-
return { error: 'too_large' };
|
|
1715
|
-
};
|
|
1716
|
-
|
|
1717
|
-
const deliverScreenshots = async (queries, parsed) => {
|
|
1718
|
-
const results = (Array.isArray(parsed?.results) ? parsed.results : [])
|
|
1719
|
-
.filter((entry) => typeof entry?.image_url === 'string');
|
|
1720
|
-
const images = [];
|
|
1721
|
-
const failures = [];
|
|
1722
|
-
let viaDownload = 0;
|
|
1723
|
-
let viaSession = 0;
|
|
1724
|
-
let undelivered = 0;
|
|
1725
|
-
for (const [index, query] of queries.entries()) {
|
|
1726
|
-
const nodeId = typeof query.id === 'string' ? query.id.trim() : '';
|
|
1727
|
-
const entry =
|
|
1728
|
-
(nodeId && results.find((candidate) => candidate.id === nodeId))
|
|
1729
|
-
|| results[index];
|
|
1730
|
-
const downloaded = entry
|
|
1731
|
-
? await downloadScreenshotImage(entry.image_url, fetchImpl)
|
|
1732
|
-
: { error: 'no_image_url' };
|
|
1733
|
-
if (downloaded.image) {
|
|
1734
|
-
images.push(downloaded.image);
|
|
1735
|
-
viaDownload += 1;
|
|
1736
|
-
continue;
|
|
1737
|
-
}
|
|
1738
|
-
const captured = nodeId
|
|
1739
|
-
? await captureSessionScreenshot(nodeId)
|
|
1740
|
-
: { error: 'no_node_id' };
|
|
1741
|
-
if (captured.image) {
|
|
1742
|
-
images.push(captured.image);
|
|
1743
|
-
viaSession += 1;
|
|
1744
|
-
failures.push({
|
|
1745
|
-
target: nodeId,
|
|
1746
|
-
download: downloaded.error,
|
|
1747
|
-
recoveredVia: 'session',
|
|
1748
|
-
});
|
|
1749
|
-
continue;
|
|
1750
|
-
}
|
|
1751
|
-
undelivered += 1;
|
|
1752
|
-
failures.push({
|
|
1753
|
-
target: nodeId || 'url',
|
|
1754
|
-
download: downloaded.error,
|
|
1755
|
-
capture: captured.error,
|
|
1756
|
-
});
|
|
1757
|
-
}
|
|
1758
|
-
screenshotsViaDownload += viaDownload;
|
|
1759
|
-
screenshotsViaSession += viaSession;
|
|
1760
|
-
screenshotsUndelivered += undelivered;
|
|
1761
|
-
screenshotFailures.push(...failures);
|
|
1762
|
-
screenshotFailures.splice(
|
|
1763
|
-
0,
|
|
1764
|
-
Math.max(0, screenshotFailures.length - MAX_SCREENSHOT_FAILURES_REPORTED),
|
|
1765
|
-
);
|
|
1766
|
-
return images;
|
|
1767
|
-
};
|
|
1768
|
-
|
|
1769
|
-
const readProject = (queries, pagePath) => execCode(
|
|
1770
|
-
[
|
|
1771
|
-
`const result = await framer.agent.readProject(${JSON.stringify(queries)}, { pagePath: ${JSON.stringify(pagePath)} });`,
|
|
1772
|
-
'console.log(JSON.stringify(result, null, 2));',
|
|
1773
|
-
].join('\n'),
|
|
1774
|
-
);
|
|
1775
|
-
|
|
1776
|
-
// Runs readProject screenshot queries and hands the pixels to the model.
|
|
1777
|
-
const readScreenshots = async (queries, pagePath) => {
|
|
1778
|
-
const result = await readProject(queries, pagePath);
|
|
1779
|
-
return deliverScreenshots(queries, parseStructuredOutput(result.stdout));
|
|
1780
|
-
};
|
|
1781
|
-
|
|
1782
|
-
// Framer bills style font searches to the workspace's AI credit and fails
|
|
1783
|
-
// the whole read when they cannot run. Those searches are then answered
|
|
1784
|
-
// from the Google Fonts catalog and the remaining queries are read again.
|
|
1785
|
-
// Returns null when there is nothing to answer, so the original error stands.
|
|
1786
|
-
const readProjectWithFontFallback = async (queries, pagePath) => {
|
|
1787
|
-
try {
|
|
1788
|
-
return await readProject(queries, pagePath);
|
|
1789
|
-
} catch (error) {
|
|
1790
|
-
const styleSearch = (query) =>
|
|
1791
|
-
query.type === 'font-search' && typeof query.query === 'string';
|
|
1792
|
-
if (!queries.some(styleSearch)) throw error;
|
|
1793
|
-
let catalog;
|
|
1794
|
-
try {
|
|
1795
|
-
catalog = await loadGoogleFontsCatalog(fetchImpl);
|
|
1796
|
-
} catch (catalogError) {
|
|
1797
|
-
trace?.warn?.('google_fonts_fallback_unavailable', {
|
|
1798
|
-
message: catalogError?.message,
|
|
1799
|
-
});
|
|
1800
|
-
throw error;
|
|
1801
|
-
}
|
|
1802
|
-
const remaining = queries.filter((query) => !styleSearch(query));
|
|
1803
|
-
const remainingResults = remaining.length
|
|
1804
|
-
? parseStructuredOutput((await readProject(remaining, pagePath)).stdout)?.results || []
|
|
1805
|
-
: [];
|
|
1806
|
-
fontSearchFallbacks += 1;
|
|
1807
|
-
trace?.warn?.('framer_font_search_fallback', {
|
|
1808
|
-
message: boundedText(String(error?.message || ''), 300),
|
|
1809
|
-
});
|
|
1810
|
-
const reason = boundedText(String(error?.message || 'unavailable'), 160);
|
|
1811
|
-
let next = 0;
|
|
1812
|
-
return {
|
|
1813
|
-
stdout: JSON.stringify({
|
|
1814
|
-
results: queries.map((query) => (styleSearch(query)
|
|
1815
|
-
? {
|
|
1816
|
-
type: 'font-search',
|
|
1817
|
-
source: 'google-fonts',
|
|
1818
|
-
note: `Framer's style font search failed (${reason}), so these candidates come from the Google Fonts library, which Framer includes. Choose by how the family looks, set fontName to it, and use a listed weight. If you know a Google Fonts family that fits better, confirm it with a font-search by name.`,
|
|
1819
|
-
results: searchGoogleFonts(catalog, query),
|
|
1820
|
-
}
|
|
1821
|
-
: remainingResults[next++] ?? null)),
|
|
1822
|
-
}),
|
|
1823
|
-
stderr: '',
|
|
1824
|
-
};
|
|
1825
|
-
}
|
|
1826
|
-
};
|
|
1827
|
-
|
|
1828
|
-
const referenceAssetFiles = Array.isArray(assignment?.context?.referenceAssetFiles)
|
|
1829
|
-
? assignment.context.referenceAssetFiles
|
|
1830
|
-
: [];
|
|
1831
|
-
const uploadedAttachments = new Map();
|
|
1832
|
-
const uploadAttachment = async (attachmentId) => {
|
|
1833
|
-
const file = referenceAssetFiles.find((candidate) => candidate?.id === attachmentId);
|
|
1834
|
-
if (!file) {
|
|
1835
|
-
throw toolError(
|
|
1836
|
-
'FRAMER_ATTACHMENT_NOT_FOUND',
|
|
1837
|
-
`No user attachment "${attachmentId}" is available in this run. Use an id from context.referenceAssetFiles.`,
|
|
1838
|
-
);
|
|
1839
|
-
}
|
|
1840
|
-
const cached = uploadedAttachments.get(attachmentId);
|
|
1841
|
-
if (cached) return cached;
|
|
1842
|
-
const root = path.resolve(cwd || '.');
|
|
1843
|
-
const filePath = path.resolve(root, String(file.path || ''));
|
|
1844
|
-
if (!filePath.startsWith(`${root}${path.sep}`) || !fs.existsSync(filePath)) {
|
|
1845
|
-
throw toolError(
|
|
1846
|
-
'FRAMER_ATTACHMENT_NOT_FOUND',
|
|
1847
|
-
`The attachment "${attachmentId}" is no longer available on this computer.`,
|
|
1848
|
-
);
|
|
1849
|
-
}
|
|
1850
|
-
const dataUrl = `data:${file.mimeType || 'image/png'};base64,${fs.readFileSync(filePath).toString('base64')}`;
|
|
1851
|
-
const result = await execCode(
|
|
1852
|
-
[
|
|
1853
|
-
`const asset = await framer.uploadImage({ image: ${JSON.stringify(dataUrl)} });`,
|
|
1854
|
-
`console.log(JSON.stringify({ attachmentId: ${JSON.stringify(attachmentId)}, url: asset.url, thumbnailUrl: asset.thumbnailUrl }));`,
|
|
1855
|
-
].join('\n'),
|
|
1856
|
-
{ timeoutMs: 60_000 },
|
|
1857
|
-
);
|
|
1858
|
-
uploadedAttachments.set(attachmentId, result);
|
|
1859
|
-
return result;
|
|
1860
|
-
};
|
|
1861
|
-
|
|
1862
|
-
// Selected layers that do not resolve to native nodes (stale selections,
|
|
1863
|
-
// aliases, layers inside component instances) no longer stop the run. The
|
|
1864
|
-
// model is told which targets to locate by name, and the reason is kept for
|
|
1865
|
-
// debugging.
|
|
1866
|
-
let unresolvedTargets = [];
|
|
1867
|
-
const validateAuthoritativeNodeIds = async () => {
|
|
1868
|
-
await ensureSession();
|
|
1869
|
-
if (authoritativeNodeIds.length === 0) return { unresolvedTargets };
|
|
1870
|
-
if (nodeValidationPromise) return nodeValidationPromise;
|
|
1871
|
-
nodeValidationPromise = (async () => {
|
|
1872
|
-
reportActivity({
|
|
1873
|
-
kind: 'status_update',
|
|
1874
|
-
source: 'framer-agent',
|
|
1875
|
-
message: 'Validating the selected Framer layers.',
|
|
1876
|
-
});
|
|
1877
|
-
const result = await execCode(
|
|
1878
|
-
[
|
|
1879
|
-
`const requestedIds = ${JSON.stringify(authoritativeNodeIds)};`,
|
|
1880
|
-
'const resolved = await Promise.all(requestedIds.map(async (requestedId) => {',
|
|
1881
|
-
' try {',
|
|
1882
|
-
' const node = await framer.agent.getNode({ id: requestedId },',
|
|
1883
|
-
` { pagePath: ${JSON.stringify(activePagePath)} });`,
|
|
1884
|
-
' return { requestedId, resolvedId: node?.id ?? null, error: node ? null : "not found on the active page" };',
|
|
1885
|
-
' } catch (error) {',
|
|
1886
|
-
' return { requestedId, resolvedId: null, error: String(error?.message || error).slice(0, 200) };',
|
|
1887
|
-
' }',
|
|
1888
|
-
'}));',
|
|
1889
|
-
'console.log(JSON.stringify({ resolved }));',
|
|
1890
|
-
].join('\n'),
|
|
1891
|
-
{ timeoutMs: 30_000 },
|
|
1892
|
-
);
|
|
1893
|
-
const parsed = parseStructuredOutput(result.stdout);
|
|
1894
|
-
const resolved = Array.isArray(parsed?.resolved)
|
|
1895
|
-
? parsed.resolved
|
|
1896
|
-
: [];
|
|
1897
|
-
unresolvedTargets = authoritativeNodeIds.flatMap((requestedId) => {
|
|
1898
|
-
const match = resolved.find(
|
|
1899
|
-
(entry) =>
|
|
1900
|
-
isRecord(entry)
|
|
1901
|
-
&& entry.requestedId === requestedId,
|
|
1902
|
-
);
|
|
1903
|
-
if (match?.resolvedId === requestedId) return [];
|
|
1904
|
-
return [{
|
|
1905
|
-
id: requestedId,
|
|
1906
|
-
reason: !match
|
|
1907
|
-
? 'not checked'
|
|
1908
|
-
: match.resolvedId
|
|
1909
|
-
? `resolves to ${match.resolvedId}`
|
|
1910
|
-
: String(match.error || 'not found on the active page'),
|
|
1911
|
-
}];
|
|
1912
|
-
});
|
|
1913
|
-
reportActivity({
|
|
1914
|
-
kind: 'status_update',
|
|
1915
|
-
source: 'framer-agent',
|
|
1916
|
-
message: unresolvedTargets.length > 0
|
|
1917
|
-
? 'Some selected layers could not be matched exactly, so Dexter will locate them by name.'
|
|
1918
|
-
: 'The selected Framer layers are ready.',
|
|
1919
|
-
});
|
|
1920
|
-
return { unresolvedTargets };
|
|
1921
|
-
})().catch((error) => {
|
|
1922
|
-
nodeValidationPromise = null;
|
|
1923
|
-
throw error;
|
|
1924
|
-
});
|
|
1925
|
-
return nodeValidationPromise;
|
|
1926
|
-
};
|
|
1927
|
-
|
|
1928
|
-
const normalizedResult = (result) => {
|
|
1929
|
-
const parsed = parseStructuredOutput(result.stdout);
|
|
1930
|
-
return {
|
|
1931
|
-
...(parsed === null
|
|
1932
|
-
? { output: boundedText(result.stdout, MAX_MODEL_RESULT_BYTES) }
|
|
1933
|
-
: { result: boundedStructuredValue(parsed) }),
|
|
1934
|
-
...(result.stderr.trim()
|
|
1935
|
-
? { diagnostics: boundedText(result.stderr, 20_000) }
|
|
1936
|
-
: {}),
|
|
1937
|
-
...(branchChange ? { branch: branchChange } : {}),
|
|
1938
|
-
};
|
|
1939
|
-
};
|
|
1940
|
-
const normalizedCodeFileReadResult = (result) => ({
|
|
1941
|
-
result: parseStructuredOutput(result.stdout),
|
|
1942
|
-
...(result.stderr.trim()
|
|
1943
|
-
? { diagnostics: boundedText(result.stderr, 20_000) }
|
|
1944
|
-
: {}),
|
|
1945
|
-
...(branchChange ? { branch: branchChange } : {}),
|
|
1946
|
-
});
|
|
1947
|
-
|
|
1948
|
-
const markInspection = ({
|
|
1949
|
-
visual = false,
|
|
1950
|
-
visualAttempted = false,
|
|
1951
|
-
verifies = [],
|
|
1952
|
-
} = {}) => {
|
|
1953
|
-
operationSequence += 1;
|
|
1954
|
-
lastInspectionSequence = operationSequence;
|
|
1955
|
-
lastInspectionAt = new Date().toISOString();
|
|
1956
|
-
verificationEvidence.set('structural', operationSequence);
|
|
1957
|
-
if (visualAttempted) lastScreenshotAttemptSequence = operationSequence;
|
|
1958
|
-
if (visual) {
|
|
1959
|
-
screenshotCalls += 1;
|
|
1960
|
-
lastScreenshotSequence = operationSequence;
|
|
1961
|
-
verificationEvidence.set('visual', operationSequence);
|
|
1962
|
-
}
|
|
1963
|
-
for (const check of normalizeVerificationValues(
|
|
1964
|
-
verifies,
|
|
1965
|
-
GENERIC_VERIFICATION_CHECKS,
|
|
1966
|
-
)) {
|
|
1967
|
-
verificationEvidence.set(check, operationSequence);
|
|
1968
|
-
}
|
|
1969
|
-
};
|
|
1970
|
-
|
|
1971
|
-
const markMutation = ({ code = false } = {}) => {
|
|
1972
|
-
operationSequence += 1;
|
|
1973
|
-
lastMutationSequence = operationSequence;
|
|
1974
|
-
if (code) lastCodeMutationSequence = operationSequence;
|
|
1975
|
-
lastMutationAt = new Date().toISOString();
|
|
1976
|
-
};
|
|
1977
|
-
|
|
1978
|
-
const requiredVerificationChecks = () =>
|
|
1979
|
-
taskPlan?.verification || [];
|
|
1980
|
-
const taskPlanSnapshot = () => {
|
|
1981
|
-
if (!taskPlan) return null;
|
|
1982
|
-
return {
|
|
1983
|
-
summary: taskPlan.summary,
|
|
1984
|
-
domains: [...taskPlan.domains],
|
|
1985
|
-
verification: [...taskPlan.verification],
|
|
1986
|
-
...(taskPlan.behaviors.length > 0
|
|
1987
|
-
? {
|
|
1988
|
-
behaviors: taskPlan.behaviors.map((behavior) => ({
|
|
1989
|
-
...behavior,
|
|
1990
|
-
targetNodeIds: [...behavior.targetNodeIds],
|
|
1991
|
-
})),
|
|
1992
|
-
}
|
|
1993
|
-
: {}),
|
|
1994
|
-
};
|
|
1995
|
-
};
|
|
1996
|
-
// Only checks observed after the latest mutation describe the current project.
|
|
1997
|
-
const freshInteractionChecks = () =>
|
|
1998
|
-
[...interactionCheckResults.values()]
|
|
1999
|
-
.filter((entry) => entry.sequence > lastMutationSequence)
|
|
2000
|
-
.map((entry) => entry.result);
|
|
2001
|
-
const interactionSummary = () =>
|
|
2002
|
-
summarizeBehaviorVerification(taskPlan, freshInteractionChecks());
|
|
2003
|
-
const interactionStatus = () => {
|
|
2004
|
-
if (!taskPlan?.behaviors?.length || mutationCalls === 0) return 'not_required';
|
|
2005
|
-
return interactionSummary().status;
|
|
2006
|
-
};
|
|
2007
|
-
const synchronized = () =>
|
|
2008
|
-
mutationCalls === 0 || lastInspectionSequence > lastMutationSequence;
|
|
2009
|
-
const verificationCheckCompleted = (check) => {
|
|
2010
|
-
if (mutationCalls === 0) return true;
|
|
2011
|
-
if (check === 'structural') return synchronized();
|
|
2012
|
-
if (check === 'interactions') {
|
|
2013
|
-
return ['verified', 'not_required'].includes(interactionStatus());
|
|
2014
|
-
}
|
|
2015
|
-
// Canvas edits cannot change a code file, so only code edits stale it.
|
|
2016
|
-
const staleAfter =
|
|
2017
|
-
check === 'code' ? lastCodeMutationSequence : lastMutationSequence;
|
|
2018
|
-
return Number(verificationEvidence.get(check) || 0) > staleAfter;
|
|
2019
|
-
};
|
|
2020
|
-
const visualVerificationRequired = () =>
|
|
2021
|
-
requiredVerificationChecks().includes('visual');
|
|
2022
|
-
// The model asked for the final canvas, but no screenshot could be handed to
|
|
2023
|
-
// it. That is reported as unverified rather than blamed on the model.
|
|
2024
|
-
const visualVerificationUnavailable = () =>
|
|
2025
|
-
mutationCalls > 0
|
|
2026
|
-
&& lastScreenshotAttemptSequence > lastMutationSequence
|
|
2027
|
-
&& lastScreenshotSequence <= lastMutationSequence;
|
|
2028
|
-
const visualVerificationStatus = () => {
|
|
2029
|
-
if (!visualVerificationRequired() || mutationCalls === 0) return 'not_required';
|
|
2030
|
-
if (verificationCheckCompleted('visual')) return 'seen';
|
|
2031
|
-
return visualVerificationUnavailable() ? 'unavailable' : 'missing';
|
|
2032
|
-
};
|
|
2033
|
-
const requiredChecks = () => [
|
|
2034
|
-
...new Set(['structural', ...requiredVerificationChecks()]),
|
|
2035
|
-
];
|
|
2036
|
-
// Checks the model attempted but that live evidence could not confirm. They
|
|
2037
|
-
// are reported as unverified and do not fail the run.
|
|
2038
|
-
const unavailableChecks = () => [
|
|
2039
|
-
...(visualVerificationStatus() === 'unavailable' ? ['visual'] : []),
|
|
2040
|
-
...(interactionStatus() === 'unverified' ? ['interactions'] : []),
|
|
2041
|
-
];
|
|
2042
|
-
const missingVerificationChecks = () => {
|
|
2043
|
-
if (mutationCalls === 0) return [];
|
|
2044
|
-
const missing = [];
|
|
2045
|
-
if (!taskPlan) missing.push('task-plan');
|
|
2046
|
-
const unavailable = unavailableChecks();
|
|
2047
|
-
for (const check of requiredChecks()) {
|
|
2048
|
-
if (unavailable.includes(check)) continue;
|
|
2049
|
-
if (!verificationCheckCompleted(check)) missing.push(check);
|
|
2050
|
-
}
|
|
2051
|
-
return missing;
|
|
2052
|
-
};
|
|
2053
|
-
const verification = () => ({
|
|
2054
|
-
ownedBy: 'harness',
|
|
2055
|
-
required: mutationCalls > 0,
|
|
2056
|
-
completed: missingVerificationChecks().length === 0,
|
|
2057
|
-
structuralCompleted: synchronized(),
|
|
2058
|
-
visualRequired: visualVerificationRequired() && mutationCalls > 0,
|
|
2059
|
-
visualCompleted: visualVerificationStatus() !== 'missing',
|
|
2060
|
-
visualStatus: visualVerificationStatus(),
|
|
2061
|
-
screenshotCalls,
|
|
2062
|
-
// Every failed CDN download is listed; recoveredVia marks the ones the
|
|
2063
|
-
// session capture still delivered to the model.
|
|
2064
|
-
screenshotDelivery: {
|
|
2065
|
-
delivered: screenshotsViaDownload + screenshotsViaSession,
|
|
2066
|
-
viaDownload: screenshotsViaDownload,
|
|
2067
|
-
viaSession: screenshotsViaSession,
|
|
2068
|
-
undelivered: screenshotsUndelivered,
|
|
2069
|
-
failures: screenshotFailures.map((failure) => ({ ...failure })),
|
|
2070
|
-
},
|
|
2071
|
-
...(requiredVerificationChecks().includes('interactions')
|
|
2072
|
-
|| interactionVerificationCalls > 0
|
|
2073
|
-
? {
|
|
2074
|
-
interactionVerificationCalls,
|
|
2075
|
-
interactionVerification: {
|
|
2076
|
-
status: interactionStatus(),
|
|
2077
|
-
behaviors: taskPlan?.behaviors?.length ? interactionSummary().behaviors : [],
|
|
2078
|
-
checks: freshInteractionChecks().map((check) => ({ ...check })),
|
|
2079
|
-
verifiedAt: interactionVerifiedAt,
|
|
2080
|
-
},
|
|
2081
|
-
}
|
|
2082
|
-
: {}),
|
|
2083
|
-
taskPlan: taskPlanSnapshot(),
|
|
2084
|
-
requiredChecks: requiredChecks(),
|
|
2085
|
-
completedChecks: requiredChecks().filter(verificationCheckCompleted),
|
|
2086
|
-
unavailableChecks: unavailableChecks(),
|
|
2087
|
-
missingChecks: missingVerificationChecks(),
|
|
2088
|
-
lastMutationAt,
|
|
2089
|
-
lastInspectionAt,
|
|
2090
|
-
});
|
|
2091
|
-
|
|
2092
|
-
const inspectFinalState = async () => {
|
|
2093
|
-
await serial;
|
|
2094
|
-
return {
|
|
2095
|
-
synchronized: synchronized(),
|
|
2096
|
-
sourceHash: null,
|
|
2097
|
-
changed: mutationCalls > 0,
|
|
2098
|
-
mutationCalls,
|
|
2099
|
-
readCalls,
|
|
2100
|
-
branch: branchChange,
|
|
2101
|
-
lastInspectionAt,
|
|
2102
|
-
lastMutationAt,
|
|
2103
|
-
taskPlan: taskPlanSnapshot(),
|
|
2104
|
-
verification: verification(),
|
|
2105
|
-
};
|
|
2106
|
-
};
|
|
2107
|
-
|
|
2108
|
-
const withUnavailableChecks = (completion) => {
|
|
2109
|
-
const unavailable = unavailableChecks();
|
|
2110
|
-
if (unavailable.length === 0) return completion;
|
|
2111
|
-
const unverifiedBehaviors = interactionSummary().behaviors
|
|
2112
|
-
.filter((behavior) => behavior.verdict === 'unknown')
|
|
2113
|
-
.map((behavior) => `${behavior.behaviorId} (${behavior.unknowns[0] || 'not confirmed'})`);
|
|
2114
|
-
return {
|
|
2115
|
-
...completion,
|
|
2116
|
-
checks: [
|
|
2117
|
-
...(Array.isArray(completion?.checks) ? completion.checks : []),
|
|
2118
|
-
...(unavailable.includes('visual')
|
|
2119
|
-
? [{
|
|
2120
|
-
command: 'Visual verification',
|
|
2121
|
-
status: 'skipped',
|
|
2122
|
-
output:
|
|
2123
|
-
'Screenshots of the final canvas could not be delivered to the model, so the visual result is unverified.',
|
|
2124
|
-
}]
|
|
2125
|
-
: []),
|
|
2126
|
-
...(unavailable.includes('interactions')
|
|
2127
|
-
? [{
|
|
2128
|
-
command: 'Interaction verification',
|
|
2129
|
-
status: 'skipped',
|
|
2130
|
-
output:
|
|
2131
|
-
`The live project did not expose evidence for: ${unverifiedBehaviors.join('; ')}. These behaviors are unverified.`,
|
|
2132
|
-
}]
|
|
2133
|
-
: []),
|
|
2134
|
-
],
|
|
2135
|
-
};
|
|
2136
|
-
};
|
|
2137
|
-
|
|
2138
|
-
const finalizeCompletion = (completion) => {
|
|
2139
|
-
if (completion?.status !== 'completed' || mutationCalls === 0) {
|
|
2140
|
-
return completion;
|
|
2141
|
-
}
|
|
2142
|
-
if (verification().completed) return withUnavailableChecks(completion);
|
|
2143
|
-
const missing = missingVerificationChecks().map((check) => {
|
|
2144
|
-
if (check === 'task-plan') return 'a harness task and verification plan';
|
|
2145
|
-
if (check === 'structural') return 'a final structural read';
|
|
2146
|
-
if (check === 'visual') return 'post-mutation screenshot verification';
|
|
2147
|
-
return `post-mutation ${check} verification`;
|
|
2148
|
-
});
|
|
2149
|
-
const interactionDetails = (taskPlan?.behaviors?.length ? interactionSummary().behaviors : [])
|
|
2150
|
-
.filter((behavior) => behavior.verdict === 'contradicted' || !behavior.checked)
|
|
2151
|
-
.map((behavior) =>
|
|
2152
|
-
`${behavior.behaviorId}=${behavior.verdict === 'contradicted' ? behavior.contradictions[0] : 'not checked after the latest change'}`);
|
|
2153
|
-
const reason = [
|
|
2154
|
-
`The harness ended before completing ${missing.join(' and ')}.`,
|
|
2155
|
-
...(interactionDetails.length > 0
|
|
2156
|
-
? [`Interaction evidence: ${interactionDetails.join('; ')}.`]
|
|
2157
|
-
: []),
|
|
2158
|
-
].join(' ');
|
|
2159
|
-
const failed = withUnavailableChecks(completion);
|
|
2160
|
-
return {
|
|
2161
|
-
...failed,
|
|
2162
|
-
status: 'failed',
|
|
2163
|
-
summary:
|
|
2164
|
-
String(completion?.summary || '').trim()
|
|
2165
|
-
|| 'The requested changes were applied.',
|
|
2166
|
-
checks: [
|
|
2167
|
-
...(Array.isArray(failed?.checks) ? failed.checks : []),
|
|
2168
|
-
{
|
|
2169
|
-
command: 'Harness-owned final verification',
|
|
2170
|
-
status: 'failed',
|
|
2171
|
-
output: reason,
|
|
2172
|
-
},
|
|
2173
|
-
],
|
|
2174
|
-
blockedReason: null,
|
|
2175
|
-
};
|
|
2176
|
-
};
|
|
2177
|
-
|
|
2178
|
-
// A model that reports failure without changing anything and without a
|
|
2179
|
-
// refused plan or edit gave up on its own. It gets one prompt to continue.
|
|
2180
|
-
const unblockedFailurePrompt = (completion) => {
|
|
2181
|
-
if (completion?.status !== 'failed' || mutationCalls > 0) return null;
|
|
2182
|
-
const lastGate = [...toolCalls]
|
|
2183
|
-
.reverse()
|
|
2184
|
-
.find((call) => call.category === 'plan' || call.category === 'mutation');
|
|
2185
|
-
if (lastGate?.status === 'failed') return null;
|
|
2186
|
-
return [
|
|
2187
|
-
'You returned failed without changing the Framer project, and no tool refused the work.',
|
|
2188
|
-
'Nothing in this run is blocking you and there is no time limit.',
|
|
2189
|
-
'Continue the same task from the current project state: plan it, apply the change, verify it, then return the required JSON outcome.',
|
|
2190
|
-
'Return failed or blocked only for a concrete blocker you can name.',
|
|
2191
|
-
].join(' ');
|
|
2192
|
-
};
|
|
2193
|
-
|
|
2194
|
-
const evidenceRequirement = (check) => {
|
|
2195
|
-
if (check === 'structural') return 'a focused live read';
|
|
2196
|
-
if (check === 'visual') {
|
|
2197
|
-
return 'a screenshot you inspect (screenshotNodeIds on your last framer_apply_changes provides it)';
|
|
2198
|
-
}
|
|
2199
|
-
if (check === 'interactions') {
|
|
2200
|
-
return 'framer_verify_interactions for every declared behavior and target node';
|
|
2201
|
-
}
|
|
2202
|
-
return `a read tagged with verifies:["${check}"]`;
|
|
2203
|
-
};
|
|
2204
|
-
|
|
2205
|
-
const verificationRepairPrompt = () => {
|
|
2206
|
-
const current = verification();
|
|
2207
|
-
if (current.completed || mutationCalls === 0) return null;
|
|
2208
|
-
const missing = [];
|
|
2209
|
-
for (const check of current.missingChecks || []) {
|
|
2210
|
-
if (check === 'task-plan') {
|
|
2211
|
-
missing.push(
|
|
2212
|
-
'declare the task domains and verification plan with framer_plan_task',
|
|
2213
|
-
);
|
|
2214
|
-
} else if (check === 'structural') {
|
|
2215
|
-
missing.push('perform a focused live read after the latest mutation');
|
|
2216
|
-
} else if (check === 'visual') {
|
|
2217
|
-
missing.push(
|
|
2218
|
-
'capture and inspect a screenshot of the affected canvas after the latest mutation',
|
|
2219
|
-
);
|
|
2220
|
-
} else if (check === 'interactions') {
|
|
2221
|
-
const summary = interactionSummary();
|
|
2222
|
-
const unchecked = summary.behaviors
|
|
2223
|
-
.filter((behavior) => !behavior.checked)
|
|
2224
|
-
.map((behavior) => behavior.uncheckedTargets.length
|
|
2225
|
-
? `${behavior.behaviorId} on ${behavior.uncheckedTargets.join(', ')}`
|
|
2226
|
-
: behavior.behaviorId);
|
|
2227
|
-
const contradicted = summary.behaviors
|
|
2228
|
-
.filter((behavior) => behavior.verdict === 'contradicted')
|
|
2229
|
-
.map((behavior) => behavior.contradictions[0]);
|
|
2230
|
-
missing.push([
|
|
2231
|
-
unchecked.length
|
|
2232
|
-
? `call framer_verify_interactions for ${unchecked.join('; ')}, naming the Framer property that carries each behavior`
|
|
2233
|
-
: '',
|
|
2234
|
-
contradicted.length
|
|
2235
|
-
? `fix or explain these mismatches: ${contradicted.join('; ')}`
|
|
2236
|
-
: '',
|
|
2237
|
-
].filter(Boolean).join(' and '));
|
|
2238
|
-
} else {
|
|
2239
|
-
missing.push(`perform a focused read tagged with verifies:["${check}"] after the latest mutation`);
|
|
2240
|
-
}
|
|
2241
|
-
}
|
|
2242
|
-
return [
|
|
2243
|
-
'Continue the same Framer task from its current project state.',
|
|
2244
|
-
'Do not redo successful work.',
|
|
2245
|
-
`Before returning, ${missing.join(' and ')}.`,
|
|
2246
|
-
'If the live project does not expose a property, report that behavior as unverified rather than changing working parts. Then return the required JSON outcome.',
|
|
2247
|
-
].join(' ');
|
|
2248
|
-
};
|
|
2249
|
-
|
|
2250
|
-
const builtInInstructions = (documentationUnavailable = false) => ({
|
|
2251
|
-
stdout: JSON.stringify({
|
|
2252
|
-
source: documentationUnavailable
|
|
2253
|
-
? 'built-in-fallback'
|
|
2254
|
-
: 'built-in',
|
|
2255
|
-
guidance: [
|
|
2256
|
-
'Use framer_read with framer.agent.getNode({ id }) or getNodes({ ids }) for node inspection.',
|
|
2257
|
-
'Use framer_read_project for supported project queries such as screenshots.',
|
|
2258
|
-
'To place a user attachment in the project, call framer_upload_attachment with its id from context.referenceAssetFiles and use the returned url as the image fill. Never upload user files to external services.',
|
|
2259
|
-
'Use framer_query_images for approved stock imagery only after checking user attachments and suitable existing project images.',
|
|
2260
|
-
'Use framer_add_svg for geometric or decorative artwork you draw as SVG markup; use icon sets for icons and real text for text.',
|
|
2261
|
-
'Use framer_apply_changes for layout and styling. Read its diagnostics, verify the result, and repair concrete issues.',
|
|
2262
|
-
'Use framer_read_code_file and framer_update_code_file for existing code components and overrides instead of generic JavaScript.',
|
|
2263
|
-
'If framer_write is rejected as unsafe, do not retry JavaScript variants. Switch to framer_apply_changes or report the concrete blocker.',
|
|
2264
|
-
'Batch related work when practical, reuse evidence already collected, and stop when the requested outcome is satisfied.',
|
|
2265
|
-
],
|
|
2266
|
-
documentationUnavailable,
|
|
2267
|
-
}),
|
|
2268
|
-
stderr: '',
|
|
2269
|
-
});
|
|
2270
|
-
|
|
2271
|
-
const reserveToolCall = (name, category) => {
|
|
2272
|
-
toolCallCount += 1;
|
|
2273
|
-
if (category === 'read') readCalls += 1;
|
|
2274
|
-
const call = {
|
|
2275
|
-
sequence: toolCallCount,
|
|
2276
|
-
name,
|
|
2277
|
-
category,
|
|
2278
|
-
status: 'failed',
|
|
2279
|
-
durationMs: 0,
|
|
2280
|
-
};
|
|
2281
|
-
toolCalls.push(call);
|
|
2282
|
-
return call;
|
|
2283
|
-
};
|
|
2284
|
-
|
|
2285
|
-
const callFingerprint = (name, rawArguments) =>
|
|
2286
|
-
crypto
|
|
2287
|
-
.createHash('sha256')
|
|
2288
|
-
.update(`${name}\n${JSON.stringify(rawArguments)}`)
|
|
2289
|
-
.digest('hex');
|
|
2290
|
-
|
|
2291
|
-
const requireTaskPlanForMutation = () => {
|
|
2292
|
-
if (!taskPlan) {
|
|
2293
|
-
throw toolError(
|
|
2294
|
-
'FRAMER_HARNESS_PLAN_REQUIRED',
|
|
2295
|
-
'Inspect the live Framer target, then call framer_plan_task before making a mutation.',
|
|
2296
|
-
);
|
|
2297
|
-
}
|
|
2298
|
-
};
|
|
2299
|
-
|
|
2300
|
-
async function invokeUnsafe(name, rawArguments = {}) {
|
|
2301
|
-
if (closed) {
|
|
2302
|
-
throw toolError(
|
|
2303
|
-
'FRAMER_AGENT_RUNTIME_CLOSED',
|
|
2304
|
-
'The Framer Agent runtime is closed.',
|
|
2305
|
-
);
|
|
2306
|
-
}
|
|
2307
|
-
if (!allowedToolNames.has(name) || !TOOL_BY_NAME.has(name)) {
|
|
2308
|
-
throw toolError(
|
|
2309
|
-
'FRAMER_AGENT_TOOL_UNKNOWN',
|
|
2310
|
-
`Unavailable Framer Agent tool: ${name}`,
|
|
2311
|
-
);
|
|
2312
|
-
}
|
|
2313
|
-
if (!isRecord(rawArguments)) {
|
|
2314
|
-
throw toolError(
|
|
2315
|
-
'FRAMER_AGENT_TOOL_INPUT_INVALID',
|
|
2316
|
-
'Framer Agent tool arguments must be an object.',
|
|
2317
|
-
);
|
|
2318
|
-
}
|
|
2319
|
-
const mutation = MUTATION_TOOL_NAMES.has(name);
|
|
2320
|
-
const category =
|
|
2321
|
-
name === 'framer_plan_task'
|
|
2322
|
-
? 'plan'
|
|
2323
|
-
: mutation
|
|
2324
|
-
? 'mutation'
|
|
2325
|
-
: 'read';
|
|
2326
|
-
const fingerprint = callFingerprint(name, rawArguments);
|
|
2327
|
-
if (
|
|
2328
|
-
repeatedFailureFingerprint === fingerprint
|
|
2329
|
-
&& repeatedFailureCount >= 5
|
|
2330
|
-
) {
|
|
2331
|
-
repeatedFailureStops += 1;
|
|
2332
|
-
throw toolError(
|
|
2333
|
-
'FRAMER_AGENT_REPEATED_CALL_LOOP',
|
|
2334
|
-
'The same Framer tool call has failed five times in a row. Change the approach or report the concrete blocker.',
|
|
2335
|
-
);
|
|
2336
|
-
}
|
|
2337
|
-
|
|
2338
|
-
const startedAt = Date.now();
|
|
2339
|
-
const toolCall = reserveToolCall(name, category);
|
|
2340
|
-
reportActivity({
|
|
2341
|
-
kind: 'tool_active',
|
|
2342
|
-
source: 'framer-agent',
|
|
2343
|
-
name,
|
|
2344
|
-
phase: 'started',
|
|
2345
|
-
message:
|
|
2346
|
-
mutation
|
|
2347
|
-
? 'Updating the Framer project'
|
|
2348
|
-
: name === 'framer_plan_task'
|
|
2349
|
-
? 'Planning the Framer work and verification'
|
|
2350
|
-
: 'Inspecting the Framer project',
|
|
2351
|
-
});
|
|
2352
|
-
try {
|
|
2353
|
-
let result;
|
|
2354
|
-
let modelImages = [];
|
|
2355
|
-
let screenshotRequestCount = 0;
|
|
2356
|
-
if (name === 'framer_write' && lowLevelWriteDisabled) {
|
|
2357
|
-
lowLevelWriteBlockedCalls += 1;
|
|
2358
|
-
throw toolError(
|
|
2359
|
-
'FRAMER_AGENT_WRITE_FALLBACK_REQUIRED',
|
|
2360
|
-
'Low-level JavaScript writes are disabled for this run after repeated safety rejections. Use framer_apply_changes, inspect the project, or report the concrete blocker.',
|
|
2361
|
-
);
|
|
2362
|
-
} else if (name === 'framer_instructions') {
|
|
2363
|
-
const queries = Array.isArray(rawArguments.queries)
|
|
2364
|
-
? uniqueStrings(rawArguments.queries, 8)
|
|
2365
|
-
: [];
|
|
2366
|
-
const guideIds = Array.isArray(rawArguments.guides)
|
|
2367
|
-
? uniqueStrings(rawArguments.guides, 6)
|
|
2368
|
-
: [];
|
|
2369
|
-
if (guideIds.length > 0) {
|
|
2370
|
-
const guides = guideIds.flatMap((id) => {
|
|
2371
|
-
const entry = guideCatalog.get(id);
|
|
2372
|
-
const loaded = entry
|
|
2373
|
-
? readGuidanceFile(
|
|
2374
|
-
entry.root,
|
|
2375
|
-
entry.path,
|
|
2376
|
-
MAX_GUIDANCE_FILE_BYTES,
|
|
2377
|
-
entry.section,
|
|
2378
|
-
)
|
|
2379
|
-
: null;
|
|
2380
|
-
if (!loaded) return [];
|
|
2381
|
-
if (!guidesRead.includes(id)) guidesRead.push(id);
|
|
2382
|
-
return [{ id, content: loaded.content }];
|
|
2383
|
-
});
|
|
2384
|
-
const unknown = guideIds.filter(
|
|
2385
|
-
(id) => !guides.some((guide) => guide.id === id),
|
|
2386
|
-
);
|
|
2387
|
-
result = {
|
|
2388
|
-
stdout: JSON.stringify({
|
|
2389
|
-
guides,
|
|
2390
|
-
...(unknown.length > 0
|
|
2391
|
-
? {
|
|
2392
|
-
unknownGuides: unknown,
|
|
2393
|
-
availableGuides: [...guideCatalog.keys()],
|
|
2394
|
-
}
|
|
2395
|
-
: {}),
|
|
2396
|
-
}),
|
|
2397
|
-
stderr: '',
|
|
2398
|
-
};
|
|
2399
|
-
} else if (queries.length > 0 && !instructionLookupUnavailable) {
|
|
2400
|
-
try {
|
|
2401
|
-
result = await executeCli(
|
|
2402
|
-
['docs', ...queries],
|
|
2403
|
-
{ timeoutMs: 30_000 },
|
|
2404
|
-
);
|
|
2405
|
-
} catch (error) {
|
|
2406
|
-
instructionFallbacks += 1;
|
|
2407
|
-
instructionLookupUnavailable = true;
|
|
2408
|
-
trace?.warn?.('framer_agent_docs_fallback', {
|
|
2409
|
-
code: error?.code,
|
|
2410
|
-
message: error?.message,
|
|
2411
|
-
});
|
|
2412
|
-
reportActivity({
|
|
2413
|
-
kind: 'status_update',
|
|
2414
|
-
source: 'framer-agent',
|
|
2415
|
-
message:
|
|
2416
|
-
'Targeted Framer documentation is unavailable, so I am continuing with the built-in harness guidance.',
|
|
2417
|
-
});
|
|
2418
|
-
result = builtInInstructions(true);
|
|
2419
|
-
}
|
|
2420
|
-
} else {
|
|
2421
|
-
if (queries.length > 0) instructionFallbacks += 1;
|
|
2422
|
-
result = builtInInstructions(queries.length > 0);
|
|
2423
|
-
}
|
|
2424
|
-
} else if (name === 'framer_read_website') {
|
|
2425
|
-
if (typeof readWebsite !== 'function') {
|
|
2426
|
-
throw toolError(
|
|
2427
|
-
'FRAMER_WEBSITE_READ_UNAVAILABLE',
|
|
2428
|
-
'Reading websites is not available in this run.',
|
|
2429
|
-
);
|
|
2430
|
-
}
|
|
2431
|
-
const website = await readWebsite({
|
|
2432
|
-
url: String(rawArguments.url || '').trim(),
|
|
2433
|
-
screenshotTop: Number.isInteger(rawArguments.screenshotTop)
|
|
2434
|
-
? rawArguments.screenshotTop
|
|
2435
|
-
: undefined,
|
|
2436
|
-
});
|
|
2437
|
-
modelImages = (Array.isArray(website?.images) ? website.images : [])
|
|
2438
|
-
.filter((image) => typeof image?.data === 'string' && image.data)
|
|
2439
|
-
.map((image) => ({ url: null, mimeType: image.mimeType || 'image/jpeg', data: image.data }));
|
|
2440
|
-
result = { stdout: JSON.stringify(website?.result ?? {}), stderr: '' };
|
|
2441
|
-
} else if (name === 'framer_context') {
|
|
2442
|
-
result = await execCode(
|
|
2443
|
-
[
|
|
2444
|
-
'const [context, branch] = await Promise.all([',
|
|
2445
|
-
' framer.agent.getContext(),',
|
|
2446
|
-
' framer.agent.getActiveBranch().catch(() => null),',
|
|
2447
|
-
']);',
|
|
2448
|
-
'console.log(JSON.stringify({ context, branch }, null, 2));',
|
|
2449
|
-
].join('\n'),
|
|
2450
|
-
);
|
|
2451
|
-
markInspection();
|
|
2452
|
-
} else if (name === 'framer_plan_task') {
|
|
2453
|
-
if (lastInspectionSequence === 0) {
|
|
2454
|
-
throw toolError(
|
|
2455
|
-
'FRAMER_HARNESS_INSPECTION_REQUIRED',
|
|
2456
|
-
'Inspect the live Framer target before declaring the task and verification plan.',
|
|
2457
|
-
);
|
|
2458
|
-
}
|
|
2459
|
-
const nextPlan = normalizeTaskPlan(rawArguments);
|
|
2460
|
-
taskPlan = {
|
|
2461
|
-
summary: nextPlan.summary,
|
|
2462
|
-
domains: uniqueStrings([
|
|
2463
|
-
...(taskPlan?.domains || []),
|
|
2464
|
-
...nextPlan.domains,
|
|
2465
|
-
]),
|
|
2466
|
-
verification: uniqueStrings([
|
|
2467
|
-
...(taskPlan?.verification || []),
|
|
2468
|
-
...nextPlan.verification,
|
|
2469
|
-
]),
|
|
2470
|
-
...mergeBehaviorPlans(taskPlan, nextPlan),
|
|
2471
|
-
};
|
|
2472
|
-
result = {
|
|
2473
|
-
stdout: JSON.stringify({
|
|
2474
|
-
plan: taskPlanSnapshot(),
|
|
2475
|
-
beforeFinishing: `After your final change, and before returning, produce: ${requiredChecks().map(evidenceRequirement).join('; ')}.`,
|
|
2476
|
-
}),
|
|
2477
|
-
stderr: '',
|
|
2478
|
-
};
|
|
2479
|
-
} else if (name === 'framer_verify_interactions') {
|
|
2480
|
-
if (!taskPlan?.behaviors?.length) {
|
|
2481
|
-
throw toolError(
|
|
2482
|
-
'FRAMER_BEHAVIOR_CONTRACT_REQUIRED',
|
|
2483
|
-
'Declare the behaviors to confirm in framer_plan_task before verifying them.',
|
|
2484
|
-
);
|
|
2485
|
-
}
|
|
2486
|
-
const checks = normalizeBehaviorEvidence(rawArguments.checks, taskPlan);
|
|
2487
|
-
const pagePath = normalizePagePath(rawArguments.pagePath);
|
|
2488
|
-
const liveResult = await execCode(
|
|
2489
|
-
[
|
|
2490
|
-
`const requestedIds = ${JSON.stringify(evidenceNodeIds(checks))};`,
|
|
2491
|
-
'const nodes = await Promise.all(requestedIds.map(async (id) => ({',
|
|
2492
|
-
' id,',
|
|
2493
|
-
' node: await framer.agent.serialize({ id, depth: 1 },',
|
|
2494
|
-
` { pagePath: ${JSON.stringify(pagePath)} }).catch(() => null),`,
|
|
2495
|
-
'})));',
|
|
2496
|
-
'console.log(JSON.stringify({ nodes }, null, 2));',
|
|
2497
|
-
].join('\n'),
|
|
2498
|
-
);
|
|
2499
|
-
const parsed = parseStructuredOutput(liveResult.stdout);
|
|
2500
|
-
const nodes = new Map(
|
|
2501
|
-
(Array.isArray(parsed?.nodes) ? parsed.nodes : [])
|
|
2502
|
-
.filter((entry) => isRecord(entry) && typeof entry.id === 'string' && entry.node)
|
|
2503
|
-
.map((entry) => [entry.id, entry.node]),
|
|
2504
|
-
);
|
|
2505
|
-
const results = verifyBehaviorChecks({ checks, nodes });
|
|
2506
|
-
// Live link values read here are link evidence too, so the model does
|
|
2507
|
-
// not spend another turn re-reading them under verifies:["links"].
|
|
2508
|
-
const linkChecks = results.filter((check) => /^link(\.|$)/.test(check.property));
|
|
2509
|
-
markInspection({
|
|
2510
|
-
verifies: linkChecks.length > 0
|
|
2511
|
-
&& linkChecks.every((check) => check.verdict === 'verified')
|
|
2512
|
-
? ['links']
|
|
2513
|
-
: [],
|
|
2514
|
-
});
|
|
2515
|
-
interactionVerificationCalls += 1;
|
|
2516
|
-
interactionVerifiedAt = new Date().toISOString();
|
|
2517
|
-
for (const checkResult of results) {
|
|
2518
|
-
interactionCheckResults.set(
|
|
2519
|
-
`${checkResult.behaviorId}\n${checkResult.nodeId}\n${checkResult.property}`,
|
|
2520
|
-
{ sequence: operationSequence, result: checkResult },
|
|
2521
|
-
);
|
|
2522
|
-
}
|
|
2523
|
-
const summary = interactionSummary();
|
|
2524
|
-
result = {
|
|
2525
|
-
stdout: JSON.stringify({
|
|
2526
|
-
status: summary.status,
|
|
2527
|
-
checks: results,
|
|
2528
|
-
behaviors: summary.behaviors,
|
|
2529
|
-
}),
|
|
2530
|
-
stderr: liveResult.stderr,
|
|
2531
|
-
};
|
|
2532
|
-
} else if (name === 'framer_read_project') {
|
|
2533
|
-
const queries = Array.isArray(rawArguments.queries)
|
|
2534
|
-
? rawArguments.queries.slice(0, 50)
|
|
2535
|
-
: [];
|
|
2536
|
-
if (!queries.length || queries.some((query) => !isRecord(query))) {
|
|
2537
|
-
throw toolError(
|
|
2538
|
-
'FRAMER_AGENT_QUERY_INVALID',
|
|
2539
|
-
'framer_read_project requires one or more query objects.',
|
|
2540
|
-
);
|
|
2541
|
-
}
|
|
2542
|
-
const pagePath = normalizePagePath(rawArguments.pagePath);
|
|
2543
|
-
result = await readProjectWithFontFallback(queries, pagePath);
|
|
2544
|
-
const requestedScreenshots = screenshotQueries(rawArguments);
|
|
2545
|
-
screenshotRequestCount = requestedScreenshots.length;
|
|
2546
|
-
modelImages = requestedScreenshots.length
|
|
2547
|
-
? await deliverScreenshots(
|
|
2548
|
-
requestedScreenshots,
|
|
2549
|
-
parseStructuredOutput(result.stdout),
|
|
2550
|
-
)
|
|
2551
|
-
: [];
|
|
2552
|
-
markInspection({
|
|
2553
|
-
visual: modelImages.length > 0,
|
|
2554
|
-
visualAttempted: requestedScreenshots.length > 0,
|
|
2555
|
-
verifies: rawArguments.verifies,
|
|
2556
|
-
});
|
|
2557
|
-
} else if (name === 'framer_query_images') {
|
|
2558
|
-
const query = String(rawArguments.query || '').trim();
|
|
2559
|
-
if (query.length < 2) {
|
|
2560
|
-
throw toolError(
|
|
2561
|
-
'FRAMER_AGENT_IMAGE_QUERY_INVALID',
|
|
2562
|
-
'A specific image-search query is required.',
|
|
2563
|
-
);
|
|
2564
|
-
}
|
|
2565
|
-
const count = Math.max(
|
|
2566
|
-
1,
|
|
2567
|
-
Math.min(8, Math.floor(Number(rawArguments.count) || 4)),
|
|
2568
|
-
);
|
|
2569
|
-
const orientation = ['landscape', 'portrait', 'squarish']
|
|
2570
|
-
.includes(rawArguments.orientation)
|
|
2571
|
-
? rawArguments.orientation
|
|
2572
|
-
: undefined;
|
|
2573
|
-
result = await execCode(
|
|
2574
|
-
[
|
|
2575
|
-
'const result = await framer.agent.queryImages({',
|
|
2576
|
-
' source: "unsplash",',
|
|
2577
|
-
` query: ${JSON.stringify(query)},`,
|
|
2578
|
-
` count: ${count},`,
|
|
2579
|
-
...(orientation
|
|
2580
|
-
? [` orientation: ${JSON.stringify(orientation)},`]
|
|
2581
|
-
: []),
|
|
2582
|
-
'});',
|
|
2583
|
-
'console.log(JSON.stringify(result, null, 2));',
|
|
2584
|
-
].join('\n'),
|
|
2585
|
-
{ timeoutMs: 30_000 },
|
|
2586
|
-
);
|
|
2587
|
-
imageSearchCalls += 1;
|
|
2588
|
-
} else if (name === 'framer_upload_attachment') {
|
|
2589
|
-
result = await uploadAttachment(String(rawArguments.attachmentId || ''));
|
|
2590
|
-
} else if (name === 'framer_add_svg') {
|
|
2591
|
-
const svg = normalizeSvgMarkup(rawArguments.svg);
|
|
2592
|
-
const parentId = String(rawArguments.parentId || '').trim();
|
|
2593
|
-
if (!parentId) {
|
|
2594
|
-
throw toolError(
|
|
2595
|
-
'FRAMER_SVG_PARENT_REQUIRED',
|
|
2596
|
-
'framer_add_svg needs the id of the parent node.',
|
|
2597
|
-
);
|
|
2598
|
-
}
|
|
2599
|
-
const index = Number.isInteger(rawArguments.index) && rawArguments.index >= 0
|
|
2600
|
-
? rawArguments.index
|
|
2601
|
-
: null;
|
|
2602
|
-
const svgName = String(rawArguments.name || '').trim().slice(0, 120) || 'SVG';
|
|
2603
|
-
const attributes = normalizeSvgAttributes(rawArguments.attributes);
|
|
2604
|
-
requireTaskPlanForMutation();
|
|
2605
|
-
// addSVG has no parent option and replaces a selected SVG, so clear
|
|
2606
|
-
// the selection, find the one new SVG node, then move it into place.
|
|
2607
|
-
result = await execCode(
|
|
2608
|
-
[
|
|
2609
|
-
`const svg = ${JSON.stringify(svg)};`,
|
|
2610
|
-
`const parentId = ${JSON.stringify(parentId)};`,
|
|
2611
|
-
`const index = ${JSON.stringify(index)};`,
|
|
2612
|
-
`const attributes = ${JSON.stringify({ name: svgName, position: 'relative', ...attributes })};`,
|
|
2613
|
-
'const fail = (code, message) => console.log(JSON.stringify({ ok: false, error: { code, message } }));',
|
|
2614
|
-
'const parent = await framer.getNode(parentId);',
|
|
2615
|
-
'if (!parent) {',
|
|
2616
|
-
' fail("FRAMER_SVG_PARENT_NOT_FOUND", `No node exists with id "${parentId}".`);',
|
|
2617
|
-
'} else {',
|
|
2618
|
-
' const previousIds = new Set((await framer.getNodesWithType("SVGNode")).map((node) => node.id));',
|
|
2619
|
-
' try { await framer.setSelection([]); } catch {}',
|
|
2620
|
-
' await framer.addSVG({ svg, name: attributes.name });',
|
|
2621
|
-
' let node = null;',
|
|
2622
|
-
' for (let attempt = 0; attempt < 6 && !node; attempt += 1) {',
|
|
2623
|
-
' if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 150));',
|
|
2624
|
-
' const inserted = (await framer.getNodesWithType("SVGNode")).filter((candidate) => !previousIds.has(candidate.id));',
|
|
2625
|
-
' if (inserted.length === 1) node = inserted[0];',
|
|
2626
|
-
' }',
|
|
2627
|
-
' if (!node) {',
|
|
2628
|
-
' fail("FRAMER_SVG_NOT_INSERTED", "Framer did not expose exactly one new SVG node after inserting the markup.");',
|
|
2629
|
-
' } else {',
|
|
2630
|
-
' try {',
|
|
2631
|
-
' await framer.setParent(node.id, parentId, index ?? undefined);',
|
|
2632
|
-
' await node.setAttributes(attributes);',
|
|
2633
|
-
' const placedParent = await node.getParent();',
|
|
2634
|
-
' if (placedParent?.id !== parentId) throw new Error("The SVG did not land inside the requested parent.");',
|
|
2635
|
-
' const placed = await framer.getNode(node.id);',
|
|
2636
|
-
' console.log(JSON.stringify({',
|
|
2637
|
-
' ok: true,',
|
|
2638
|
-
' node: {',
|
|
2639
|
-
' id: node.id,',
|
|
2640
|
-
' name: placed?.name ?? attributes.name,',
|
|
2641
|
-
' parentId,',
|
|
2642
|
-
' position: placed?.position ?? null,',
|
|
2643
|
-
' width: placed?.width ?? null,',
|
|
2644
|
-
' height: placed?.height ?? null,',
|
|
2645
|
-
' },',
|
|
2646
|
-
' }));',
|
|
2647
|
-
' } catch (error) {',
|
|
2648
|
-
' await framer.removeNodes([node.id]).catch(() => undefined);',
|
|
2649
|
-
' fail("FRAMER_SVG_PLACEMENT_FAILED", String(error?.message || error));',
|
|
2650
|
-
' }',
|
|
2651
|
-
' }',
|
|
2652
|
-
'}',
|
|
2653
|
-
].join('\n'),
|
|
2654
|
-
);
|
|
2655
|
-
const parsed = parseStructuredOutput(result.stdout);
|
|
2656
|
-
if (!isRecord(parsed) || parsed.ok !== true) {
|
|
2657
|
-
throw structuredResultError(
|
|
2658
|
-
parsed,
|
|
2659
|
-
'FRAMER_SVG_PLACEMENT_FAILED',
|
|
2660
|
-
'Dexter could not place the SVG in the Framer project.',
|
|
2661
|
-
);
|
|
2662
|
-
}
|
|
2663
|
-
mutationCalls += 1;
|
|
2664
|
-
markMutation();
|
|
2665
|
-
mutationReceipts.push({
|
|
2666
|
-
sequence: operationSequence,
|
|
2667
|
-
kind: 'framer_add_svg',
|
|
2668
|
-
nodeId: parsed.node?.id ?? null,
|
|
2669
|
-
parentId,
|
|
2670
|
-
svgHash: crypto.createHash('sha256').update(svg).digest('hex'),
|
|
2671
|
-
});
|
|
2672
|
-
} else if (name === 'framer_read_code_file') {
|
|
2673
|
-
const filePath = normalizeCodeFilePath(rawArguments.path);
|
|
2674
|
-
result = await execCode(
|
|
2675
|
-
[
|
|
2676
|
-
`const requestedPath = ${JSON.stringify(filePath)};`,
|
|
2677
|
-
'try {',
|
|
2678
|
-
' const file = await framer.getCodeFile(requestedPath);',
|
|
2679
|
-
' if (!file) {',
|
|
2680
|
-
' console.log(JSON.stringify({',
|
|
2681
|
-
' ok: false,',
|
|
2682
|
-
' error: {',
|
|
2683
|
-
' code: "FRAMER_CODE_FILE_NOT_FOUND",',
|
|
2684
|
-
' message: `No Framer code file exists at "${requestedPath}".`,',
|
|
2685
|
-
' },',
|
|
2686
|
-
' }));',
|
|
2687
|
-
' } else {',
|
|
2688
|
-
' const content = typeof file.content === "string" ? file.content : "";',
|
|
2689
|
-
` const maximumContentChars = ${MAX_CODE_FILE_CONTENT_CHARS};`,
|
|
2690
|
-
' if (content.length > maximumContentChars) {',
|
|
2691
|
-
' console.log(JSON.stringify({',
|
|
2692
|
-
' ok: false,',
|
|
2693
|
-
' error: {',
|
|
2694
|
-
' code: "FRAMER_CODE_FILE_TOO_LARGE",',
|
|
2695
|
-
' message: `The Framer code file contains ${content.length} characters; Dexter supports complete code-file reads up to ${maximumContentChars} characters.`,',
|
|
2696
|
-
' },',
|
|
2697
|
-
' }));',
|
|
2698
|
-
' } else {',
|
|
2699
|
-
' console.log(JSON.stringify({',
|
|
2700
|
-
' ok: true,',
|
|
2701
|
-
' file: {',
|
|
2702
|
-
' id: file.id,',
|
|
2703
|
-
' name: file.name,',
|
|
2704
|
-
' path: file.path,',
|
|
2705
|
-
' versionId: file.versionId,',
|
|
2706
|
-
' content,',
|
|
2707
|
-
' exports: file.exports,',
|
|
2708
|
-
' },',
|
|
2709
|
-
' }));',
|
|
2710
|
-
' }',
|
|
2711
|
-
' }',
|
|
2712
|
-
'} catch (error) {',
|
|
2713
|
-
' console.log(JSON.stringify({',
|
|
2714
|
-
' ok: false,',
|
|
2715
|
-
' error: {',
|
|
2716
|
-
' code: "FRAMER_CODE_FILE_READ_FAILED",',
|
|
2717
|
-
' message: String(error?.message || error),',
|
|
2718
|
-
' },',
|
|
2719
|
-
' }));',
|
|
2720
|
-
'}',
|
|
2721
|
-
].join('\n'),
|
|
2722
|
-
);
|
|
2723
|
-
const parsed = parseStructuredOutput(result.stdout);
|
|
2724
|
-
if (!isRecord(parsed) || parsed.ok !== true) {
|
|
2725
|
-
throw structuredResultError(
|
|
2726
|
-
parsed,
|
|
2727
|
-
'FRAMER_CODE_FILE_READ_FAILED',
|
|
2728
|
-
`Dexter could not read ${filePath}.`,
|
|
2729
|
-
);
|
|
2730
|
-
}
|
|
2731
|
-
const content =
|
|
2732
|
-
typeof parsed.file?.content === 'string'
|
|
2733
|
-
? parsed.file.content
|
|
2734
|
-
: '';
|
|
2735
|
-
result = {
|
|
2736
|
-
...result,
|
|
2737
|
-
stdout: JSON.stringify({
|
|
2738
|
-
...parsed,
|
|
2739
|
-
file: {
|
|
2740
|
-
...parsed.file,
|
|
2741
|
-
content,
|
|
2742
|
-
complete: true,
|
|
2743
|
-
contentChars: content.length,
|
|
2744
|
-
contentBytes: Buffer.byteLength(content, 'utf8'),
|
|
2745
|
-
lineCount:
|
|
2746
|
-
content.length === 0
|
|
2747
|
-
? 0
|
|
2748
|
-
: content.split(/\r\n|\r|\n/).length,
|
|
2749
|
-
sourceHash: crypto
|
|
2750
|
-
.createHash('sha256')
|
|
2751
|
-
.update(content)
|
|
2752
|
-
.digest('hex'),
|
|
2753
|
-
},
|
|
2754
|
-
}),
|
|
2755
|
-
};
|
|
2756
|
-
markInspection();
|
|
2757
|
-
} else if (name === 'framer_update_code_file') {
|
|
2758
|
-
const filePath = normalizeCodeFilePath(rawArguments.path);
|
|
2759
|
-
const expectedVersionId =
|
|
2760
|
-
String(rawArguments.expectedVersionId || '').trim();
|
|
2761
|
-
const content =
|
|
2762
|
-
typeof rawArguments.content === 'string'
|
|
2763
|
-
? rawArguments.content
|
|
2764
|
-
: '';
|
|
2765
|
-
if (!expectedVersionId) {
|
|
2766
|
-
throw toolError(
|
|
2767
|
-
'FRAMER_CODE_FILE_VERSION_REQUIRED',
|
|
2768
|
-
'Read the Framer code file first and provide its current version ID.',
|
|
2769
|
-
);
|
|
2770
|
-
}
|
|
2771
|
-
requireTaskPlanForMutation();
|
|
2772
|
-
result = await execCode(
|
|
2773
|
-
[
|
|
2774
|
-
`const requestedPath = ${JSON.stringify(filePath)};`,
|
|
2775
|
-
`const expectedVersionId = ${JSON.stringify(expectedVersionId)};`,
|
|
2776
|
-
`const nextContent = ${JSON.stringify(content)};`,
|
|
2777
|
-
'try {',
|
|
2778
|
-
' const file = await framer.getCodeFile(requestedPath);',
|
|
2779
|
-
' if (!file) {',
|
|
2780
|
-
' console.log(JSON.stringify({',
|
|
2781
|
-
' ok: false,',
|
|
2782
|
-
' error: {',
|
|
2783
|
-
' code: "FRAMER_CODE_FILE_NOT_FOUND",',
|
|
2784
|
-
' message: `No Framer code file exists at "${requestedPath}".`,',
|
|
2785
|
-
' },',
|
|
2786
|
-
' }));',
|
|
2787
|
-
' } else if (file.versionId !== expectedVersionId) {',
|
|
2788
|
-
' console.log(JSON.stringify({',
|
|
2789
|
-
' ok: false,',
|
|
2790
|
-
' error: {',
|
|
2791
|
-
' code: "FRAMER_CODE_FILE_VERSION_CONFLICT",',
|
|
2792
|
-
' message: `The Framer code file changed after it was read. Read "${requestedPath}" again before updating it.`,',
|
|
2793
|
-
' },',
|
|
2794
|
-
' actualVersionId: file.versionId,',
|
|
2795
|
-
' }));',
|
|
2796
|
-
' } else if (typeof file.setFileContent !== "function") {',
|
|
2797
|
-
' console.log(JSON.stringify({',
|
|
2798
|
-
' ok: false,',
|
|
2799
|
-
' error: {',
|
|
2800
|
-
' code: "FRAMER_CODE_FILE_UPDATE_UNAVAILABLE",',
|
|
2801
|
-
' message: "This Framer session does not expose CodeFile.setFileContent.",',
|
|
2802
|
-
' },',
|
|
2803
|
-
' }));',
|
|
2804
|
-
' } else if (typeof framer.isAllowedTo === "function" && !framer.isAllowedTo("CodeFile.setFileContent")) {',
|
|
2805
|
-
' console.log(JSON.stringify({',
|
|
2806
|
-
' ok: false,',
|
|
2807
|
-
' error: {',
|
|
2808
|
-
' code: "FRAMER_CODE_FILE_WRITE_NOT_ALLOWED",',
|
|
2809
|
-
' message: "Framer has not granted this session permission to update code files.",',
|
|
2810
|
-
' },',
|
|
2811
|
-
' }));',
|
|
2812
|
-
' } else {',
|
|
2813
|
-
' const updated = await file.setFileContent(nextContent);',
|
|
2814
|
-
' let saved = null;',
|
|
2815
|
-
' for (let attempt = 0; attempt < 3; attempt += 1) {',
|
|
2816
|
-
' saved = await framer.getCodeFile(updated?.id || file.id);',
|
|
2817
|
-
' if (saved?.content === nextContent) break;',
|
|
2818
|
-
' if (attempt < 2) {',
|
|
2819
|
-
' await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)));',
|
|
2820
|
-
' }',
|
|
2821
|
-
' }',
|
|
2822
|
-
' if (!saved || saved.content !== nextContent) {',
|
|
2823
|
-
' console.log(JSON.stringify({',
|
|
2824
|
-
' ok: false,',
|
|
2825
|
-
' error: {',
|
|
2826
|
-
' code: "FRAMER_CODE_FILE_READBACK_MISMATCH",',
|
|
2827
|
-
' message: "Framer did not return the expected source after saving the code file.",',
|
|
2828
|
-
' },',
|
|
2829
|
-
' }));',
|
|
2830
|
-
' } else {',
|
|
2831
|
-
' let diagnostics = [];',
|
|
2832
|
-
' let typecheckError = null;',
|
|
2833
|
-
' try {',
|
|
2834
|
-
' diagnostics = typeof saved.typecheck === "function"',
|
|
2835
|
-
' ? await saved.typecheck()',
|
|
2836
|
-
' : await framer.typecheckCode(saved.path, saved.content);',
|
|
2837
|
-
' } catch (error) {',
|
|
2838
|
-
' typecheckError = String(error?.message || error);',
|
|
2839
|
-
' }',
|
|
2840
|
-
' const normalizedDiagnostics = Array.isArray(diagnostics)',
|
|
2841
|
-
' ? diagnostics.slice(0, 40).map((diagnostic) => ({',
|
|
2842
|
-
' message: diagnostic?.message,',
|
|
2843
|
-
' code: diagnostic?.code,',
|
|
2844
|
-
' category: diagnostic?.category,',
|
|
2845
|
-
' fileName: diagnostic?.fileName,',
|
|
2846
|
-
' span: diagnostic?.span,',
|
|
2847
|
-
' }))',
|
|
2848
|
-
' : [];',
|
|
2849
|
-
' const hasErrors = normalizedDiagnostics.some((diagnostic) =>',
|
|
2850
|
-
' diagnostic.category === 1',
|
|
2851
|
-
' || String(diagnostic.category || "").toLowerCase() === "error"',
|
|
2852
|
-
' );',
|
|
2853
|
-
' console.log(JSON.stringify({',
|
|
2854
|
-
' ok: true,',
|
|
2855
|
-
' file: {',
|
|
2856
|
-
' id: saved.id,',
|
|
2857
|
-
' name: saved.name,',
|
|
2858
|
-
' path: saved.path,',
|
|
2859
|
-
' versionId: saved.versionId,',
|
|
2860
|
-
' },',
|
|
2861
|
-
' previousVersionId: expectedVersionId,',
|
|
2862
|
-
' readbackMatches: true,',
|
|
2863
|
-
' typecheck: {',
|
|
2864
|
-
' passed: !typecheckError && !hasErrors,',
|
|
2865
|
-
' error: typecheckError,',
|
|
2866
|
-
' diagnostics: normalizedDiagnostics,',
|
|
2867
|
-
' },',
|
|
2868
|
-
' }));',
|
|
2869
|
-
' }',
|
|
2870
|
-
' }',
|
|
2871
|
-
'} catch (error) {',
|
|
2872
|
-
' console.log(JSON.stringify({',
|
|
2873
|
-
' ok: false,',
|
|
2874
|
-
' error: {',
|
|
2875
|
-
' code: "FRAMER_CODE_FILE_UPDATE_FAILED",',
|
|
2876
|
-
' message: String(error?.message || error),',
|
|
2877
|
-
' },',
|
|
2878
|
-
' }));',
|
|
2879
|
-
'}',
|
|
2880
|
-
].join('\n'),
|
|
2881
|
-
);
|
|
2882
|
-
const parsed = parseStructuredOutput(result.stdout);
|
|
2883
|
-
if (!isRecord(parsed) || parsed.ok !== true) {
|
|
2884
|
-
throw structuredResultError(
|
|
2885
|
-
parsed,
|
|
2886
|
-
'FRAMER_CODE_FILE_UPDATE_FAILED',
|
|
2887
|
-
`Dexter could not update ${filePath}.`,
|
|
2888
|
-
);
|
|
2889
|
-
}
|
|
2890
|
-
mutationCalls += 1;
|
|
2891
|
-
markMutation({ code: true });
|
|
2892
|
-
const mutationSequence = operationSequence;
|
|
2893
|
-
markInspection({
|
|
2894
|
-
verifies: parsed.typecheck?.passed === true ? ['code'] : [],
|
|
2895
|
-
});
|
|
2896
|
-
const contentHash = crypto
|
|
2897
|
-
.createHash('sha256')
|
|
2898
|
-
.update(content)
|
|
2899
|
-
.digest('hex');
|
|
2900
|
-
mutationReceipts.push({
|
|
2901
|
-
sequence: mutationSequence,
|
|
2902
|
-
kind: 'framer_update_code_file',
|
|
2903
|
-
path: filePath,
|
|
2904
|
-
previousVersionId: expectedVersionId,
|
|
2905
|
-
versionId:
|
|
2906
|
-
typeof parsed.file?.versionId === 'string'
|
|
2907
|
-
? parsed.file.versionId
|
|
2908
|
-
: null,
|
|
2909
|
-
contentHash,
|
|
2910
|
-
readbackMatches: parsed.readbackMatches === true,
|
|
2911
|
-
typecheckPassed: parsed.typecheck?.passed === true,
|
|
2912
|
-
});
|
|
2913
|
-
result = {
|
|
2914
|
-
...result,
|
|
2915
|
-
stdout: JSON.stringify({
|
|
2916
|
-
...parsed,
|
|
2917
|
-
contentHash,
|
|
2918
|
-
}),
|
|
2919
|
-
};
|
|
2920
|
-
} else if (name === 'framer_apply_changes') {
|
|
2921
|
-
const changes = String(rawArguments.changes || '').trim();
|
|
2922
|
-
if (!changes) {
|
|
2923
|
-
throw toolError(
|
|
2924
|
-
'FRAMER_AGENT_CHANGES_REQUIRED',
|
|
2925
|
-
'Framer Agent changes are required.',
|
|
2926
|
-
);
|
|
2927
|
-
}
|
|
2928
|
-
requireTaskPlanForMutation();
|
|
2929
|
-
const pagePath = normalizePagePath(rawArguments.pagePath);
|
|
2930
|
-
result = await execCode(
|
|
2931
|
-
[
|
|
2932
|
-
`const result = await framer.agent.applyChanges(${JSON.stringify(changes)}, { pagePath: ${JSON.stringify(pagePath)} });`,
|
|
2933
|
-
'console.log(JSON.stringify(result ?? { ok: true }, null, 2));',
|
|
2934
|
-
].join('\n'),
|
|
2935
|
-
);
|
|
2936
|
-
mutationCalls += 1;
|
|
2937
|
-
applyChangesCalls += 1;
|
|
2938
|
-
markMutation();
|
|
2939
|
-
const parsedResult = parseStructuredOutput(result.stdout);
|
|
2940
|
-
mutationReceipts.push({
|
|
2941
|
-
sequence: operationSequence,
|
|
2942
|
-
kind: 'framer_apply_changes',
|
|
2943
|
-
pagePath,
|
|
2944
|
-
...summarizeFramerChanges(changes),
|
|
2945
|
-
renamedIds: canonicalIdMappings(parsedResult),
|
|
2946
|
-
});
|
|
2947
|
-
// A screenshot taken here saves the model a whole turn re-reading
|
|
2948
|
-
// its context just to look at the change.
|
|
2949
|
-
const screenshotIds = Array.isArray(rawArguments.screenshotNodeIds)
|
|
2950
|
-
? uniqueStrings(rawArguments.screenshotNodeIds, MAX_SCREENSHOTS_PER_CALL)
|
|
2951
|
-
: [];
|
|
2952
|
-
if (screenshotIds.length > 0) {
|
|
2953
|
-
const queries = screenshotIds.map((id) => ({ type: 'screenshot', id }));
|
|
2954
|
-
modelImages = await readScreenshots(queries, pagePath);
|
|
2955
|
-
screenshotRequestCount = queries.length;
|
|
2956
|
-
markInspection({
|
|
2957
|
-
visual: modelImages.length > 0,
|
|
2958
|
-
visualAttempted: true,
|
|
2959
|
-
});
|
|
2960
|
-
}
|
|
2961
|
-
} else if (name === 'framer_read') {
|
|
2962
|
-
result = await execCode(
|
|
2963
|
-
validateCode(rawArguments.code, {
|
|
2964
|
-
write: false,
|
|
2965
|
-
allowPublishing,
|
|
2966
|
-
}),
|
|
2967
|
-
);
|
|
2968
|
-
markInspection({ verifies: rawArguments.verifies });
|
|
2969
|
-
} else if (name === 'framer_write') {
|
|
2970
|
-
if (CODE_FILE_SOURCE_WRITE_PATTERN.test(String(rawArguments.code || ''))) {
|
|
2971
|
-
throw toolError(
|
|
2972
|
-
'FRAMER_CODE_FILE_TOOL_REQUIRED',
|
|
2973
|
-
'Use framer_read_code_file followed by framer_update_code_file to edit an existing Framer code file.',
|
|
2974
|
-
);
|
|
2975
|
-
}
|
|
2976
|
-
const code = validateCode(rawArguments.code, {
|
|
2977
|
-
write: true,
|
|
2978
|
-
allowPublishing,
|
|
2979
|
-
});
|
|
2980
|
-
const reason = String(rawArguments.reason || '').trim();
|
|
2981
|
-
if (reason.length < 10) {
|
|
2982
|
-
throw toolError(
|
|
2983
|
-
'FRAMER_AGENT_WRITE_REASON_REQUIRED',
|
|
2984
|
-
'Explain why this operation cannot be completed with framer_apply_changes before using framer_write.',
|
|
2985
|
-
);
|
|
2986
|
-
}
|
|
2987
|
-
requireTaskPlanForMutation();
|
|
2988
|
-
result = await execCode(code);
|
|
2989
|
-
mutationCalls += 1;
|
|
2990
|
-
lowLevelWriteCalls += 1;
|
|
2991
|
-
markMutation();
|
|
2992
|
-
mutationReceipts.push({
|
|
2993
|
-
sequence: operationSequence,
|
|
2994
|
-
kind: 'framer_write',
|
|
2995
|
-
reason: reason.slice(0, 500),
|
|
2996
|
-
codeHash: crypto.createHash('sha256').update(code).digest('hex'),
|
|
2997
|
-
});
|
|
2998
|
-
} else {
|
|
2999
|
-
throw toolError(
|
|
3000
|
-
'FRAMER_AGENT_TOOL_UNKNOWN',
|
|
3001
|
-
`Unknown Framer Agent tool: ${name}`,
|
|
3002
|
-
);
|
|
3003
|
-
}
|
|
3004
|
-
const durationMs = Date.now() - startedAt;
|
|
3005
|
-
repeatedFailureFingerprint = null;
|
|
3006
|
-
repeatedFailureCount = 0;
|
|
3007
|
-
toolCall.status = 'succeeded';
|
|
3008
|
-
toolCall.durationMs = durationMs;
|
|
3009
|
-
reportActivity({
|
|
3010
|
-
kind: 'tool_active',
|
|
3011
|
-
source: 'framer-agent',
|
|
3012
|
-
name,
|
|
3013
|
-
phase: 'completed',
|
|
3014
|
-
message:
|
|
3015
|
-
mutation
|
|
3016
|
-
? 'Updated the Framer project'
|
|
3017
|
-
: 'Finished inspecting the Framer project',
|
|
3018
|
-
durationMs,
|
|
3019
|
-
});
|
|
3020
|
-
const normalized =
|
|
3021
|
-
name === 'framer_read_code_file'
|
|
3022
|
-
? normalizedCodeFileReadResult(result)
|
|
3023
|
-
: normalizedResult(result);
|
|
3024
|
-
// Result size is what this call adds to every later model turn.
|
|
3025
|
-
toolCall.resultBytes = Buffer.byteLength(JSON.stringify(normalized), 'utf8');
|
|
3026
|
-
if (modelImages.length > 0) toolCall.imageCount = modelImages.length;
|
|
3027
|
-
if (name === 'framer_read_website') {
|
|
3028
|
-
return modelImages.length ? { ...normalized, modelImages } : normalized;
|
|
3029
|
-
}
|
|
3030
|
-
// Naming the evidence still owed after the latest change lets the model
|
|
3031
|
-
// finish it in this session instead of in a resumed repair turn.
|
|
3032
|
-
const evidenceStillDue =
|
|
3033
|
-
taskPlan && mutationCalls > 0 && (category === 'read' || screenshotRequestCount > 0)
|
|
3034
|
-
? missingVerificationChecks()
|
|
3035
|
-
: [];
|
|
3036
|
-
return {
|
|
3037
|
-
...normalized,
|
|
3038
|
-
...(modelImages.length ? { modelImages } : {}),
|
|
3039
|
-
...(screenshotRequestCount > modelImages.length
|
|
3040
|
-
? {
|
|
3041
|
-
screenshotWarning:
|
|
3042
|
-
`${screenshotRequestCount - modelImages.length} screenshot(s) could not be downloaded for you to view. Treat that visual state as unverified.`,
|
|
3043
|
-
}
|
|
3044
|
-
: {}),
|
|
3045
|
-
...(evidenceStillDue.length > 0 ? { evidenceStillDue } : {}),
|
|
3046
|
-
};
|
|
3047
|
-
} catch (error) {
|
|
3048
|
-
if (
|
|
3049
|
-
name === 'framer_write'
|
|
3050
|
-
&& error?.code === 'FRAMER_AGENT_CODE_UNSAFE'
|
|
3051
|
-
) {
|
|
3052
|
-
unsafeWriteFailures += 1;
|
|
3053
|
-
if (
|
|
3054
|
-
!lowLevelWriteDisabled
|
|
3055
|
-
&& unsafeWriteFailures >= MAX_UNSAFE_WRITE_FAILURES
|
|
3056
|
-
) {
|
|
3057
|
-
lowLevelWriteDisabled = true;
|
|
3058
|
-
reportActivity({
|
|
3059
|
-
kind: 'status_update',
|
|
3060
|
-
source: 'framer-agent',
|
|
3061
|
-
message:
|
|
3062
|
-
'Repeated low-level writes were rejected by the safety boundary, so I switched the run to the safer Framer change path.',
|
|
3063
|
-
});
|
|
3064
|
-
}
|
|
3065
|
-
}
|
|
3066
|
-
if (
|
|
3067
|
-
name === 'framer_write'
|
|
3068
|
-
&& error?.code === 'FRAMER_AGENT_COMMAND_FAILED'
|
|
3069
|
-
) {
|
|
3070
|
-
lowLevelWriteFailures += 1;
|
|
3071
|
-
if (
|
|
3072
|
-
!lowLevelWriteDisabled
|
|
3073
|
-
&& lowLevelWriteFailures >= MAX_LOW_LEVEL_WRITE_FAILURES
|
|
3074
|
-
) {
|
|
3075
|
-
lowLevelWriteDisabled = true;
|
|
3076
|
-
reportActivity({
|
|
3077
|
-
kind: 'status_update',
|
|
3078
|
-
source: 'framer-agent',
|
|
3079
|
-
message:
|
|
3080
|
-
'Repeated low-level Framer writes failed, so I switched the run back to semantic Framer changes and focused inspection.',
|
|
3081
|
-
});
|
|
3082
|
-
}
|
|
3083
|
-
}
|
|
3084
|
-
if (repeatedFailureFingerprint === fingerprint) {
|
|
3085
|
-
repeatedFailureCount += 1;
|
|
3086
|
-
} else {
|
|
3087
|
-
repeatedFailureFingerprint = fingerprint;
|
|
3088
|
-
repeatedFailureCount = 1;
|
|
3089
|
-
}
|
|
3090
|
-
toolCall.durationMs = Date.now() - startedAt;
|
|
3091
|
-
toolCall.errorCode = error?.code || 'FRAMER_AGENT_TOOL_FAILED';
|
|
3092
|
-
toolCall.errorMessage = boundedText(
|
|
3093
|
-
String(error?.message || 'Framer Agent tool failed.'),
|
|
3094
|
-
300,
|
|
3095
|
-
);
|
|
3096
|
-
trace?.warn?.('framer_agent_tool_failed', {
|
|
3097
|
-
name,
|
|
3098
|
-
code: error?.code,
|
|
3099
|
-
message: error?.message,
|
|
3100
|
-
});
|
|
3101
|
-
reportActivity({
|
|
3102
|
-
kind: 'tool_active',
|
|
3103
|
-
source: 'framer-agent',
|
|
3104
|
-
name,
|
|
3105
|
-
phase: 'failed',
|
|
3106
|
-
message:
|
|
3107
|
-
mutation
|
|
3108
|
-
? 'The Framer project update failed'
|
|
3109
|
-
: 'The Framer project inspection failed',
|
|
3110
|
-
durationMs: Date.now() - startedAt,
|
|
3111
|
-
});
|
|
3112
|
-
throw error;
|
|
3113
|
-
}
|
|
3114
|
-
}
|
|
3115
|
-
|
|
3116
|
-
function invoke(name, args = {}) {
|
|
3117
|
-
const operation = serial.then(() => invokeUnsafe(name, args));
|
|
3118
|
-
serial = operation.catch(() => undefined);
|
|
3119
|
-
return operation;
|
|
3120
|
-
}
|
|
3121
|
-
|
|
3122
|
-
async function startGateway() {
|
|
3123
|
-
if (gateway) return gateway;
|
|
3124
|
-
const { createServer } = await import('node:http');
|
|
3125
|
-
const token = crypto.randomBytes(32).toString('base64url');
|
|
3126
|
-
server = createServer((request, response) => {
|
|
3127
|
-
const fail = (status, code, message) => {
|
|
3128
|
-
response.writeHead(status, { 'content-type': 'application/json' });
|
|
3129
|
-
response.end(JSON.stringify({ ok: false, error: { code, message } }));
|
|
3130
|
-
};
|
|
3131
|
-
if (request.method !== 'POST' || request.url !== '/tool') {
|
|
3132
|
-
fail(404, 'FRAMER_AGENT_GATEWAY_NOT_FOUND', 'Framer Agent tool endpoint not found.');
|
|
3133
|
-
return;
|
|
3134
|
-
}
|
|
3135
|
-
if (request.headers.authorization !== `Bearer ${token}`) {
|
|
3136
|
-
fail(401, 'FRAMER_AGENT_GATEWAY_UNAUTHORIZED', 'Invalid Framer Agent tool token.');
|
|
3137
|
-
return;
|
|
3138
|
-
}
|
|
3139
|
-
let body = '';
|
|
3140
|
-
request.on('data', (chunk) => {
|
|
3141
|
-
body += chunk.toString('utf8');
|
|
3142
|
-
if (body.length > 512 * 1024) request.destroy();
|
|
3143
|
-
});
|
|
3144
|
-
request.on('end', async () => {
|
|
3145
|
-
try {
|
|
3146
|
-
const payload = JSON.parse(body || '{}');
|
|
3147
|
-
const result = await invoke(
|
|
3148
|
-
String(payload.name || ''),
|
|
3149
|
-
payload.arguments || {},
|
|
3150
|
-
);
|
|
3151
|
-
response.writeHead(200, { 'content-type': 'application/json' });
|
|
3152
|
-
response.end(JSON.stringify({ ok: true, result }));
|
|
3153
|
-
} catch (error) {
|
|
3154
|
-
fail(
|
|
3155
|
-
Number(error?.statusCode) || 400,
|
|
3156
|
-
error?.code || 'FRAMER_AGENT_TOOL_FAILED',
|
|
3157
|
-
error?.message || 'Framer Agent tool failed.',
|
|
3158
|
-
);
|
|
3159
|
-
}
|
|
3160
|
-
});
|
|
3161
|
-
});
|
|
3162
|
-
await new Promise((resolve, reject) => {
|
|
3163
|
-
server.once('error', reject);
|
|
3164
|
-
server.listen(0, '127.0.0.1', resolve);
|
|
3165
|
-
});
|
|
3166
|
-
const address = server.address();
|
|
3167
|
-
if (!address || typeof address !== 'object') {
|
|
3168
|
-
throw new Error('Framer Agent tool gateway did not bind to a local port.');
|
|
3169
|
-
}
|
|
3170
|
-
gateway = {
|
|
3171
|
-
url: `http://127.0.0.1:${address.port}/tool`,
|
|
3172
|
-
token,
|
|
3173
|
-
};
|
|
3174
|
-
return gateway;
|
|
3175
|
-
}
|
|
3176
|
-
|
|
3177
|
-
return {
|
|
3178
|
-
definitions,
|
|
3179
|
-
toolNames: definitions.map((definition) => definition.name),
|
|
3180
|
-
preflight: validateAuthoritativeNodeIds,
|
|
3181
|
-
loadProjectGuidance,
|
|
3182
|
-
configurePromptContext,
|
|
3183
|
-
invoke,
|
|
3184
|
-
startGateway,
|
|
3185
|
-
synchronize: inspectFinalState,
|
|
3186
|
-
inspect: inspectFinalState,
|
|
3187
|
-
finalizeCompletion,
|
|
3188
|
-
verificationRepairPrompt,
|
|
3189
|
-
unblockedFailurePrompt,
|
|
3190
|
-
sourceHash: () => null,
|
|
3191
|
-
hasPendingChanges: () => false,
|
|
3192
|
-
directWorkspace: true,
|
|
3193
|
-
summary: () => ({
|
|
3194
|
-
projectId,
|
|
3195
|
-
projectUrl,
|
|
3196
|
-
unresolvedTargets: unresolvedTargets.map((target) => ({ ...target })),
|
|
3197
|
-
mutationCalls,
|
|
3198
|
-
applyChangesCalls,
|
|
3199
|
-
lowLevelWriteCalls,
|
|
3200
|
-
imageSearchCalls,
|
|
3201
|
-
interactionVerificationCalls,
|
|
3202
|
-
mutationReceipts: mutationReceipts.map((receipt) => ({
|
|
3203
|
-
...receipt,
|
|
3204
|
-
...(Array.isArray(receipt.commands)
|
|
3205
|
-
? {
|
|
3206
|
-
commands: receipt.commands.map((command) => ({
|
|
3207
|
-
...command,
|
|
3208
|
-
...(Array.isArray(command.eventActions)
|
|
3209
|
-
? {
|
|
3210
|
-
eventActions: command.eventActions.map((action) => ({
|
|
3211
|
-
...action,
|
|
3212
|
-
})),
|
|
3213
|
-
}
|
|
3214
|
-
: {}),
|
|
3215
|
-
})),
|
|
3216
|
-
}
|
|
3217
|
-
: {}),
|
|
3218
|
-
...(receipt.renamedIds
|
|
3219
|
-
? { renamedIds: { ...receipt.renamedIds } }
|
|
3220
|
-
: {}),
|
|
3221
|
-
})),
|
|
3222
|
-
readCalls,
|
|
3223
|
-
toolCallCount,
|
|
3224
|
-
toolDurationMs: toolCalls.reduce(
|
|
3225
|
-
(total, call) => total + call.durationMs,
|
|
3226
|
-
0,
|
|
3227
|
-
),
|
|
3228
|
-
toolCalls: toolCalls.map((call) => ({ ...call })),
|
|
3229
|
-
workingPreferences: {
|
|
3230
|
-
focus: workingPreferences.mode,
|
|
3231
|
-
writeScope: workingPreferences.writeScope,
|
|
3232
|
-
preferBatchedMutations:
|
|
3233
|
-
workingPreferences.preferBatchedMutations,
|
|
3234
|
-
avoidRepeatedInspection:
|
|
3235
|
-
workingPreferences.avoidRepeatedInspection,
|
|
3236
|
-
expandScopeOnlyWhenNeeded:
|
|
3237
|
-
workingPreferences.expandScopeOnlyWhenNeeded,
|
|
3238
|
-
},
|
|
3239
|
-
loopProtection: {
|
|
3240
|
-
identicalFailureLimit: 5,
|
|
3241
|
-
blockedCalls: repeatedFailureStops,
|
|
3242
|
-
unsafeWriteFailureLimit: MAX_UNSAFE_WRITE_FAILURES,
|
|
3243
|
-
unsafeWriteFailures,
|
|
3244
|
-
lowLevelWriteFailureLimit: MAX_LOW_LEVEL_WRITE_FAILURES,
|
|
3245
|
-
lowLevelWriteFailures,
|
|
3246
|
-
lowLevelWriteDisabled,
|
|
3247
|
-
lowLevelWriteBlockedCalls,
|
|
3248
|
-
instructionFallbacks,
|
|
3249
|
-
},
|
|
3250
|
-
fontSearchFallbacks,
|
|
3251
|
-
branch: branchChange,
|
|
3252
|
-
lastInspectionAt,
|
|
3253
|
-
lastMutationAt,
|
|
3254
|
-
taskPlan: taskPlanSnapshot(),
|
|
3255
|
-
verification: verification(),
|
|
3256
|
-
framerAgentVersion,
|
|
3257
|
-
framerSetup: {
|
|
3258
|
-
completed: framerSetupCompleted,
|
|
3259
|
-
attempts: framerSetupAttempts,
|
|
3260
|
-
recoveredSession: framerSetupRecoveredSession,
|
|
3261
|
-
},
|
|
3262
|
-
framerGuidanceLoaded: guidanceMetadata.loaded,
|
|
3263
|
-
doctrineLoaded: promptMetadata.doctrineLoaded,
|
|
3264
|
-
guidanceHash: guidanceMetadata.contentHash,
|
|
3265
|
-
projectContextHash: guidanceMetadata.projectContextHash,
|
|
3266
|
-
visualVerificationRequired:
|
|
3267
|
-
verification().visualRequired,
|
|
3268
|
-
visualVerificationCompleted:
|
|
3269
|
-
verification().visualCompleted,
|
|
3270
|
-
guidance: {
|
|
3271
|
-
loaded: guidanceMetadata.loaded,
|
|
3272
|
-
contentHash: guidanceMetadata.contentHash,
|
|
3273
|
-
projectContextHash: guidanceMetadata.projectContextHash,
|
|
3274
|
-
files: [...guidanceMetadata.files],
|
|
3275
|
-
bytes: guidanceMetadata.bytes,
|
|
3276
|
-
guidesRead: [...guidesRead],
|
|
3277
|
-
},
|
|
3278
|
-
doctrine: { ...promptMetadata },
|
|
3279
|
-
}),
|
|
3280
|
-
fatalInfrastructureError: () => null,
|
|
3281
|
-
waitForFatalInfrastructure: () => new Promise(() => undefined),
|
|
3282
|
-
close: async ({ waitForPending = true } = {}) => {
|
|
3283
|
-
closed = true;
|
|
3284
|
-
abortController.abort();
|
|
3285
|
-
if (waitForPending) await serial.catch(() => undefined);
|
|
3286
|
-
if (server) {
|
|
3287
|
-
if (!waitForPending) server.closeAllConnections?.();
|
|
3288
|
-
await new Promise((resolve) => server.close(() => resolve()));
|
|
3289
|
-
server = null;
|
|
3290
|
-
}
|
|
3291
|
-
},
|
|
3292
|
-
};
|
|
3293
|
-
}
|
|
3294
|
-
|
|
3295
|
-
export function __resetFramerAgentToolRuntimeForTests() {
|
|
3296
|
-
sessionsByProject.clear();
|
|
3297
|
-
framerSetupPromise = null;
|
|
3298
|
-
}
|