@gakim-digital/dexter-bridge 0.5.21 → 0.11.1
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 +129 -35
- package/package.json +19 -5
- package/src/agent.js +1575 -334
- package/src/agentOutput.js +209 -0
- package/src/api.js +82 -1
- package/src/cli.js +267 -39
- package/src/config.js +35 -7
- package/src/framerAgentTools.js +2547 -0
- package/src/framerBehaviorContract.js +962 -0
- package/src/harnessMcpServer.js +240 -0
- package/src/harnessTools.js +548 -0
- package/src/logger.js +2 -1
- package/src/nativeSkills.js +295 -0
- package/src/outcomeWorkspace.js +351 -0
- package/src/protocol.js +288 -0
- package/src/providers/acp.js +241 -0
- package/src/providers/codexAppServer.js +1059 -157
- package/src/providers/codexStructuredOutput.js +243 -16
- package/src/providers/directByok.js +197 -0
- package/src/providers/index.js +33 -7
- package/src/providers/openCode.js +607 -0
- package/src/runtimeProfiles.js +284 -0
- package/src/providers/claudeAgentSdk.js +0 -507
|
@@ -0,0 +1,2547 @@
|
|
|
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 {
|
|
7
|
+
BEHAVIOR_ACTIVATIONS,
|
|
8
|
+
BEHAVIOR_LAYOUT_EFFECTS,
|
|
9
|
+
BEHAVIOR_MECHANISMS,
|
|
10
|
+
BEHAVIOR_PRESENTATIONS,
|
|
11
|
+
canonicalIdMappings,
|
|
12
|
+
evidenceNodeIds,
|
|
13
|
+
mergeBehaviorPlans,
|
|
14
|
+
normalizeBehaviorEvidence,
|
|
15
|
+
normalizeBehaviorPlan,
|
|
16
|
+
summarizeFramerChanges,
|
|
17
|
+
verifyBehaviorEvidence,
|
|
18
|
+
} from './framerBehaviorContract.js';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
22
|
+
const MAX_MODEL_RESULT_BYTES = 120 * 1024;
|
|
23
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 5 * 60_000;
|
|
24
|
+
const SESSION_TIMEOUT_MS = 10 * 60_000;
|
|
25
|
+
const MAX_UNSAFE_WRITE_FAILURES = 2;
|
|
26
|
+
const MAX_LOW_LEVEL_WRITE_FAILURES = 3;
|
|
27
|
+
const MAX_GUIDANCE_BYTES = 180 * 1024;
|
|
28
|
+
const MAX_GUIDANCE_FILE_BYTES = 48 * 1024;
|
|
29
|
+
const PROJECT_ID_PATTERN = /^[A-Za-z0-9]{20}$/;
|
|
30
|
+
const PROJECT_SEGMENT_PATTERN =
|
|
31
|
+
/^(?:.+--)?([A-Za-z0-9]+)(?:-[A-Za-z0-9]+)?$/;
|
|
32
|
+
const FRAMER_PROJECT_HOST_PATTERN = /(^|\.)framer(?:local)?\.com$/i;
|
|
33
|
+
const PAGE_PATH_PATTERN = /^\/[^\0\r\n]{0,499}$/;
|
|
34
|
+
const UNSAFE_EXEC_PATTERN =
|
|
35
|
+
/\b(?:process|globalThis|global|require|import\s*\(|child_process|worker_threads|fs|os|net|tls|dgram|http|https|fetch|WebSocket|eval|Function)\b/;
|
|
36
|
+
const MUTATING_EXEC_PATTERN =
|
|
37
|
+
/\.(?:set|add|create|remove|delete|update|insert|move|clone|duplicate|upload|publish|deploy|switch|merge|rename|join|leave|apply)[A-Za-z0-9_]*\s*\(/;
|
|
38
|
+
const TASK_DOMAINS = [
|
|
39
|
+
'visual',
|
|
40
|
+
'content',
|
|
41
|
+
'links',
|
|
42
|
+
'interactions',
|
|
43
|
+
'responsive',
|
|
44
|
+
'data',
|
|
45
|
+
'code',
|
|
46
|
+
'structure',
|
|
47
|
+
];
|
|
48
|
+
const VERIFICATION_CHECKS = [
|
|
49
|
+
'structural',
|
|
50
|
+
'visual',
|
|
51
|
+
'links',
|
|
52
|
+
'interactions',
|
|
53
|
+
'responsive',
|
|
54
|
+
'code',
|
|
55
|
+
'data',
|
|
56
|
+
];
|
|
57
|
+
const GENERIC_VERIFICATION_CHECKS = VERIFICATION_CHECKS.filter(
|
|
58
|
+
(check) => check !== 'interactions',
|
|
59
|
+
);
|
|
60
|
+
const EMPTY_SCHEMA = {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {},
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const FRAMER_AGENT_TOOL_DEFINITIONS = [
|
|
67
|
+
{
|
|
68
|
+
name: 'progress_update',
|
|
69
|
+
description:
|
|
70
|
+
'Tell the user what you are about to do or are doing now in one or two natural first-person sentences. Name the specific page area, component, or behavior and the concrete change; optionally say what you will check next. Avoid generic updates such as "working on the next step", "thinking", "loading", or "making changes". Call this before the first Framer inspection or edit and at meaningful phase changes.',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
message: { type: 'string', minLength: 1, maxLength: 500 },
|
|
75
|
+
},
|
|
76
|
+
required: ['message'],
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'framer_instructions',
|
|
82
|
+
description:
|
|
83
|
+
'Get concise Framer Agent guidance, or request targeted official API documentation when an exact method or type is unclear. Most runs should rely on the tool descriptions and call this only when needed.',
|
|
84
|
+
inputSchema: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
queries: {
|
|
88
|
+
type: 'array',
|
|
89
|
+
maxItems: 8,
|
|
90
|
+
items: { type: 'string', minLength: 1, maxLength: 120 },
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
additionalProperties: false,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'framer_context',
|
|
98
|
+
description:
|
|
99
|
+
'Read current project context, including pages, components, styles, fonts, CMS collections, and the active branch.',
|
|
100
|
+
inputSchema: EMPTY_SCHEMA,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'framer_read_project',
|
|
104
|
+
description:
|
|
105
|
+
'Run Framer Agent readProject queries against the connected project. Use this for focused canvas inspection and screenshots.',
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {
|
|
109
|
+
queries: {
|
|
110
|
+
type: 'array',
|
|
111
|
+
minItems: 1,
|
|
112
|
+
maxItems: 50,
|
|
113
|
+
items: { type: 'object', additionalProperties: true },
|
|
114
|
+
},
|
|
115
|
+
pagePath: { type: 'string', maxLength: 500, default: '/' },
|
|
116
|
+
verifies: {
|
|
117
|
+
type: 'array',
|
|
118
|
+
uniqueItems: true,
|
|
119
|
+
maxItems: 6,
|
|
120
|
+
items: { type: 'string', enum: GENERIC_VERIFICATION_CHECKS },
|
|
121
|
+
description:
|
|
122
|
+
'Verification evidence this read is intended to establish after mutations. Interaction verification requires framer_verify_interactions.',
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
required: ['queries'],
|
|
126
|
+
additionalProperties: false,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'framer_plan_task',
|
|
131
|
+
description:
|
|
132
|
+
'After inspecting the live Framer target and before the first mutation, declare your semantic understanding, observable behavior requirements, chosen implementation mechanisms, and completion evidence. Dexter does not classify the request or choose mechanisms for you.',
|
|
133
|
+
inputSchema: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: {
|
|
136
|
+
summary: {
|
|
137
|
+
type: 'string',
|
|
138
|
+
minLength: 10,
|
|
139
|
+
maxLength: 1000,
|
|
140
|
+
},
|
|
141
|
+
domains: {
|
|
142
|
+
type: 'array',
|
|
143
|
+
minItems: 1,
|
|
144
|
+
uniqueItems: true,
|
|
145
|
+
items: { type: 'string', enum: TASK_DOMAINS },
|
|
146
|
+
},
|
|
147
|
+
verification: {
|
|
148
|
+
type: 'array',
|
|
149
|
+
minItems: 1,
|
|
150
|
+
uniqueItems: true,
|
|
151
|
+
items: { type: 'string', enum: VERIFICATION_CHECKS },
|
|
152
|
+
},
|
|
153
|
+
behaviors: {
|
|
154
|
+
type: 'array',
|
|
155
|
+
maxItems: 20,
|
|
156
|
+
description:
|
|
157
|
+
'Required for interaction work. Describe observable behavior independently from its implementation.',
|
|
158
|
+
items: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
properties: {
|
|
161
|
+
id: { type: 'string', minLength: 1, maxLength: 120 },
|
|
162
|
+
description: { type: 'string', minLength: 5, maxLength: 500 },
|
|
163
|
+
triggerDescription: {
|
|
164
|
+
type: 'string',
|
|
165
|
+
minLength: 3,
|
|
166
|
+
maxLength: 300,
|
|
167
|
+
},
|
|
168
|
+
activation: {
|
|
169
|
+
type: 'string',
|
|
170
|
+
enum: BEHAVIOR_ACTIVATIONS,
|
|
171
|
+
},
|
|
172
|
+
presentation: {
|
|
173
|
+
type: 'string',
|
|
174
|
+
enum: BEHAVIOR_PRESENTATIONS,
|
|
175
|
+
},
|
|
176
|
+
stateChange: { type: 'boolean' },
|
|
177
|
+
layoutEffect: {
|
|
178
|
+
type: 'string',
|
|
179
|
+
enum: BEHAVIOR_LAYOUT_EFFECTS,
|
|
180
|
+
},
|
|
181
|
+
reversible: { type: 'boolean' },
|
|
182
|
+
repeatable: { type: 'boolean' },
|
|
183
|
+
expectedTargets: {
|
|
184
|
+
type: 'integer',
|
|
185
|
+
minimum: 1,
|
|
186
|
+
maximum: 50,
|
|
187
|
+
},
|
|
188
|
+
expectedContent: {
|
|
189
|
+
type: 'array',
|
|
190
|
+
maxItems: 20,
|
|
191
|
+
items: { type: 'string', minLength: 1, maxLength: 500 },
|
|
192
|
+
},
|
|
193
|
+
initialState: {
|
|
194
|
+
type: 'string',
|
|
195
|
+
minLength: 1,
|
|
196
|
+
maxLength: 300,
|
|
197
|
+
},
|
|
198
|
+
activatedState: {
|
|
199
|
+
type: 'string',
|
|
200
|
+
minLength: 1,
|
|
201
|
+
maxLength: 300,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
required: [
|
|
205
|
+
'id',
|
|
206
|
+
'description',
|
|
207
|
+
'triggerDescription',
|
|
208
|
+
'activation',
|
|
209
|
+
'presentation',
|
|
210
|
+
'stateChange',
|
|
211
|
+
'layoutEffect',
|
|
212
|
+
'reversible',
|
|
213
|
+
'repeatable',
|
|
214
|
+
'expectedTargets',
|
|
215
|
+
'initialState',
|
|
216
|
+
'activatedState',
|
|
217
|
+
],
|
|
218
|
+
additionalProperties: false,
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
mechanisms: {
|
|
222
|
+
type: 'array',
|
|
223
|
+
maxItems: 20,
|
|
224
|
+
description:
|
|
225
|
+
'Required for every declared behavior. Choose the mechanism from live project capabilities and explain why it satisfies the behavior.',
|
|
226
|
+
items: {
|
|
227
|
+
type: 'object',
|
|
228
|
+
properties: {
|
|
229
|
+
behaviorId: { type: 'string', minLength: 1, maxLength: 120 },
|
|
230
|
+
mechanism: {
|
|
231
|
+
type: 'string',
|
|
232
|
+
enum: BEHAVIOR_MECHANISMS,
|
|
233
|
+
},
|
|
234
|
+
rationale: {
|
|
235
|
+
type: 'string',
|
|
236
|
+
minLength: 10,
|
|
237
|
+
maxLength: 800,
|
|
238
|
+
},
|
|
239
|
+
representativeFirst: { type: 'boolean', default: true },
|
|
240
|
+
},
|
|
241
|
+
required: ['behaviorId', 'mechanism', 'rationale'],
|
|
242
|
+
additionalProperties: false,
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
rejectedMechanisms: {
|
|
246
|
+
type: 'array',
|
|
247
|
+
maxItems: 30,
|
|
248
|
+
description:
|
|
249
|
+
'Mechanisms already disproven by user-observed failure or prior verification. Do not silently reuse them.',
|
|
250
|
+
items: {
|
|
251
|
+
type: 'object',
|
|
252
|
+
properties: {
|
|
253
|
+
behaviorId: { type: 'string', minLength: 1, maxLength: 120 },
|
|
254
|
+
behaviorSignature: {
|
|
255
|
+
type: 'string',
|
|
256
|
+
minLength: 1,
|
|
257
|
+
maxLength: 128,
|
|
258
|
+
},
|
|
259
|
+
mechanism: {
|
|
260
|
+
type: 'string',
|
|
261
|
+
enum: BEHAVIOR_MECHANISMS,
|
|
262
|
+
},
|
|
263
|
+
reason: { type: 'string', minLength: 5, maxLength: 800 },
|
|
264
|
+
source: {
|
|
265
|
+
type: 'string',
|
|
266
|
+
enum: ['user_reported_failure', 'verification_failure'],
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
required: ['mechanism', 'reason'],
|
|
270
|
+
additionalProperties: false,
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
required: ['summary', 'domains', 'verification'],
|
|
275
|
+
additionalProperties: false,
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: 'framer_verify_interactions',
|
|
280
|
+
description:
|
|
281
|
+
'Verify the observable behavior contract from the live Framer tree. The runtime derives the required action and invariants from the planned behavior and mechanism; supply only canonical evidence node IDs. This is the only tool that can satisfy interaction verification.',
|
|
282
|
+
inputSchema: {
|
|
283
|
+
type: 'object',
|
|
284
|
+
properties: {
|
|
285
|
+
pagePath: { type: 'string', maxLength: 500, default: '/' },
|
|
286
|
+
checks: {
|
|
287
|
+
type: 'array',
|
|
288
|
+
minItems: 1,
|
|
289
|
+
maxItems: 20,
|
|
290
|
+
items: {
|
|
291
|
+
type: 'object',
|
|
292
|
+
properties: {
|
|
293
|
+
description: {
|
|
294
|
+
type: 'string',
|
|
295
|
+
minLength: 1,
|
|
296
|
+
maxLength: 300,
|
|
297
|
+
},
|
|
298
|
+
behaviorId: {
|
|
299
|
+
type: 'string',
|
|
300
|
+
minLength: 1,
|
|
301
|
+
maxLength: 120,
|
|
302
|
+
},
|
|
303
|
+
triggerNodeId: {
|
|
304
|
+
type: 'string',
|
|
305
|
+
minLength: 1,
|
|
306
|
+
maxLength: 200,
|
|
307
|
+
},
|
|
308
|
+
event: {
|
|
309
|
+
type: 'string',
|
|
310
|
+
minLength: 1,
|
|
311
|
+
maxLength: 80,
|
|
312
|
+
},
|
|
313
|
+
targetNodeId: {
|
|
314
|
+
type: 'string',
|
|
315
|
+
minLength: 1,
|
|
316
|
+
maxLength: 200,
|
|
317
|
+
},
|
|
318
|
+
componentNodeId: {
|
|
319
|
+
type: 'string',
|
|
320
|
+
minLength: 1,
|
|
321
|
+
maxLength: 200,
|
|
322
|
+
},
|
|
323
|
+
instanceNodeIds: {
|
|
324
|
+
type: 'array',
|
|
325
|
+
maxItems: 50,
|
|
326
|
+
uniqueItems: true,
|
|
327
|
+
items: {
|
|
328
|
+
type: 'string',
|
|
329
|
+
minLength: 1,
|
|
330
|
+
maxLength: 200,
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
openVariantNodeId: {
|
|
334
|
+
type: 'string',
|
|
335
|
+
minLength: 1,
|
|
336
|
+
maxLength: 200,
|
|
337
|
+
},
|
|
338
|
+
closedVariantNodeId: {
|
|
339
|
+
type: 'string',
|
|
340
|
+
minLength: 1,
|
|
341
|
+
maxLength: 200,
|
|
342
|
+
},
|
|
343
|
+
openAnswerNodeId: {
|
|
344
|
+
type: 'string',
|
|
345
|
+
minLength: 1,
|
|
346
|
+
maxLength: 200,
|
|
347
|
+
},
|
|
348
|
+
closedAnswerNodeId: {
|
|
349
|
+
type: 'string',
|
|
350
|
+
minLength: 1,
|
|
351
|
+
maxLength: 200,
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
required: [
|
|
355
|
+
'behaviorId',
|
|
356
|
+
'triggerNodeId',
|
|
357
|
+
'event',
|
|
358
|
+
],
|
|
359
|
+
additionalProperties: false,
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
required: ['checks'],
|
|
364
|
+
additionalProperties: false,
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
name: 'framer_query_images',
|
|
369
|
+
description:
|
|
370
|
+
'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.',
|
|
371
|
+
inputSchema: {
|
|
372
|
+
type: 'object',
|
|
373
|
+
properties: {
|
|
374
|
+
query: { type: 'string', minLength: 2, maxLength: 200 },
|
|
375
|
+
count: { type: 'integer', minimum: 1, maximum: 8, default: 4 },
|
|
376
|
+
orientation: {
|
|
377
|
+
type: 'string',
|
|
378
|
+
enum: ['landscape', 'portrait', 'squarish'],
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
required: ['query'],
|
|
382
|
+
additionalProperties: false,
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
name: 'framer_apply_changes',
|
|
387
|
+
description:
|
|
388
|
+
'Apply Framer Agent DSL changes directly to the connected project. Prefer this for page, layout, style, design-token, component, and CMS-on-canvas work.',
|
|
389
|
+
inputSchema: {
|
|
390
|
+
type: 'object',
|
|
391
|
+
properties: {
|
|
392
|
+
changes: { type: 'string', minLength: 1, maxLength: 250_000 },
|
|
393
|
+
pagePath: { type: 'string', maxLength: 500, default: '/' },
|
|
394
|
+
},
|
|
395
|
+
required: ['changes'],
|
|
396
|
+
additionalProperties: false,
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
name: 'framer_read',
|
|
401
|
+
description:
|
|
402
|
+
'Execute read-only JavaScript with the official Framer Agent connection for capabilities not covered by readProject.',
|
|
403
|
+
inputSchema: {
|
|
404
|
+
type: 'object',
|
|
405
|
+
properties: {
|
|
406
|
+
code: { type: 'string', minLength: 1, maxLength: 200_000 },
|
|
407
|
+
verifies: {
|
|
408
|
+
type: 'array',
|
|
409
|
+
uniqueItems: true,
|
|
410
|
+
maxItems: 6,
|
|
411
|
+
items: { type: 'string', enum: GENERIC_VERIFICATION_CHECKS },
|
|
412
|
+
description:
|
|
413
|
+
'Verification evidence this read is intended to establish after mutations. Interaction verification requires framer_verify_interactions.',
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
required: ['code'],
|
|
417
|
+
additionalProperties: false,
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
name: 'framer_write',
|
|
422
|
+
description:
|
|
423
|
+
'Execute project-scoped JavaScript with the official Framer Agent connection for mutations not covered by applyChanges, such as code files, localization, redirects, assets, CMS, or branch operations. Publishing is disabled unless Dexter explicitly authorizes it. If a write is rejected as unsafe, do not retry JavaScript variants; switch to framer_apply_changes or report the concrete blocker.',
|
|
424
|
+
inputSchema: {
|
|
425
|
+
type: 'object',
|
|
426
|
+
properties: {
|
|
427
|
+
code: { type: 'string', minLength: 1, maxLength: 200_000 },
|
|
428
|
+
reason: {
|
|
429
|
+
type: 'string',
|
|
430
|
+
minLength: 10,
|
|
431
|
+
maxLength: 500,
|
|
432
|
+
description:
|
|
433
|
+
'Explain the specific Framer capability needed and why framer_apply_changes cannot perform it.',
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
required: ['code', 'reason'],
|
|
437
|
+
additionalProperties: false,
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
];
|
|
441
|
+
|
|
442
|
+
const TOOL_BY_NAME = new Map(
|
|
443
|
+
FRAMER_AGENT_TOOL_DEFINITIONS.map((definition) => [
|
|
444
|
+
definition.name,
|
|
445
|
+
definition,
|
|
446
|
+
]),
|
|
447
|
+
);
|
|
448
|
+
const sessionsByProject = new Map();
|
|
449
|
+
let hostedSetupPromise = null;
|
|
450
|
+
|
|
451
|
+
function isRecord(value) {
|
|
452
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function toolError(code, message, statusCode = 400) {
|
|
456
|
+
return Object.assign(new Error(message), { code, statusCode });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function boundedText(value, maximum) {
|
|
460
|
+
const text = String(value || '');
|
|
461
|
+
if (Buffer.byteLength(text, 'utf8') <= maximum) return text;
|
|
462
|
+
return `${text.slice(0, maximum)}\n...[output clipped]`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function hasInstalledFramerSkill(env) {
|
|
466
|
+
const home = String(env?.HOME || '').trim();
|
|
467
|
+
if (!home) return false;
|
|
468
|
+
return [
|
|
469
|
+
path.join(home, '.agents', 'skills', 'framer', 'SKILL.md'),
|
|
470
|
+
path.join(home, '.claude', 'skills', 'framer', 'SKILL.md'),
|
|
471
|
+
].some((skillPath) => fs.existsSync(skillPath));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function framerSkillRoot(env) {
|
|
475
|
+
const home = String(env?.HOME || '').trim();
|
|
476
|
+
if (!home) return null;
|
|
477
|
+
return [
|
|
478
|
+
path.join(home, '.agents', 'skills', 'framer'),
|
|
479
|
+
path.join(home, '.claude', 'skills', 'framer'),
|
|
480
|
+
].find((root) => fs.existsSync(path.join(root, 'SKILL.md'))) || null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function installedFramerAgentVersion() {
|
|
484
|
+
try {
|
|
485
|
+
let current = path.dirname(resolveFramerAgentCli());
|
|
486
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
487
|
+
const packagePath = path.join(current, 'package.json');
|
|
488
|
+
if (fs.existsSync(packagePath)) {
|
|
489
|
+
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
490
|
+
if (manifest?.name === '@framer/agent') {
|
|
491
|
+
return String(manifest.version || '').trim() || null;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const parent = path.dirname(current);
|
|
495
|
+
if (parent === current) break;
|
|
496
|
+
current = parent;
|
|
497
|
+
}
|
|
498
|
+
} catch {
|
|
499
|
+
// Version telemetry must never block a run.
|
|
500
|
+
}
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function readGuidanceFile(projectRoot, relativePath, remainingBytes) {
|
|
505
|
+
const normalized = relativePath.replaceAll('\\', '/');
|
|
506
|
+
if (
|
|
507
|
+
normalized.startsWith('/')
|
|
508
|
+
|| normalized.split('/').some((segment) => segment === '..')
|
|
509
|
+
) return null;
|
|
510
|
+
const absolutePath = path.resolve(projectRoot, normalized);
|
|
511
|
+
if (
|
|
512
|
+
absolutePath !== projectRoot
|
|
513
|
+
&& !absolutePath.startsWith(`${projectRoot}${path.sep}`)
|
|
514
|
+
) return null;
|
|
515
|
+
if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
const maximum = Math.min(MAX_GUIDANCE_FILE_BYTES, remainingBytes);
|
|
519
|
+
if (maximum <= 0) return null;
|
|
520
|
+
return {
|
|
521
|
+
path: normalized,
|
|
522
|
+
content: boundedText(fs.readFileSync(absolutePath, 'utf8'), maximum),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function normalizeProjectId(value) {
|
|
527
|
+
const projectId = String(value || '').trim();
|
|
528
|
+
if (!PROJECT_ID_PATTERN.test(projectId)) {
|
|
529
|
+
throw toolError(
|
|
530
|
+
'FRAMER_AGENT_PROJECT_INVALID',
|
|
531
|
+
'The Framer project identifier is missing or invalid.',
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return projectId;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function projectIdFromUrl(url) {
|
|
538
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
539
|
+
if (
|
|
540
|
+
segments.length !== 2
|
|
541
|
+
|| segments[0]?.toLowerCase() !== 'projects'
|
|
542
|
+
|| !segments[1]
|
|
543
|
+
) return '';
|
|
544
|
+
try {
|
|
545
|
+
const projectId = decodeURIComponent(segments[1])
|
|
546
|
+
.match(PROJECT_SEGMENT_PATTERN)?.[1] || '';
|
|
547
|
+
return PROJECT_ID_PATTERN.test(projectId) ? projectId : '';
|
|
548
|
+
} catch {
|
|
549
|
+
return '';
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function normalizeProjectUrl(value, expectedProjectId) {
|
|
554
|
+
let url;
|
|
555
|
+
try {
|
|
556
|
+
url = new URL(String(value || '').trim());
|
|
557
|
+
} catch {
|
|
558
|
+
throw toolError(
|
|
559
|
+
'FRAMER_AGENT_PROJECT_URL_INVALID',
|
|
560
|
+
'The exact Framer project URL is missing or invalid.',
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
if (
|
|
564
|
+
url.protocol !== 'https:'
|
|
565
|
+
|| !FRAMER_PROJECT_HOST_PATTERN.test(url.hostname)
|
|
566
|
+
|| url.username
|
|
567
|
+
|| url.password
|
|
568
|
+
) {
|
|
569
|
+
throw toolError(
|
|
570
|
+
'FRAMER_AGENT_PROJECT_URL_INVALID',
|
|
571
|
+
'The exact Framer project URL is missing or invalid.',
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
const projectId = projectIdFromUrl(url);
|
|
575
|
+
if (!projectId || projectId !== expectedProjectId) {
|
|
576
|
+
throw toolError(
|
|
577
|
+
'FRAMER_AGENT_PROJECT_MISMATCH',
|
|
578
|
+
'The Framer project URL does not match the project selected in Dexter.',
|
|
579
|
+
409,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
url.hash = '';
|
|
583
|
+
return url.toString();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function normalizePagePath(value) {
|
|
587
|
+
const pagePath = String(value || '/').trim() || '/';
|
|
588
|
+
if (!PAGE_PATH_PATTERN.test(pagePath)) {
|
|
589
|
+
throw toolError(
|
|
590
|
+
'FRAMER_AGENT_PAGE_PATH_INVALID',
|
|
591
|
+
'The Framer page path must start with "/" and contain no control characters.',
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
return pagePath;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function uniqueStrings(values, maximum = 500) {
|
|
598
|
+
return Array.from(
|
|
599
|
+
new Set(
|
|
600
|
+
values
|
|
601
|
+
.filter((value) => typeof value === 'string')
|
|
602
|
+
.map((value) => value.trim())
|
|
603
|
+
.filter(Boolean),
|
|
604
|
+
),
|
|
605
|
+
).slice(0, maximum);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function normalizeVerificationValues(values, allowed) {
|
|
609
|
+
return uniqueStrings(Array.isArray(values) ? values : [])
|
|
610
|
+
.filter((value) => allowed.includes(value));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function normalizeTaskPlan(rawArguments, inheritedRejectedMechanisms = []) {
|
|
614
|
+
const summary = String(rawArguments.summary || '').trim().slice(0, 1000);
|
|
615
|
+
const domains = normalizeVerificationValues(
|
|
616
|
+
rawArguments.domains,
|
|
617
|
+
TASK_DOMAINS,
|
|
618
|
+
);
|
|
619
|
+
const requestedVerification = normalizeVerificationValues(
|
|
620
|
+
rawArguments.verification,
|
|
621
|
+
VERIFICATION_CHECKS,
|
|
622
|
+
);
|
|
623
|
+
if (summary.length < 10 || domains.length === 0) {
|
|
624
|
+
throw toolError(
|
|
625
|
+
'FRAMER_HARNESS_PLAN_INVALID',
|
|
626
|
+
'Describe the understood task and declare at least one task domain.',
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
const verification = new Set(['structural', ...requestedVerification]);
|
|
630
|
+
if (
|
|
631
|
+
domains.some((domain) =>
|
|
632
|
+
['visual', 'interactions', 'responsive'].includes(domain))
|
|
633
|
+
) verification.add('visual');
|
|
634
|
+
if (domains.includes('links')) verification.add('links');
|
|
635
|
+
if (domains.includes('interactions')) verification.add('interactions');
|
|
636
|
+
if (domains.includes('responsive')) verification.add('responsive');
|
|
637
|
+
if (domains.includes('code')) verification.add('code');
|
|
638
|
+
if (domains.includes('data')) verification.add('data');
|
|
639
|
+
const behaviorPlan = normalizeBehaviorPlan(
|
|
640
|
+
rawArguments,
|
|
641
|
+
inheritedRejectedMechanisms,
|
|
642
|
+
);
|
|
643
|
+
if (
|
|
644
|
+
domains.includes('interactions')
|
|
645
|
+
&& behaviorPlan.behaviors.length === 0
|
|
646
|
+
) {
|
|
647
|
+
throw toolError(
|
|
648
|
+
'FRAMER_BEHAVIOR_CONTRACT_REQUIRED',
|
|
649
|
+
'Interaction work requires observable behaviors and one compatible mechanism decision per behavior.',
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
if (behaviorPlan.behaviors.length > 0) {
|
|
653
|
+
if (!domains.includes('interactions')) domains.push('interactions');
|
|
654
|
+
verification.add('interactions');
|
|
655
|
+
}
|
|
656
|
+
return {
|
|
657
|
+
summary,
|
|
658
|
+
domains,
|
|
659
|
+
verification: [...verification],
|
|
660
|
+
...behaviorPlan,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function workingPreferencesFromAssignment(assignment) {
|
|
665
|
+
const context = isRecord(assignment?.context) ? assignment.context : {};
|
|
666
|
+
const grant = isRecord(context.contextGrant) ? context.contextGrant : {};
|
|
667
|
+
const supplied = isRecord(context.workingPreferences)
|
|
668
|
+
? context.workingPreferences
|
|
669
|
+
: {};
|
|
670
|
+
const selectedNodeIds = uniqueStrings([
|
|
671
|
+
...(Array.isArray(context.selectedNodeIds)
|
|
672
|
+
? context.selectedNodeIds
|
|
673
|
+
: []),
|
|
674
|
+
...(Array.isArray(grant.selectedNodeIds)
|
|
675
|
+
? grant.selectedNodeIds
|
|
676
|
+
: []),
|
|
677
|
+
]);
|
|
678
|
+
const selectionFirst =
|
|
679
|
+
supplied.focus === 'selection-first'
|
|
680
|
+
|| (
|
|
681
|
+
selectedNodeIds.length > 0
|
|
682
|
+
&& grant.writeScope === 'selection'
|
|
683
|
+
);
|
|
684
|
+
return {
|
|
685
|
+
mode: selectionFirst ? 'selection-first' : 'standard',
|
|
686
|
+
writeScope: String(
|
|
687
|
+
supplied.writeScope || grant.writeScope || 'none',
|
|
688
|
+
).trim() || 'none',
|
|
689
|
+
selectionFirst,
|
|
690
|
+
preferBatchedMutations: supplied.preferBatchedMutations !== false,
|
|
691
|
+
avoidRepeatedInspection: supplied.avoidRepeatedInspection !== false,
|
|
692
|
+
expandScopeOnlyWhenNeeded:
|
|
693
|
+
supplied.expandScopeOnlyWhenNeeded !== false,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function toolDefinitionsForPreferences(requestedTools, preferences) {
|
|
698
|
+
return FRAMER_AGENT_TOOL_DEFINITIONS
|
|
699
|
+
.filter((tool) => requestedTools.has(tool.name))
|
|
700
|
+
.map((tool) => {
|
|
701
|
+
if (!preferences.selectionFirst) return tool;
|
|
702
|
+
if (tool.name === 'framer_context') {
|
|
703
|
+
return {
|
|
704
|
+
...tool,
|
|
705
|
+
description:
|
|
706
|
+
'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.',
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
if (tool.name === 'framer_read_project') {
|
|
710
|
+
return {
|
|
711
|
+
...tool,
|
|
712
|
+
description:
|
|
713
|
+
'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.',
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
if (tool.name === 'framer_apply_changes') {
|
|
717
|
+
return {
|
|
718
|
+
...tool,
|
|
719
|
+
description:
|
|
720
|
+
'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.',
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
if (tool.name === 'framer_read') {
|
|
724
|
+
return {
|
|
725
|
+
...tool,
|
|
726
|
+
description:
|
|
727
|
+
'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.',
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
if (tool.name === 'framer_write') {
|
|
731
|
+
return {
|
|
732
|
+
...tool,
|
|
733
|
+
description:
|
|
734
|
+
'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.',
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
return tool;
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function boundedStructuredValue(value, maximum = MAX_MODEL_RESULT_BYTES) {
|
|
742
|
+
let serialized;
|
|
743
|
+
try {
|
|
744
|
+
serialized = JSON.stringify(value);
|
|
745
|
+
} catch {
|
|
746
|
+
return {
|
|
747
|
+
truncated: true,
|
|
748
|
+
output: '[Framer result could not be serialized.]',
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
if (Buffer.byteLength(serialized, 'utf8') <= maximum) return value;
|
|
752
|
+
return {
|
|
753
|
+
truncated: true,
|
|
754
|
+
output: boundedText(serialized, maximum),
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function authoritativeNodeIdsFromAssignment(assignment) {
|
|
759
|
+
const context = isRecord(assignment?.context) ? assignment.context : {};
|
|
760
|
+
const grant = isRecord(context.contextGrant) ? context.contextGrant : {};
|
|
761
|
+
return uniqueStrings([
|
|
762
|
+
context.activePageId,
|
|
763
|
+
...(Array.isArray(context.selectedNodeIds)
|
|
764
|
+
? context.selectedNodeIds
|
|
765
|
+
: []),
|
|
766
|
+
grant.activePageId,
|
|
767
|
+
...(Array.isArray(grant.selectedNodeIds)
|
|
768
|
+
? grant.selectedNodeIds
|
|
769
|
+
: []),
|
|
770
|
+
...(Array.isArray(grant.contextNodeIds)
|
|
771
|
+
? grant.contextNodeIds
|
|
772
|
+
: []),
|
|
773
|
+
...(Array.isArray(grant.writableNodeIds)
|
|
774
|
+
? grant.writableNodeIds
|
|
775
|
+
: []),
|
|
776
|
+
]);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function validateCode(value, { write = false, allowPublishing = false } = {}) {
|
|
780
|
+
const code = String(value || '').trim();
|
|
781
|
+
if (!code) {
|
|
782
|
+
throw toolError(
|
|
783
|
+
'FRAMER_AGENT_CODE_REQUIRED',
|
|
784
|
+
'Framer Agent code is required.',
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
if (UNSAFE_EXEC_PATTERN.test(code)) {
|
|
788
|
+
throw toolError(
|
|
789
|
+
'FRAMER_AGENT_CODE_UNSAFE',
|
|
790
|
+
'Framer Agent code may use only the provided framer, state, and console globals.',
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
if (!write && MUTATING_EXEC_PATTERN.test(code)) {
|
|
794
|
+
throw toolError(
|
|
795
|
+
'FRAMER_AGENT_READ_ONLY_VIOLATION',
|
|
796
|
+
'Use framer_write or framer_apply_changes for project mutations.',
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
if (
|
|
800
|
+
!allowPublishing &&
|
|
801
|
+
/\.(?:publish|deploy)\s*\(/.test(code)
|
|
802
|
+
) {
|
|
803
|
+
throw toolError(
|
|
804
|
+
'FRAMER_AGENT_PUBLISH_NOT_AUTHORIZED',
|
|
805
|
+
'Publishing is not authorized for this Dexter run.',
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
return code;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function extractJsonArray(value) {
|
|
812
|
+
const text = String(value || '').trim();
|
|
813
|
+
const start = text.indexOf('[');
|
|
814
|
+
const end = text.lastIndexOf(']');
|
|
815
|
+
if (start < 0 || end <= start) return [];
|
|
816
|
+
try {
|
|
817
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
818
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
819
|
+
} catch {
|
|
820
|
+
return [];
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function abortError() {
|
|
825
|
+
return Object.assign(new Error('The Framer authorization was cancelled.'), {
|
|
826
|
+
name: 'AbortError',
|
|
827
|
+
code: 'ABORT_ERR',
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function parseStructuredOutput(value) {
|
|
832
|
+
const text = String(value || '').trim();
|
|
833
|
+
if (!text) return null;
|
|
834
|
+
const candidates = [text];
|
|
835
|
+
const firstObject = text.indexOf('{');
|
|
836
|
+
const lastObject = text.lastIndexOf('}');
|
|
837
|
+
if (firstObject >= 0 && lastObject > firstObject) {
|
|
838
|
+
candidates.push(text.slice(firstObject, lastObject + 1));
|
|
839
|
+
}
|
|
840
|
+
const firstArray = text.indexOf('[');
|
|
841
|
+
const lastArray = text.lastIndexOf(']');
|
|
842
|
+
if (firstArray >= 0 && lastArray > firstArray) {
|
|
843
|
+
candidates.push(text.slice(firstArray, lastArray + 1));
|
|
844
|
+
}
|
|
845
|
+
for (const candidate of candidates) {
|
|
846
|
+
try {
|
|
847
|
+
return JSON.parse(candidate);
|
|
848
|
+
} catch {
|
|
849
|
+
// Keep the original text when the command intentionally prints prose.
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function parseBranchChange(value) {
|
|
856
|
+
const match = String(value || '').match(
|
|
857
|
+
/\[FRAMER_BRANCH_CHANGE\]\s+title="([^"]*)"\s+id="([^"]*)"\s+url="([^"]*)"/,
|
|
858
|
+
);
|
|
859
|
+
return match
|
|
860
|
+
? { title: match[1], id: match[2], url: match[3] }
|
|
861
|
+
: null;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
export function resolveFramerAgentCli() {
|
|
865
|
+
try {
|
|
866
|
+
return require.resolve('@framer/agent');
|
|
867
|
+
} catch {
|
|
868
|
+
throw toolError(
|
|
869
|
+
'FRAMER_AGENT_NOT_INSTALLED',
|
|
870
|
+
'The official Framer Agent runtime is missing. Update Dexter Bridge and reconnect.',
|
|
871
|
+
503,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
export function runFramerAgentCli(args, {
|
|
877
|
+
cwd = process.cwd(),
|
|
878
|
+
env = process.env,
|
|
879
|
+
input,
|
|
880
|
+
signal,
|
|
881
|
+
timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS,
|
|
882
|
+
onOutput,
|
|
883
|
+
cliPath = resolveFramerAgentCli(),
|
|
884
|
+
} = {}) {
|
|
885
|
+
return new Promise((resolve, reject) => {
|
|
886
|
+
const child = spawn(process.execPath, [cliPath, ...args], {
|
|
887
|
+
cwd,
|
|
888
|
+
env,
|
|
889
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
890
|
+
windowsHide: true,
|
|
891
|
+
signal,
|
|
892
|
+
});
|
|
893
|
+
let stdout = '';
|
|
894
|
+
let stderr = '';
|
|
895
|
+
let outputBytes = 0;
|
|
896
|
+
let settled = false;
|
|
897
|
+
let timer;
|
|
898
|
+
const finish = (callback, value) => {
|
|
899
|
+
if (settled) return;
|
|
900
|
+
settled = true;
|
|
901
|
+
clearTimeout(timer);
|
|
902
|
+
callback(value);
|
|
903
|
+
};
|
|
904
|
+
const append = (channel, chunk) => {
|
|
905
|
+
const text = chunk.toString('utf8');
|
|
906
|
+
outputBytes += Buffer.byteLength(text, 'utf8');
|
|
907
|
+
if (outputBytes > MAX_OUTPUT_BYTES) {
|
|
908
|
+
child.kill('SIGTERM');
|
|
909
|
+
finish(
|
|
910
|
+
reject,
|
|
911
|
+
toolError(
|
|
912
|
+
'FRAMER_AGENT_OUTPUT_TOO_LARGE',
|
|
913
|
+
'The Framer Agent command returned too much output.',
|
|
914
|
+
),
|
|
915
|
+
);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (channel === 'stdout') stdout += text;
|
|
919
|
+
else stderr += text;
|
|
920
|
+
onOutput?.({ channel, text });
|
|
921
|
+
};
|
|
922
|
+
child.stdout.on('data', (chunk) => append('stdout', chunk));
|
|
923
|
+
child.stderr.on('data', (chunk) => append('stderr', chunk));
|
|
924
|
+
child.once('error', (error) => finish(reject, error));
|
|
925
|
+
child.once('close', (code, signal) => {
|
|
926
|
+
if (settled) return;
|
|
927
|
+
if (code === 0) {
|
|
928
|
+
finish(resolve, {
|
|
929
|
+
stdout: boundedText(stdout, MAX_OUTPUT_BYTES),
|
|
930
|
+
stderr: boundedText(stderr, MAX_OUTPUT_BYTES),
|
|
931
|
+
code,
|
|
932
|
+
signal,
|
|
933
|
+
});
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
const message = boundedText(stderr || stdout, 8_000).trim();
|
|
937
|
+
finish(
|
|
938
|
+
reject,
|
|
939
|
+
toolError(
|
|
940
|
+
/authoriz|log in|login|api key/i.test(message)
|
|
941
|
+
? 'FRAMER_AGENT_AUTHORIZATION_REQUIRED'
|
|
942
|
+
: 'FRAMER_AGENT_COMMAND_FAILED',
|
|
943
|
+
message || `Framer Agent exited with code ${code}.`,
|
|
944
|
+
/authoriz|log in|login|api key/i.test(message) ? 409 : 500,
|
|
945
|
+
),
|
|
946
|
+
);
|
|
947
|
+
});
|
|
948
|
+
timer = setTimeout(() => {
|
|
949
|
+
child.kill('SIGTERM');
|
|
950
|
+
finish(
|
|
951
|
+
reject,
|
|
952
|
+
toolError(
|
|
953
|
+
'FRAMER_AGENT_TIMEOUT',
|
|
954
|
+
'The Framer Agent command timed out.',
|
|
955
|
+
504,
|
|
956
|
+
),
|
|
957
|
+
);
|
|
958
|
+
}, Math.max(1_000, Number(timeoutMs) || DEFAULT_COMMAND_TIMEOUT_MS));
|
|
959
|
+
timer.unref?.();
|
|
960
|
+
if (input !== undefined) child.stdin.end(String(input));
|
|
961
|
+
else child.stdin.end();
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function sessionIdFromOutput(value) {
|
|
966
|
+
return String(value || '')
|
|
967
|
+
.split(/\r?\n/)
|
|
968
|
+
.map((line) => line.trim())
|
|
969
|
+
.find(
|
|
970
|
+
(line) =>
|
|
971
|
+
line &&
|
|
972
|
+
!line.startsWith('[') &&
|
|
973
|
+
!/^framer external agents/i.test(line) &&
|
|
974
|
+
/^[A-Za-z0-9_-]{4,200}$/.test(line),
|
|
975
|
+
) || null;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function isAuthorizationFailure(error) {
|
|
979
|
+
return (
|
|
980
|
+
error?.code === 'FRAMER_AGENT_AUTHORIZATION_REQUIRED'
|
|
981
|
+
|| /authoriz|log in|login|api key|does not have access|session expired/i.test(
|
|
982
|
+
String(error?.message || ''),
|
|
983
|
+
)
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function projectConnectionError(error) {
|
|
988
|
+
if (/project not found/i.test(String(error?.message || ''))) {
|
|
989
|
+
return toolError(
|
|
990
|
+
'FRAMER_AGENT_PROJECT_NOT_FOUND',
|
|
991
|
+
'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.',
|
|
992
|
+
409,
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
if (isAuthorizationFailure(error)) {
|
|
996
|
+
return toolError(
|
|
997
|
+
'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
|
|
998
|
+
'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.',
|
|
999
|
+
409,
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
return error;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
export function createFramerAgentToolRuntime({
|
|
1006
|
+
assignment,
|
|
1007
|
+
cwd,
|
|
1008
|
+
env = process.env,
|
|
1009
|
+
trace,
|
|
1010
|
+
onActivity,
|
|
1011
|
+
runCli = runFramerAgentCli,
|
|
1012
|
+
authorizeProject,
|
|
1013
|
+
verifyProjectAuthorization,
|
|
1014
|
+
} = {}) {
|
|
1015
|
+
const projectId = normalizeProjectId(assignment?.context?.framerProjectId);
|
|
1016
|
+
const projectUrl = normalizeProjectUrl(
|
|
1017
|
+
assignment?.context?.framerProjectUrl,
|
|
1018
|
+
projectId,
|
|
1019
|
+
);
|
|
1020
|
+
const sessionCacheKey = projectId;
|
|
1021
|
+
const requestedTools = Array.isArray(assignment?.toolProtocol?.tools)
|
|
1022
|
+
? assignment.toolProtocol.tools
|
|
1023
|
+
: FRAMER_AGENT_TOOL_DEFINITIONS.map((tool) => tool.name);
|
|
1024
|
+
const allowedToolNames = new Set(
|
|
1025
|
+
requestedTools.filter((name) => TOOL_BY_NAME.has(name)),
|
|
1026
|
+
);
|
|
1027
|
+
const workingPreferences = workingPreferencesFromAssignment(assignment);
|
|
1028
|
+
const definitions = toolDefinitionsForPreferences(
|
|
1029
|
+
allowedToolNames,
|
|
1030
|
+
workingPreferences,
|
|
1031
|
+
);
|
|
1032
|
+
const allowPublishing = assignment?.context?.permissions?.publish === true;
|
|
1033
|
+
const inheritedRejectedMechanisms =
|
|
1034
|
+
assignment?.context?.repairContext?.rejectedMechanisms
|
|
1035
|
+
|| assignment?.repairContext?.rejectedMechanisms
|
|
1036
|
+
|| [];
|
|
1037
|
+
const authorizedInstanceContainerIds = uniqueStrings(
|
|
1038
|
+
assignment?.context?.permissions?.scopedComponentArtifacts
|
|
1039
|
+
?.instanceContainerNodeIds
|
|
1040
|
+
|| assignment?.context?.contextGrant?.writableNodeIds
|
|
1041
|
+
|| [],
|
|
1042
|
+
);
|
|
1043
|
+
const activePagePath = normalizePagePath(
|
|
1044
|
+
assignment?.context?.activePagePath,
|
|
1045
|
+
);
|
|
1046
|
+
const authoritativeNodeIds =
|
|
1047
|
+
authoritativeNodeIdsFromAssignment(assignment);
|
|
1048
|
+
let sessionId = null;
|
|
1049
|
+
let sessionPromise = null;
|
|
1050
|
+
let nodeValidationPromise = null;
|
|
1051
|
+
let server = null;
|
|
1052
|
+
let gateway = null;
|
|
1053
|
+
let closed = false;
|
|
1054
|
+
let serial = Promise.resolve();
|
|
1055
|
+
let mutationCalls = 0;
|
|
1056
|
+
let applyChangesCalls = 0;
|
|
1057
|
+
let lowLevelWriteCalls = 0;
|
|
1058
|
+
let imageSearchCalls = 0;
|
|
1059
|
+
let readCalls = 0;
|
|
1060
|
+
let progressCalls = 0;
|
|
1061
|
+
let toolCallCount = 0;
|
|
1062
|
+
const toolCalls = [];
|
|
1063
|
+
let repeatedFailureFingerprint = null;
|
|
1064
|
+
let repeatedFailureCount = 0;
|
|
1065
|
+
let repeatedFailureStops = 0;
|
|
1066
|
+
let unsafeWriteFailures = 0;
|
|
1067
|
+
let lowLevelWriteFailures = 0;
|
|
1068
|
+
let lowLevelWriteDisabled = false;
|
|
1069
|
+
let lowLevelWriteBlockedCalls = 0;
|
|
1070
|
+
let instructionFallbacks = 0;
|
|
1071
|
+
let instructionLookupUnavailable = false;
|
|
1072
|
+
let branchChange = null;
|
|
1073
|
+
let lastInspectionAt = null;
|
|
1074
|
+
let lastMutationAt = null;
|
|
1075
|
+
let screenshotCalls = 0;
|
|
1076
|
+
let interactionVerificationCalls = 0;
|
|
1077
|
+
let interactionVerification = {
|
|
1078
|
+
completed: false,
|
|
1079
|
+
verificationLevel: 'unverified',
|
|
1080
|
+
checks: [],
|
|
1081
|
+
behaviors: [],
|
|
1082
|
+
verifiedAt: null,
|
|
1083
|
+
};
|
|
1084
|
+
const representativeVerifiedBehaviorIds = new Set();
|
|
1085
|
+
const repeatedBehaviorVerificationFailures = new Map();
|
|
1086
|
+
const mutationReceipts = [];
|
|
1087
|
+
let screenshotBeforeMutation = false;
|
|
1088
|
+
let lastScreenshotSequence = 0;
|
|
1089
|
+
let taskPlan = null;
|
|
1090
|
+
const verificationEvidence = new Map();
|
|
1091
|
+
let guidanceMetadata = {
|
|
1092
|
+
loaded: false,
|
|
1093
|
+
contentHash: null,
|
|
1094
|
+
projectContextHash: null,
|
|
1095
|
+
files: [],
|
|
1096
|
+
bytes: 0,
|
|
1097
|
+
};
|
|
1098
|
+
let promptMetadata = {
|
|
1099
|
+
doctrineLoaded: false,
|
|
1100
|
+
doctrineVersion: null,
|
|
1101
|
+
};
|
|
1102
|
+
const framerAgentVersion = installedFramerAgentVersion();
|
|
1103
|
+
let operationSequence = 0;
|
|
1104
|
+
let lastMutationSequence = 0;
|
|
1105
|
+
let lastInspectionSequence = 0;
|
|
1106
|
+
const abortController = new AbortController();
|
|
1107
|
+
|
|
1108
|
+
const reportActivity = (event) => {
|
|
1109
|
+
try {
|
|
1110
|
+
void Promise.resolve(onActivity?.(event)).catch(() => undefined);
|
|
1111
|
+
} catch {
|
|
1112
|
+
// Observability must never interrupt a Framer operation.
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
const executeCli = async (args, options = {}) => {
|
|
1117
|
+
const result = await runCli(args, {
|
|
1118
|
+
cwd,
|
|
1119
|
+
env,
|
|
1120
|
+
signal: abortController.signal,
|
|
1121
|
+
...options,
|
|
1122
|
+
});
|
|
1123
|
+
const nextBranch = parseBranchChange(result.stdout);
|
|
1124
|
+
if (nextBranch) branchChange = nextBranch;
|
|
1125
|
+
return result;
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
const ensureHostedSetup = async () => {
|
|
1129
|
+
if (env.DEXTER_HOSTED_FRAMER_AUTO_SETUP !== 'true') return;
|
|
1130
|
+
if (hasInstalledFramerSkill(env)) return;
|
|
1131
|
+
if (!hostedSetupPromise) {
|
|
1132
|
+
reportActivity({
|
|
1133
|
+
kind: 'status_update',
|
|
1134
|
+
source: 'framer-agent',
|
|
1135
|
+
message: 'Preparing the Framer harness.',
|
|
1136
|
+
});
|
|
1137
|
+
hostedSetupPromise = executeCli(['setup'], {
|
|
1138
|
+
timeoutMs: 30_000,
|
|
1139
|
+
}).catch((error) => {
|
|
1140
|
+
hostedSetupPromise = null;
|
|
1141
|
+
throw error;
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
await hostedSetupPromise;
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
const projectIsAuthorizedLocally = async () => {
|
|
1148
|
+
const listed = await executeCli(['project', 'list'], {
|
|
1149
|
+
timeoutMs: 30_000,
|
|
1150
|
+
}).catch(() => ({ stdout: '[]', stderr: '' }));
|
|
1151
|
+
return extractJsonArray(listed.stdout).some(
|
|
1152
|
+
(item) => isRecord(item) && item.projectId === projectId,
|
|
1153
|
+
);
|
|
1154
|
+
};
|
|
1155
|
+
|
|
1156
|
+
const resolveProjectAuthorization = async () => {
|
|
1157
|
+
if (typeof authorizeProject !== 'function') {
|
|
1158
|
+
throw toolError(
|
|
1159
|
+
'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
|
|
1160
|
+
'This Framer project needs to be connected before Dexter can use it.',
|
|
1161
|
+
409,
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
let authorization = await authorizeProject({
|
|
1165
|
+
projectId,
|
|
1166
|
+
initiate: true,
|
|
1167
|
+
forceRefresh: false,
|
|
1168
|
+
signal: abortController.signal,
|
|
1169
|
+
});
|
|
1170
|
+
if (authorization?.framerProjectId !== projectId) {
|
|
1171
|
+
throw toolError(
|
|
1172
|
+
'FRAMER_AGENT_PROJECT_MISMATCH',
|
|
1173
|
+
'Dexter received authorization for a different Framer project.',
|
|
1174
|
+
409,
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
if (authorization?.status === 'authorization_required') {
|
|
1178
|
+
reportActivity({
|
|
1179
|
+
kind: 'status_update',
|
|
1180
|
+
source: 'framer-agent',
|
|
1181
|
+
message:
|
|
1182
|
+
'This Framer project needs a project API key before Dexter can continue.',
|
|
1183
|
+
});
|
|
1184
|
+
throw toolError(
|
|
1185
|
+
'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
|
|
1186
|
+
'Connect this Framer project in Dexter by adding a project API key, then retry.',
|
|
1187
|
+
409,
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
const apiKey = String(authorization?.apiKey || '').trim();
|
|
1191
|
+
if (authorization?.status !== 'ready' || !apiKey) {
|
|
1192
|
+
throw toolError(
|
|
1193
|
+
'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
|
|
1194
|
+
'Framer project authorization was not completed. Reconnect this project in Dexter and try again.',
|
|
1195
|
+
409,
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
const authorizedProjectUrl = normalizeProjectUrl(
|
|
1199
|
+
authorization.framerProjectUrl,
|
|
1200
|
+
projectId,
|
|
1201
|
+
);
|
|
1202
|
+
return { ...authorization, apiKey, framerProjectUrl: authorizedProjectUrl };
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
const installProjectAuthorization = async (authorization) => {
|
|
1206
|
+
await executeCli(['project', 'auth', projectId, authorization.apiKey], {
|
|
1207
|
+
timeoutMs: 30_000,
|
|
1208
|
+
});
|
|
1209
|
+
reportActivity({
|
|
1210
|
+
kind: 'status_update',
|
|
1211
|
+
source: 'framer-agent',
|
|
1212
|
+
message: 'This Framer project is authorized for the coding harness.',
|
|
1213
|
+
});
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
const createSession = async () => {
|
|
1217
|
+
return executeCli(['session', 'new', projectId], {
|
|
1218
|
+
timeoutMs: SESSION_TIMEOUT_MS,
|
|
1219
|
+
});
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
const reportRejectedAuthorization = async (authorization, error) => {
|
|
1223
|
+
if (
|
|
1224
|
+
typeof verifyProjectAuthorization !== 'function'
|
|
1225
|
+
|| !authorization?.credentialVersion
|
|
1226
|
+
) return;
|
|
1227
|
+
await verifyProjectAuthorization({
|
|
1228
|
+
status: 'rejected',
|
|
1229
|
+
framerProjectId: projectId,
|
|
1230
|
+
framerProjectUrl: projectUrl,
|
|
1231
|
+
credentialVersion: authorization.credentialVersion,
|
|
1232
|
+
errorCode: String(error?.code || 'FRAMER_AGENT_AUTHORIZATION_REJECTED')
|
|
1233
|
+
.slice(0, 120),
|
|
1234
|
+
signal: abortController.signal,
|
|
1235
|
+
}).catch(() => undefined);
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
const verifyConnectedProject = async (activeSessionId, authorization) => {
|
|
1239
|
+
const result = await executeCli(['exec', '-s', activeSessionId], {
|
|
1240
|
+
input: [
|
|
1241
|
+
'const [project, branch] = await Promise.all([',
|
|
1242
|
+
' framer.getProjectInfo(),',
|
|
1243
|
+
' framer.agent.getActiveBranch(),',
|
|
1244
|
+
']);',
|
|
1245
|
+
'console.log(JSON.stringify({ projectName: project?.name ?? null, projectUrl: branch?.url ?? null }));',
|
|
1246
|
+
].join('\n'),
|
|
1247
|
+
timeoutMs: 30_000,
|
|
1248
|
+
});
|
|
1249
|
+
const observed = parseStructuredOutput(result.stdout);
|
|
1250
|
+
const observedProjectUrl = normalizeProjectUrl(
|
|
1251
|
+
observed?.projectUrl,
|
|
1252
|
+
projectId,
|
|
1253
|
+
);
|
|
1254
|
+
if (
|
|
1255
|
+
typeof verifyProjectAuthorization === 'function'
|
|
1256
|
+
&& authorization?.credentialVersion
|
|
1257
|
+
) {
|
|
1258
|
+
await verifyProjectAuthorization({
|
|
1259
|
+
status: 'verified',
|
|
1260
|
+
framerProjectId: projectId,
|
|
1261
|
+
framerProjectUrl: observedProjectUrl,
|
|
1262
|
+
credentialVersion: authorization.credentialVersion,
|
|
1263
|
+
signal: abortController.signal,
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
const ensureSession = async () => {
|
|
1269
|
+
await ensureHostedSetup();
|
|
1270
|
+
if (sessionId) return sessionId;
|
|
1271
|
+
if (sessionPromise) return sessionPromise;
|
|
1272
|
+
sessionPromise = (async () => {
|
|
1273
|
+
reportActivity({
|
|
1274
|
+
kind: 'status_update',
|
|
1275
|
+
source: 'framer-agent',
|
|
1276
|
+
message: 'Connecting the coding harness to this Framer project.',
|
|
1277
|
+
});
|
|
1278
|
+
let authorization = null;
|
|
1279
|
+
if (typeof authorizeProject === 'function') {
|
|
1280
|
+
authorization = await resolveProjectAuthorization();
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const cached = sessionsByProject.get(sessionCacheKey);
|
|
1284
|
+
if (
|
|
1285
|
+
cached
|
|
1286
|
+
&& (
|
|
1287
|
+
typeof authorizeProject !== 'function'
|
|
1288
|
+
|| (
|
|
1289
|
+
authorization?.verificationStatus === 'verified'
|
|
1290
|
+
&& cached.credentialVersion === authorization.credentialVersion
|
|
1291
|
+
)
|
|
1292
|
+
)
|
|
1293
|
+
) {
|
|
1294
|
+
sessionId = cached.sessionId;
|
|
1295
|
+
return sessionId;
|
|
1296
|
+
}
|
|
1297
|
+
if (cached) {
|
|
1298
|
+
await executeCli(['session', 'destroy', cached.sessionId], {
|
|
1299
|
+
timeoutMs: 10_000,
|
|
1300
|
+
}).catch(() => undefined);
|
|
1301
|
+
sessionsByProject.delete(sessionCacheKey);
|
|
1302
|
+
}
|
|
1303
|
+
if (!authorization && !(await projectIsAuthorizedLocally())) {
|
|
1304
|
+
throw toolError(
|
|
1305
|
+
'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
|
|
1306
|
+
'This Framer project needs to be connected before Dexter can use it.',
|
|
1307
|
+
409,
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
let created;
|
|
1311
|
+
try {
|
|
1312
|
+
if (authorization) {
|
|
1313
|
+
await installProjectAuthorization(authorization);
|
|
1314
|
+
}
|
|
1315
|
+
created = await createSession();
|
|
1316
|
+
} catch (error) {
|
|
1317
|
+
if (isAuthorizationFailure(error)) {
|
|
1318
|
+
await reportRejectedAuthorization(authorization, error);
|
|
1319
|
+
}
|
|
1320
|
+
throw projectConnectionError(error);
|
|
1321
|
+
}
|
|
1322
|
+
sessionId = sessionIdFromOutput(created.stdout);
|
|
1323
|
+
if (!sessionId) {
|
|
1324
|
+
const refreshed = await executeCli(['session', 'list'], {
|
|
1325
|
+
timeoutMs: 10_000,
|
|
1326
|
+
}).catch(() => ({ stdout: '[]', stderr: '' }));
|
|
1327
|
+
const connected = extractJsonArray(refreshed.stdout).find(
|
|
1328
|
+
(item) => isRecord(item) && item.projectId === projectId && item.id,
|
|
1329
|
+
);
|
|
1330
|
+
sessionId = connected?.id ? String(connected.id) : null;
|
|
1331
|
+
}
|
|
1332
|
+
if (!sessionId) {
|
|
1333
|
+
throw toolError(
|
|
1334
|
+
'FRAMER_AGENT_SESSION_UNAVAILABLE',
|
|
1335
|
+
'Framer Agent connected but did not return a usable project session.',
|
|
1336
|
+
503,
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
try {
|
|
1340
|
+
await verifyConnectedProject(sessionId, authorization);
|
|
1341
|
+
} catch (error) {
|
|
1342
|
+
await executeCli(['session', 'destroy', sessionId], {
|
|
1343
|
+
timeoutMs: 10_000,
|
|
1344
|
+
}).catch(() => undefined);
|
|
1345
|
+
sessionId = null;
|
|
1346
|
+
if (
|
|
1347
|
+
isAuthorizationFailure(error)
|
|
1348
|
+
|| error?.code === 'FRAMER_AGENT_PROJECT_MISMATCH'
|
|
1349
|
+
) {
|
|
1350
|
+
await reportRejectedAuthorization(authorization, error);
|
|
1351
|
+
}
|
|
1352
|
+
throw projectConnectionError(error);
|
|
1353
|
+
}
|
|
1354
|
+
sessionsByProject.set(sessionCacheKey, {
|
|
1355
|
+
sessionId,
|
|
1356
|
+
credentialVersion: authorization?.credentialVersion || null,
|
|
1357
|
+
});
|
|
1358
|
+
reportActivity({
|
|
1359
|
+
kind: 'status_update',
|
|
1360
|
+
source: 'framer-agent',
|
|
1361
|
+
message: 'The coding harness is connected to the Framer canvas.',
|
|
1362
|
+
});
|
|
1363
|
+
return sessionId;
|
|
1364
|
+
})().finally(() => {
|
|
1365
|
+
sessionPromise = null;
|
|
1366
|
+
});
|
|
1367
|
+
return sessionPromise;
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
const loadProjectGuidance = async () => {
|
|
1371
|
+
await ensureSession();
|
|
1372
|
+
const skillRoot = framerSkillRoot(env);
|
|
1373
|
+
if (!skillRoot) return { content: '', ...guidanceMetadata };
|
|
1374
|
+
const projectRoot = path.join(skillRoot, 'projects', projectId);
|
|
1375
|
+
if (!fs.existsSync(projectRoot)) {
|
|
1376
|
+
return { content: '', ...guidanceMetadata };
|
|
1377
|
+
}
|
|
1378
|
+
const requestedFiles = [
|
|
1379
|
+
{ root: skillRoot, path: 'SKILL.md', label: 'SKILL.md' },
|
|
1380
|
+
{ root: projectRoot, path: 'index.md', label: 'project/index.md' },
|
|
1381
|
+
{
|
|
1382
|
+
root: projectRoot,
|
|
1383
|
+
path: 'project-inventory.md',
|
|
1384
|
+
label: 'project/project-inventory.md',
|
|
1385
|
+
},
|
|
1386
|
+
{ root: projectRoot, path: 'prompt/core-principles.md' },
|
|
1387
|
+
{ root: projectRoot, path: 'prompt/core-examples.md' },
|
|
1388
|
+
{ root: projectRoot, path: 'prompt/implementation-strategy.md' },
|
|
1389
|
+
{ root: projectRoot, path: 'prompt/updating-the-project.md' },
|
|
1390
|
+
{ root: projectRoot, path: 'prompt/tools.md' },
|
|
1391
|
+
{ root: projectRoot, path: 'prompt/overview.md' },
|
|
1392
|
+
{ root: projectRoot, path: 'prompt/guardrails.md' },
|
|
1393
|
+
{ root: projectRoot, path: 'prompt/critical-reminders.md' },
|
|
1394
|
+
...(visualVerificationRequired
|
|
1395
|
+
? [
|
|
1396
|
+
{ root: projectRoot, path: 'prompt/design-rules.md' },
|
|
1397
|
+
{ root: projectRoot, path: 'prompt/how-projects-work.md' },
|
|
1398
|
+
]
|
|
1399
|
+
: []),
|
|
1400
|
+
];
|
|
1401
|
+
const files = [];
|
|
1402
|
+
let remainingBytes = MAX_GUIDANCE_BYTES;
|
|
1403
|
+
for (const requestedFile of requestedFiles) {
|
|
1404
|
+
const loaded = readGuidanceFile(
|
|
1405
|
+
requestedFile.root,
|
|
1406
|
+
requestedFile.path,
|
|
1407
|
+
remainingBytes,
|
|
1408
|
+
);
|
|
1409
|
+
if (!loaded) continue;
|
|
1410
|
+
files.push({
|
|
1411
|
+
...loaded,
|
|
1412
|
+
path: requestedFile.label || requestedFile.path,
|
|
1413
|
+
});
|
|
1414
|
+
remainingBytes -= Buffer.byteLength(loaded.content, 'utf8');
|
|
1415
|
+
if (remainingBytes <= 0) break;
|
|
1416
|
+
}
|
|
1417
|
+
const content = files
|
|
1418
|
+
.map((file) => `## ${file.path}\n\n${file.content}`)
|
|
1419
|
+
.join('\n\n');
|
|
1420
|
+
const inventory = files.find(
|
|
1421
|
+
(file) => file.path.endsWith('project-inventory.md'),
|
|
1422
|
+
)?.content || '';
|
|
1423
|
+
guidanceMetadata = {
|
|
1424
|
+
loaded: Boolean(content),
|
|
1425
|
+
contentHash: content
|
|
1426
|
+
? crypto.createHash('sha256').update(content).digest('hex')
|
|
1427
|
+
: null,
|
|
1428
|
+
projectContextHash: inventory
|
|
1429
|
+
? crypto.createHash('sha256').update(inventory).digest('hex')
|
|
1430
|
+
: null,
|
|
1431
|
+
files: files.map((file) => file.path),
|
|
1432
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
1433
|
+
};
|
|
1434
|
+
return { content, ...guidanceMetadata };
|
|
1435
|
+
};
|
|
1436
|
+
|
|
1437
|
+
const configurePromptContext = ({
|
|
1438
|
+
doctrineLoaded = false,
|
|
1439
|
+
doctrineVersion = null,
|
|
1440
|
+
} = {}) => {
|
|
1441
|
+
promptMetadata = {
|
|
1442
|
+
doctrineLoaded: doctrineLoaded === true,
|
|
1443
|
+
doctrineVersion:
|
|
1444
|
+
typeof doctrineVersion === 'string' && doctrineVersion.trim()
|
|
1445
|
+
? doctrineVersion.trim().slice(0, 160)
|
|
1446
|
+
: null,
|
|
1447
|
+
};
|
|
1448
|
+
};
|
|
1449
|
+
|
|
1450
|
+
const execCode = async (code, { timeoutMs } = {}) => {
|
|
1451
|
+
let activeSessionId = await ensureSession();
|
|
1452
|
+
try {
|
|
1453
|
+
return await executeCli(['exec', '-s', activeSessionId], {
|
|
1454
|
+
input: code,
|
|
1455
|
+
timeoutMs,
|
|
1456
|
+
});
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
if (
|
|
1459
|
+
error?.code !== 'FRAMER_AGENT_COMMAND_FAILED'
|
|
1460
|
+
|| !/session|connection|not found|closed|expired/i.test(
|
|
1461
|
+
String(error?.message || ''),
|
|
1462
|
+
)
|
|
1463
|
+
) {
|
|
1464
|
+
throw error;
|
|
1465
|
+
}
|
|
1466
|
+
sessionsByProject.delete(sessionCacheKey);
|
|
1467
|
+
sessionId = null;
|
|
1468
|
+
activeSessionId = await ensureSession();
|
|
1469
|
+
return executeCli(['exec', '-s', activeSessionId], {
|
|
1470
|
+
input: code,
|
|
1471
|
+
timeoutMs,
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1475
|
+
|
|
1476
|
+
const validateAuthoritativeNodeIds = async () => {
|
|
1477
|
+
await ensureSession();
|
|
1478
|
+
if (authoritativeNodeIds.length === 0) return;
|
|
1479
|
+
if (nodeValidationPromise) return nodeValidationPromise;
|
|
1480
|
+
nodeValidationPromise = (async () => {
|
|
1481
|
+
reportActivity({
|
|
1482
|
+
kind: 'status_update',
|
|
1483
|
+
source: 'framer-agent',
|
|
1484
|
+
message: 'Validating the selected Framer layers.',
|
|
1485
|
+
});
|
|
1486
|
+
const result = await execCode(
|
|
1487
|
+
[
|
|
1488
|
+
`const requestedIds = ${JSON.stringify(authoritativeNodeIds)};`,
|
|
1489
|
+
'const resolved = await Promise.all(requestedIds.map(async (requestedId) => {',
|
|
1490
|
+
' const node = await framer.agent.getNode({ id: requestedId },',
|
|
1491
|
+
` { pagePath: ${JSON.stringify(activePagePath)} }).catch(() => null);`,
|
|
1492
|
+
' return { requestedId, resolvedId: node?.id ?? null };',
|
|
1493
|
+
'}));',
|
|
1494
|
+
'console.log(JSON.stringify({ resolved }));',
|
|
1495
|
+
].join('\n'),
|
|
1496
|
+
{ timeoutMs: 30_000 },
|
|
1497
|
+
);
|
|
1498
|
+
const parsed = parseStructuredOutput(result.stdout);
|
|
1499
|
+
const resolved = Array.isArray(parsed?.resolved)
|
|
1500
|
+
? parsed.resolved
|
|
1501
|
+
: [];
|
|
1502
|
+
const invalid = authoritativeNodeIds.filter((requestedId) => {
|
|
1503
|
+
const match = resolved.find(
|
|
1504
|
+
(entry) =>
|
|
1505
|
+
isRecord(entry)
|
|
1506
|
+
&& entry.requestedId === requestedId,
|
|
1507
|
+
);
|
|
1508
|
+
return !match || match.resolvedId !== requestedId;
|
|
1509
|
+
});
|
|
1510
|
+
if (invalid.length > 0) {
|
|
1511
|
+
throw toolError(
|
|
1512
|
+
'FRAMER_AGENT_NODE_ID_INVALID',
|
|
1513
|
+
`Dexter received Framer targets that do not resolve to exact native node IDs: ${invalid.join(', ')}. Re-select the affected layers in Framer and try again.`,
|
|
1514
|
+
409,
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
reportActivity({
|
|
1518
|
+
kind: 'status_update',
|
|
1519
|
+
source: 'framer-agent',
|
|
1520
|
+
message: 'The selected Framer layers are ready.',
|
|
1521
|
+
});
|
|
1522
|
+
})().catch((error) => {
|
|
1523
|
+
nodeValidationPromise = null;
|
|
1524
|
+
throw error;
|
|
1525
|
+
});
|
|
1526
|
+
return nodeValidationPromise;
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
const normalizedResult = (result) => {
|
|
1530
|
+
const parsed = parseStructuredOutput(result.stdout);
|
|
1531
|
+
return {
|
|
1532
|
+
...(parsed === null
|
|
1533
|
+
? { output: boundedText(result.stdout, MAX_MODEL_RESULT_BYTES) }
|
|
1534
|
+
: { result: boundedStructuredValue(parsed) }),
|
|
1535
|
+
...(result.stderr.trim()
|
|
1536
|
+
? { diagnostics: boundedText(result.stderr, 20_000) }
|
|
1537
|
+
: {}),
|
|
1538
|
+
...(branchChange ? { branch: branchChange } : {}),
|
|
1539
|
+
};
|
|
1540
|
+
};
|
|
1541
|
+
|
|
1542
|
+
const markInspection = ({ visual = false, verifies = [] } = {}) => {
|
|
1543
|
+
operationSequence += 1;
|
|
1544
|
+
lastInspectionSequence = operationSequence;
|
|
1545
|
+
lastInspectionAt = new Date().toISOString();
|
|
1546
|
+
verificationEvidence.set('structural', operationSequence);
|
|
1547
|
+
if (visual) {
|
|
1548
|
+
screenshotCalls += 1;
|
|
1549
|
+
lastScreenshotSequence = operationSequence;
|
|
1550
|
+
verificationEvidence.set('visual', operationSequence);
|
|
1551
|
+
if (lastMutationSequence === 0) screenshotBeforeMutation = true;
|
|
1552
|
+
}
|
|
1553
|
+
for (const check of normalizeVerificationValues(
|
|
1554
|
+
verifies,
|
|
1555
|
+
GENERIC_VERIFICATION_CHECKS,
|
|
1556
|
+
)) {
|
|
1557
|
+
verificationEvidence.set(check, operationSequence);
|
|
1558
|
+
}
|
|
1559
|
+
};
|
|
1560
|
+
|
|
1561
|
+
const markMutation = () => {
|
|
1562
|
+
operationSequence += 1;
|
|
1563
|
+
lastMutationSequence = operationSequence;
|
|
1564
|
+
lastMutationAt = new Date().toISOString();
|
|
1565
|
+
};
|
|
1566
|
+
|
|
1567
|
+
const requiredVerificationChecks = () =>
|
|
1568
|
+
taskPlan?.verification || [];
|
|
1569
|
+
const taskPlanSnapshot = () => {
|
|
1570
|
+
if (!taskPlan) return null;
|
|
1571
|
+
return {
|
|
1572
|
+
summary: taskPlan.summary,
|
|
1573
|
+
domains: [...taskPlan.domains],
|
|
1574
|
+
verification: [...taskPlan.verification],
|
|
1575
|
+
...(taskPlan.behaviors.length > 0
|
|
1576
|
+
? {
|
|
1577
|
+
behaviors: taskPlan.behaviors.map((behavior) => ({
|
|
1578
|
+
...behavior,
|
|
1579
|
+
expectedContent: [...behavior.expectedContent],
|
|
1580
|
+
})),
|
|
1581
|
+
mechanisms: taskPlan.mechanisms.map((decision) => ({
|
|
1582
|
+
...decision,
|
|
1583
|
+
})),
|
|
1584
|
+
rejectedMechanisms: taskPlan.rejectedMechanisms.map((entry) => ({
|
|
1585
|
+
...entry,
|
|
1586
|
+
})),
|
|
1587
|
+
compatibility: taskPlan.compatibility.map((entry) => ({
|
|
1588
|
+
...entry,
|
|
1589
|
+
requiredCapabilities: [...entry.requiredCapabilities],
|
|
1590
|
+
})),
|
|
1591
|
+
}
|
|
1592
|
+
: {}),
|
|
1593
|
+
};
|
|
1594
|
+
};
|
|
1595
|
+
const synchronized = () =>
|
|
1596
|
+
mutationCalls === 0 || lastInspectionSequence > lastMutationSequence;
|
|
1597
|
+
const verificationCheckCompleted = (check) => {
|
|
1598
|
+
if (mutationCalls === 0) return true;
|
|
1599
|
+
if (check === 'structural') return synchronized();
|
|
1600
|
+
if (check === 'visual') {
|
|
1601
|
+
return (
|
|
1602
|
+
screenshotBeforeMutation
|
|
1603
|
+
&& lastScreenshotSequence > lastMutationSequence
|
|
1604
|
+
);
|
|
1605
|
+
}
|
|
1606
|
+
return Number(verificationEvidence.get(check) || 0) > lastMutationSequence;
|
|
1607
|
+
};
|
|
1608
|
+
const visualVerificationRequired = () =>
|
|
1609
|
+
applyChangesCalls > 0
|
|
1610
|
+
|| requiredVerificationChecks().includes('visual');
|
|
1611
|
+
const visualVerificationCompleted = () =>
|
|
1612
|
+
!visualVerificationRequired()
|
|
1613
|
+
|| mutationCalls === 0
|
|
1614
|
+
|| (
|
|
1615
|
+
screenshotBeforeMutation
|
|
1616
|
+
&& lastScreenshotSequence > lastMutationSequence
|
|
1617
|
+
);
|
|
1618
|
+
const missingVerificationChecks = () => {
|
|
1619
|
+
if (mutationCalls === 0) return [];
|
|
1620
|
+
const missing = [];
|
|
1621
|
+
if (!taskPlan) missing.push('task-plan');
|
|
1622
|
+
const required = new Set([
|
|
1623
|
+
'structural',
|
|
1624
|
+
...requiredVerificationChecks(),
|
|
1625
|
+
...(applyChangesCalls > 0 ? ['visual'] : []),
|
|
1626
|
+
]);
|
|
1627
|
+
for (const check of required) {
|
|
1628
|
+
if (!verificationCheckCompleted(check)) missing.push(check);
|
|
1629
|
+
}
|
|
1630
|
+
return missing;
|
|
1631
|
+
};
|
|
1632
|
+
const verification = () => ({
|
|
1633
|
+
ownedBy: 'harness',
|
|
1634
|
+
required: mutationCalls > 0,
|
|
1635
|
+
completed: missingVerificationChecks().length === 0,
|
|
1636
|
+
structuralCompleted: synchronized(),
|
|
1637
|
+
visualRequired: visualVerificationRequired() && mutationCalls > 0,
|
|
1638
|
+
visualCompleted: visualVerificationCompleted(),
|
|
1639
|
+
screenshotCalls,
|
|
1640
|
+
...(requiredVerificationChecks().includes('interactions')
|
|
1641
|
+
|| interactionVerificationCalls > 0
|
|
1642
|
+
? {
|
|
1643
|
+
interactionVerificationCalls,
|
|
1644
|
+
interactionVerification: {
|
|
1645
|
+
completed: verificationCheckCompleted('interactions'),
|
|
1646
|
+
verificationLevel:
|
|
1647
|
+
verificationCheckCompleted('interactions')
|
|
1648
|
+
? interactionVerification.verificationLevel
|
|
1649
|
+
: 'unverified',
|
|
1650
|
+
checks: interactionVerification.checks.map((check) => ({
|
|
1651
|
+
...check,
|
|
1652
|
+
failures: [...check.failures],
|
|
1653
|
+
})),
|
|
1654
|
+
behaviors: interactionVerification.behaviors.map((behavior) => ({
|
|
1655
|
+
...behavior,
|
|
1656
|
+
failures: [...behavior.failures],
|
|
1657
|
+
})),
|
|
1658
|
+
verifiedAt: interactionVerification.verifiedAt,
|
|
1659
|
+
},
|
|
1660
|
+
}
|
|
1661
|
+
: {}),
|
|
1662
|
+
taskPlan: taskPlanSnapshot(),
|
|
1663
|
+
requiredChecks: [
|
|
1664
|
+
...new Set([
|
|
1665
|
+
'structural',
|
|
1666
|
+
...requiredVerificationChecks(),
|
|
1667
|
+
...(applyChangesCalls > 0 ? ['visual'] : []),
|
|
1668
|
+
]),
|
|
1669
|
+
],
|
|
1670
|
+
completedChecks: [
|
|
1671
|
+
...new Set([
|
|
1672
|
+
'structural',
|
|
1673
|
+
...requiredVerificationChecks(),
|
|
1674
|
+
...(applyChangesCalls > 0 ? ['visual'] : []),
|
|
1675
|
+
]),
|
|
1676
|
+
].filter(verificationCheckCompleted),
|
|
1677
|
+
missingChecks: missingVerificationChecks(),
|
|
1678
|
+
lastMutationAt,
|
|
1679
|
+
lastInspectionAt,
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
const inspectFinalState = async () => {
|
|
1683
|
+
await serial;
|
|
1684
|
+
return {
|
|
1685
|
+
synchronized: synchronized(),
|
|
1686
|
+
sourceHash: null,
|
|
1687
|
+
changed: mutationCalls > 0,
|
|
1688
|
+
mutationCalls,
|
|
1689
|
+
readCalls,
|
|
1690
|
+
branch: branchChange,
|
|
1691
|
+
lastInspectionAt,
|
|
1692
|
+
lastMutationAt,
|
|
1693
|
+
taskPlan: taskPlanSnapshot(),
|
|
1694
|
+
verification: verification(),
|
|
1695
|
+
};
|
|
1696
|
+
};
|
|
1697
|
+
|
|
1698
|
+
const finalizeCompletion = (completion) => {
|
|
1699
|
+
if (
|
|
1700
|
+
completion?.status !== 'completed'
|
|
1701
|
+
|| mutationCalls === 0
|
|
1702
|
+
|| verification().completed
|
|
1703
|
+
) {
|
|
1704
|
+
return completion;
|
|
1705
|
+
}
|
|
1706
|
+
const missing = missingVerificationChecks().map((check) => {
|
|
1707
|
+
if (check === 'task-plan') return 'a harness task and verification plan';
|
|
1708
|
+
if (check === 'structural') return 'a final structural read';
|
|
1709
|
+
if (check === 'visual') return 'before-and-after screenshot verification';
|
|
1710
|
+
return `post-mutation ${check} verification`;
|
|
1711
|
+
});
|
|
1712
|
+
const reason =
|
|
1713
|
+
`The harness ended before completing ${missing.join(' and ')}.`;
|
|
1714
|
+
return {
|
|
1715
|
+
...completion,
|
|
1716
|
+
status: 'failed',
|
|
1717
|
+
summary: `${String(completion?.summary || '').trim()} ${reason}`.trim(),
|
|
1718
|
+
checks: [
|
|
1719
|
+
...(Array.isArray(completion?.checks) ? completion.checks : []),
|
|
1720
|
+
{
|
|
1721
|
+
command: 'Harness-owned final verification',
|
|
1722
|
+
status: 'failed',
|
|
1723
|
+
output: reason,
|
|
1724
|
+
},
|
|
1725
|
+
],
|
|
1726
|
+
blockedReason: null,
|
|
1727
|
+
};
|
|
1728
|
+
};
|
|
1729
|
+
|
|
1730
|
+
const verificationRepairPrompt = () => {
|
|
1731
|
+
const current = verification();
|
|
1732
|
+
if (current.completed || mutationCalls === 0) return null;
|
|
1733
|
+
const missing = [];
|
|
1734
|
+
for (const check of current.missingChecks || []) {
|
|
1735
|
+
if (check === 'task-plan') {
|
|
1736
|
+
missing.push(
|
|
1737
|
+
'declare the task domains and verification plan with framer_plan_task',
|
|
1738
|
+
);
|
|
1739
|
+
} else if (check === 'structural') {
|
|
1740
|
+
missing.push('perform a focused live read after the latest mutation');
|
|
1741
|
+
} else if (check === 'visual') {
|
|
1742
|
+
missing.push(
|
|
1743
|
+
screenshotBeforeMutation
|
|
1744
|
+
? 'capture and inspect the affected canvas after the latest mutation'
|
|
1745
|
+
: 'report that the required baseline screenshot was missed; do not claim visual verification',
|
|
1746
|
+
);
|
|
1747
|
+
} else {
|
|
1748
|
+
missing.push(
|
|
1749
|
+
check === 'interactions'
|
|
1750
|
+
? 'call framer_verify_interactions with canonical evidence node IDs for every declared behavior and chosen mechanism'
|
|
1751
|
+
: `perform a focused read tagged with verifies:["${check}"] after the latest mutation`,
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
return [
|
|
1756
|
+
'Continue the same Framer task from its current project state.',
|
|
1757
|
+
'Do not redo successful work.',
|
|
1758
|
+
`Before returning, ${missing.join(' and ')}.`,
|
|
1759
|
+
'Repair only concrete problems you observe, then return the required JSON outcome.',
|
|
1760
|
+
].join(' ');
|
|
1761
|
+
};
|
|
1762
|
+
|
|
1763
|
+
const builtInInstructions = (documentationUnavailable = false) => ({
|
|
1764
|
+
stdout: JSON.stringify({
|
|
1765
|
+
source: documentationUnavailable
|
|
1766
|
+
? 'built-in-fallback'
|
|
1767
|
+
: 'built-in',
|
|
1768
|
+
guidance: [
|
|
1769
|
+
'Use framer_read with framer.agent.getNode({ id }) or getNodes({ ids }) for node inspection.',
|
|
1770
|
+
'Use framer_read_project for supported project queries such as screenshots.',
|
|
1771
|
+
'Use framer_query_images for approved stock imagery only after checking user attachments and suitable existing project images.',
|
|
1772
|
+
'Use framer_apply_changes for layout and styling. Read its diagnostics, verify the result, and repair concrete issues.',
|
|
1773
|
+
'If framer_write is rejected as unsafe, do not retry JavaScript variants. Switch to framer_apply_changes or report the concrete blocker.',
|
|
1774
|
+
'Batch related work when practical, reuse evidence already collected, and stop when the requested outcome is satisfied.',
|
|
1775
|
+
],
|
|
1776
|
+
documentationUnavailable,
|
|
1777
|
+
}),
|
|
1778
|
+
stderr: '',
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
const reserveToolCall = (name, category) => {
|
|
1782
|
+
toolCallCount += 1;
|
|
1783
|
+
if (category === 'progress') progressCalls += 1;
|
|
1784
|
+
if (category === 'read') readCalls += 1;
|
|
1785
|
+
const call = {
|
|
1786
|
+
sequence: toolCallCount,
|
|
1787
|
+
name,
|
|
1788
|
+
category,
|
|
1789
|
+
status: 'failed',
|
|
1790
|
+
durationMs: 0,
|
|
1791
|
+
};
|
|
1792
|
+
toolCalls.push(call);
|
|
1793
|
+
return call;
|
|
1794
|
+
};
|
|
1795
|
+
|
|
1796
|
+
const callFingerprint = (name, rawArguments) =>
|
|
1797
|
+
crypto
|
|
1798
|
+
.createHash('sha256')
|
|
1799
|
+
.update(`${name}\n${JSON.stringify(rawArguments)}`)
|
|
1800
|
+
.digest('hex');
|
|
1801
|
+
|
|
1802
|
+
const requireTaskPlanForMutation = ({ canvas = false } = {}) => {
|
|
1803
|
+
if (!taskPlan) {
|
|
1804
|
+
throw toolError(
|
|
1805
|
+
'FRAMER_HARNESS_PLAN_REQUIRED',
|
|
1806
|
+
'Inspect the live Framer target, then call framer_plan_task before making a mutation.',
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
if (
|
|
1810
|
+
(canvas || taskPlan.verification.includes('visual'))
|
|
1811
|
+
&& !screenshotBeforeMutation
|
|
1812
|
+
) {
|
|
1813
|
+
throw toolError(
|
|
1814
|
+
'FRAMER_HARNESS_BASELINE_SCREENSHOT_REQUIRED',
|
|
1815
|
+
'Capture and inspect a screenshot of the affected canvas before the first visual mutation.',
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
const rejectedSelectedMechanisms = taskPlan.behaviors.filter((behavior) => {
|
|
1819
|
+
const selected = taskPlan.mechanisms.find((decision) =>
|
|
1820
|
+
decision.behaviorId === behavior.id);
|
|
1821
|
+
return selected && taskPlan.rejectedMechanisms.some((rejected) =>
|
|
1822
|
+
rejected.mechanism === selected.mechanism
|
|
1823
|
+
&& (
|
|
1824
|
+
rejected.behaviorId === behavior.id
|
|
1825
|
+
|| rejected.behaviorSignature === behavior.signature
|
|
1826
|
+
));
|
|
1827
|
+
});
|
|
1828
|
+
if (rejectedSelectedMechanisms.length > 0) {
|
|
1829
|
+
throw toolError(
|
|
1830
|
+
'FRAMER_BEHAVIOR_MECHANISM_REPLAN_REQUIRED',
|
|
1831
|
+
`Choose a different mechanism for ${rejectedSelectedMechanisms.map((behavior) => behavior.id).join(', ')} before making another mutation.`,
|
|
1832
|
+
);
|
|
1833
|
+
}
|
|
1834
|
+
if (mutationCalls > 0) {
|
|
1835
|
+
const pendingRepresentative = taskPlan.behaviors
|
|
1836
|
+
.filter((behavior) => behavior.expectedTargets > 1)
|
|
1837
|
+
.filter((behavior) =>
|
|
1838
|
+
taskPlan.mechanisms.find((decision) =>
|
|
1839
|
+
decision.behaviorId === behavior.id
|
|
1840
|
+
&& decision.representativeFirst))
|
|
1841
|
+
.filter((behavior) =>
|
|
1842
|
+
!representativeVerifiedBehaviorIds.has(behavior.id));
|
|
1843
|
+
if (pendingRepresentative.length > 0) {
|
|
1844
|
+
throw toolError(
|
|
1845
|
+
'FRAMER_BEHAVIOR_REPRESENTATIVE_VERIFICATION_REQUIRED',
|
|
1846
|
+
`Verify one representative implementation for ${pendingRepresentative.map((behavior) => behavior.id).join(', ')} before batching further mutations.`,
|
|
1847
|
+
);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
};
|
|
1851
|
+
|
|
1852
|
+
async function invokeUnsafe(name, rawArguments = {}) {
|
|
1853
|
+
if (closed) {
|
|
1854
|
+
throw toolError(
|
|
1855
|
+
'FRAMER_AGENT_RUNTIME_CLOSED',
|
|
1856
|
+
'The Framer Agent runtime is closed.',
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1859
|
+
if (!allowedToolNames.has(name) || !TOOL_BY_NAME.has(name)) {
|
|
1860
|
+
throw toolError(
|
|
1861
|
+
'FRAMER_AGENT_TOOL_UNKNOWN',
|
|
1862
|
+
`Unavailable Framer Agent tool: ${name}`,
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
if (!isRecord(rawArguments)) {
|
|
1866
|
+
throw toolError(
|
|
1867
|
+
'FRAMER_AGENT_TOOL_INPUT_INVALID',
|
|
1868
|
+
'Framer Agent tool arguments must be an object.',
|
|
1869
|
+
);
|
|
1870
|
+
}
|
|
1871
|
+
const mutation =
|
|
1872
|
+
name === 'framer_apply_changes' || name === 'framer_write';
|
|
1873
|
+
const category =
|
|
1874
|
+
name === 'framer_plan_task'
|
|
1875
|
+
? 'plan'
|
|
1876
|
+
: mutation
|
|
1877
|
+
? 'mutation'
|
|
1878
|
+
: 'read';
|
|
1879
|
+
const fingerprint = callFingerprint(name, rawArguments);
|
|
1880
|
+
if (
|
|
1881
|
+
repeatedFailureFingerprint === fingerprint
|
|
1882
|
+
&& repeatedFailureCount >= 5
|
|
1883
|
+
) {
|
|
1884
|
+
repeatedFailureStops += 1;
|
|
1885
|
+
throw toolError(
|
|
1886
|
+
'FRAMER_AGENT_REPEATED_CALL_LOOP',
|
|
1887
|
+
'The same Framer tool call has failed five times in a row. Change the approach or report the concrete blocker.',
|
|
1888
|
+
);
|
|
1889
|
+
}
|
|
1890
|
+
if (name === 'progress_update') {
|
|
1891
|
+
const message = String(rawArguments.message || '').trim().slice(0, 500);
|
|
1892
|
+
if (!message) {
|
|
1893
|
+
throw toolError(
|
|
1894
|
+
'FRAMER_AGENT_PROGRESS_INVALID',
|
|
1895
|
+
'A progress message is required.',
|
|
1896
|
+
);
|
|
1897
|
+
}
|
|
1898
|
+
const toolCall = reserveToolCall(name, 'progress');
|
|
1899
|
+
reportActivity({
|
|
1900
|
+
kind: 'status_update',
|
|
1901
|
+
source: 'harness',
|
|
1902
|
+
message,
|
|
1903
|
+
});
|
|
1904
|
+
repeatedFailureFingerprint = null;
|
|
1905
|
+
repeatedFailureCount = 0;
|
|
1906
|
+
toolCall.status = 'succeeded';
|
|
1907
|
+
return { acknowledged: true };
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
const startedAt = Date.now();
|
|
1911
|
+
const toolCall = reserveToolCall(name, category);
|
|
1912
|
+
reportActivity({
|
|
1913
|
+
kind: 'tool_active',
|
|
1914
|
+
source: 'framer-agent',
|
|
1915
|
+
name,
|
|
1916
|
+
phase: 'started',
|
|
1917
|
+
message:
|
|
1918
|
+
name === 'framer_apply_changes' || name === 'framer_write'
|
|
1919
|
+
? 'Updating the Framer project'
|
|
1920
|
+
: name === 'framer_plan_task'
|
|
1921
|
+
? 'Planning the Framer work and verification'
|
|
1922
|
+
: 'Inspecting the Framer project',
|
|
1923
|
+
});
|
|
1924
|
+
try {
|
|
1925
|
+
let result;
|
|
1926
|
+
if (name === 'framer_write' && lowLevelWriteDisabled) {
|
|
1927
|
+
lowLevelWriteBlockedCalls += 1;
|
|
1928
|
+
throw toolError(
|
|
1929
|
+
'FRAMER_AGENT_WRITE_FALLBACK_REQUIRED',
|
|
1930
|
+
'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.',
|
|
1931
|
+
);
|
|
1932
|
+
} else if (name === 'framer_instructions') {
|
|
1933
|
+
const queries = Array.isArray(rawArguments.queries)
|
|
1934
|
+
? uniqueStrings(rawArguments.queries, 8)
|
|
1935
|
+
: [];
|
|
1936
|
+
if (queries.length > 0 && !instructionLookupUnavailable) {
|
|
1937
|
+
try {
|
|
1938
|
+
result = await executeCli(
|
|
1939
|
+
['docs', ...queries],
|
|
1940
|
+
{ timeoutMs: 30_000 },
|
|
1941
|
+
);
|
|
1942
|
+
} catch (error) {
|
|
1943
|
+
instructionFallbacks += 1;
|
|
1944
|
+
instructionLookupUnavailable = true;
|
|
1945
|
+
trace?.warn?.('framer_agent_docs_fallback', {
|
|
1946
|
+
code: error?.code,
|
|
1947
|
+
message: error?.message,
|
|
1948
|
+
});
|
|
1949
|
+
reportActivity({
|
|
1950
|
+
kind: 'status_update',
|
|
1951
|
+
source: 'framer-agent',
|
|
1952
|
+
message:
|
|
1953
|
+
'Targeted Framer documentation is unavailable, so I am continuing with the built-in harness guidance.',
|
|
1954
|
+
});
|
|
1955
|
+
result = builtInInstructions(true);
|
|
1956
|
+
}
|
|
1957
|
+
} else {
|
|
1958
|
+
if (queries.length > 0) instructionFallbacks += 1;
|
|
1959
|
+
result = builtInInstructions(queries.length > 0);
|
|
1960
|
+
}
|
|
1961
|
+
} else if (name === 'framer_context') {
|
|
1962
|
+
result = await execCode(
|
|
1963
|
+
[
|
|
1964
|
+
'const [context, branch] = await Promise.all([',
|
|
1965
|
+
' framer.agent.getContext(),',
|
|
1966
|
+
' framer.agent.getActiveBranch().catch(() => null),',
|
|
1967
|
+
']);',
|
|
1968
|
+
'console.log(JSON.stringify({ context, branch }, null, 2));',
|
|
1969
|
+
].join('\n'),
|
|
1970
|
+
);
|
|
1971
|
+
markInspection();
|
|
1972
|
+
} else if (name === 'framer_plan_task') {
|
|
1973
|
+
if (lastInspectionSequence === 0) {
|
|
1974
|
+
throw toolError(
|
|
1975
|
+
'FRAMER_HARNESS_INSPECTION_REQUIRED',
|
|
1976
|
+
'Inspect the live Framer target before declaring the task and verification plan.',
|
|
1977
|
+
);
|
|
1978
|
+
}
|
|
1979
|
+
const nextPlan = normalizeTaskPlan(
|
|
1980
|
+
rawArguments,
|
|
1981
|
+
inheritedRejectedMechanisms,
|
|
1982
|
+
);
|
|
1983
|
+
if (
|
|
1984
|
+
mutationCalls > 0
|
|
1985
|
+
&& nextPlan.verification.includes('visual')
|
|
1986
|
+
&& !screenshotBeforeMutation
|
|
1987
|
+
) {
|
|
1988
|
+
throw toolError(
|
|
1989
|
+
'FRAMER_HARNESS_BASELINE_SCREENSHOT_REQUIRED',
|
|
1990
|
+
'Visual verification cannot be added after mutations when no baseline screenshot was captured.',
|
|
1991
|
+
);
|
|
1992
|
+
}
|
|
1993
|
+
const previousMechanisms = new Map(
|
|
1994
|
+
(taskPlan?.mechanisms || []).map((decision) => [
|
|
1995
|
+
decision.behaviorId,
|
|
1996
|
+
decision.mechanism,
|
|
1997
|
+
]),
|
|
1998
|
+
);
|
|
1999
|
+
const behaviorPlan = mergeBehaviorPlans(taskPlan, nextPlan);
|
|
2000
|
+
taskPlan = {
|
|
2001
|
+
summary: nextPlan.summary,
|
|
2002
|
+
domains: uniqueStrings([
|
|
2003
|
+
...(taskPlan?.domains || []),
|
|
2004
|
+
...nextPlan.domains,
|
|
2005
|
+
]),
|
|
2006
|
+
verification: uniqueStrings([
|
|
2007
|
+
...(taskPlan?.verification || []),
|
|
2008
|
+
...nextPlan.verification,
|
|
2009
|
+
]),
|
|
2010
|
+
...behaviorPlan,
|
|
2011
|
+
};
|
|
2012
|
+
for (const decision of taskPlan.mechanisms) {
|
|
2013
|
+
const prior = previousMechanisms.get(decision.behaviorId);
|
|
2014
|
+
if (prior && prior !== decision.mechanism) {
|
|
2015
|
+
representativeVerifiedBehaviorIds.delete(decision.behaviorId);
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
result = {
|
|
2019
|
+
stdout: JSON.stringify({
|
|
2020
|
+
plan: {
|
|
2021
|
+
summary: taskPlan.summary,
|
|
2022
|
+
domains: [...taskPlan.domains],
|
|
2023
|
+
verification: [...taskPlan.verification],
|
|
2024
|
+
behaviors: taskPlan.behaviors.map((behavior) => ({
|
|
2025
|
+
...behavior,
|
|
2026
|
+
expectedContent: [...behavior.expectedContent],
|
|
2027
|
+
})),
|
|
2028
|
+
mechanisms: taskPlan.mechanisms.map((decision) => ({
|
|
2029
|
+
...decision,
|
|
2030
|
+
})),
|
|
2031
|
+
compatibility: taskPlan.compatibility.map((entry) => ({
|
|
2032
|
+
...entry,
|
|
2033
|
+
requiredCapabilities: [...entry.requiredCapabilities],
|
|
2034
|
+
})),
|
|
2035
|
+
rejectedMechanisms: taskPlan.rejectedMechanisms.map((entry) => ({
|
|
2036
|
+
...entry,
|
|
2037
|
+
})),
|
|
2038
|
+
},
|
|
2039
|
+
instruction:
|
|
2040
|
+
'Implement one representative first when required, verify it, then complete coverage and gather every derived verification result after the final mutation.',
|
|
2041
|
+
}),
|
|
2042
|
+
stderr: '',
|
|
2043
|
+
};
|
|
2044
|
+
} else if (name === 'framer_verify_interactions') {
|
|
2045
|
+
if (mutationCalls === 0) {
|
|
2046
|
+
throw toolError(
|
|
2047
|
+
'FRAMER_HARNESS_INTERACTION_MUTATION_REQUIRED',
|
|
2048
|
+
'Verify interactions only after the relevant Framer mutation has succeeded.',
|
|
2049
|
+
);
|
|
2050
|
+
}
|
|
2051
|
+
if (!taskPlan?.behaviors?.length) {
|
|
2052
|
+
throw toolError(
|
|
2053
|
+
'FRAMER_BEHAVIOR_CONTRACT_REQUIRED',
|
|
2054
|
+
'Declare observable behaviors and compatible mechanisms with framer_plan_task before interaction verification.',
|
|
2055
|
+
);
|
|
2056
|
+
}
|
|
2057
|
+
const checks = normalizeBehaviorEvidence(
|
|
2058
|
+
rawArguments.checks,
|
|
2059
|
+
taskPlan,
|
|
2060
|
+
);
|
|
2061
|
+
const pagePath = normalizePagePath(rawArguments.pagePath);
|
|
2062
|
+
const requestedIds = evidenceNodeIds(checks);
|
|
2063
|
+
const liveResult = await execCode(
|
|
2064
|
+
[
|
|
2065
|
+
`const requestedIds = ${JSON.stringify(requestedIds)};`,
|
|
2066
|
+
'const nodes = await Promise.all(requestedIds.map(async (id) => ({',
|
|
2067
|
+
' id,',
|
|
2068
|
+
' node: await framer.agent.serialize({ id, depth: 8 },',
|
|
2069
|
+
` { pagePath: ${JSON.stringify(pagePath)} }).catch(() => null),`,
|
|
2070
|
+
' parentId: await framer.agent.getParentNode({ id },',
|
|
2071
|
+
` { pagePath: ${JSON.stringify(pagePath)} }).then((node) => node?.id ?? null).catch(() => null),`,
|
|
2072
|
+
' ancestorIds: await framer.agent.getAncestors({ id },',
|
|
2073
|
+
` { pagePath: ${JSON.stringify(pagePath)} }).then((nodes) => Array.isArray(nodes) ? nodes.map((node) => node?.id).filter(Boolean) : []).catch(() => []),`,
|
|
2074
|
+
'})));',
|
|
2075
|
+
'console.log(JSON.stringify({ nodes }, null, 2));',
|
|
2076
|
+
].join('\n'),
|
|
2077
|
+
);
|
|
2078
|
+
const parsed = parseStructuredOutput(liveResult.stdout);
|
|
2079
|
+
const nodesById = new Map(
|
|
2080
|
+
(Array.isArray(parsed?.nodes) ? parsed.nodes : [])
|
|
2081
|
+
.filter((entry) => isRecord(entry) && typeof entry.id === 'string')
|
|
2082
|
+
.map((entry) => [entry.id, {
|
|
2083
|
+
node: entry.node || null,
|
|
2084
|
+
parentId:
|
|
2085
|
+
typeof entry.parentId === 'string'
|
|
2086
|
+
? entry.parentId
|
|
2087
|
+
: null,
|
|
2088
|
+
ancestorIds: Array.isArray(entry.ancestorIds)
|
|
2089
|
+
? entry.ancestorIds
|
|
2090
|
+
: [],
|
|
2091
|
+
}]),
|
|
2092
|
+
);
|
|
2093
|
+
const verified = verifyBehaviorEvidence({
|
|
2094
|
+
plan: taskPlan,
|
|
2095
|
+
checks,
|
|
2096
|
+
snapshots: nodesById,
|
|
2097
|
+
context: {
|
|
2098
|
+
lowLevelWriteCalls,
|
|
2099
|
+
authorizedInstanceContainerIds,
|
|
2100
|
+
},
|
|
2101
|
+
});
|
|
2102
|
+
markInspection();
|
|
2103
|
+
interactionVerificationCalls += 1;
|
|
2104
|
+
interactionVerification = {
|
|
2105
|
+
...verified,
|
|
2106
|
+
verifiedAt: new Date().toISOString(),
|
|
2107
|
+
};
|
|
2108
|
+
for (const behaviorResult of verified.behaviors) {
|
|
2109
|
+
const failedChecks = verified.checks.filter((check) =>
|
|
2110
|
+
check.behaviorId === behaviorResult.behaviorId && !check.passed);
|
|
2111
|
+
if (failedChecks.length === 0) {
|
|
2112
|
+
repeatedBehaviorVerificationFailures.delete(
|
|
2113
|
+
behaviorResult.behaviorId,
|
|
2114
|
+
);
|
|
2115
|
+
continue;
|
|
2116
|
+
}
|
|
2117
|
+
const failureFingerprint = crypto
|
|
2118
|
+
.createHash('sha256')
|
|
2119
|
+
.update(JSON.stringify({
|
|
2120
|
+
mechanism: behaviorResult.mechanism,
|
|
2121
|
+
failures: failedChecks
|
|
2122
|
+
.flatMap((check) => check.failures)
|
|
2123
|
+
.sort(),
|
|
2124
|
+
}))
|
|
2125
|
+
.digest('hex');
|
|
2126
|
+
const prior = repeatedBehaviorVerificationFailures.get(
|
|
2127
|
+
behaviorResult.behaviorId,
|
|
2128
|
+
);
|
|
2129
|
+
const count =
|
|
2130
|
+
prior?.fingerprint === failureFingerprint
|
|
2131
|
+
? prior.count + 1
|
|
2132
|
+
: 1;
|
|
2133
|
+
repeatedBehaviorVerificationFailures.set(
|
|
2134
|
+
behaviorResult.behaviorId,
|
|
2135
|
+
{ fingerprint: failureFingerprint, count },
|
|
2136
|
+
);
|
|
2137
|
+
if (count < 2 || !behaviorResult.mechanism) continue;
|
|
2138
|
+
const alreadyRejected = taskPlan.rejectedMechanisms.some((rejected) =>
|
|
2139
|
+
rejected.mechanism === behaviorResult.mechanism
|
|
2140
|
+
&& (
|
|
2141
|
+
rejected.behaviorId === behaviorResult.behaviorId
|
|
2142
|
+
|| rejected.behaviorSignature
|
|
2143
|
+
=== behaviorResult.behaviorSignature
|
|
2144
|
+
));
|
|
2145
|
+
if (alreadyRejected) continue;
|
|
2146
|
+
taskPlan = {
|
|
2147
|
+
...taskPlan,
|
|
2148
|
+
rejectedMechanisms: [
|
|
2149
|
+
...taskPlan.rejectedMechanisms,
|
|
2150
|
+
{
|
|
2151
|
+
behaviorId: behaviorResult.behaviorId,
|
|
2152
|
+
behaviorSignature: behaviorResult.behaviorSignature,
|
|
2153
|
+
mechanism: behaviorResult.mechanism,
|
|
2154
|
+
reason:
|
|
2155
|
+
`Repeated structural verification failed: ${failedChecks
|
|
2156
|
+
.flatMap((check) => check.failures)
|
|
2157
|
+
.join('; ')
|
|
2158
|
+
.slice(0, 700)}`,
|
|
2159
|
+
source: 'verification_failure',
|
|
2160
|
+
},
|
|
2161
|
+
],
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
verified.representativeBehaviorIds.forEach((behaviorId) =>
|
|
2165
|
+
representativeVerifiedBehaviorIds.add(behaviorId));
|
|
2166
|
+
if (interactionVerification.completed) {
|
|
2167
|
+
verificationEvidence.set('interactions', operationSequence);
|
|
2168
|
+
} else {
|
|
2169
|
+
verificationEvidence.delete('interactions');
|
|
2170
|
+
}
|
|
2171
|
+
result = {
|
|
2172
|
+
stdout: JSON.stringify(interactionVerification),
|
|
2173
|
+
stderr: liveResult.stderr,
|
|
2174
|
+
};
|
|
2175
|
+
} else if (name === 'framer_read_project') {
|
|
2176
|
+
const queries = Array.isArray(rawArguments.queries)
|
|
2177
|
+
? rawArguments.queries.slice(0, 50)
|
|
2178
|
+
: [];
|
|
2179
|
+
if (!queries.length || queries.some((query) => !isRecord(query))) {
|
|
2180
|
+
throw toolError(
|
|
2181
|
+
'FRAMER_AGENT_QUERY_INVALID',
|
|
2182
|
+
'framer_read_project requires one or more query objects.',
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
const pagePath = normalizePagePath(rawArguments.pagePath);
|
|
2186
|
+
result = await execCode(
|
|
2187
|
+
[
|
|
2188
|
+
`const result = await framer.agent.readProject(${JSON.stringify(queries)}, { pagePath: ${JSON.stringify(pagePath)} });`,
|
|
2189
|
+
'console.log(JSON.stringify(result, null, 2));',
|
|
2190
|
+
].join('\n'),
|
|
2191
|
+
);
|
|
2192
|
+
markInspection({
|
|
2193
|
+
visual: queries.some((query) => query.type === 'screenshot'),
|
|
2194
|
+
verifies: rawArguments.verifies,
|
|
2195
|
+
});
|
|
2196
|
+
} else if (name === 'framer_query_images') {
|
|
2197
|
+
const query = String(rawArguments.query || '').trim();
|
|
2198
|
+
if (query.length < 2) {
|
|
2199
|
+
throw toolError(
|
|
2200
|
+
'FRAMER_AGENT_IMAGE_QUERY_INVALID',
|
|
2201
|
+
'A specific image-search query is required.',
|
|
2202
|
+
);
|
|
2203
|
+
}
|
|
2204
|
+
const count = Math.max(
|
|
2205
|
+
1,
|
|
2206
|
+
Math.min(8, Math.floor(Number(rawArguments.count) || 4)),
|
|
2207
|
+
);
|
|
2208
|
+
const orientation = ['landscape', 'portrait', 'squarish']
|
|
2209
|
+
.includes(rawArguments.orientation)
|
|
2210
|
+
? rawArguments.orientation
|
|
2211
|
+
: undefined;
|
|
2212
|
+
result = await execCode(
|
|
2213
|
+
[
|
|
2214
|
+
'const result = await framer.agent.queryImages({',
|
|
2215
|
+
' source: "unsplash",',
|
|
2216
|
+
` query: ${JSON.stringify(query)},`,
|
|
2217
|
+
` count: ${count},`,
|
|
2218
|
+
...(orientation
|
|
2219
|
+
? [` orientation: ${JSON.stringify(orientation)},`]
|
|
2220
|
+
: []),
|
|
2221
|
+
'});',
|
|
2222
|
+
'console.log(JSON.stringify(result, null, 2));',
|
|
2223
|
+
].join('\n'),
|
|
2224
|
+
{ timeoutMs: 30_000 },
|
|
2225
|
+
);
|
|
2226
|
+
imageSearchCalls += 1;
|
|
2227
|
+
} else if (name === 'framer_apply_changes') {
|
|
2228
|
+
const changes = String(rawArguments.changes || '').trim();
|
|
2229
|
+
if (!changes) {
|
|
2230
|
+
throw toolError(
|
|
2231
|
+
'FRAMER_AGENT_CHANGES_REQUIRED',
|
|
2232
|
+
'Framer Agent changes are required.',
|
|
2233
|
+
);
|
|
2234
|
+
}
|
|
2235
|
+
requireTaskPlanForMutation({ canvas: true });
|
|
2236
|
+
const pagePath = normalizePagePath(rawArguments.pagePath);
|
|
2237
|
+
result = await execCode(
|
|
2238
|
+
[
|
|
2239
|
+
`const result = await framer.agent.applyChanges(${JSON.stringify(changes)}, { pagePath: ${JSON.stringify(pagePath)} });`,
|
|
2240
|
+
'console.log(JSON.stringify(result ?? { ok: true }, null, 2));',
|
|
2241
|
+
].join('\n'),
|
|
2242
|
+
);
|
|
2243
|
+
mutationCalls += 1;
|
|
2244
|
+
applyChangesCalls += 1;
|
|
2245
|
+
markMutation();
|
|
2246
|
+
const parsedResult = parseStructuredOutput(result.stdout);
|
|
2247
|
+
mutationReceipts.push({
|
|
2248
|
+
sequence: operationSequence,
|
|
2249
|
+
kind: 'framer_apply_changes',
|
|
2250
|
+
pagePath,
|
|
2251
|
+
...summarizeFramerChanges(changes),
|
|
2252
|
+
renamedIds: canonicalIdMappings(parsedResult),
|
|
2253
|
+
});
|
|
2254
|
+
} else if (name === 'framer_read') {
|
|
2255
|
+
result = await execCode(
|
|
2256
|
+
validateCode(rawArguments.code, {
|
|
2257
|
+
write: false,
|
|
2258
|
+
allowPublishing,
|
|
2259
|
+
}),
|
|
2260
|
+
);
|
|
2261
|
+
markInspection({ verifies: rawArguments.verifies });
|
|
2262
|
+
} else if (name === 'framer_write') {
|
|
2263
|
+
const code = validateCode(rawArguments.code, {
|
|
2264
|
+
write: true,
|
|
2265
|
+
allowPublishing,
|
|
2266
|
+
});
|
|
2267
|
+
const reason = String(rawArguments.reason || '').trim();
|
|
2268
|
+
if (reason.length < 10) {
|
|
2269
|
+
throw toolError(
|
|
2270
|
+
'FRAMER_AGENT_WRITE_REASON_REQUIRED',
|
|
2271
|
+
'Explain why this operation cannot be completed with framer_apply_changes before using framer_write.',
|
|
2272
|
+
);
|
|
2273
|
+
}
|
|
2274
|
+
requireTaskPlanForMutation();
|
|
2275
|
+
result = await execCode(code);
|
|
2276
|
+
mutationCalls += 1;
|
|
2277
|
+
lowLevelWriteCalls += 1;
|
|
2278
|
+
markMutation();
|
|
2279
|
+
mutationReceipts.push({
|
|
2280
|
+
sequence: operationSequence,
|
|
2281
|
+
kind: 'framer_write',
|
|
2282
|
+
reason: reason.slice(0, 500),
|
|
2283
|
+
codeHash: crypto.createHash('sha256').update(code).digest('hex'),
|
|
2284
|
+
});
|
|
2285
|
+
} else {
|
|
2286
|
+
throw toolError(
|
|
2287
|
+
'FRAMER_AGENT_TOOL_UNKNOWN',
|
|
2288
|
+
`Unknown Framer Agent tool: ${name}`,
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2291
|
+
const durationMs = Date.now() - startedAt;
|
|
2292
|
+
repeatedFailureFingerprint = null;
|
|
2293
|
+
repeatedFailureCount = 0;
|
|
2294
|
+
toolCall.status = 'succeeded';
|
|
2295
|
+
toolCall.durationMs = durationMs;
|
|
2296
|
+
reportActivity({
|
|
2297
|
+
kind: 'tool_active',
|
|
2298
|
+
source: 'framer-agent',
|
|
2299
|
+
name,
|
|
2300
|
+
phase: 'completed',
|
|
2301
|
+
message:
|
|
2302
|
+
name === 'framer_apply_changes' || name === 'framer_write'
|
|
2303
|
+
? 'Updated the Framer project'
|
|
2304
|
+
: 'Finished inspecting the Framer project',
|
|
2305
|
+
durationMs,
|
|
2306
|
+
});
|
|
2307
|
+
return normalizedResult(result);
|
|
2308
|
+
} catch (error) {
|
|
2309
|
+
if (
|
|
2310
|
+
name === 'framer_write'
|
|
2311
|
+
&& error?.code === 'FRAMER_AGENT_CODE_UNSAFE'
|
|
2312
|
+
) {
|
|
2313
|
+
unsafeWriteFailures += 1;
|
|
2314
|
+
if (
|
|
2315
|
+
!lowLevelWriteDisabled
|
|
2316
|
+
&& unsafeWriteFailures >= MAX_UNSAFE_WRITE_FAILURES
|
|
2317
|
+
) {
|
|
2318
|
+
lowLevelWriteDisabled = true;
|
|
2319
|
+
reportActivity({
|
|
2320
|
+
kind: 'status_update',
|
|
2321
|
+
source: 'framer-agent',
|
|
2322
|
+
message:
|
|
2323
|
+
'Repeated low-level writes were rejected by the safety boundary, so I switched the run to the safer Framer change path.',
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
if (
|
|
2328
|
+
name === 'framer_write'
|
|
2329
|
+
&& error?.code === 'FRAMER_AGENT_COMMAND_FAILED'
|
|
2330
|
+
) {
|
|
2331
|
+
lowLevelWriteFailures += 1;
|
|
2332
|
+
if (
|
|
2333
|
+
!lowLevelWriteDisabled
|
|
2334
|
+
&& lowLevelWriteFailures >= MAX_LOW_LEVEL_WRITE_FAILURES
|
|
2335
|
+
) {
|
|
2336
|
+
lowLevelWriteDisabled = true;
|
|
2337
|
+
reportActivity({
|
|
2338
|
+
kind: 'status_update',
|
|
2339
|
+
source: 'framer-agent',
|
|
2340
|
+
message:
|
|
2341
|
+
'Repeated low-level Framer writes failed, so I switched the run back to semantic Framer changes and focused inspection.',
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
if (repeatedFailureFingerprint === fingerprint) {
|
|
2346
|
+
repeatedFailureCount += 1;
|
|
2347
|
+
} else {
|
|
2348
|
+
repeatedFailureFingerprint = fingerprint;
|
|
2349
|
+
repeatedFailureCount = 1;
|
|
2350
|
+
}
|
|
2351
|
+
toolCall.durationMs = Date.now() - startedAt;
|
|
2352
|
+
toolCall.errorCode = error?.code || 'FRAMER_AGENT_TOOL_FAILED';
|
|
2353
|
+
trace?.warn?.('framer_agent_tool_failed', {
|
|
2354
|
+
name,
|
|
2355
|
+
code: error?.code,
|
|
2356
|
+
message: error?.message,
|
|
2357
|
+
});
|
|
2358
|
+
reportActivity({
|
|
2359
|
+
kind: 'tool_active',
|
|
2360
|
+
source: 'framer-agent',
|
|
2361
|
+
name,
|
|
2362
|
+
phase: 'failed',
|
|
2363
|
+
message:
|
|
2364
|
+
name === 'framer_apply_changes' || name === 'framer_write'
|
|
2365
|
+
? 'The Framer project update failed'
|
|
2366
|
+
: 'The Framer project inspection failed',
|
|
2367
|
+
durationMs: Date.now() - startedAt,
|
|
2368
|
+
});
|
|
2369
|
+
throw error;
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
function invoke(name, args = {}) {
|
|
2374
|
+
const operation = serial.then(() => invokeUnsafe(name, args));
|
|
2375
|
+
serial = operation.catch(() => undefined);
|
|
2376
|
+
return operation;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
async function startGateway() {
|
|
2380
|
+
if (gateway) return gateway;
|
|
2381
|
+
const { createServer } = await import('node:http');
|
|
2382
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
2383
|
+
server = createServer((request, response) => {
|
|
2384
|
+
const fail = (status, code, message) => {
|
|
2385
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
2386
|
+
response.end(JSON.stringify({ ok: false, error: { code, message } }));
|
|
2387
|
+
};
|
|
2388
|
+
if (request.method !== 'POST' || request.url !== '/tool') {
|
|
2389
|
+
fail(404, 'FRAMER_AGENT_GATEWAY_NOT_FOUND', 'Framer Agent tool endpoint not found.');
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
if (request.headers.authorization !== `Bearer ${token}`) {
|
|
2393
|
+
fail(401, 'FRAMER_AGENT_GATEWAY_UNAUTHORIZED', 'Invalid Framer Agent tool token.');
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
let body = '';
|
|
2397
|
+
request.on('data', (chunk) => {
|
|
2398
|
+
body += chunk.toString('utf8');
|
|
2399
|
+
if (body.length > 512 * 1024) request.destroy();
|
|
2400
|
+
});
|
|
2401
|
+
request.on('end', async () => {
|
|
2402
|
+
try {
|
|
2403
|
+
const payload = JSON.parse(body || '{}');
|
|
2404
|
+
const result = await invoke(
|
|
2405
|
+
String(payload.name || ''),
|
|
2406
|
+
payload.arguments || {},
|
|
2407
|
+
);
|
|
2408
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
2409
|
+
response.end(JSON.stringify({ ok: true, result }));
|
|
2410
|
+
} catch (error) {
|
|
2411
|
+
fail(
|
|
2412
|
+
Number(error?.statusCode) || 400,
|
|
2413
|
+
error?.code || 'FRAMER_AGENT_TOOL_FAILED',
|
|
2414
|
+
error?.message || 'Framer Agent tool failed.',
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
});
|
|
2418
|
+
});
|
|
2419
|
+
await new Promise((resolve, reject) => {
|
|
2420
|
+
server.once('error', reject);
|
|
2421
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
2422
|
+
});
|
|
2423
|
+
const address = server.address();
|
|
2424
|
+
if (!address || typeof address !== 'object') {
|
|
2425
|
+
throw new Error('Framer Agent tool gateway did not bind to a local port.');
|
|
2426
|
+
}
|
|
2427
|
+
gateway = {
|
|
2428
|
+
url: `http://127.0.0.1:${address.port}/tool`,
|
|
2429
|
+
token,
|
|
2430
|
+
};
|
|
2431
|
+
return gateway;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
return {
|
|
2435
|
+
definitions,
|
|
2436
|
+
toolNames: definitions.map((definition) => definition.name),
|
|
2437
|
+
preflight: validateAuthoritativeNodeIds,
|
|
2438
|
+
loadProjectGuidance,
|
|
2439
|
+
configurePromptContext,
|
|
2440
|
+
invoke,
|
|
2441
|
+
startGateway,
|
|
2442
|
+
synchronize: inspectFinalState,
|
|
2443
|
+
inspect: inspectFinalState,
|
|
2444
|
+
finalizeCompletion,
|
|
2445
|
+
verificationRepairPrompt,
|
|
2446
|
+
sourceHash: () => null,
|
|
2447
|
+
hasPendingChanges: () => false,
|
|
2448
|
+
directWorkspace: true,
|
|
2449
|
+
summary: () => ({
|
|
2450
|
+
projectId,
|
|
2451
|
+
projectUrl,
|
|
2452
|
+
mutationCalls,
|
|
2453
|
+
applyChangesCalls,
|
|
2454
|
+
lowLevelWriteCalls,
|
|
2455
|
+
imageSearchCalls,
|
|
2456
|
+
interactionVerificationCalls,
|
|
2457
|
+
mutationReceipts: mutationReceipts.map((receipt) => ({
|
|
2458
|
+
...receipt,
|
|
2459
|
+
...(Array.isArray(receipt.commands)
|
|
2460
|
+
? {
|
|
2461
|
+
commands: receipt.commands.map((command) => ({
|
|
2462
|
+
...command,
|
|
2463
|
+
...(Array.isArray(command.eventActions)
|
|
2464
|
+
? {
|
|
2465
|
+
eventActions: command.eventActions.map((action) => ({
|
|
2466
|
+
...action,
|
|
2467
|
+
})),
|
|
2468
|
+
}
|
|
2469
|
+
: {}),
|
|
2470
|
+
})),
|
|
2471
|
+
}
|
|
2472
|
+
: {}),
|
|
2473
|
+
...(receipt.renamedIds
|
|
2474
|
+
? { renamedIds: { ...receipt.renamedIds } }
|
|
2475
|
+
: {}),
|
|
2476
|
+
})),
|
|
2477
|
+
readCalls,
|
|
2478
|
+
progressCalls,
|
|
2479
|
+
toolCallCount,
|
|
2480
|
+
toolDurationMs: toolCalls.reduce(
|
|
2481
|
+
(total, call) => total + call.durationMs,
|
|
2482
|
+
0,
|
|
2483
|
+
),
|
|
2484
|
+
toolCalls: toolCalls.map((call) => ({ ...call })),
|
|
2485
|
+
workingPreferences: {
|
|
2486
|
+
focus: workingPreferences.mode,
|
|
2487
|
+
writeScope: workingPreferences.writeScope,
|
|
2488
|
+
preferBatchedMutations:
|
|
2489
|
+
workingPreferences.preferBatchedMutations,
|
|
2490
|
+
avoidRepeatedInspection:
|
|
2491
|
+
workingPreferences.avoidRepeatedInspection,
|
|
2492
|
+
expandScopeOnlyWhenNeeded:
|
|
2493
|
+
workingPreferences.expandScopeOnlyWhenNeeded,
|
|
2494
|
+
},
|
|
2495
|
+
loopProtection: {
|
|
2496
|
+
identicalFailureLimit: 5,
|
|
2497
|
+
blockedCalls: repeatedFailureStops,
|
|
2498
|
+
unsafeWriteFailureLimit: MAX_UNSAFE_WRITE_FAILURES,
|
|
2499
|
+
unsafeWriteFailures,
|
|
2500
|
+
lowLevelWriteFailureLimit: MAX_LOW_LEVEL_WRITE_FAILURES,
|
|
2501
|
+
lowLevelWriteFailures,
|
|
2502
|
+
lowLevelWriteDisabled,
|
|
2503
|
+
lowLevelWriteBlockedCalls,
|
|
2504
|
+
instructionFallbacks,
|
|
2505
|
+
},
|
|
2506
|
+
branch: branchChange,
|
|
2507
|
+
lastInspectionAt,
|
|
2508
|
+
lastMutationAt,
|
|
2509
|
+
taskPlan: taskPlanSnapshot(),
|
|
2510
|
+
verification: verification(),
|
|
2511
|
+
framerAgentVersion,
|
|
2512
|
+
framerGuidanceLoaded: guidanceMetadata.loaded,
|
|
2513
|
+
doctrineLoaded: promptMetadata.doctrineLoaded,
|
|
2514
|
+
guidanceHash: guidanceMetadata.contentHash,
|
|
2515
|
+
projectContextHash: guidanceMetadata.projectContextHash,
|
|
2516
|
+
visualVerificationRequired:
|
|
2517
|
+
verification().visualRequired,
|
|
2518
|
+
visualVerificationCompleted:
|
|
2519
|
+
verification().visualCompleted,
|
|
2520
|
+
guidance: {
|
|
2521
|
+
loaded: guidanceMetadata.loaded,
|
|
2522
|
+
contentHash: guidanceMetadata.contentHash,
|
|
2523
|
+
projectContextHash: guidanceMetadata.projectContextHash,
|
|
2524
|
+
files: [...guidanceMetadata.files],
|
|
2525
|
+
bytes: guidanceMetadata.bytes,
|
|
2526
|
+
},
|
|
2527
|
+
doctrine: { ...promptMetadata },
|
|
2528
|
+
}),
|
|
2529
|
+
fatalInfrastructureError: () => null,
|
|
2530
|
+
waitForFatalInfrastructure: () => new Promise(() => undefined),
|
|
2531
|
+
close: async ({ waitForPending = true } = {}) => {
|
|
2532
|
+
closed = true;
|
|
2533
|
+
abortController.abort();
|
|
2534
|
+
if (waitForPending) await serial.catch(() => undefined);
|
|
2535
|
+
if (server) {
|
|
2536
|
+
if (!waitForPending) server.closeAllConnections?.();
|
|
2537
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
2538
|
+
server = null;
|
|
2539
|
+
}
|
|
2540
|
+
},
|
|
2541
|
+
};
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
export function __resetFramerAgentToolRuntimeForTests() {
|
|
2545
|
+
sessionsByProject.clear();
|
|
2546
|
+
hostedSetupPromise = null;
|
|
2547
|
+
}
|