@gakim-digital/dexter-bridge 0.11.16 → 0.11.19

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.
@@ -4,6 +4,7 @@ import { spawn } from 'node:child_process';
4
4
  import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
6
  import jsTokens from 'js-tokens';
7
+ import { loadGoogleFontsCatalog, searchGoogleFonts } from './googleFontsFallback.js';
7
8
  import {
8
9
  canonicalIdMappings,
9
10
  evidenceNodeIds,
@@ -49,7 +50,7 @@ const PAGE_PATH_PATTERN = /^\/[^\0\r\n]{0,499}$/;
49
50
  const UNSAFE_EXEC_PATTERN =
50
51
  /\b(?:process|globalThis|global|require|import\s*\(|child_process|worker_threads|fs|os|net|tls|dgram|http|https|fetch|WebSocket|eval|Function)\b/;
51
52
  const MUTATING_EXEC_PATTERN =
52
- /\.(?:set|add|create|remove|delete|update|insert|move|clone|duplicate|upload|publish|deploy|switch|merge|rename|join|leave|apply)[A-Za-z0-9_]*\s*\(/;
53
+ /\.(?:(?:set|add|delete|join|apply)(?:[A-Z_][A-Za-z0-9_]*)|(?:create|remove|update|insert|move|clone|duplicate|upload|publish|deploy|switch|merge|rename|leave)[A-Za-z0-9_]*)\s*\(/;
53
54
  const CODE_FILE_SOURCE_WRITE_PATTERN =
54
55
  /(?:\.setFileContent|\[\s*['"]setFileContent['"]\s*\])\s*\(/;
55
56
  const TASK_DOMAINS = [
@@ -81,19 +82,6 @@ const EMPTY_SCHEMA = {
81
82
  };
82
83
 
83
84
  export const FRAMER_AGENT_TOOL_DEFINITIONS = [
84
- {
85
- name: 'progress_update',
86
- description:
87
- '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.',
88
- inputSchema: {
89
- type: 'object',
90
- properties: {
91
- message: { type: 'string', minLength: 1, maxLength: 500 },
92
- },
93
- required: ['message'],
94
- additionalProperties: false,
95
- },
96
- },
97
85
  {
98
86
  name: 'framer_instructions',
99
87
  description:
@@ -124,7 +112,7 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
124
112
  {
125
113
  name: 'framer_read_project',
126
114
  description:
127
- 'Run Framer Agent readProject queries against the connected project. Use this for focused canvas inspection and screenshots.',
115
+ 'Run Framer Agent readProject queries: "screenshot", "font-search", and "implementation-guide-from-index" are the only query types. Read nodes and layout with framer_read instead.',
128
116
  inputSchema: {
129
117
  type: 'object',
130
118
  properties: {
@@ -148,6 +136,24 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
148
136
  additionalProperties: false,
149
137
  },
150
138
  },
139
+ {
140
+ name: 'framer_read_website',
141
+ description:
142
+ 'Read one page of a website the user named: exact copy per section, fonts and text styles, colours, image URLs (including CSS backgrounds), and desktop and phone screenshots. Use it before recreating or migrating that page. Only sites the user named can be read.',
143
+ inputSchema: {
144
+ type: 'object',
145
+ properties: {
146
+ url: { type: 'string', minLength: 1, maxLength: 2048 },
147
+ screenshotTop: {
148
+ type: 'integer',
149
+ minimum: 0,
150
+ description: 'Desktop page offset in pixels for the screenshots. Use the value the previous result suggests to see further down.',
151
+ },
152
+ },
153
+ required: ['url'],
154
+ additionalProperties: false,
155
+ },
156
+ },
151
157
  {
152
158
  name: 'framer_plan_task',
153
159
  description:
@@ -210,7 +216,7 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
210
216
  {
211
217
  name: 'framer_verify_interactions',
212
218
  description:
213
- 'Confirm planned behaviors against the live project. For each target node, name the Framer property that carries the behavior (for example loopEffect, hoverEffect, appearEffect, link, or onTap) and optionally a literal the value must contain (such as a route or SHOW_OVERLAY). Results: verified (the property is set and matches), contradicted (the property holds a different value), or unknown (the property was not found on the node). Unknown never blocks your work; it is reported as unverified. Checks accumulate across calls, so you can verify targets on several pages.',
219
+ 'Confirm planned behaviors against the live project. For each target node, name the Framer property that carries the behavior (for example loopEffect, hoverEffect, appearEffect, link, or onTap, or a field inside one such as link.href or hoverEffect.opacity) and optionally a literal the value must contain (such as a route or SHOW_OVERLAY). Results: verified (the property is set and matches), contradicted (the property holds a different value), or unknown (the property was not found on the node). Unknown never blocks your work; it is reported as unverified. Checks accumulate across calls, so you can verify targets on several pages.',
214
220
  inputSchema: {
215
221
  type: 'object',
216
222
  properties: {
@@ -270,12 +276,20 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
270
276
  {
271
277
  name: 'framer_apply_changes',
272
278
  description:
273
- 'Apply Framer Agent DSL changes directly to the connected project. Prefer this for page, layout, style, design-token, component, and CMS-on-canvas work.',
279
+ 'Apply Framer Agent DSL changes directly to the connected project. Prefer this for page, layout, style, design-token, component, and CMS-on-canvas work. Pass screenshotNodeIds to get screenshots of those nodes taken after the change in the same result, instead of a separate read.',
274
280
  inputSchema: {
275
281
  type: 'object',
276
282
  properties: {
277
283
  changes: { type: 'string', minLength: 1, maxLength: 250_000 },
278
284
  pagePath: { type: 'string', maxLength: 500, default: '/' },
285
+ screenshotNodeIds: {
286
+ type: 'array',
287
+ maxItems: MAX_SCREENSHOTS_PER_CALL,
288
+ uniqueItems: true,
289
+ items: { type: 'string', minLength: 1, maxLength: 200 },
290
+ description:
291
+ 'Nodes (or component variants) to screenshot after the change is applied. The screenshots count as visual evidence after this mutation.',
292
+ },
279
293
  },
280
294
  required: ['changes'],
281
295
  additionalProperties: false,
@@ -342,7 +356,7 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
342
356
  {
343
357
  name: 'framer_read',
344
358
  description:
345
- 'Execute read-only JavaScript with the official Framer Agent connection for capabilities not covered by readProject.',
359
+ 'Execute read-only JavaScript with the official Framer Agent connection. Use it to read nodes and layout, for example with framer.agent.getNode, serialize, or serializeNodes; combine several reads in one script.',
346
360
  inputSchema: {
347
361
  type: 'object',
348
362
  properties: {
@@ -521,12 +535,21 @@ function onDemandGuideCatalog(projectRoot) {
521
535
  return catalog;
522
536
  }
523
537
 
538
+ // Returns a "## " or "### " section up to the next heading of the same or a
539
+ // higher level.
524
540
  function markdownSection(markdown, heading) {
525
541
  const lines = markdown.split('\n');
526
- const start = lines.findIndex((line) => line.trim() === `## ${heading}`);
542
+ const headingLevel = (line) => /^(#{1,6}) /.exec(line)?.[1].length || 0;
543
+ const start = lines.findIndex(
544
+ (line) => headingLevel(line) >= 2
545
+ && line.trim().replace(/^#+ /, '') === heading,
546
+ );
527
547
  if (start < 0) return '';
548
+ const level = headingLevel(lines[start]);
528
549
  const end = lines.findIndex(
529
- (line, index) => index > start && line.startsWith('## '),
550
+ (line, index) => index > start
551
+ && headingLevel(line) > 0
552
+ && headingLevel(line) <= level,
530
553
  );
531
554
  return lines.slice(start, end < 0 ? undefined : end).join('\n').trim();
532
555
  }
@@ -1129,6 +1152,7 @@ export function createFramerAgentToolRuntime({
1129
1152
  fetchImpl = fetch,
1130
1153
  authorizeProject,
1131
1154
  verifyProjectAuthorization,
1155
+ readWebsite,
1132
1156
  } = {}) {
1133
1157
  const projectId = normalizeProjectId(assignment?.context?.framerProjectId);
1134
1158
  const projectUrl = normalizeProjectUrl(
@@ -1177,7 +1201,6 @@ export function createFramerAgentToolRuntime({
1177
1201
  let lowLevelWriteCalls = 0;
1178
1202
  let imageSearchCalls = 0;
1179
1203
  let readCalls = 0;
1180
- let progressCalls = 0;
1181
1204
  let toolCallCount = 0;
1182
1205
  const toolCalls = [];
1183
1206
  let repeatedFailureFingerprint = null;
@@ -1188,6 +1211,7 @@ export function createFramerAgentToolRuntime({
1188
1211
  let lowLevelWriteDisabled = false;
1189
1212
  let lowLevelWriteBlockedCalls = 0;
1190
1213
  let instructionFallbacks = 0;
1214
+ let fontSearchFallbacks = 0;
1191
1215
  let instructionLookupUnavailable = false;
1192
1216
  let branchChange = null;
1193
1217
  let lastInspectionAt = null;
@@ -1537,14 +1561,23 @@ export function createFramerAgentToolRuntime({
1537
1561
  // Every run gets the guidance an edit always needs: principles, command
1538
1562
  // syntax, tool APIs, scope and replica rules, and the edit loop. Topic
1539
1563
  // guides (CMS, components, creation strategy, examples, ...) are listed
1540
- // in a catalog and read on demand through framer_instructions.
1564
+ // in a catalog and read on demand through framer_instructions. The CLI,
1565
+ // session, and file-map parts of SKILL.md and index.md do not apply to
1566
+ // Dexter's tools, and index.md changes every session, so both are left
1567
+ // out; the guidance stays byte-identical across runs and can be cached.
1568
+ // The project inventory changes with the project and is returned apart.
1541
1569
  const requestedFiles = [
1542
- { root: skillRoot, path: 'SKILL.md', label: 'SKILL.md' },
1543
- { root: projectRoot, path: 'index.md', label: 'project/index.md' },
1544
1570
  {
1545
- root: projectRoot,
1546
- path: 'project-inventory.md',
1547
- label: 'project/project-inventory.md',
1571
+ root: skillRoot,
1572
+ path: 'SKILL.md',
1573
+ section: 'Method Selection',
1574
+ label: 'SKILL.md#Method Selection',
1575
+ },
1576
+ {
1577
+ root: skillRoot,
1578
+ path: 'SKILL.md',
1579
+ section: 'Core Usage Principles',
1580
+ label: 'SKILL.md#Core Usage Principles',
1548
1581
  },
1549
1582
  { root: projectRoot, path: 'prompt/overview.md' },
1550
1583
  { root: projectRoot, path: 'prompt/core-principles.md' },
@@ -1590,8 +1623,10 @@ export function createFramerAgentToolRuntime({
1590
1623
  ...files.map((file) => `## ${file.path}\n\n${file.content}`),
1591
1624
  ...(catalogText ? [catalogText] : []),
1592
1625
  ].join('\n\n');
1593
- const inventory = files.find(
1594
- (file) => file.path.endsWith('project-inventory.md'),
1626
+ const inventory = readGuidanceFile(
1627
+ projectRoot,
1628
+ 'project-inventory.md',
1629
+ MAX_GUIDANCE_FILE_BYTES,
1595
1630
  )?.content || '';
1596
1631
  guidanceMetadata = {
1597
1632
  loaded: Boolean(content),
@@ -1601,10 +1636,13 @@ export function createFramerAgentToolRuntime({
1601
1636
  projectContextHash: inventory
1602
1637
  ? crypto.createHash('sha256').update(inventory).digest('hex')
1603
1638
  : null,
1604
- files: files.map((file) => file.path),
1605
- bytes: Buffer.byteLength(content, 'utf8'),
1639
+ files: [
1640
+ ...files.map((file) => file.path),
1641
+ ...(inventory ? ['project/project-inventory.md'] : []),
1642
+ ],
1643
+ bytes: Buffer.byteLength(content + inventory, 'utf8'),
1606
1644
  };
1607
- return { content, ...guidanceMetadata };
1645
+ return { content, inventory, ...guidanceMetadata };
1608
1646
  };
1609
1647
 
1610
1648
  const configurePromptContext = ({
@@ -1717,7 +1755,74 @@ export function createFramerAgentToolRuntime({
1717
1755
  capture: captured.error,
1718
1756
  });
1719
1757
  }
1720
- return { images, viaDownload, viaSession, undelivered, failures };
1758
+ screenshotsViaDownload += viaDownload;
1759
+ screenshotsViaSession += viaSession;
1760
+ screenshotsUndelivered += undelivered;
1761
+ screenshotFailures.push(...failures);
1762
+ screenshotFailures.splice(
1763
+ 0,
1764
+ Math.max(0, screenshotFailures.length - MAX_SCREENSHOT_FAILURES_REPORTED),
1765
+ );
1766
+ return images;
1767
+ };
1768
+
1769
+ const readProject = (queries, pagePath) => execCode(
1770
+ [
1771
+ `const result = await framer.agent.readProject(${JSON.stringify(queries)}, { pagePath: ${JSON.stringify(pagePath)} });`,
1772
+ 'console.log(JSON.stringify(result, null, 2));',
1773
+ ].join('\n'),
1774
+ );
1775
+
1776
+ // Runs readProject screenshot queries and hands the pixels to the model.
1777
+ const readScreenshots = async (queries, pagePath) => {
1778
+ const result = await readProject(queries, pagePath);
1779
+ return deliverScreenshots(queries, parseStructuredOutput(result.stdout));
1780
+ };
1781
+
1782
+ // Framer bills style font searches to the workspace's AI credit and fails
1783
+ // the whole read when they cannot run. Those searches are then answered
1784
+ // from the Google Fonts catalog and the remaining queries are read again.
1785
+ // Returns null when there is nothing to answer, so the original error stands.
1786
+ const readProjectWithFontFallback = async (queries, pagePath) => {
1787
+ try {
1788
+ return await readProject(queries, pagePath);
1789
+ } catch (error) {
1790
+ const styleSearch = (query) =>
1791
+ query.type === 'font-search' && typeof query.query === 'string';
1792
+ if (!queries.some(styleSearch)) throw error;
1793
+ let catalog;
1794
+ try {
1795
+ catalog = await loadGoogleFontsCatalog(fetchImpl);
1796
+ } catch (catalogError) {
1797
+ trace?.warn?.('google_fonts_fallback_unavailable', {
1798
+ message: catalogError?.message,
1799
+ });
1800
+ throw error;
1801
+ }
1802
+ const remaining = queries.filter((query) => !styleSearch(query));
1803
+ const remainingResults = remaining.length
1804
+ ? parseStructuredOutput((await readProject(remaining, pagePath)).stdout)?.results || []
1805
+ : [];
1806
+ fontSearchFallbacks += 1;
1807
+ trace?.warn?.('framer_font_search_fallback', {
1808
+ message: boundedText(String(error?.message || ''), 300),
1809
+ });
1810
+ const reason = boundedText(String(error?.message || 'unavailable'), 160);
1811
+ let next = 0;
1812
+ return {
1813
+ stdout: JSON.stringify({
1814
+ results: queries.map((query) => (styleSearch(query)
1815
+ ? {
1816
+ type: 'font-search',
1817
+ source: 'google-fonts',
1818
+ note: `Framer's style font search failed (${reason}), so these candidates come from the Google Fonts library, which Framer includes. Choose by how the family looks, set fontName to it, and use a listed weight. If you know a Google Fonts family that fits better, confirm it with a font-search by name.`,
1819
+ results: searchGoogleFonts(catalog, query),
1820
+ }
1821
+ : remainingResults[next++] ?? null)),
1822
+ }),
1823
+ stderr: '',
1824
+ };
1825
+ }
1721
1826
  };
1722
1827
 
1723
1828
  const referenceAssetFiles = Array.isArray(assignment?.context?.referenceAssetFiles)
@@ -2086,6 +2191,17 @@ export function createFramerAgentToolRuntime({
2086
2191
  ].join(' ');
2087
2192
  };
2088
2193
 
2194
+ const evidenceRequirement = (check) => {
2195
+ if (check === 'structural') return 'a focused live read';
2196
+ if (check === 'visual') {
2197
+ return 'a screenshot you inspect (screenshotNodeIds on your last framer_apply_changes provides it)';
2198
+ }
2199
+ if (check === 'interactions') {
2200
+ return 'framer_verify_interactions for every declared behavior and target node';
2201
+ }
2202
+ return `a read tagged with verifies:["${check}"]`;
2203
+ };
2204
+
2089
2205
  const verificationRepairPrompt = () => {
2090
2206
  const current = verification();
2091
2207
  if (current.completed || mutationCalls === 0) return null;
@@ -2154,7 +2270,6 @@ export function createFramerAgentToolRuntime({
2154
2270
 
2155
2271
  const reserveToolCall = (name, category) => {
2156
2272
  toolCallCount += 1;
2157
- if (category === 'progress') progressCalls += 1;
2158
2273
  if (category === 'read') readCalls += 1;
2159
2274
  const call = {
2160
2275
  sequence: toolCallCount,
@@ -2219,25 +2334,6 @@ export function createFramerAgentToolRuntime({
2219
2334
  'The same Framer tool call has failed five times in a row. Change the approach or report the concrete blocker.',
2220
2335
  );
2221
2336
  }
2222
- if (name === 'progress_update') {
2223
- const message = String(rawArguments.message || '').trim().slice(0, 500);
2224
- if (!message) {
2225
- throw toolError(
2226
- 'FRAMER_AGENT_PROGRESS_INVALID',
2227
- 'A progress message is required.',
2228
- );
2229
- }
2230
- const toolCall = reserveToolCall(name, 'progress');
2231
- reportActivity({
2232
- kind: 'status_update',
2233
- source: 'harness',
2234
- message,
2235
- });
2236
- repeatedFailureFingerprint = null;
2237
- repeatedFailureCount = 0;
2238
- toolCall.status = 'succeeded';
2239
- return { acknowledged: true };
2240
- }
2241
2337
 
2242
2338
  const startedAt = Date.now();
2243
2339
  const toolCall = reserveToolCall(name, category);
@@ -2256,6 +2352,7 @@ export function createFramerAgentToolRuntime({
2256
2352
  try {
2257
2353
  let result;
2258
2354
  let modelImages = [];
2355
+ let screenshotRequestCount = 0;
2259
2356
  if (name === 'framer_write' && lowLevelWriteDisabled) {
2260
2357
  lowLevelWriteBlockedCalls += 1;
2261
2358
  throw toolError(
@@ -2324,6 +2421,23 @@ export function createFramerAgentToolRuntime({
2324
2421
  if (queries.length > 0) instructionFallbacks += 1;
2325
2422
  result = builtInInstructions(queries.length > 0);
2326
2423
  }
2424
+ } else if (name === 'framer_read_website') {
2425
+ if (typeof readWebsite !== 'function') {
2426
+ throw toolError(
2427
+ 'FRAMER_WEBSITE_READ_UNAVAILABLE',
2428
+ 'Reading websites is not available in this run.',
2429
+ );
2430
+ }
2431
+ const website = await readWebsite({
2432
+ url: String(rawArguments.url || '').trim(),
2433
+ screenshotTop: Number.isInteger(rawArguments.screenshotTop)
2434
+ ? rawArguments.screenshotTop
2435
+ : undefined,
2436
+ });
2437
+ modelImages = (Array.isArray(website?.images) ? website.images : [])
2438
+ .filter((image) => typeof image?.data === 'string' && image.data)
2439
+ .map((image) => ({ url: null, mimeType: image.mimeType || 'image/jpeg', data: image.data }));
2440
+ result = { stdout: JSON.stringify(website?.result ?? {}), stderr: '' };
2327
2441
  } else if (name === 'framer_context') {
2328
2442
  result = await execCode(
2329
2443
  [
@@ -2356,7 +2470,10 @@ export function createFramerAgentToolRuntime({
2356
2470
  ...mergeBehaviorPlans(taskPlan, nextPlan),
2357
2471
  };
2358
2472
  result = {
2359
- stdout: JSON.stringify({ plan: taskPlanSnapshot() }),
2473
+ stdout: JSON.stringify({
2474
+ plan: taskPlanSnapshot(),
2475
+ beforeFinishing: `After your final change, and before returning, produce: ${requiredChecks().map(evidenceRequirement).join('; ')}.`,
2476
+ }),
2360
2477
  stderr: '',
2361
2478
  };
2362
2479
  } else if (name === 'framer_verify_interactions') {
@@ -2386,7 +2503,15 @@ export function createFramerAgentToolRuntime({
2386
2503
  .map((entry) => [entry.id, entry.node]),
2387
2504
  );
2388
2505
  const results = verifyBehaviorChecks({ checks, nodes });
2389
- markInspection();
2506
+ // Live link values read here are link evidence too, so the model does
2507
+ // not spend another turn re-reading them under verifies:["links"].
2508
+ const linkChecks = results.filter((check) => /^link(\.|$)/.test(check.property));
2509
+ markInspection({
2510
+ verifies: linkChecks.length > 0
2511
+ && linkChecks.every((check) => check.verdict === 'verified')
2512
+ ? ['links']
2513
+ : [],
2514
+ });
2390
2515
  interactionVerificationCalls += 1;
2391
2516
  interactionVerifiedAt = new Date().toISOString();
2392
2517
  for (const checkResult of results) {
@@ -2415,28 +2540,15 @@ export function createFramerAgentToolRuntime({
2415
2540
  );
2416
2541
  }
2417
2542
  const pagePath = normalizePagePath(rawArguments.pagePath);
2418
- result = await execCode(
2419
- [
2420
- `const result = await framer.agent.readProject(${JSON.stringify(queries)}, { pagePath: ${JSON.stringify(pagePath)} });`,
2421
- 'console.log(JSON.stringify(result, null, 2));',
2422
- ].join('\n'),
2423
- );
2543
+ result = await readProjectWithFontFallback(queries, pagePath);
2424
2544
  const requestedScreenshots = screenshotQueries(rawArguments);
2425
- const delivery = requestedScreenshots.length
2545
+ screenshotRequestCount = requestedScreenshots.length;
2546
+ modelImages = requestedScreenshots.length
2426
2547
  ? await deliverScreenshots(
2427
2548
  requestedScreenshots,
2428
2549
  parseStructuredOutput(result.stdout),
2429
2550
  )
2430
- : { images: [], viaDownload: 0, viaSession: 0, undelivered: 0, failures: [] };
2431
- modelImages = delivery.images;
2432
- screenshotsViaDownload += delivery.viaDownload;
2433
- screenshotsViaSession += delivery.viaSession;
2434
- screenshotsUndelivered += delivery.undelivered;
2435
- screenshotFailures.push(...delivery.failures);
2436
- screenshotFailures.splice(
2437
- 0,
2438
- Math.max(0, screenshotFailures.length - MAX_SCREENSHOT_FAILURES_REPORTED),
2439
- );
2551
+ : [];
2440
2552
  markInspection({
2441
2553
  visual: modelImages.length > 0,
2442
2554
  visualAttempted: requestedScreenshots.length > 0,
@@ -2832,6 +2944,20 @@ export function createFramerAgentToolRuntime({
2832
2944
  ...summarizeFramerChanges(changes),
2833
2945
  renamedIds: canonicalIdMappings(parsedResult),
2834
2946
  });
2947
+ // A screenshot taken here saves the model a whole turn re-reading
2948
+ // its context just to look at the change.
2949
+ const screenshotIds = Array.isArray(rawArguments.screenshotNodeIds)
2950
+ ? uniqueStrings(rawArguments.screenshotNodeIds, MAX_SCREENSHOTS_PER_CALL)
2951
+ : [];
2952
+ if (screenshotIds.length > 0) {
2953
+ const queries = screenshotIds.map((id) => ({ type: 'screenshot', id }));
2954
+ modelImages = await readScreenshots(queries, pagePath);
2955
+ screenshotRequestCount = queries.length;
2956
+ markInspection({
2957
+ visual: modelImages.length > 0,
2958
+ visualAttempted: true,
2959
+ });
2960
+ }
2835
2961
  } else if (name === 'framer_read') {
2836
2962
  result = await execCode(
2837
2963
  validateCode(rawArguments.code, {
@@ -2898,17 +3024,25 @@ export function createFramerAgentToolRuntime({
2898
3024
  // Result size is what this call adds to every later model turn.
2899
3025
  toolCall.resultBytes = Buffer.byteLength(JSON.stringify(normalized), 'utf8');
2900
3026
  if (modelImages.length > 0) toolCall.imageCount = modelImages.length;
2901
- if (name !== 'framer_read_project') return normalized;
2902
- const screenshotCount = screenshotQueries(rawArguments).length;
3027
+ if (name === 'framer_read_website') {
3028
+ return modelImages.length ? { ...normalized, modelImages } : normalized;
3029
+ }
3030
+ // Naming the evidence still owed after the latest change lets the model
3031
+ // finish it in this session instead of in a resumed repair turn.
3032
+ const evidenceStillDue =
3033
+ taskPlan && mutationCalls > 0 && (category === 'read' || screenshotRequestCount > 0)
3034
+ ? missingVerificationChecks()
3035
+ : [];
2903
3036
  return {
2904
3037
  ...normalized,
2905
3038
  ...(modelImages.length ? { modelImages } : {}),
2906
- ...(screenshotCount > modelImages.length
3039
+ ...(screenshotRequestCount > modelImages.length
2907
3040
  ? {
2908
3041
  screenshotWarning:
2909
- `${screenshotCount - modelImages.length} screenshot(s) could not be downloaded for you to view. Treat that visual state as unverified.`,
3042
+ `${screenshotRequestCount - modelImages.length} screenshot(s) could not be downloaded for you to view. Treat that visual state as unverified.`,
2910
3043
  }
2911
3044
  : {}),
3045
+ ...(evidenceStillDue.length > 0 ? { evidenceStillDue } : {}),
2912
3046
  };
2913
3047
  } catch (error) {
2914
3048
  if (
@@ -2955,6 +3089,10 @@ export function createFramerAgentToolRuntime({
2955
3089
  }
2956
3090
  toolCall.durationMs = Date.now() - startedAt;
2957
3091
  toolCall.errorCode = error?.code || 'FRAMER_AGENT_TOOL_FAILED';
3092
+ toolCall.errorMessage = boundedText(
3093
+ String(error?.message || 'Framer Agent tool failed.'),
3094
+ 300,
3095
+ );
2958
3096
  trace?.warn?.('framer_agent_tool_failed', {
2959
3097
  name,
2960
3098
  code: error?.code,
@@ -3082,7 +3220,6 @@ export function createFramerAgentToolRuntime({
3082
3220
  : {}),
3083
3221
  })),
3084
3222
  readCalls,
3085
- progressCalls,
3086
3223
  toolCallCount,
3087
3224
  toolDurationMs: toolCalls.reduce(
3088
3225
  (total, call) => total + call.durationMs,
@@ -3110,6 +3247,7 @@ export function createFramerAgentToolRuntime({
3110
3247
  lowLevelWriteBlockedCalls,
3111
3248
  instructionFallbacks,
3112
3249
  },
3250
+ fontSearchFallbacks,
3113
3251
  branch: branchChange,
3114
3252
  lastInspectionAt,
3115
3253
  lastMutationAt,
@@ -110,8 +110,22 @@ function hasContent(value) {
110
110
  }
111
111
 
112
112
  // Finds the property on the node itself. Child nodes are skipped so a
113
- // descendant cannot satisfy a claim about its parent.
113
+ // descendant cannot satisfy a claim about its parent. A dotted name such as
114
+ // `link.href` or `hoverEffect.opacity`, the way the change language writes
115
+ // it, reads the field inside the property's value.
114
116
  function propertyValues(node, property) {
117
+ const values = keyValues(node, property);
118
+ const [head, ...path] = property.split('.');
119
+ if (values.length > 0 || path.length === 0) return values;
120
+ return keyValues(node, head)
121
+ .map((value) => path.reduce(
122
+ (current, key) => (isRecord(current) ? current[key] : undefined),
123
+ value,
124
+ ))
125
+ .filter(hasContent);
126
+ }
127
+
128
+ function keyValues(node, property) {
115
129
  const values = [];
116
130
  const visit = (current, depth) => {
117
131
  if (depth > MAX_SEARCH_DEPTH || values.length >= 10) return;