@gakim-digital/dexter-bridge 0.11.17 → 0.11.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/package.json +1 -1
- package/src/agent.js +112 -31
- package/src/cli.js +42 -13
- package/src/config.js +64 -3
- package/src/framerAgentTools.js +171 -76
- package/src/framerBehaviorContract.js +15 -1
- package/src/googleFontsFallback.js +196 -0
- package/src/protocol.js +90 -68
- package/src/providers/codexAppServer.js +46 -1
- package/src/providers/cursorAcp.js +456 -0
- package/src/providers/index.js +12 -6
- package/src/runtimeProfiles.js +40 -6
package/src/framerAgentTools.js
CHANGED
|
@@ -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,
|
|
@@ -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
|
|
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: {
|
|
@@ -228,7 +216,7 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
|
|
|
228
216
|
{
|
|
229
217
|
name: 'framer_verify_interactions',
|
|
230
218
|
description:
|
|
231
|
-
'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.',
|
|
232
220
|
inputSchema: {
|
|
233
221
|
type: 'object',
|
|
234
222
|
properties: {
|
|
@@ -288,12 +276,20 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
|
|
|
288
276
|
{
|
|
289
277
|
name: 'framer_apply_changes',
|
|
290
278
|
description:
|
|
291
|
-
'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.',
|
|
292
280
|
inputSchema: {
|
|
293
281
|
type: 'object',
|
|
294
282
|
properties: {
|
|
295
283
|
changes: { type: 'string', minLength: 1, maxLength: 250_000 },
|
|
296
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
|
+
},
|
|
297
293
|
},
|
|
298
294
|
required: ['changes'],
|
|
299
295
|
additionalProperties: false,
|
|
@@ -360,7 +356,7 @@ export const FRAMER_AGENT_TOOL_DEFINITIONS = [
|
|
|
360
356
|
{
|
|
361
357
|
name: 'framer_read',
|
|
362
358
|
description:
|
|
363
|
-
'Execute read-only JavaScript with the official Framer Agent connection for
|
|
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.',
|
|
364
360
|
inputSchema: {
|
|
365
361
|
type: 'object',
|
|
366
362
|
properties: {
|
|
@@ -539,12 +535,21 @@ function onDemandGuideCatalog(projectRoot) {
|
|
|
539
535
|
return catalog;
|
|
540
536
|
}
|
|
541
537
|
|
|
538
|
+
// Returns a "## " or "### " section up to the next heading of the same or a
|
|
539
|
+
// higher level.
|
|
542
540
|
function markdownSection(markdown, heading) {
|
|
543
541
|
const lines = markdown.split('\n');
|
|
544
|
-
const
|
|
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
|
+
);
|
|
545
547
|
if (start < 0) return '';
|
|
548
|
+
const level = headingLevel(lines[start]);
|
|
546
549
|
const end = lines.findIndex(
|
|
547
|
-
(line, index) => index > start
|
|
550
|
+
(line, index) => index > start
|
|
551
|
+
&& headingLevel(line) > 0
|
|
552
|
+
&& headingLevel(line) <= level,
|
|
548
553
|
);
|
|
549
554
|
return lines.slice(start, end < 0 ? undefined : end).join('\n').trim();
|
|
550
555
|
}
|
|
@@ -1196,7 +1201,6 @@ export function createFramerAgentToolRuntime({
|
|
|
1196
1201
|
let lowLevelWriteCalls = 0;
|
|
1197
1202
|
let imageSearchCalls = 0;
|
|
1198
1203
|
let readCalls = 0;
|
|
1199
|
-
let progressCalls = 0;
|
|
1200
1204
|
let toolCallCount = 0;
|
|
1201
1205
|
const toolCalls = [];
|
|
1202
1206
|
let repeatedFailureFingerprint = null;
|
|
@@ -1207,6 +1211,7 @@ export function createFramerAgentToolRuntime({
|
|
|
1207
1211
|
let lowLevelWriteDisabled = false;
|
|
1208
1212
|
let lowLevelWriteBlockedCalls = 0;
|
|
1209
1213
|
let instructionFallbacks = 0;
|
|
1214
|
+
let fontSearchFallbacks = 0;
|
|
1210
1215
|
let instructionLookupUnavailable = false;
|
|
1211
1216
|
let branchChange = null;
|
|
1212
1217
|
let lastInspectionAt = null;
|
|
@@ -1556,14 +1561,23 @@ export function createFramerAgentToolRuntime({
|
|
|
1556
1561
|
// Every run gets the guidance an edit always needs: principles, command
|
|
1557
1562
|
// syntax, tool APIs, scope and replica rules, and the edit loop. Topic
|
|
1558
1563
|
// guides (CMS, components, creation strategy, examples, ...) are listed
|
|
1559
|
-
// 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.
|
|
1560
1569
|
const requestedFiles = [
|
|
1561
|
-
{ root: skillRoot, path: 'SKILL.md', label: 'SKILL.md' },
|
|
1562
|
-
{ root: projectRoot, path: 'index.md', label: 'project/index.md' },
|
|
1563
1570
|
{
|
|
1564
|
-
root:
|
|
1565
|
-
path: '
|
|
1566
|
-
|
|
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',
|
|
1567
1581
|
},
|
|
1568
1582
|
{ root: projectRoot, path: 'prompt/overview.md' },
|
|
1569
1583
|
{ root: projectRoot, path: 'prompt/core-principles.md' },
|
|
@@ -1609,8 +1623,10 @@ export function createFramerAgentToolRuntime({
|
|
|
1609
1623
|
...files.map((file) => `## ${file.path}\n\n${file.content}`),
|
|
1610
1624
|
...(catalogText ? [catalogText] : []),
|
|
1611
1625
|
].join('\n\n');
|
|
1612
|
-
const inventory =
|
|
1613
|
-
|
|
1626
|
+
const inventory = readGuidanceFile(
|
|
1627
|
+
projectRoot,
|
|
1628
|
+
'project-inventory.md',
|
|
1629
|
+
MAX_GUIDANCE_FILE_BYTES,
|
|
1614
1630
|
)?.content || '';
|
|
1615
1631
|
guidanceMetadata = {
|
|
1616
1632
|
loaded: Boolean(content),
|
|
@@ -1620,10 +1636,13 @@ export function createFramerAgentToolRuntime({
|
|
|
1620
1636
|
projectContextHash: inventory
|
|
1621
1637
|
? crypto.createHash('sha256').update(inventory).digest('hex')
|
|
1622
1638
|
: null,
|
|
1623
|
-
files:
|
|
1624
|
-
|
|
1639
|
+
files: [
|
|
1640
|
+
...files.map((file) => file.path),
|
|
1641
|
+
...(inventory ? ['project/project-inventory.md'] : []),
|
|
1642
|
+
],
|
|
1643
|
+
bytes: Buffer.byteLength(content + inventory, 'utf8'),
|
|
1625
1644
|
};
|
|
1626
|
-
return { content, ...guidanceMetadata };
|
|
1645
|
+
return { content, inventory, ...guidanceMetadata };
|
|
1627
1646
|
};
|
|
1628
1647
|
|
|
1629
1648
|
const configurePromptContext = ({
|
|
@@ -1736,7 +1755,74 @@ export function createFramerAgentToolRuntime({
|
|
|
1736
1755
|
capture: captured.error,
|
|
1737
1756
|
});
|
|
1738
1757
|
}
|
|
1739
|
-
|
|
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
|
+
}
|
|
1740
1826
|
};
|
|
1741
1827
|
|
|
1742
1828
|
const referenceAssetFiles = Array.isArray(assignment?.context?.referenceAssetFiles)
|
|
@@ -2105,6 +2191,17 @@ export function createFramerAgentToolRuntime({
|
|
|
2105
2191
|
].join(' ');
|
|
2106
2192
|
};
|
|
2107
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
|
+
|
|
2108
2205
|
const verificationRepairPrompt = () => {
|
|
2109
2206
|
const current = verification();
|
|
2110
2207
|
if (current.completed || mutationCalls === 0) return null;
|
|
@@ -2173,7 +2270,6 @@ export function createFramerAgentToolRuntime({
|
|
|
2173
2270
|
|
|
2174
2271
|
const reserveToolCall = (name, category) => {
|
|
2175
2272
|
toolCallCount += 1;
|
|
2176
|
-
if (category === 'progress') progressCalls += 1;
|
|
2177
2273
|
if (category === 'read') readCalls += 1;
|
|
2178
2274
|
const call = {
|
|
2179
2275
|
sequence: toolCallCount,
|
|
@@ -2238,25 +2334,6 @@ export function createFramerAgentToolRuntime({
|
|
|
2238
2334
|
'The same Framer tool call has failed five times in a row. Change the approach or report the concrete blocker.',
|
|
2239
2335
|
);
|
|
2240
2336
|
}
|
|
2241
|
-
if (name === 'progress_update') {
|
|
2242
|
-
const message = String(rawArguments.message || '').trim().slice(0, 500);
|
|
2243
|
-
if (!message) {
|
|
2244
|
-
throw toolError(
|
|
2245
|
-
'FRAMER_AGENT_PROGRESS_INVALID',
|
|
2246
|
-
'A progress message is required.',
|
|
2247
|
-
);
|
|
2248
|
-
}
|
|
2249
|
-
const toolCall = reserveToolCall(name, 'progress');
|
|
2250
|
-
reportActivity({
|
|
2251
|
-
kind: 'status_update',
|
|
2252
|
-
source: 'harness',
|
|
2253
|
-
message,
|
|
2254
|
-
});
|
|
2255
|
-
repeatedFailureFingerprint = null;
|
|
2256
|
-
repeatedFailureCount = 0;
|
|
2257
|
-
toolCall.status = 'succeeded';
|
|
2258
|
-
return { acknowledged: true };
|
|
2259
|
-
}
|
|
2260
2337
|
|
|
2261
2338
|
const startedAt = Date.now();
|
|
2262
2339
|
const toolCall = reserveToolCall(name, category);
|
|
@@ -2275,6 +2352,7 @@ export function createFramerAgentToolRuntime({
|
|
|
2275
2352
|
try {
|
|
2276
2353
|
let result;
|
|
2277
2354
|
let modelImages = [];
|
|
2355
|
+
let screenshotRequestCount = 0;
|
|
2278
2356
|
if (name === 'framer_write' && lowLevelWriteDisabled) {
|
|
2279
2357
|
lowLevelWriteBlockedCalls += 1;
|
|
2280
2358
|
throw toolError(
|
|
@@ -2392,7 +2470,10 @@ export function createFramerAgentToolRuntime({
|
|
|
2392
2470
|
...mergeBehaviorPlans(taskPlan, nextPlan),
|
|
2393
2471
|
};
|
|
2394
2472
|
result = {
|
|
2395
|
-
stdout: JSON.stringify({
|
|
2473
|
+
stdout: JSON.stringify({
|
|
2474
|
+
plan: taskPlanSnapshot(),
|
|
2475
|
+
beforeFinishing: `After your final change, and before returning, produce: ${requiredChecks().map(evidenceRequirement).join('; ')}.`,
|
|
2476
|
+
}),
|
|
2396
2477
|
stderr: '',
|
|
2397
2478
|
};
|
|
2398
2479
|
} else if (name === 'framer_verify_interactions') {
|
|
@@ -2422,7 +2503,15 @@ export function createFramerAgentToolRuntime({
|
|
|
2422
2503
|
.map((entry) => [entry.id, entry.node]),
|
|
2423
2504
|
);
|
|
2424
2505
|
const results = verifyBehaviorChecks({ checks, nodes });
|
|
2425
|
-
|
|
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
|
+
});
|
|
2426
2515
|
interactionVerificationCalls += 1;
|
|
2427
2516
|
interactionVerifiedAt = new Date().toISOString();
|
|
2428
2517
|
for (const checkResult of results) {
|
|
@@ -2451,28 +2540,15 @@ export function createFramerAgentToolRuntime({
|
|
|
2451
2540
|
);
|
|
2452
2541
|
}
|
|
2453
2542
|
const pagePath = normalizePagePath(rawArguments.pagePath);
|
|
2454
|
-
result = await
|
|
2455
|
-
[
|
|
2456
|
-
`const result = await framer.agent.readProject(${JSON.stringify(queries)}, { pagePath: ${JSON.stringify(pagePath)} });`,
|
|
2457
|
-
'console.log(JSON.stringify(result, null, 2));',
|
|
2458
|
-
].join('\n'),
|
|
2459
|
-
);
|
|
2543
|
+
result = await readProjectWithFontFallback(queries, pagePath);
|
|
2460
2544
|
const requestedScreenshots = screenshotQueries(rawArguments);
|
|
2461
|
-
|
|
2545
|
+
screenshotRequestCount = requestedScreenshots.length;
|
|
2546
|
+
modelImages = requestedScreenshots.length
|
|
2462
2547
|
? await deliverScreenshots(
|
|
2463
2548
|
requestedScreenshots,
|
|
2464
2549
|
parseStructuredOutput(result.stdout),
|
|
2465
2550
|
)
|
|
2466
|
-
:
|
|
2467
|
-
modelImages = delivery.images;
|
|
2468
|
-
screenshotsViaDownload += delivery.viaDownload;
|
|
2469
|
-
screenshotsViaSession += delivery.viaSession;
|
|
2470
|
-
screenshotsUndelivered += delivery.undelivered;
|
|
2471
|
-
screenshotFailures.push(...delivery.failures);
|
|
2472
|
-
screenshotFailures.splice(
|
|
2473
|
-
0,
|
|
2474
|
-
Math.max(0, screenshotFailures.length - MAX_SCREENSHOT_FAILURES_REPORTED),
|
|
2475
|
-
);
|
|
2551
|
+
: [];
|
|
2476
2552
|
markInspection({
|
|
2477
2553
|
visual: modelImages.length > 0,
|
|
2478
2554
|
visualAttempted: requestedScreenshots.length > 0,
|
|
@@ -2868,6 +2944,20 @@ export function createFramerAgentToolRuntime({
|
|
|
2868
2944
|
...summarizeFramerChanges(changes),
|
|
2869
2945
|
renamedIds: canonicalIdMappings(parsedResult),
|
|
2870
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
|
+
}
|
|
2871
2961
|
} else if (name === 'framer_read') {
|
|
2872
2962
|
result = await execCode(
|
|
2873
2963
|
validateCode(rawArguments.code, {
|
|
@@ -2937,17 +3027,22 @@ export function createFramerAgentToolRuntime({
|
|
|
2937
3027
|
if (name === 'framer_read_website') {
|
|
2938
3028
|
return modelImages.length ? { ...normalized, modelImages } : normalized;
|
|
2939
3029
|
}
|
|
2940
|
-
|
|
2941
|
-
|
|
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
|
+
: [];
|
|
2942
3036
|
return {
|
|
2943
3037
|
...normalized,
|
|
2944
3038
|
...(modelImages.length ? { modelImages } : {}),
|
|
2945
|
-
...(
|
|
3039
|
+
...(screenshotRequestCount > modelImages.length
|
|
2946
3040
|
? {
|
|
2947
3041
|
screenshotWarning:
|
|
2948
|
-
`${
|
|
3042
|
+
`${screenshotRequestCount - modelImages.length} screenshot(s) could not be downloaded for you to view. Treat that visual state as unverified.`,
|
|
2949
3043
|
}
|
|
2950
3044
|
: {}),
|
|
3045
|
+
...(evidenceStillDue.length > 0 ? { evidenceStillDue } : {}),
|
|
2951
3046
|
};
|
|
2952
3047
|
} catch (error) {
|
|
2953
3048
|
if (
|
|
@@ -3125,7 +3220,6 @@ export function createFramerAgentToolRuntime({
|
|
|
3125
3220
|
: {}),
|
|
3126
3221
|
})),
|
|
3127
3222
|
readCalls,
|
|
3128
|
-
progressCalls,
|
|
3129
3223
|
toolCallCount,
|
|
3130
3224
|
toolDurationMs: toolCalls.reduce(
|
|
3131
3225
|
(total, call) => total + call.durationMs,
|
|
@@ -3153,6 +3247,7 @@ export function createFramerAgentToolRuntime({
|
|
|
3153
3247
|
lowLevelWriteBlockedCalls,
|
|
3154
3248
|
instructionFallbacks,
|
|
3155
3249
|
},
|
|
3250
|
+
fontSearchFallbacks,
|
|
3156
3251
|
branch: branchChange,
|
|
3157
3252
|
lastInspectionAt,
|
|
3158
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;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Framer's style-based font search ("font-search" with a `query`) is matched
|
|
2
|
+
// by Framer's AI and billed to the workspace's AI credit; when that fails, the
|
|
3
|
+
// run still needs typography candidates. Google Fonts publishes its full
|
|
4
|
+
// catalog (category, weights, italics, variable axes, popularity) without a
|
|
5
|
+
// key, and every Google Fonts family is in Framer's font library, so the
|
|
6
|
+
// bridge answers those searches from it in Framer's own result shape.
|
|
7
|
+
|
|
8
|
+
const GOOGLE_FONTS_METADATA_URL = 'https://fonts.google.com/metadata/fonts';
|
|
9
|
+
const CATALOG_TTL_MS = 24 * 60 * 60_000;
|
|
10
|
+
const CATALOG_TIMEOUT_MS = 20_000;
|
|
11
|
+
const MAX_RESULTS = 20;
|
|
12
|
+
|
|
13
|
+
const WEIGHT_DESCRIPTORS = {
|
|
14
|
+
thin: 100,
|
|
15
|
+
'extra-light': 200,
|
|
16
|
+
light: 300,
|
|
17
|
+
regular: 400,
|
|
18
|
+
medium: 500,
|
|
19
|
+
semibold: 600,
|
|
20
|
+
bold: 700,
|
|
21
|
+
'extra-bold': 800,
|
|
22
|
+
black: 900,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Words in a style query that point at a category.
|
|
26
|
+
const CATEGORY_WORDS = {
|
|
27
|
+
serif: 'serif',
|
|
28
|
+
serifs: 'serif',
|
|
29
|
+
sans: 'sans-serif',
|
|
30
|
+
'sans-serif': 'sans-serif',
|
|
31
|
+
grotesque: 'sans-serif',
|
|
32
|
+
grotesk: 'sans-serif',
|
|
33
|
+
geometric: 'sans-serif',
|
|
34
|
+
slab: 'slab',
|
|
35
|
+
mono: 'monospace',
|
|
36
|
+
monospace: 'monospace',
|
|
37
|
+
monospaced: 'monospace',
|
|
38
|
+
code: 'monospace',
|
|
39
|
+
script: 'handwriting',
|
|
40
|
+
handwriting: 'handwriting',
|
|
41
|
+
handwritten: 'handwriting',
|
|
42
|
+
calligraphy: 'handwriting',
|
|
43
|
+
display: 'display',
|
|
44
|
+
poster: 'display',
|
|
45
|
+
};
|
|
46
|
+
const NARROW_WORDS = new Set(['condensed', 'narrow', 'compressed']);
|
|
47
|
+
|
|
48
|
+
let cachedCatalog = null;
|
|
49
|
+
let cachedAt = 0;
|
|
50
|
+
let pendingCatalog = null;
|
|
51
|
+
|
|
52
|
+
export async function loadGoogleFontsCatalog(fetchImpl = fetch) {
|
|
53
|
+
if (cachedCatalog && Date.now() - cachedAt < CATALOG_TTL_MS) return cachedCatalog;
|
|
54
|
+
pendingCatalog ||= (async () => {
|
|
55
|
+
const response = await fetchImpl(GOOGLE_FONTS_METADATA_URL, {
|
|
56
|
+
signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS),
|
|
57
|
+
});
|
|
58
|
+
if (!response.ok) throw new Error(`Google Fonts catalog returned HTTP ${response.status}.`);
|
|
59
|
+
const text = await response.text();
|
|
60
|
+
// The feed starts with an anti-JSON-hijacking prefix before the object.
|
|
61
|
+
const families = JSON.parse(text.slice(text.indexOf('{'))).familyMetadataList;
|
|
62
|
+
if (!Array.isArray(families) || families.length === 0) {
|
|
63
|
+
throw new Error('Google Fonts catalog is empty.');
|
|
64
|
+
}
|
|
65
|
+
cachedCatalog = families;
|
|
66
|
+
cachedAt = Date.now();
|
|
67
|
+
return families;
|
|
68
|
+
})().finally(() => {
|
|
69
|
+
pendingCatalog = null;
|
|
70
|
+
});
|
|
71
|
+
return pendingCatalog;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function categoryOf(family) {
|
|
75
|
+
if (family.category === 'Monospace') return 'monospace';
|
|
76
|
+
if (family.category === 'Handwriting') return 'handwriting';
|
|
77
|
+
if (family.category === 'Display') return 'display';
|
|
78
|
+
if (family.category === 'Serif') return 'serif';
|
|
79
|
+
return 'sans-serif';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fontFacts(family) {
|
|
83
|
+
const keys = Object.keys(family.fonts || {});
|
|
84
|
+
const weights = [...new Set(keys.map((key) => Number.parseInt(key, 10)))]
|
|
85
|
+
.filter(Number.isFinite)
|
|
86
|
+
.sort((a, b) => a - b);
|
|
87
|
+
const styles = [
|
|
88
|
+
...(keys.some((key) => !key.endsWith('i')) ? ['normal'] : []),
|
|
89
|
+
...(keys.some((key) => key.endsWith('i')) ? ['italic'] : []),
|
|
90
|
+
];
|
|
91
|
+
const axes = (family.axes || []).map((axis) => ({
|
|
92
|
+
tag: axis.tag,
|
|
93
|
+
minValue: axis.min,
|
|
94
|
+
maxValue: axis.max,
|
|
95
|
+
defaultValue: axis.defaultValue,
|
|
96
|
+
}));
|
|
97
|
+
return { weights, styles, axes };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hasDescriptor(family, facts, descriptor) {
|
|
101
|
+
const classifications = family.classifications || [];
|
|
102
|
+
if (descriptor in WEIGHT_DESCRIPTORS) {
|
|
103
|
+
const weight = WEIGHT_DESCRIPTORS[descriptor];
|
|
104
|
+
const wght = facts.axes.find((axis) => axis.tag === 'wght');
|
|
105
|
+
return facts.weights.includes(weight)
|
|
106
|
+
|| Boolean(wght && wght.minValue <= weight && wght.maxValue >= weight);
|
|
107
|
+
}
|
|
108
|
+
if (descriptor.startsWith('variation-axis/')) {
|
|
109
|
+
const tag = descriptor.slice('variation-axis/'.length);
|
|
110
|
+
return facts.axes.some((axis) => axis.tag === tag);
|
|
111
|
+
}
|
|
112
|
+
switch (descriptor) {
|
|
113
|
+
case 'serif':
|
|
114
|
+
return family.category === 'Serif' || family.stroke === 'Serif';
|
|
115
|
+
case 'sans-serif':
|
|
116
|
+
return family.category === 'Sans Serif' || family.stroke === 'Sans Serif';
|
|
117
|
+
case 'slab':
|
|
118
|
+
return family.stroke === 'Slab Serif';
|
|
119
|
+
case 'monospace':
|
|
120
|
+
return family.category === 'Monospace' || classifications.includes('Monospace');
|
|
121
|
+
case 'display':
|
|
122
|
+
return family.category === 'Display' || classifications.includes('Display');
|
|
123
|
+
case 'handwriting':
|
|
124
|
+
return family.category === 'Handwriting' || classifications.includes('Handwriting');
|
|
125
|
+
case 'normal':
|
|
126
|
+
case 'italic':
|
|
127
|
+
return facts.styles.includes(descriptor);
|
|
128
|
+
default:
|
|
129
|
+
// Descriptors the catalog cannot judge do not exclude a family.
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function queryScore(family, facts, words) {
|
|
135
|
+
const nameWords = family.family.toLowerCase().split(/\s+/);
|
|
136
|
+
let score = 0;
|
|
137
|
+
for (const word of words) {
|
|
138
|
+
const category = CATEGORY_WORDS[word];
|
|
139
|
+
if (category && hasDescriptor(family, facts, category)) score += 2;
|
|
140
|
+
if (NARROW_WORDS.has(word)) {
|
|
141
|
+
const width = facts.axes.find((axis) => axis.tag === 'wdth');
|
|
142
|
+
if (nameWords.some((part) => NARROW_WORDS.has(part)) || (width && width.minValue < 100)) {
|
|
143
|
+
score += 2;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (
|
|
147
|
+
!category
|
|
148
|
+
&& word.length >= 4
|
|
149
|
+
&& nameWords.some((part) => part.slice(0, 5) === word.slice(0, 5))
|
|
150
|
+
) {
|
|
151
|
+
score += 3;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return score;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Ranks catalog families for one font-search query: every `mustHave`
|
|
158
|
+
// descriptor is required, query words raise matching families, and Google
|
|
159
|
+
// Fonts popularity breaks ties. More candidates than `limit` are returned
|
|
160
|
+
// because keyword matching is coarse; the model chooses by how they look.
|
|
161
|
+
export function searchGoogleFonts(catalog, query = {}) {
|
|
162
|
+
const mustHave = (Array.isArray(query.mustHave) ? query.mustHave : [])
|
|
163
|
+
.map((descriptor) => String(descriptor).trim().toLowerCase())
|
|
164
|
+
.filter(Boolean);
|
|
165
|
+
const words = String(query.query || '')
|
|
166
|
+
.toLowerCase()
|
|
167
|
+
.split(/[^a-z0-9-]+/)
|
|
168
|
+
.filter(Boolean);
|
|
169
|
+
const limit = Math.min(
|
|
170
|
+
MAX_RESULTS,
|
|
171
|
+
Math.max(10, (Number.parseInt(query.limit, 10) || 5) * 2),
|
|
172
|
+
);
|
|
173
|
+
return catalog
|
|
174
|
+
.filter((family) =>
|
|
175
|
+
typeof family?.family === 'string'
|
|
176
|
+
&& !family.isNoto
|
|
177
|
+
&& !family.isBrandFont
|
|
178
|
+
&& (family.subsets || []).includes('latin'))
|
|
179
|
+
.map((family) => ({ family, facts: fontFacts(family) }))
|
|
180
|
+
.filter(({ family, facts }) =>
|
|
181
|
+
facts.weights.length > 0
|
|
182
|
+
&& mustHave.every((descriptor) => hasDescriptor(family, facts, descriptor)))
|
|
183
|
+
.map((entry) => ({ ...entry, score: queryScore(entry.family, entry.facts, words) }))
|
|
184
|
+
.sort((a, b) =>
|
|
185
|
+
b.score - a.score
|
|
186
|
+
|| (a.family.popularity ?? Infinity) - (b.family.popularity ?? Infinity))
|
|
187
|
+
.slice(0, limit)
|
|
188
|
+
.map(({ family, facts }) => ({
|
|
189
|
+
name: family.family,
|
|
190
|
+
weights: facts.weights,
|
|
191
|
+
styles: facts.styles,
|
|
192
|
+
category: categoryOf(family),
|
|
193
|
+
variationAxes: facts.axes,
|
|
194
|
+
reason: 'Google Fonts catalog match',
|
|
195
|
+
}));
|
|
196
|
+
}
|