@gakim-digital/dexter-bridge 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,62 @@
1
1
  import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
2
3
  import { spawn } from 'node:child_process';
3
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';
4
19
 
5
20
  const require = createRequire(import.meta.url);
6
21
  const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
22
+ const MAX_MODEL_RESULT_BYTES = 120 * 1024;
7
23
  const DEFAULT_COMMAND_TIMEOUT_MS = 5 * 60_000;
8
24
  const SESSION_TIMEOUT_MS = 10 * 60_000;
9
- const AUTHORIZATION_TIMEOUT_MS = 10 * 60_000;
10
- const AUTHORIZATION_POLL_MS = 2_000;
11
- const PROJECT_ID_PATTERN = /^[A-Za-z0-9_-]{1,200}$/;
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;
12
33
  const PAGE_PATH_PATTERN = /^\/[^\0\r\n]{0,499}$/;
13
34
  const UNSAFE_EXEC_PATTERN =
14
35
  /\b(?:process|globalThis|global|require|import\s*\(|child_process|worker_threads|fs|os|net|tls|dgram|http|https|fetch|WebSocket|eval|Function)\b/;
15
36
  const MUTATING_EXEC_PATTERN =
16
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*\(/;
17
-
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
+ );
18
60
  const EMPTY_SCHEMA = {
19
61
  type: 'object',
20
62
  properties: {},
@@ -25,7 +67,7 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
25
67
  {
26
68
  name: 'progress_update',
27
69
  description:
28
- 'Tell the user what you are doing and why it matters in one or two natural first-person sentences. Call this before the first Framer inspection or edit and at meaningful phase changes.',
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.',
29
71
  inputSchema: {
30
72
  type: 'object',
31
73
  properties: {
@@ -38,8 +80,18 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
38
80
  {
39
81
  name: 'framer_instructions',
40
82
  description:
41
- 'Load Framer’s official agent command reference. Call this before the first canvas change unless the current session already established the command syntax.',
42
- inputSchema: EMPTY_SCHEMA,
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
+ },
43
95
  },
44
96
  {
45
97
  name: 'framer_context',
@@ -61,11 +113,275 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
61
113
  items: { type: 'object', additionalProperties: true },
62
114
  },
63
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
+ },
64
124
  },
65
125
  required: ['queries'],
66
126
  additionalProperties: false,
67
127
  },
68
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
+ },
69
385
  {
70
386
  name: 'framer_apply_changes',
71
387
  description:
@@ -88,6 +404,14 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
88
404
  type: 'object',
89
405
  properties: {
90
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
+ },
91
415
  },
92
416
  required: ['code'],
93
417
  additionalProperties: false,
@@ -96,13 +420,20 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
96
420
  {
97
421
  name: 'framer_write',
98
422
  description:
99
- '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.',
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.',
100
424
  inputSchema: {
101
425
  type: 'object',
102
426
  properties: {
103
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
+ },
104
435
  },
105
- required: ['code'],
436
+ required: ['code', 'reason'],
106
437
  additionalProperties: false,
107
438
  },
108
439
  },
@@ -131,6 +462,67 @@ function boundedText(value, maximum) {
131
462
  return `${text.slice(0, maximum)}\n...[output clipped]`;
132
463
  }
133
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
+
134
526
  function normalizeProjectId(value) {
135
527
  const projectId = String(value || '').trim();
136
528
  if (!PROJECT_ID_PATTERN.test(projectId)) {
@@ -142,6 +534,55 @@ function normalizeProjectId(value) {
142
534
  return projectId;
143
535
  }
144
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
+
145
586
  function normalizePagePath(value) {
146
587
  const pagePath = String(value || '/').trim() || '/';
147
588
  if (!PAGE_PATH_PATTERN.test(pagePath)) {
@@ -164,6 +605,156 @@ function uniqueStrings(values, maximum = 500) {
164
605
  ).slice(0, maximum);
165
606
  }
166
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
+
167
758
  function authoritativeNodeIdsFromAssignment(assignment) {
168
759
  const context = isRecord(assignment?.context) ? assignment.context : {};
169
760
  const grant = isRecord(context.contextGrant) ? context.contextGrant : {};
@@ -237,89 +828,6 @@ function abortError() {
237
828
  });
238
829
  }
239
830
 
240
- function wait(delayMs, signal) {
241
- return new Promise((resolve, reject) => {
242
- if (signal?.aborted) {
243
- reject(abortError());
244
- return;
245
- }
246
- const onAbort = () => {
247
- clearTimeout(timer);
248
- reject(abortError());
249
- };
250
- const timer = setTimeout(() => {
251
- signal?.removeEventListener('abort', onAbort);
252
- resolve();
253
- }, Math.max(0, delayMs));
254
- timer.unref?.();
255
- signal?.addEventListener('abort', onAbort, { once: true });
256
- });
257
- }
258
-
259
- function authorizationUrl(value) {
260
- let parsed;
261
- try {
262
- parsed = new URL(String(value || ''));
263
- } catch {
264
- throw toolError(
265
- 'FRAMER_AGENT_AUTHORIZATION_URL_INVALID',
266
- 'Dexter received an invalid Framer authorization URL.',
267
- 502,
268
- );
269
- }
270
- const hostname = parsed.hostname.toLowerCase();
271
- const trusted =
272
- hostname === 'framer.com'
273
- || hostname.endsWith('.framer.com')
274
- || hostname === 'framerlocal.com'
275
- || hostname.endsWith('.framerlocal.com');
276
- if (
277
- parsed.protocol !== 'https:'
278
- || parsed.username
279
- || parsed.password
280
- || !trusted
281
- ) {
282
- throw toolError(
283
- 'FRAMER_AGENT_AUTHORIZATION_URL_INVALID',
284
- 'Dexter blocked an untrusted Framer authorization URL.',
285
- 502,
286
- );
287
- }
288
- return parsed.toString();
289
- }
290
-
291
- export function openFramerAuthorizationUrl(
292
- value,
293
- {
294
- platform = process.platform,
295
- spawnImpl = spawn,
296
- } = {},
297
- ) {
298
- const url = authorizationUrl(value);
299
- const command =
300
- platform === 'darwin'
301
- ? '/usr/bin/open'
302
- : platform === 'win32'
303
- ? 'rundll32.exe'
304
- : 'xdg-open';
305
- const args =
306
- platform === 'win32'
307
- ? ['url.dll,FileProtocolHandler', url]
308
- : [url];
309
- return new Promise((resolve, reject) => {
310
- const child = spawnImpl(command, args, {
311
- stdio: 'ignore',
312
- detached: true,
313
- windowsHide: true,
314
- });
315
- child.once?.('error', (error) => reject(error));
316
- child.once?.('spawn', () => {
317
- child.unref?.();
318
- resolve(true);
319
- });
320
- });
321
- }
322
-
323
831
  function parseStructuredOutput(value) {
324
832
  const text = String(value || '').trim();
325
833
  if (!text) return null;
@@ -470,8 +978,7 @@ function sessionIdFromOutput(value) {
470
978
  function isAuthorizationFailure(error) {
471
979
  return (
472
980
  error?.code === 'FRAMER_AGENT_AUTHORIZATION_REQUIRED'
473
- || error?.code === 'FRAMER_AGENT_PROJECT_NOT_FOUND'
474
- || /authoriz|log in|login|api key|project not found|session expired/i.test(
981
+ || /authoriz|log in|login|api key|does not have access|session expired/i.test(
475
982
  String(error?.message || ''),
476
983
  )
477
984
  );
@@ -481,7 +988,14 @@ function projectConnectionError(error) {
481
988
  if (/project not found/i.test(String(error?.message || ''))) {
482
989
  return toolError(
483
990
  'FRAMER_AGENT_PROJECT_NOT_FOUND',
484
- 'Framer could not authorize this project. Make sure the browser is signed into an account with access to the open project, then reconnect it in Dexter.',
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.',
485
999
  409,
486
1000
  );
487
1001
  }
@@ -496,20 +1010,48 @@ export function createFramerAgentToolRuntime({
496
1010
  onActivity,
497
1011
  runCli = runFramerAgentCli,
498
1012
  authorizeProject,
499
- openAuthorizationUrl = openFramerAuthorizationUrl,
500
- authorizationTimeoutMs = AUTHORIZATION_TIMEOUT_MS,
1013
+ verifyProjectAuthorization,
501
1014
  } = {}) {
502
1015
  const projectId = normalizeProjectId(assignment?.context?.framerProjectId);
1016
+ const projectUrl = normalizeProjectUrl(
1017
+ assignment?.context?.framerProjectUrl,
1018
+ projectId,
1019
+ );
1020
+ const sessionCacheKey = projectId;
503
1021
  const requestedTools = Array.isArray(assignment?.toolProtocol?.tools)
504
1022
  ? assignment.toolProtocol.tools
505
1023
  : FRAMER_AGENT_TOOL_DEFINITIONS.map((tool) => tool.name);
506
1024
  const allowedToolNames = new Set(
507
1025
  requestedTools.filter((name) => TOOL_BY_NAME.has(name)),
508
1026
  );
509
- const definitions = FRAMER_AGENT_TOOL_DEFINITIONS.filter((tool) =>
510
- allowedToolNames.has(tool.name),
1027
+ const workingPreferences = workingPreferencesFromAssignment(assignment);
1028
+ const definitions = toolDefinitionsForPreferences(
1029
+ allowedToolNames,
1030
+ workingPreferences,
511
1031
  );
1032
+ const exposedToolNames = new Set(
1033
+ definitions.map((definition) => definition.name),
1034
+ );
1035
+ const missingToolNames = uniqueStrings(requestedTools)
1036
+ .filter((name) => !exposedToolNames.has(name));
1037
+ if (missingToolNames.length > 0) {
1038
+ throw toolError(
1039
+ 'FRAMER_HARNESS_TOOL_CATALOG_INCOMPLETE',
1040
+ `Dexter could not start because this bridge does not expose the required Framer tools: ${missingToolNames.join(', ')}.`,
1041
+ 500,
1042
+ );
1043
+ }
512
1044
  const allowPublishing = assignment?.context?.permissions?.publish === true;
1045
+ const inheritedRejectedMechanisms =
1046
+ assignment?.context?.repairContext?.rejectedMechanisms
1047
+ || assignment?.repairContext?.rejectedMechanisms
1048
+ || [];
1049
+ const authorizedInstanceContainerIds = uniqueStrings(
1050
+ assignment?.context?.permissions?.scopedComponentArtifacts
1051
+ ?.instanceContainerNodeIds
1052
+ || assignment?.context?.contextGrant?.writableNodeIds
1053
+ || [],
1054
+ );
513
1055
  const activePagePath = normalizePagePath(
514
1056
  assignment?.context?.activePagePath,
515
1057
  );
@@ -523,9 +1065,53 @@ export function createFramerAgentToolRuntime({
523
1065
  let closed = false;
524
1066
  let serial = Promise.resolve();
525
1067
  let mutationCalls = 0;
1068
+ let applyChangesCalls = 0;
1069
+ let lowLevelWriteCalls = 0;
1070
+ let imageSearchCalls = 0;
526
1071
  let readCalls = 0;
1072
+ let progressCalls = 0;
1073
+ let toolCallCount = 0;
1074
+ const toolCalls = [];
1075
+ let repeatedFailureFingerprint = null;
1076
+ let repeatedFailureCount = 0;
1077
+ let repeatedFailureStops = 0;
1078
+ let unsafeWriteFailures = 0;
1079
+ let lowLevelWriteFailures = 0;
1080
+ let lowLevelWriteDisabled = false;
1081
+ let lowLevelWriteBlockedCalls = 0;
1082
+ let instructionFallbacks = 0;
1083
+ let instructionLookupUnavailable = false;
527
1084
  let branchChange = null;
528
1085
  let lastInspectionAt = null;
1086
+ let lastMutationAt = null;
1087
+ let screenshotCalls = 0;
1088
+ let interactionVerificationCalls = 0;
1089
+ let interactionVerification = {
1090
+ completed: false,
1091
+ verificationLevel: 'unverified',
1092
+ checks: [],
1093
+ behaviors: [],
1094
+ verifiedAt: null,
1095
+ };
1096
+ const representativeVerifiedBehaviorIds = new Set();
1097
+ const repeatedBehaviorVerificationFailures = new Map();
1098
+ const mutationReceipts = [];
1099
+ let screenshotBeforeMutation = false;
1100
+ let lastScreenshotSequence = 0;
1101
+ let taskPlan = null;
1102
+ const verificationEvidence = new Map();
1103
+ let guidanceMetadata = {
1104
+ loaded: false,
1105
+ contentHash: null,
1106
+ projectContextHash: null,
1107
+ files: [],
1108
+ bytes: 0,
1109
+ };
1110
+ let promptMetadata = {
1111
+ doctrineLoaded: false,
1112
+ doctrineVersion: null,
1113
+ };
1114
+ const framerAgentVersion = installedFramerAgentVersion();
529
1115
  let operationSequence = 0;
530
1116
  let lastMutationSequence = 0;
531
1117
  let lastInspectionSequence = 0;
@@ -553,6 +1139,7 @@ export function createFramerAgentToolRuntime({
553
1139
 
554
1140
  const ensureHostedSetup = async () => {
555
1141
  if (env.DEXTER_HOSTED_FRAMER_AUTO_SETUP !== 'true') return;
1142
+ if (hasInstalledFramerSkill(env)) return;
556
1143
  if (!hostedSetupPromise) {
557
1144
  reportActivity({
558
1145
  kind: 'status_update',
@@ -578,7 +1165,7 @@ export function createFramerAgentToolRuntime({
578
1165
  );
579
1166
  };
580
1167
 
581
- const acquireProjectAuthorization = async ({ forceRefresh = false } = {}) => {
1168
+ const resolveProjectAuthorization = async () => {
582
1169
  if (typeof authorizeProject !== 'function') {
583
1170
  throw toolError(
584
1171
  'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
@@ -589,7 +1176,7 @@ export function createFramerAgentToolRuntime({
589
1176
  let authorization = await authorizeProject({
590
1177
  projectId,
591
1178
  initiate: true,
592
- forceRefresh,
1179
+ forceRefresh: false,
593
1180
  signal: abortController.signal,
594
1181
  });
595
1182
  if (authorization?.framerProjectId !== projectId) {
@@ -600,52 +1187,17 @@ export function createFramerAgentToolRuntime({
600
1187
  );
601
1188
  }
602
1189
  if (authorization?.status === 'authorization_required') {
603
- const url = authorizationUrl(authorization.authUrl);
604
1190
  reportActivity({
605
1191
  kind: 'status_update',
606
1192
  source: 'framer-agent',
607
1193
  message:
608
- 'Approve access to this Framer project in the browser. Dexter will continue automatically.',
1194
+ 'This Framer project needs a project API key before Dexter can continue.',
609
1195
  });
610
- try {
611
- await openAuthorizationUrl(url);
612
- } catch {
613
- throw toolError(
614
- 'FRAMER_AGENT_AUTHORIZATION_OPEN_FAILED',
615
- 'Dexter could not open Framer authorization in the browser. Reconnect this project from Dexter settings.',
616
- 409,
617
- );
618
- }
619
- const configuredExpiry = new Date(
620
- String(authorization.expiresAt || ''),
621
- ).getTime();
622
- const deadline = Math.min(
623
- Number.isFinite(configuredExpiry)
624
- ? configuredExpiry
625
- : Date.now() + authorizationTimeoutMs,
626
- Date.now() + authorizationTimeoutMs,
1196
+ throw toolError(
1197
+ 'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
1198
+ 'Connect this Framer project in Dexter by adding a project API key, then retry.',
1199
+ 409,
627
1200
  );
628
- while (
629
- authorization?.status === 'authorization_required'
630
- && Date.now() < deadline
631
- ) {
632
- await wait(
633
- Math.max(
634
- 500,
635
- Math.min(
636
- 5_000,
637
- Number(authorization.pollIntervalMs) || AUTHORIZATION_POLL_MS,
638
- ),
639
- ),
640
- abortController.signal,
641
- );
642
- authorization = await authorizeProject({
643
- projectId,
644
- initiate: false,
645
- forceRefresh: false,
646
- signal: abortController.signal,
647
- });
648
- }
649
1201
  }
650
1202
  const apiKey = String(authorization?.apiKey || '').trim();
651
1203
  if (authorization?.status !== 'ready' || !apiKey) {
@@ -655,13 +1207,21 @@ export function createFramerAgentToolRuntime({
655
1207
  409,
656
1208
  );
657
1209
  }
658
- await executeCli(['project', 'auth', projectId, apiKey], {
1210
+ const authorizedProjectUrl = normalizeProjectUrl(
1211
+ authorization.framerProjectUrl,
1212
+ projectId,
1213
+ );
1214
+ return { ...authorization, apiKey, framerProjectUrl: authorizedProjectUrl };
1215
+ };
1216
+
1217
+ const installProjectAuthorization = async (authorization) => {
1218
+ await executeCli(['project', 'auth', projectId, authorization.apiKey], {
659
1219
  timeoutMs: 30_000,
660
1220
  });
661
1221
  reportActivity({
662
1222
  kind: 'status_update',
663
1223
  source: 'framer-agent',
664
- message: 'This Framer project is authorized for the local harness.',
1224
+ message: 'This Framer project is authorized for the coding harness.',
665
1225
  });
666
1226
  };
667
1227
 
@@ -671,14 +1231,55 @@ export function createFramerAgentToolRuntime({
671
1231
  });
672
1232
  };
673
1233
 
1234
+ const reportRejectedAuthorization = async (authorization, error) => {
1235
+ if (
1236
+ typeof verifyProjectAuthorization !== 'function'
1237
+ || !authorization?.credentialVersion
1238
+ ) return;
1239
+ await verifyProjectAuthorization({
1240
+ status: 'rejected',
1241
+ framerProjectId: projectId,
1242
+ framerProjectUrl: projectUrl,
1243
+ credentialVersion: authorization.credentialVersion,
1244
+ errorCode: String(error?.code || 'FRAMER_AGENT_AUTHORIZATION_REJECTED')
1245
+ .slice(0, 120),
1246
+ signal: abortController.signal,
1247
+ }).catch(() => undefined);
1248
+ };
1249
+
1250
+ const verifyConnectedProject = async (activeSessionId, authorization) => {
1251
+ const result = await executeCli(['exec', '-s', activeSessionId], {
1252
+ input: [
1253
+ 'const [project, branch] = await Promise.all([',
1254
+ ' framer.getProjectInfo(),',
1255
+ ' framer.agent.getActiveBranch(),',
1256
+ ']);',
1257
+ 'console.log(JSON.stringify({ projectName: project?.name ?? null, projectUrl: branch?.url ?? null }));',
1258
+ ].join('\n'),
1259
+ timeoutMs: 30_000,
1260
+ });
1261
+ const observed = parseStructuredOutput(result.stdout);
1262
+ const observedProjectUrl = normalizeProjectUrl(
1263
+ observed?.projectUrl,
1264
+ projectId,
1265
+ );
1266
+ if (
1267
+ typeof verifyProjectAuthorization === 'function'
1268
+ && authorization?.credentialVersion
1269
+ ) {
1270
+ await verifyProjectAuthorization({
1271
+ status: 'verified',
1272
+ framerProjectId: projectId,
1273
+ framerProjectUrl: observedProjectUrl,
1274
+ credentialVersion: authorization.credentialVersion,
1275
+ signal: abortController.signal,
1276
+ });
1277
+ }
1278
+ };
1279
+
674
1280
  const ensureSession = async () => {
675
1281
  await ensureHostedSetup();
676
1282
  if (sessionId) return sessionId;
677
- const cached = sessionsByProject.get(projectId);
678
- if (cached) {
679
- sessionId = cached;
680
- return sessionId;
681
- }
682
1283
  if (sessionPromise) return sessionPromise;
683
1284
  sessionPromise = (async () => {
684
1285
  reportActivity({
@@ -686,23 +1287,49 @@ export function createFramerAgentToolRuntime({
686
1287
  source: 'framer-agent',
687
1288
  message: 'Connecting the coding harness to this Framer project.',
688
1289
  });
689
- if (!(await projectIsAuthorizedLocally())) {
690
- await acquireProjectAuthorization();
1290
+ let authorization = null;
1291
+ if (typeof authorizeProject === 'function') {
1292
+ authorization = await resolveProjectAuthorization();
1293
+ }
1294
+
1295
+ const cached = sessionsByProject.get(sessionCacheKey);
1296
+ if (
1297
+ cached
1298
+ && (
1299
+ typeof authorizeProject !== 'function'
1300
+ || (
1301
+ authorization?.verificationStatus === 'verified'
1302
+ && cached.credentialVersion === authorization.credentialVersion
1303
+ )
1304
+ )
1305
+ ) {
1306
+ sessionId = cached.sessionId;
1307
+ return sessionId;
1308
+ }
1309
+ if (cached) {
1310
+ await executeCli(['session', 'destroy', cached.sessionId], {
1311
+ timeoutMs: 10_000,
1312
+ }).catch(() => undefined);
1313
+ sessionsByProject.delete(sessionCacheKey);
1314
+ }
1315
+ if (!authorization && !(await projectIsAuthorizedLocally())) {
1316
+ throw toolError(
1317
+ 'FRAMER_AGENT_AUTHORIZATION_REQUIRED',
1318
+ 'This Framer project needs to be connected before Dexter can use it.',
1319
+ 409,
1320
+ );
691
1321
  }
692
1322
  let created;
693
1323
  try {
1324
+ if (authorization) {
1325
+ await installProjectAuthorization(authorization);
1326
+ }
694
1327
  created = await createSession();
695
1328
  } catch (error) {
696
1329
  if (isAuthorizationFailure(error)) {
697
- await acquireProjectAuthorization({ forceRefresh: true });
698
- try {
699
- created = await createSession();
700
- } catch (retryError) {
701
- throw projectConnectionError(retryError);
702
- }
703
- } else {
704
- throw projectConnectionError(error);
1330
+ await reportRejectedAuthorization(authorization, error);
705
1331
  }
1332
+ throw projectConnectionError(error);
706
1333
  }
707
1334
  sessionId = sessionIdFromOutput(created.stdout);
708
1335
  if (!sessionId) {
@@ -721,7 +1348,25 @@ export function createFramerAgentToolRuntime({
721
1348
  503,
722
1349
  );
723
1350
  }
724
- sessionsByProject.set(projectId, sessionId);
1351
+ try {
1352
+ await verifyConnectedProject(sessionId, authorization);
1353
+ } catch (error) {
1354
+ await executeCli(['session', 'destroy', sessionId], {
1355
+ timeoutMs: 10_000,
1356
+ }).catch(() => undefined);
1357
+ sessionId = null;
1358
+ if (
1359
+ isAuthorizationFailure(error)
1360
+ || error?.code === 'FRAMER_AGENT_PROJECT_MISMATCH'
1361
+ ) {
1362
+ await reportRejectedAuthorization(authorization, error);
1363
+ }
1364
+ throw projectConnectionError(error);
1365
+ }
1366
+ sessionsByProject.set(sessionCacheKey, {
1367
+ sessionId,
1368
+ credentialVersion: authorization?.credentialVersion || null,
1369
+ });
725
1370
  reportActivity({
726
1371
  kind: 'status_update',
727
1372
  source: 'framer-agent',
@@ -734,6 +1379,86 @@ export function createFramerAgentToolRuntime({
734
1379
  return sessionPromise;
735
1380
  };
736
1381
 
1382
+ const loadProjectGuidance = async () => {
1383
+ await ensureSession();
1384
+ const skillRoot = framerSkillRoot(env);
1385
+ if (!skillRoot) return { content: '', ...guidanceMetadata };
1386
+ const projectRoot = path.join(skillRoot, 'projects', projectId);
1387
+ if (!fs.existsSync(projectRoot)) {
1388
+ return { content: '', ...guidanceMetadata };
1389
+ }
1390
+ const requestedFiles = [
1391
+ { root: skillRoot, path: 'SKILL.md', label: 'SKILL.md' },
1392
+ { root: projectRoot, path: 'index.md', label: 'project/index.md' },
1393
+ {
1394
+ root: projectRoot,
1395
+ path: 'project-inventory.md',
1396
+ label: 'project/project-inventory.md',
1397
+ },
1398
+ { root: projectRoot, path: 'prompt/core-principles.md' },
1399
+ { root: projectRoot, path: 'prompt/core-examples.md' },
1400
+ { root: projectRoot, path: 'prompt/implementation-strategy.md' },
1401
+ { root: projectRoot, path: 'prompt/updating-the-project.md' },
1402
+ { root: projectRoot, path: 'prompt/tools.md' },
1403
+ { root: projectRoot, path: 'prompt/overview.md' },
1404
+ { root: projectRoot, path: 'prompt/guardrails.md' },
1405
+ { root: projectRoot, path: 'prompt/critical-reminders.md' },
1406
+ ...(visualVerificationRequired
1407
+ ? [
1408
+ { root: projectRoot, path: 'prompt/design-rules.md' },
1409
+ { root: projectRoot, path: 'prompt/how-projects-work.md' },
1410
+ ]
1411
+ : []),
1412
+ ];
1413
+ const files = [];
1414
+ let remainingBytes = MAX_GUIDANCE_BYTES;
1415
+ for (const requestedFile of requestedFiles) {
1416
+ const loaded = readGuidanceFile(
1417
+ requestedFile.root,
1418
+ requestedFile.path,
1419
+ remainingBytes,
1420
+ );
1421
+ if (!loaded) continue;
1422
+ files.push({
1423
+ ...loaded,
1424
+ path: requestedFile.label || requestedFile.path,
1425
+ });
1426
+ remainingBytes -= Buffer.byteLength(loaded.content, 'utf8');
1427
+ if (remainingBytes <= 0) break;
1428
+ }
1429
+ const content = files
1430
+ .map((file) => `## ${file.path}\n\n${file.content}`)
1431
+ .join('\n\n');
1432
+ const inventory = files.find(
1433
+ (file) => file.path.endsWith('project-inventory.md'),
1434
+ )?.content || '';
1435
+ guidanceMetadata = {
1436
+ loaded: Boolean(content),
1437
+ contentHash: content
1438
+ ? crypto.createHash('sha256').update(content).digest('hex')
1439
+ : null,
1440
+ projectContextHash: inventory
1441
+ ? crypto.createHash('sha256').update(inventory).digest('hex')
1442
+ : null,
1443
+ files: files.map((file) => file.path),
1444
+ bytes: Buffer.byteLength(content, 'utf8'),
1445
+ };
1446
+ return { content, ...guidanceMetadata };
1447
+ };
1448
+
1449
+ const configurePromptContext = ({
1450
+ doctrineLoaded = false,
1451
+ doctrineVersion = null,
1452
+ } = {}) => {
1453
+ promptMetadata = {
1454
+ doctrineLoaded: doctrineLoaded === true,
1455
+ doctrineVersion:
1456
+ typeof doctrineVersion === 'string' && doctrineVersion.trim()
1457
+ ? doctrineVersion.trim().slice(0, 160)
1458
+ : null,
1459
+ };
1460
+ };
1461
+
737
1462
  const execCode = async (code, { timeoutMs } = {}) => {
738
1463
  let activeSessionId = await ensureSession();
739
1464
  try {
@@ -750,7 +1475,7 @@ export function createFramerAgentToolRuntime({
750
1475
  ) {
751
1476
  throw error;
752
1477
  }
753
- sessionsByProject.delete(projectId);
1478
+ sessionsByProject.delete(sessionCacheKey);
754
1479
  sessionId = null;
755
1480
  activeSessionId = await ensureSession();
756
1481
  return executeCli(['exec', '-s', activeSessionId], {
@@ -816,7 +1541,9 @@ export function createFramerAgentToolRuntime({
816
1541
  const normalizedResult = (result) => {
817
1542
  const parsed = parseStructuredOutput(result.stdout);
818
1543
  return {
819
- ...(parsed === null ? { output: boundedText(result.stdout, 200_000) } : { result: parsed }),
1544
+ ...(parsed === null
1545
+ ? { output: boundedText(result.stdout, MAX_MODEL_RESULT_BYTES) }
1546
+ : { result: boundedStructuredValue(parsed) }),
820
1547
  ...(result.stderr.trim()
821
1548
  ? { diagnostics: boundedText(result.stderr, 20_000) }
822
1549
  : {}),
@@ -824,19 +1551,315 @@ export function createFramerAgentToolRuntime({
824
1551
  };
825
1552
  };
826
1553
 
827
- const markInspection = () => {
1554
+ const markInspection = ({ visual = false, verifies = [] } = {}) => {
828
1555
  operationSequence += 1;
829
1556
  lastInspectionSequence = operationSequence;
830
1557
  lastInspectionAt = new Date().toISOString();
1558
+ verificationEvidence.set('structural', operationSequence);
1559
+ if (visual) {
1560
+ screenshotCalls += 1;
1561
+ lastScreenshotSequence = operationSequence;
1562
+ verificationEvidence.set('visual', operationSequence);
1563
+ if (lastMutationSequence === 0) screenshotBeforeMutation = true;
1564
+ }
1565
+ for (const check of normalizeVerificationValues(
1566
+ verifies,
1567
+ GENERIC_VERIFICATION_CHECKS,
1568
+ )) {
1569
+ verificationEvidence.set(check, operationSequence);
1570
+ }
831
1571
  };
832
1572
 
833
1573
  const markMutation = () => {
834
1574
  operationSequence += 1;
835
1575
  lastMutationSequence = operationSequence;
1576
+ lastMutationAt = new Date().toISOString();
836
1577
  };
837
1578
 
1579
+ const requiredVerificationChecks = () =>
1580
+ taskPlan?.verification || [];
1581
+ const taskPlanSnapshot = () => {
1582
+ if (!taskPlan) return null;
1583
+ return {
1584
+ summary: taskPlan.summary,
1585
+ domains: [...taskPlan.domains],
1586
+ verification: [...taskPlan.verification],
1587
+ ...(taskPlan.behaviors.length > 0
1588
+ ? {
1589
+ behaviors: taskPlan.behaviors.map((behavior) => ({
1590
+ ...behavior,
1591
+ expectedContent: [...behavior.expectedContent],
1592
+ })),
1593
+ mechanisms: taskPlan.mechanisms.map((decision) => ({
1594
+ ...decision,
1595
+ })),
1596
+ rejectedMechanisms: taskPlan.rejectedMechanisms.map((entry) => ({
1597
+ ...entry,
1598
+ })),
1599
+ compatibility: taskPlan.compatibility.map((entry) => ({
1600
+ ...entry,
1601
+ requiredCapabilities: [...entry.requiredCapabilities],
1602
+ })),
1603
+ }
1604
+ : {}),
1605
+ };
1606
+ };
838
1607
  const synchronized = () =>
839
1608
  mutationCalls === 0 || lastInspectionSequence > lastMutationSequence;
1609
+ const verificationCheckCompleted = (check) => {
1610
+ if (mutationCalls === 0) return true;
1611
+ if (check === 'structural') return synchronized();
1612
+ if (check === 'visual') {
1613
+ return (
1614
+ screenshotBeforeMutation
1615
+ && lastScreenshotSequence > lastMutationSequence
1616
+ );
1617
+ }
1618
+ return Number(verificationEvidence.get(check) || 0) > lastMutationSequence;
1619
+ };
1620
+ const visualVerificationRequired = () =>
1621
+ applyChangesCalls > 0
1622
+ || requiredVerificationChecks().includes('visual');
1623
+ const visualVerificationCompleted = () =>
1624
+ !visualVerificationRequired()
1625
+ || mutationCalls === 0
1626
+ || (
1627
+ screenshotBeforeMutation
1628
+ && lastScreenshotSequence > lastMutationSequence
1629
+ );
1630
+ const missingVerificationChecks = () => {
1631
+ if (mutationCalls === 0) return [];
1632
+ const missing = [];
1633
+ if (!taskPlan) missing.push('task-plan');
1634
+ const required = new Set([
1635
+ 'structural',
1636
+ ...requiredVerificationChecks(),
1637
+ ...(applyChangesCalls > 0 ? ['visual'] : []),
1638
+ ]);
1639
+ for (const check of required) {
1640
+ if (!verificationCheckCompleted(check)) missing.push(check);
1641
+ }
1642
+ return missing;
1643
+ };
1644
+ const verification = () => ({
1645
+ ownedBy: 'harness',
1646
+ required: mutationCalls > 0,
1647
+ completed: missingVerificationChecks().length === 0,
1648
+ structuralCompleted: synchronized(),
1649
+ visualRequired: visualVerificationRequired() && mutationCalls > 0,
1650
+ visualCompleted: visualVerificationCompleted(),
1651
+ screenshotCalls,
1652
+ ...(requiredVerificationChecks().includes('interactions')
1653
+ || interactionVerificationCalls > 0
1654
+ ? {
1655
+ interactionVerificationCalls,
1656
+ interactionVerification: {
1657
+ completed: verificationCheckCompleted('interactions'),
1658
+ verificationLevel:
1659
+ verificationCheckCompleted('interactions')
1660
+ ? interactionVerification.verificationLevel
1661
+ : 'unverified',
1662
+ checks: interactionVerification.checks.map((check) => ({
1663
+ ...check,
1664
+ failures: [...check.failures],
1665
+ })),
1666
+ behaviors: interactionVerification.behaviors.map((behavior) => ({
1667
+ ...behavior,
1668
+ failures: [...behavior.failures],
1669
+ })),
1670
+ verifiedAt: interactionVerification.verifiedAt,
1671
+ },
1672
+ }
1673
+ : {}),
1674
+ taskPlan: taskPlanSnapshot(),
1675
+ requiredChecks: [
1676
+ ...new Set([
1677
+ 'structural',
1678
+ ...requiredVerificationChecks(),
1679
+ ...(applyChangesCalls > 0 ? ['visual'] : []),
1680
+ ]),
1681
+ ],
1682
+ completedChecks: [
1683
+ ...new Set([
1684
+ 'structural',
1685
+ ...requiredVerificationChecks(),
1686
+ ...(applyChangesCalls > 0 ? ['visual'] : []),
1687
+ ]),
1688
+ ].filter(verificationCheckCompleted),
1689
+ missingChecks: missingVerificationChecks(),
1690
+ lastMutationAt,
1691
+ lastInspectionAt,
1692
+ });
1693
+
1694
+ const inspectFinalState = async () => {
1695
+ await serial;
1696
+ return {
1697
+ synchronized: synchronized(),
1698
+ sourceHash: null,
1699
+ changed: mutationCalls > 0,
1700
+ mutationCalls,
1701
+ readCalls,
1702
+ branch: branchChange,
1703
+ lastInspectionAt,
1704
+ lastMutationAt,
1705
+ taskPlan: taskPlanSnapshot(),
1706
+ verification: verification(),
1707
+ };
1708
+ };
1709
+
1710
+ const finalizeCompletion = (completion) => {
1711
+ if (
1712
+ completion?.status !== 'completed'
1713
+ || mutationCalls === 0
1714
+ || verification().completed
1715
+ ) {
1716
+ return completion;
1717
+ }
1718
+ const missing = missingVerificationChecks().map((check) => {
1719
+ if (check === 'task-plan') return 'a harness task and verification plan';
1720
+ if (check === 'structural') return 'a final structural read';
1721
+ if (check === 'visual') return 'before-and-after screenshot verification';
1722
+ return `post-mutation ${check} verification`;
1723
+ });
1724
+ const reason =
1725
+ `The harness ended before completing ${missing.join(' and ')}.`;
1726
+ return {
1727
+ ...completion,
1728
+ status: 'failed',
1729
+ summary: `${String(completion?.summary || '').trim()} ${reason}`.trim(),
1730
+ checks: [
1731
+ ...(Array.isArray(completion?.checks) ? completion.checks : []),
1732
+ {
1733
+ command: 'Harness-owned final verification',
1734
+ status: 'failed',
1735
+ output: reason,
1736
+ },
1737
+ ],
1738
+ blockedReason: null,
1739
+ };
1740
+ };
1741
+
1742
+ const verificationRepairPrompt = () => {
1743
+ const current = verification();
1744
+ if (current.completed || mutationCalls === 0) return null;
1745
+ const missing = [];
1746
+ for (const check of current.missingChecks || []) {
1747
+ if (check === 'task-plan') {
1748
+ missing.push(
1749
+ 'declare the task domains and verification plan with framer_plan_task',
1750
+ );
1751
+ } else if (check === 'structural') {
1752
+ missing.push('perform a focused live read after the latest mutation');
1753
+ } else if (check === 'visual') {
1754
+ missing.push(
1755
+ screenshotBeforeMutation
1756
+ ? 'capture and inspect the affected canvas after the latest mutation'
1757
+ : 'report that the required baseline screenshot was missed; do not claim visual verification',
1758
+ );
1759
+ } else {
1760
+ missing.push(
1761
+ check === 'interactions'
1762
+ ? 'call framer_verify_interactions with canonical evidence node IDs for every declared behavior and chosen mechanism'
1763
+ : `perform a focused read tagged with verifies:["${check}"] after the latest mutation`,
1764
+ );
1765
+ }
1766
+ }
1767
+ return [
1768
+ 'Continue the same Framer task from its current project state.',
1769
+ 'Do not redo successful work.',
1770
+ `Before returning, ${missing.join(' and ')}.`,
1771
+ 'Repair only concrete problems you observe, then return the required JSON outcome.',
1772
+ ].join(' ');
1773
+ };
1774
+
1775
+ const builtInInstructions = (documentationUnavailable = false) => ({
1776
+ stdout: JSON.stringify({
1777
+ source: documentationUnavailable
1778
+ ? 'built-in-fallback'
1779
+ : 'built-in',
1780
+ guidance: [
1781
+ 'Use framer_read with framer.agent.getNode({ id }) or getNodes({ ids }) for node inspection.',
1782
+ 'Use framer_read_project for supported project queries such as screenshots.',
1783
+ 'Use framer_query_images for approved stock imagery only after checking user attachments and suitable existing project images.',
1784
+ 'Use framer_apply_changes for layout and styling. Read its diagnostics, verify the result, and repair concrete issues.',
1785
+ 'If framer_write is rejected as unsafe, do not retry JavaScript variants. Switch to framer_apply_changes or report the concrete blocker.',
1786
+ 'Batch related work when practical, reuse evidence already collected, and stop when the requested outcome is satisfied.',
1787
+ ],
1788
+ documentationUnavailable,
1789
+ }),
1790
+ stderr: '',
1791
+ });
1792
+
1793
+ const reserveToolCall = (name, category) => {
1794
+ toolCallCount += 1;
1795
+ if (category === 'progress') progressCalls += 1;
1796
+ if (category === 'read') readCalls += 1;
1797
+ const call = {
1798
+ sequence: toolCallCount,
1799
+ name,
1800
+ category,
1801
+ status: 'failed',
1802
+ durationMs: 0,
1803
+ };
1804
+ toolCalls.push(call);
1805
+ return call;
1806
+ };
1807
+
1808
+ const callFingerprint = (name, rawArguments) =>
1809
+ crypto
1810
+ .createHash('sha256')
1811
+ .update(`${name}\n${JSON.stringify(rawArguments)}`)
1812
+ .digest('hex');
1813
+
1814
+ const requireTaskPlanForMutation = ({ canvas = false } = {}) => {
1815
+ if (!taskPlan) {
1816
+ throw toolError(
1817
+ 'FRAMER_HARNESS_PLAN_REQUIRED',
1818
+ 'Inspect the live Framer target, then call framer_plan_task before making a mutation.',
1819
+ );
1820
+ }
1821
+ if (
1822
+ (canvas || taskPlan.verification.includes('visual'))
1823
+ && !screenshotBeforeMutation
1824
+ ) {
1825
+ throw toolError(
1826
+ 'FRAMER_HARNESS_BASELINE_SCREENSHOT_REQUIRED',
1827
+ 'Capture and inspect a screenshot of the affected canvas before the first visual mutation.',
1828
+ );
1829
+ }
1830
+ const rejectedSelectedMechanisms = taskPlan.behaviors.filter((behavior) => {
1831
+ const selected = taskPlan.mechanisms.find((decision) =>
1832
+ decision.behaviorId === behavior.id);
1833
+ return selected && taskPlan.rejectedMechanisms.some((rejected) =>
1834
+ rejected.mechanism === selected.mechanism
1835
+ && (
1836
+ rejected.behaviorId === behavior.id
1837
+ || rejected.behaviorSignature === behavior.signature
1838
+ ));
1839
+ });
1840
+ if (rejectedSelectedMechanisms.length > 0) {
1841
+ throw toolError(
1842
+ 'FRAMER_BEHAVIOR_MECHANISM_REPLAN_REQUIRED',
1843
+ `Choose a different mechanism for ${rejectedSelectedMechanisms.map((behavior) => behavior.id).join(', ')} before making another mutation.`,
1844
+ );
1845
+ }
1846
+ if (mutationCalls > 0) {
1847
+ const pendingRepresentative = taskPlan.behaviors
1848
+ .filter((behavior) => behavior.expectedTargets > 1)
1849
+ .filter((behavior) =>
1850
+ taskPlan.mechanisms.find((decision) =>
1851
+ decision.behaviorId === behavior.id
1852
+ && decision.representativeFirst))
1853
+ .filter((behavior) =>
1854
+ !representativeVerifiedBehaviorIds.has(behavior.id));
1855
+ if (pendingRepresentative.length > 0) {
1856
+ throw toolError(
1857
+ 'FRAMER_BEHAVIOR_REPRESENTATIVE_VERIFICATION_REQUIRED',
1858
+ `Verify one representative implementation for ${pendingRepresentative.map((behavior) => behavior.id).join(', ')} before batching further mutations.`,
1859
+ );
1860
+ }
1861
+ }
1862
+ };
840
1863
 
841
1864
  async function invokeUnsafe(name, rawArguments = {}) {
842
1865
  if (closed) {
@@ -857,6 +1880,25 @@ export function createFramerAgentToolRuntime({
857
1880
  'Framer Agent tool arguments must be an object.',
858
1881
  );
859
1882
  }
1883
+ const mutation =
1884
+ name === 'framer_apply_changes' || name === 'framer_write';
1885
+ const category =
1886
+ name === 'framer_plan_task'
1887
+ ? 'plan'
1888
+ : mutation
1889
+ ? 'mutation'
1890
+ : 'read';
1891
+ const fingerprint = callFingerprint(name, rawArguments);
1892
+ if (
1893
+ repeatedFailureFingerprint === fingerprint
1894
+ && repeatedFailureCount >= 5
1895
+ ) {
1896
+ repeatedFailureStops += 1;
1897
+ throw toolError(
1898
+ 'FRAMER_AGENT_REPEATED_CALL_LOOP',
1899
+ 'The same Framer tool call has failed five times in a row. Change the approach or report the concrete blocker.',
1900
+ );
1901
+ }
860
1902
  if (name === 'progress_update') {
861
1903
  const message = String(rawArguments.message || '').trim().slice(0, 500);
862
1904
  if (!message) {
@@ -865,15 +1907,20 @@ export function createFramerAgentToolRuntime({
865
1907
  'A progress message is required.',
866
1908
  );
867
1909
  }
1910
+ const toolCall = reserveToolCall(name, 'progress');
868
1911
  reportActivity({
869
1912
  kind: 'status_update',
870
1913
  source: 'harness',
871
1914
  message,
872
1915
  });
1916
+ repeatedFailureFingerprint = null;
1917
+ repeatedFailureCount = 0;
1918
+ toolCall.status = 'succeeded';
873
1919
  return { acknowledged: true };
874
1920
  }
875
1921
 
876
1922
  const startedAt = Date.now();
1923
+ const toolCall = reserveToolCall(name, category);
877
1924
  reportActivity({
878
1925
  kind: 'tool_active',
879
1926
  source: 'framer-agent',
@@ -882,17 +1929,48 @@ export function createFramerAgentToolRuntime({
882
1929
  message:
883
1930
  name === 'framer_apply_changes' || name === 'framer_write'
884
1931
  ? 'Updating the Framer project'
1932
+ : name === 'framer_plan_task'
1933
+ ? 'Planning the Framer work and verification'
885
1934
  : 'Inspecting the Framer project',
886
1935
  });
887
1936
  try {
888
1937
  let result;
889
- if (name === 'framer_instructions') {
890
- readCalls += 1;
891
- result = await execCode(
892
- 'console.log(await framer.agent.getSystemPrompt())',
1938
+ if (name === 'framer_write' && lowLevelWriteDisabled) {
1939
+ lowLevelWriteBlockedCalls += 1;
1940
+ throw toolError(
1941
+ 'FRAMER_AGENT_WRITE_FALLBACK_REQUIRED',
1942
+ '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.',
893
1943
  );
1944
+ } else if (name === 'framer_instructions') {
1945
+ const queries = Array.isArray(rawArguments.queries)
1946
+ ? uniqueStrings(rawArguments.queries, 8)
1947
+ : [];
1948
+ if (queries.length > 0 && !instructionLookupUnavailable) {
1949
+ try {
1950
+ result = await executeCli(
1951
+ ['docs', ...queries],
1952
+ { timeoutMs: 30_000 },
1953
+ );
1954
+ } catch (error) {
1955
+ instructionFallbacks += 1;
1956
+ instructionLookupUnavailable = true;
1957
+ trace?.warn?.('framer_agent_docs_fallback', {
1958
+ code: error?.code,
1959
+ message: error?.message,
1960
+ });
1961
+ reportActivity({
1962
+ kind: 'status_update',
1963
+ source: 'framer-agent',
1964
+ message:
1965
+ 'Targeted Framer documentation is unavailable, so I am continuing with the built-in harness guidance.',
1966
+ });
1967
+ result = builtInInstructions(true);
1968
+ }
1969
+ } else {
1970
+ if (queries.length > 0) instructionFallbacks += 1;
1971
+ result = builtInInstructions(queries.length > 0);
1972
+ }
894
1973
  } else if (name === 'framer_context') {
895
- readCalls += 1;
896
1974
  result = await execCode(
897
1975
  [
898
1976
  'const [context, branch] = await Promise.all([',
@@ -903,8 +1981,210 @@ export function createFramerAgentToolRuntime({
903
1981
  ].join('\n'),
904
1982
  );
905
1983
  markInspection();
1984
+ } else if (name === 'framer_plan_task') {
1985
+ if (lastInspectionSequence === 0) {
1986
+ throw toolError(
1987
+ 'FRAMER_HARNESS_INSPECTION_REQUIRED',
1988
+ 'Inspect the live Framer target before declaring the task and verification plan.',
1989
+ );
1990
+ }
1991
+ const nextPlan = normalizeTaskPlan(
1992
+ rawArguments,
1993
+ inheritedRejectedMechanisms,
1994
+ );
1995
+ if (
1996
+ mutationCalls > 0
1997
+ && nextPlan.verification.includes('visual')
1998
+ && !screenshotBeforeMutation
1999
+ ) {
2000
+ throw toolError(
2001
+ 'FRAMER_HARNESS_BASELINE_SCREENSHOT_REQUIRED',
2002
+ 'Visual verification cannot be added after mutations when no baseline screenshot was captured.',
2003
+ );
2004
+ }
2005
+ const previousMechanisms = new Map(
2006
+ (taskPlan?.mechanisms || []).map((decision) => [
2007
+ decision.behaviorId,
2008
+ decision.mechanism,
2009
+ ]),
2010
+ );
2011
+ const behaviorPlan = mergeBehaviorPlans(taskPlan, nextPlan);
2012
+ taskPlan = {
2013
+ summary: nextPlan.summary,
2014
+ domains: uniqueStrings([
2015
+ ...(taskPlan?.domains || []),
2016
+ ...nextPlan.domains,
2017
+ ]),
2018
+ verification: uniqueStrings([
2019
+ ...(taskPlan?.verification || []),
2020
+ ...nextPlan.verification,
2021
+ ]),
2022
+ ...behaviorPlan,
2023
+ };
2024
+ for (const decision of taskPlan.mechanisms) {
2025
+ const prior = previousMechanisms.get(decision.behaviorId);
2026
+ if (prior && prior !== decision.mechanism) {
2027
+ representativeVerifiedBehaviorIds.delete(decision.behaviorId);
2028
+ }
2029
+ }
2030
+ result = {
2031
+ stdout: JSON.stringify({
2032
+ plan: {
2033
+ summary: taskPlan.summary,
2034
+ domains: [...taskPlan.domains],
2035
+ verification: [...taskPlan.verification],
2036
+ behaviors: taskPlan.behaviors.map((behavior) => ({
2037
+ ...behavior,
2038
+ expectedContent: [...behavior.expectedContent],
2039
+ })),
2040
+ mechanisms: taskPlan.mechanisms.map((decision) => ({
2041
+ ...decision,
2042
+ })),
2043
+ compatibility: taskPlan.compatibility.map((entry) => ({
2044
+ ...entry,
2045
+ requiredCapabilities: [...entry.requiredCapabilities],
2046
+ })),
2047
+ rejectedMechanisms: taskPlan.rejectedMechanisms.map((entry) => ({
2048
+ ...entry,
2049
+ })),
2050
+ },
2051
+ instruction:
2052
+ 'Implement one representative first when required, verify it, then complete coverage and gather every derived verification result after the final mutation.',
2053
+ }),
2054
+ stderr: '',
2055
+ };
2056
+ } else if (name === 'framer_verify_interactions') {
2057
+ if (mutationCalls === 0) {
2058
+ throw toolError(
2059
+ 'FRAMER_HARNESS_INTERACTION_MUTATION_REQUIRED',
2060
+ 'Verify interactions only after the relevant Framer mutation has succeeded.',
2061
+ );
2062
+ }
2063
+ if (!taskPlan?.behaviors?.length) {
2064
+ throw toolError(
2065
+ 'FRAMER_BEHAVIOR_CONTRACT_REQUIRED',
2066
+ 'Declare observable behaviors and compatible mechanisms with framer_plan_task before interaction verification.',
2067
+ );
2068
+ }
2069
+ const checks = normalizeBehaviorEvidence(
2070
+ rawArguments.checks,
2071
+ taskPlan,
2072
+ );
2073
+ const pagePath = normalizePagePath(rawArguments.pagePath);
2074
+ const requestedIds = evidenceNodeIds(checks);
2075
+ const liveResult = await execCode(
2076
+ [
2077
+ `const requestedIds = ${JSON.stringify(requestedIds)};`,
2078
+ 'const nodes = await Promise.all(requestedIds.map(async (id) => ({',
2079
+ ' id,',
2080
+ ' node: await framer.agent.serialize({ id, depth: 8 },',
2081
+ ` { pagePath: ${JSON.stringify(pagePath)} }).catch(() => null),`,
2082
+ ' parentId: await framer.agent.getParentNode({ id },',
2083
+ ` { pagePath: ${JSON.stringify(pagePath)} }).then((node) => node?.id ?? null).catch(() => null),`,
2084
+ ' ancestorIds: await framer.agent.getAncestors({ id },',
2085
+ ` { pagePath: ${JSON.stringify(pagePath)} }).then((nodes) => Array.isArray(nodes) ? nodes.map((node) => node?.id).filter(Boolean) : []).catch(() => []),`,
2086
+ '})));',
2087
+ 'console.log(JSON.stringify({ nodes }, null, 2));',
2088
+ ].join('\n'),
2089
+ );
2090
+ const parsed = parseStructuredOutput(liveResult.stdout);
2091
+ const nodesById = new Map(
2092
+ (Array.isArray(parsed?.nodes) ? parsed.nodes : [])
2093
+ .filter((entry) => isRecord(entry) && typeof entry.id === 'string')
2094
+ .map((entry) => [entry.id, {
2095
+ node: entry.node || null,
2096
+ parentId:
2097
+ typeof entry.parentId === 'string'
2098
+ ? entry.parentId
2099
+ : null,
2100
+ ancestorIds: Array.isArray(entry.ancestorIds)
2101
+ ? entry.ancestorIds
2102
+ : [],
2103
+ }]),
2104
+ );
2105
+ const verified = verifyBehaviorEvidence({
2106
+ plan: taskPlan,
2107
+ checks,
2108
+ snapshots: nodesById,
2109
+ context: {
2110
+ lowLevelWriteCalls,
2111
+ authorizedInstanceContainerIds,
2112
+ },
2113
+ });
2114
+ markInspection();
2115
+ interactionVerificationCalls += 1;
2116
+ interactionVerification = {
2117
+ ...verified,
2118
+ verifiedAt: new Date().toISOString(),
2119
+ };
2120
+ for (const behaviorResult of verified.behaviors) {
2121
+ const failedChecks = verified.checks.filter((check) =>
2122
+ check.behaviorId === behaviorResult.behaviorId && !check.passed);
2123
+ if (failedChecks.length === 0) {
2124
+ repeatedBehaviorVerificationFailures.delete(
2125
+ behaviorResult.behaviorId,
2126
+ );
2127
+ continue;
2128
+ }
2129
+ const failureFingerprint = crypto
2130
+ .createHash('sha256')
2131
+ .update(JSON.stringify({
2132
+ mechanism: behaviorResult.mechanism,
2133
+ failures: failedChecks
2134
+ .flatMap((check) => check.failures)
2135
+ .sort(),
2136
+ }))
2137
+ .digest('hex');
2138
+ const prior = repeatedBehaviorVerificationFailures.get(
2139
+ behaviorResult.behaviorId,
2140
+ );
2141
+ const count =
2142
+ prior?.fingerprint === failureFingerprint
2143
+ ? prior.count + 1
2144
+ : 1;
2145
+ repeatedBehaviorVerificationFailures.set(
2146
+ behaviorResult.behaviorId,
2147
+ { fingerprint: failureFingerprint, count },
2148
+ );
2149
+ if (count < 2 || !behaviorResult.mechanism) continue;
2150
+ const alreadyRejected = taskPlan.rejectedMechanisms.some((rejected) =>
2151
+ rejected.mechanism === behaviorResult.mechanism
2152
+ && (
2153
+ rejected.behaviorId === behaviorResult.behaviorId
2154
+ || rejected.behaviorSignature
2155
+ === behaviorResult.behaviorSignature
2156
+ ));
2157
+ if (alreadyRejected) continue;
2158
+ taskPlan = {
2159
+ ...taskPlan,
2160
+ rejectedMechanisms: [
2161
+ ...taskPlan.rejectedMechanisms,
2162
+ {
2163
+ behaviorId: behaviorResult.behaviorId,
2164
+ behaviorSignature: behaviorResult.behaviorSignature,
2165
+ mechanism: behaviorResult.mechanism,
2166
+ reason:
2167
+ `Repeated structural verification failed: ${failedChecks
2168
+ .flatMap((check) => check.failures)
2169
+ .join('; ')
2170
+ .slice(0, 700)}`,
2171
+ source: 'verification_failure',
2172
+ },
2173
+ ],
2174
+ };
2175
+ }
2176
+ verified.representativeBehaviorIds.forEach((behaviorId) =>
2177
+ representativeVerifiedBehaviorIds.add(behaviorId));
2178
+ if (interactionVerification.completed) {
2179
+ verificationEvidence.set('interactions', operationSequence);
2180
+ } else {
2181
+ verificationEvidence.delete('interactions');
2182
+ }
2183
+ result = {
2184
+ stdout: JSON.stringify(interactionVerification),
2185
+ stderr: liveResult.stderr,
2186
+ };
906
2187
  } else if (name === 'framer_read_project') {
907
- readCalls += 1;
908
2188
  const queries = Array.isArray(rawArguments.queries)
909
2189
  ? rawArguments.queries.slice(0, 50)
910
2190
  : [];
@@ -921,7 +2201,41 @@ export function createFramerAgentToolRuntime({
921
2201
  'console.log(JSON.stringify(result, null, 2));',
922
2202
  ].join('\n'),
923
2203
  );
924
- markInspection();
2204
+ markInspection({
2205
+ visual: queries.some((query) => query.type === 'screenshot'),
2206
+ verifies: rawArguments.verifies,
2207
+ });
2208
+ } else if (name === 'framer_query_images') {
2209
+ const query = String(rawArguments.query || '').trim();
2210
+ if (query.length < 2) {
2211
+ throw toolError(
2212
+ 'FRAMER_AGENT_IMAGE_QUERY_INVALID',
2213
+ 'A specific image-search query is required.',
2214
+ );
2215
+ }
2216
+ const count = Math.max(
2217
+ 1,
2218
+ Math.min(8, Math.floor(Number(rawArguments.count) || 4)),
2219
+ );
2220
+ const orientation = ['landscape', 'portrait', 'squarish']
2221
+ .includes(rawArguments.orientation)
2222
+ ? rawArguments.orientation
2223
+ : undefined;
2224
+ result = await execCode(
2225
+ [
2226
+ 'const result = await framer.agent.queryImages({',
2227
+ ' source: "unsplash",',
2228
+ ` query: ${JSON.stringify(query)},`,
2229
+ ` count: ${count},`,
2230
+ ...(orientation
2231
+ ? [` orientation: ${JSON.stringify(orientation)},`]
2232
+ : []),
2233
+ '});',
2234
+ 'console.log(JSON.stringify(result, null, 2));',
2235
+ ].join('\n'),
2236
+ { timeoutMs: 30_000 },
2237
+ );
2238
+ imageSearchCalls += 1;
925
2239
  } else if (name === 'framer_apply_changes') {
926
2240
  const changes = String(rawArguments.changes || '').trim();
927
2241
  if (!changes) {
@@ -930,6 +2244,7 @@ export function createFramerAgentToolRuntime({
930
2244
  'Framer Agent changes are required.',
931
2245
  );
932
2246
  }
2247
+ requireTaskPlanForMutation({ canvas: true });
933
2248
  const pagePath = normalizePagePath(rawArguments.pagePath);
934
2249
  result = await execCode(
935
2250
  [
@@ -938,31 +2253,58 @@ export function createFramerAgentToolRuntime({
938
2253
  ].join('\n'),
939
2254
  );
940
2255
  mutationCalls += 1;
2256
+ applyChangesCalls += 1;
941
2257
  markMutation();
2258
+ const parsedResult = parseStructuredOutput(result.stdout);
2259
+ mutationReceipts.push({
2260
+ sequence: operationSequence,
2261
+ kind: 'framer_apply_changes',
2262
+ pagePath,
2263
+ ...summarizeFramerChanges(changes),
2264
+ renamedIds: canonicalIdMappings(parsedResult),
2265
+ });
942
2266
  } else if (name === 'framer_read') {
943
- readCalls += 1;
944
2267
  result = await execCode(
945
2268
  validateCode(rawArguments.code, {
946
2269
  write: false,
947
2270
  allowPublishing,
948
2271
  }),
949
2272
  );
950
- markInspection();
2273
+ markInspection({ verifies: rawArguments.verifies });
951
2274
  } else if (name === 'framer_write') {
952
- result = await execCode(
953
- validateCode(rawArguments.code, {
954
- write: true,
955
- allowPublishing,
956
- }),
957
- );
2275
+ const code = validateCode(rawArguments.code, {
2276
+ write: true,
2277
+ allowPublishing,
2278
+ });
2279
+ const reason = String(rawArguments.reason || '').trim();
2280
+ if (reason.length < 10) {
2281
+ throw toolError(
2282
+ 'FRAMER_AGENT_WRITE_REASON_REQUIRED',
2283
+ 'Explain why this operation cannot be completed with framer_apply_changes before using framer_write.',
2284
+ );
2285
+ }
2286
+ requireTaskPlanForMutation();
2287
+ result = await execCode(code);
958
2288
  mutationCalls += 1;
2289
+ lowLevelWriteCalls += 1;
959
2290
  markMutation();
2291
+ mutationReceipts.push({
2292
+ sequence: operationSequence,
2293
+ kind: 'framer_write',
2294
+ reason: reason.slice(0, 500),
2295
+ codeHash: crypto.createHash('sha256').update(code).digest('hex'),
2296
+ });
960
2297
  } else {
961
2298
  throw toolError(
962
2299
  'FRAMER_AGENT_TOOL_UNKNOWN',
963
2300
  `Unknown Framer Agent tool: ${name}`,
964
2301
  );
965
2302
  }
2303
+ const durationMs = Date.now() - startedAt;
2304
+ repeatedFailureFingerprint = null;
2305
+ repeatedFailureCount = 0;
2306
+ toolCall.status = 'succeeded';
2307
+ toolCall.durationMs = durationMs;
966
2308
  reportActivity({
967
2309
  kind: 'tool_active',
968
2310
  source: 'framer-agent',
@@ -972,10 +2314,54 @@ export function createFramerAgentToolRuntime({
972
2314
  name === 'framer_apply_changes' || name === 'framer_write'
973
2315
  ? 'Updated the Framer project'
974
2316
  : 'Finished inspecting the Framer project',
975
- durationMs: Date.now() - startedAt,
2317
+ durationMs,
976
2318
  });
977
2319
  return normalizedResult(result);
978
2320
  } catch (error) {
2321
+ if (
2322
+ name === 'framer_write'
2323
+ && error?.code === 'FRAMER_AGENT_CODE_UNSAFE'
2324
+ ) {
2325
+ unsafeWriteFailures += 1;
2326
+ if (
2327
+ !lowLevelWriteDisabled
2328
+ && unsafeWriteFailures >= MAX_UNSAFE_WRITE_FAILURES
2329
+ ) {
2330
+ lowLevelWriteDisabled = true;
2331
+ reportActivity({
2332
+ kind: 'status_update',
2333
+ source: 'framer-agent',
2334
+ message:
2335
+ 'Repeated low-level writes were rejected by the safety boundary, so I switched the run to the safer Framer change path.',
2336
+ });
2337
+ }
2338
+ }
2339
+ if (
2340
+ name === 'framer_write'
2341
+ && error?.code === 'FRAMER_AGENT_COMMAND_FAILED'
2342
+ ) {
2343
+ lowLevelWriteFailures += 1;
2344
+ if (
2345
+ !lowLevelWriteDisabled
2346
+ && lowLevelWriteFailures >= MAX_LOW_LEVEL_WRITE_FAILURES
2347
+ ) {
2348
+ lowLevelWriteDisabled = true;
2349
+ reportActivity({
2350
+ kind: 'status_update',
2351
+ source: 'framer-agent',
2352
+ message:
2353
+ 'Repeated low-level Framer writes failed, so I switched the run back to semantic Framer changes and focused inspection.',
2354
+ });
2355
+ }
2356
+ }
2357
+ if (repeatedFailureFingerprint === fingerprint) {
2358
+ repeatedFailureCount += 1;
2359
+ } else {
2360
+ repeatedFailureFingerprint = fingerprint;
2361
+ repeatedFailureCount = 1;
2362
+ }
2363
+ toolCall.durationMs = Date.now() - startedAt;
2364
+ toolCall.errorCode = error?.code || 'FRAMER_AGENT_TOOL_FAILED';
979
2365
  trace?.warn?.('framer_agent_tool_failed', {
980
2366
  name,
981
2367
  code: error?.code,
@@ -1061,31 +2447,96 @@ export function createFramerAgentToolRuntime({
1061
2447
  definitions,
1062
2448
  toolNames: definitions.map((definition) => definition.name),
1063
2449
  preflight: validateAuthoritativeNodeIds,
2450
+ loadProjectGuidance,
2451
+ configurePromptContext,
1064
2452
  invoke,
1065
2453
  startGateway,
1066
- synchronize: async () => ({
1067
- synchronized: synchronized(),
1068
- sourceHash: null,
1069
- changed: mutationCalls > 0,
1070
- }),
1071
- inspect: async () => ({
1072
- synchronized: synchronized(),
1073
- sourceHash: null,
1074
- changed: mutationCalls > 0,
1075
- mutationCalls,
1076
- readCalls,
1077
- branch: branchChange,
1078
- lastInspectionAt,
1079
- }),
2454
+ synchronize: inspectFinalState,
2455
+ inspect: inspectFinalState,
2456
+ finalizeCompletion,
2457
+ verificationRepairPrompt,
1080
2458
  sourceHash: () => null,
1081
2459
  hasPendingChanges: () => false,
1082
2460
  directWorkspace: true,
1083
2461
  summary: () => ({
1084
2462
  projectId,
2463
+ projectUrl,
1085
2464
  mutationCalls,
2465
+ applyChangesCalls,
2466
+ lowLevelWriteCalls,
2467
+ imageSearchCalls,
2468
+ interactionVerificationCalls,
2469
+ mutationReceipts: mutationReceipts.map((receipt) => ({
2470
+ ...receipt,
2471
+ ...(Array.isArray(receipt.commands)
2472
+ ? {
2473
+ commands: receipt.commands.map((command) => ({
2474
+ ...command,
2475
+ ...(Array.isArray(command.eventActions)
2476
+ ? {
2477
+ eventActions: command.eventActions.map((action) => ({
2478
+ ...action,
2479
+ })),
2480
+ }
2481
+ : {}),
2482
+ })),
2483
+ }
2484
+ : {}),
2485
+ ...(receipt.renamedIds
2486
+ ? { renamedIds: { ...receipt.renamedIds } }
2487
+ : {}),
2488
+ })),
1086
2489
  readCalls,
2490
+ progressCalls,
2491
+ toolCallCount,
2492
+ toolDurationMs: toolCalls.reduce(
2493
+ (total, call) => total + call.durationMs,
2494
+ 0,
2495
+ ),
2496
+ toolCalls: toolCalls.map((call) => ({ ...call })),
2497
+ workingPreferences: {
2498
+ focus: workingPreferences.mode,
2499
+ writeScope: workingPreferences.writeScope,
2500
+ preferBatchedMutations:
2501
+ workingPreferences.preferBatchedMutations,
2502
+ avoidRepeatedInspection:
2503
+ workingPreferences.avoidRepeatedInspection,
2504
+ expandScopeOnlyWhenNeeded:
2505
+ workingPreferences.expandScopeOnlyWhenNeeded,
2506
+ },
2507
+ loopProtection: {
2508
+ identicalFailureLimit: 5,
2509
+ blockedCalls: repeatedFailureStops,
2510
+ unsafeWriteFailureLimit: MAX_UNSAFE_WRITE_FAILURES,
2511
+ unsafeWriteFailures,
2512
+ lowLevelWriteFailureLimit: MAX_LOW_LEVEL_WRITE_FAILURES,
2513
+ lowLevelWriteFailures,
2514
+ lowLevelWriteDisabled,
2515
+ lowLevelWriteBlockedCalls,
2516
+ instructionFallbacks,
2517
+ },
1087
2518
  branch: branchChange,
1088
2519
  lastInspectionAt,
2520
+ lastMutationAt,
2521
+ taskPlan: taskPlanSnapshot(),
2522
+ verification: verification(),
2523
+ framerAgentVersion,
2524
+ framerGuidanceLoaded: guidanceMetadata.loaded,
2525
+ doctrineLoaded: promptMetadata.doctrineLoaded,
2526
+ guidanceHash: guidanceMetadata.contentHash,
2527
+ projectContextHash: guidanceMetadata.projectContextHash,
2528
+ visualVerificationRequired:
2529
+ verification().visualRequired,
2530
+ visualVerificationCompleted:
2531
+ verification().visualCompleted,
2532
+ guidance: {
2533
+ loaded: guidanceMetadata.loaded,
2534
+ contentHash: guidanceMetadata.contentHash,
2535
+ projectContextHash: guidanceMetadata.projectContextHash,
2536
+ files: [...guidanceMetadata.files],
2537
+ bytes: guidanceMetadata.bytes,
2538
+ },
2539
+ doctrine: { ...promptMetadata },
1089
2540
  }),
1090
2541
  fatalInfrastructureError: () => null,
1091
2542
  waitForFatalInfrastructure: () => new Promise(() => undefined),