@gakim-digital/dexter-bridge 0.12.0 → 0.12.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.
- package/package.json +1 -1
- package/src/agent.js +1 -1
- package/src/framerMcpClient.js +8 -8
- package/src/framerTools.js +189 -13
- package/src/protocol.js +7 -0
- package/LICENSE +0 -16
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -3000,7 +3000,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
|
|
|
3000
3000
|
});
|
|
3001
3001
|
const repairResult = await Promise.race([
|
|
3002
3002
|
executeProviderOutcome(
|
|
3003
|
-
repairPrompt
|
|
3003
|
+
`${repairPrompt} The designer never sees this note: your summary still answers their original request and does not mention it.`,
|
|
3004
3004
|
providerSessionId
|
|
3005
3005
|
|| rememberedSession?.providerSessionId
|
|
3006
3006
|
|| sessionKey,
|
package/src/framerMcpClient.js
CHANGED
|
@@ -123,14 +123,14 @@ export function createFramerMcpClient({
|
|
|
123
123
|
sdkClient = client;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
async function rawCall(name, args) {
|
|
127
|
-
if (typeof callTool === 'function') return callTool(name, args);
|
|
126
|
+
async function rawCall(name, args, callTimeoutMs = timeoutMs) {
|
|
127
|
+
if (typeof callTool === 'function') return callTool(name, args, { timeoutMs: callTimeoutMs });
|
|
128
128
|
await connect();
|
|
129
129
|
try {
|
|
130
130
|
return await sdkClient.callTool(
|
|
131
131
|
{ name, arguments: args },
|
|
132
132
|
undefined,
|
|
133
|
-
{ timeout:
|
|
133
|
+
{ timeout: callTimeoutMs, resetTimeoutOnProgress: true },
|
|
134
134
|
);
|
|
135
135
|
} catch (error) {
|
|
136
136
|
if (isUnauthorized(error)) {
|
|
@@ -146,16 +146,16 @@ export function createFramerMcpClient({
|
|
|
146
146
|
|
|
147
147
|
// An expired session is refused before the program runs, so reopening the
|
|
148
148
|
// project and retrying once cannot apply a change twice.
|
|
149
|
-
async function call(name, args, retried = false) {
|
|
149
|
+
async function call(name, args, { retried = false, timeoutMs: callTimeoutMs } = {}) {
|
|
150
150
|
let result;
|
|
151
151
|
try {
|
|
152
|
-
result = unwrap(await rawCall(name, args));
|
|
152
|
+
result = unwrap(await rawCall(name, args, callTimeoutMs));
|
|
153
153
|
} catch (error) {
|
|
154
154
|
const expired = error?.code === 'FRAMER_MCP_SESSION_EXPIRED'
|
|
155
155
|
|| SESSION_EXPIRED_PATTERN.test(String(error?.message || ''));
|
|
156
156
|
if (!expired || retried || !projectId || name === 'open_project') throw error;
|
|
157
157
|
await openProject(projectId);
|
|
158
|
-
return call(name, { ...args, session }, true);
|
|
158
|
+
return call(name, { ...args, session }, { retried: true, timeoutMs: callTimeoutMs });
|
|
159
159
|
}
|
|
160
160
|
return result;
|
|
161
161
|
}
|
|
@@ -181,9 +181,9 @@ export function createFramerMcpClient({
|
|
|
181
181
|
readme: () => readme,
|
|
182
182
|
session: () => session,
|
|
183
183
|
projectId: () => projectId,
|
|
184
|
-
async exec(program) {
|
|
184
|
+
async exec(program, { timeoutMs: callTimeoutMs } = {}) {
|
|
185
185
|
requireSession();
|
|
186
|
-
return call('exec', { session, program });
|
|
186
|
+
return call('exec', { session, program }, { timeoutMs: callTimeoutMs });
|
|
187
187
|
},
|
|
188
188
|
async readDocs(paths) {
|
|
189
189
|
requireSession();
|
package/src/framerTools.js
CHANGED
|
@@ -43,9 +43,13 @@ const FIRST_EDIT_NUDGE_MS = 60_000;
|
|
|
43
43
|
const FIRST_EDIT_NUDGE = 'Nothing is on the canvas yet. You know enough to start: make your first framer_edit now with a small first section, such as the navigation and the hero headline, then build the rest two sections per edit. Read more only when a specific detail blocks the next edit.';
|
|
44
44
|
const FONT_ERROR_HINT = 'A font Framer rejected is not available to this project. Follow the preloaded project APIs guidance: resolve the family through api.fonts before setting it, or use a family the project already uses. Fix it in your next framer_edit, not at the end.';
|
|
45
45
|
const ACTIVE_PAGE_OUTLINE_DEPTH = 2;
|
|
46
|
+
// Framer returns the previous layout for about 50ms after an edit.
|
|
47
|
+
const LAYOUT_SETTLE_MS = 150;
|
|
46
48
|
const FONT_KEYS = new Set(['font', 'fontFamily', 'family']);
|
|
47
49
|
const PROJECT_MISMATCH_PATTERN = /permission|forbidden|not found|does not have access|no access|can't open|cannot open/i;
|
|
48
50
|
|
|
51
|
+
const MEASURE_TOOL_NOTE = 'For exact spacing or sizes, await dexter.measure(ids) returns each layer\'s x, y, width, and height inside its breakpoint frame, and await dexter.gap(a, b) returns the vertical and horizontal space between two layers (negative when they share that range) and whether they overlap. Both measure the rendered layout, including edits made earlier in the same program.';
|
|
52
|
+
|
|
49
53
|
// Item shapes the behavior contract (framerBehaviorContract.js) accepts.
|
|
50
54
|
const BEHAVIOR_SCHEMA = {
|
|
51
55
|
type: 'object',
|
|
@@ -72,7 +76,7 @@ const BEHAVIOR_CHECK_SCHEMA = {
|
|
|
72
76
|
const TOOL_DEFINITIONS = [
|
|
73
77
|
{
|
|
74
78
|
name: 'framer_read',
|
|
75
|
-
description:
|
|
79
|
+
description: `Read the open Framer project by running a program. Use api.tree.getNode, getNodes, serialize, and serializeNodes. Stock image search is api.images.search({ query }), one call per query. Fonts are api.fonts.get and api.fonts.list({ query }). There is no framer global. ${MEASURE_TOOL_NOTE} This tool cannot change the project.`,
|
|
76
80
|
inputSchema: {
|
|
77
81
|
type: 'object',
|
|
78
82
|
properties: {
|
|
@@ -84,7 +88,7 @@ const TOOL_DEFINITIONS = [
|
|
|
84
88
|
},
|
|
85
89
|
{
|
|
86
90
|
name: 'framer_edit',
|
|
87
|
-
description:
|
|
91
|
+
description: `Change the open Framer project by running a program. Use api.tree.insert, patch, remove, move, and replaceText. Place an SVG with tree.insert("SVGNode", id, { svg, intrinsicWidth, intrinsicHeight }). Write code files with api.files.create, write, or applyPatch. Search stock images with api.images.search({ query }). Place an image by setting fill to await api.tree.assetReference(url) with a public image URL. Load fonts with api.fonts. Pass screenshotNodeIds for the nodes you want to see after the edit. Include plan on your first framer_edit. ${MEASURE_TOOL_NOTE}`,
|
|
88
92
|
inputSchema: {
|
|
89
93
|
type: 'object',
|
|
90
94
|
properties: {
|
|
@@ -170,6 +174,32 @@ function toolError(code, message, statusCode = 400) {
|
|
|
170
174
|
return Object.assign(new Error(message), { code, statusCode });
|
|
171
175
|
}
|
|
172
176
|
|
|
177
|
+
// Framer answers "Framer stopped answering…" when a program gets no reply
|
|
178
|
+
// within its own 60-second limit; the MCP SDK reports its timeout as -32001.
|
|
179
|
+
const FRAMER_TIMEOUT_PATTERN = /stopped answering|request timed out/i;
|
|
180
|
+
|
|
181
|
+
function isFramerTimeout(error) {
|
|
182
|
+
return error?.code === -32001 || FRAMER_TIMEOUT_PATTERN.test(String(error?.message || ''));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A healthy Framer answers a no-op program in about a second, and setup calls
|
|
186
|
+
// finish within 15 seconds; when Framer stalls, every call waits out its
|
|
187
|
+
// 60-second limit. A run starts only after Framer answers within this time.
|
|
188
|
+
const FRAMER_PROBE_TIMEOUT_MS = 20_000;
|
|
189
|
+
|
|
190
|
+
function framerNotRespondingError(changesKept) {
|
|
191
|
+
return Object.assign(
|
|
192
|
+
toolError(
|
|
193
|
+
'FRAMER_NOT_RESPONDING',
|
|
194
|
+
changesKept
|
|
195
|
+
? 'Framer stopped running edits on this project partway through, so Dexter stopped. The changes made before that were kept. This is on Framer’s side and can last several minutes, so wait a little before sending again.'
|
|
196
|
+
: 'Framer isn’t running edits on this project right now, so Dexter stopped before changing anything. This is on Framer’s side and can last several minutes, so wait a little before sending again.',
|
|
197
|
+
503,
|
|
198
|
+
),
|
|
199
|
+
{ retryable: true },
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
173
203
|
function codeOutsideLiterals(code) {
|
|
174
204
|
let visible = '';
|
|
175
205
|
for (const token of jsTokens(String(code || ''))) {
|
|
@@ -358,7 +388,11 @@ const __dexterStateOf = (tree, id) => {
|
|
|
358
388
|
};
|
|
359
389
|
const __dexterCompared = new Set(['patch', 'replaceText', 'remove']);
|
|
360
390
|
const __dexterTreeRecorders = {
|
|
361
|
-
insert: (args, result) =>
|
|
391
|
+
insert: (args, result) => {
|
|
392
|
+
__dexterNote(args[1], result);
|
|
393
|
+
const real = __dexterIdOf(result);
|
|
394
|
+
if (typeof args[1] === 'string' && real) __dexterInsertedIds[args[1]] = real;
|
|
395
|
+
},
|
|
362
396
|
move: (args) => __dexterNote(args[0]),
|
|
363
397
|
duplicate: (args, result) => __dexterNote(args[0], args[1], result),
|
|
364
398
|
patchFormDestinations: (args) => __dexterNote(args[0]),
|
|
@@ -416,15 +450,92 @@ const __dexterApi = __dexterRecording(api, {}, {
|
|
|
416
450
|
});
|
|
417
451
|
`;
|
|
418
452
|
|
|
419
|
-
|
|
453
|
+
// Programs get `dexter.measure` and `dexter.gap` for exact spacing and sizes.
|
|
454
|
+
// Framer's getRect is relative to the parent, so positions are summed up to
|
|
455
|
+
// the breakpoint (or variant) frame, which keeps desktop and phone apart.
|
|
456
|
+
// Layout settles shortly after an edit; measuring straight away returns the
|
|
457
|
+
// old layout, so each call waits first.
|
|
458
|
+
const MEASURE_PRELUDE = `
|
|
459
|
+
// Layers inserted by this program are known to Framer only by their real ids
|
|
460
|
+
// until the program ends; the edit recorder maps the program's own ids.
|
|
461
|
+
const __dexterInsertedIds = {};
|
|
462
|
+
const dexter = (() => {
|
|
463
|
+
const tree = api.tree;
|
|
464
|
+
const idOf = (value) => {
|
|
465
|
+
const id = typeof value === 'string' ? value : (value && typeof value.id === 'string' ? value.id : null);
|
|
466
|
+
return (id && __dexterInsertedIds[id]) || id;
|
|
467
|
+
};
|
|
468
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, ${LAYOUT_SETTLE_MS}));
|
|
469
|
+
const one = async (value) => {
|
|
470
|
+
const id = idOf(value);
|
|
471
|
+
const node = id ? tree.get(id) : null;
|
|
472
|
+
if (!node) return { id, measured: false, note: 'Framer has no layer with this id.' };
|
|
473
|
+
const rect = await tree.getRect(id);
|
|
474
|
+
if (!rect) return { id, measured: false };
|
|
475
|
+
const frame = await tree.getGroundNodeFor(id);
|
|
476
|
+
let x = rect.x;
|
|
477
|
+
let y = rect.y;
|
|
478
|
+
if (!frame || frame.id !== id) {
|
|
479
|
+
for (const ancestor of (await tree.getAncestorsFor(id)) || []) {
|
|
480
|
+
if (frame && ancestor.id === frame.id) break;
|
|
481
|
+
const box = await tree.getRect(ancestor.id);
|
|
482
|
+
if (box) { x += box.x; y += box.y; }
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const measured = { id, name: node.name || null, frame: frame ? { id: frame.id, name: frame.name || null } : null, x, y, width: rect.width, height: rect.height };
|
|
486
|
+
if (node.type === 'RichTextNode' && /px$/.test(String(node.height || ''))) {
|
|
487
|
+
measured.note = 'This text box has a fixed height of ' + node.height + '; its words may not fill it, so visible space around them can be larger than the box edges show.';
|
|
488
|
+
}
|
|
489
|
+
return measured;
|
|
490
|
+
};
|
|
491
|
+
const measureAll = async (values) => { const out = []; for (const value of values) out.push(await one(value)); return out; };
|
|
492
|
+
return {
|
|
493
|
+
async measure(values) {
|
|
494
|
+
await settle();
|
|
495
|
+
return measureAll(Array.isArray(values) ? values : [values]);
|
|
496
|
+
},
|
|
497
|
+
async gap(first, second) {
|
|
498
|
+
await settle();
|
|
499
|
+
const [a, b] = await measureAll([first, second]);
|
|
500
|
+
if (a.measured === false || b.measured === false) return { a, b, measured: false };
|
|
501
|
+
if (a.frame?.id !== b.frame?.id) return { a, b, measured: false, note: 'These layers are in different breakpoints or frames.' };
|
|
502
|
+
const vertical = Math.max(b.y - (a.y + a.height), a.y - (b.y + b.height));
|
|
503
|
+
const horizontal = Math.max(b.x - (a.x + a.width), a.x - (b.x + b.width));
|
|
504
|
+
return { a, b, vertical, horizontal, overlap: vertical < 0 && horizontal < 0 };
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
})();
|
|
508
|
+
`;
|
|
509
|
+
|
|
510
|
+
function splitStep(program) {
|
|
420
511
|
const source = String(program || '');
|
|
421
512
|
// Framer shows a leading "// @step" comment as progress, so it stays first.
|
|
422
513
|
// The line it leaves behind keeps the program's own line numbers intact.
|
|
423
514
|
const step = source.match(/^\s*(\/\/\s*@step[^\n]*)/)?.[1] || '';
|
|
424
|
-
|
|
515
|
+
return { step, body: step ? source.replace(step, '') : source };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// A read stays at the top level so Framer's error lines point into it.
|
|
519
|
+
function readParts(program) {
|
|
520
|
+
const { step, body } = splitStep(program);
|
|
521
|
+
return { prefix: `${step}\n${MEASURE_PRELUDE}\n`, body };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function wrapReadProgram(program) {
|
|
525
|
+
const { prefix, body } = readParts(program);
|
|
526
|
+
return `${prefix}${body}`;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function wrappedReadLineOffset(program) {
|
|
530
|
+
return readParts(program).prefix.split('\n').length - 1;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function wrapperParts(program) {
|
|
534
|
+
const { step, body } = splitStep(program);
|
|
425
535
|
// A program that throws still keeps the changes it made before the throw,
|
|
426
536
|
// so the error is caught here and returned with the record of those changes.
|
|
427
537
|
const prefix = `${step}
|
|
538
|
+
${MEASURE_PRELUDE}
|
|
428
539
|
${RECORDING_PRELUDE}
|
|
429
540
|
let __dexterResult;
|
|
430
541
|
let __dexterError = null;
|
|
@@ -477,14 +588,30 @@ for (const id of ${JSON.stringify(ids)}) {
|
|
|
477
588
|
return taken`;
|
|
478
589
|
}
|
|
479
590
|
|
|
480
|
-
|
|
591
|
+
// Checks layers the model claimed against the layers the run changed. A
|
|
592
|
+
// claimed layer counts when an edit changed it, a layer inside it, or a layer
|
|
593
|
+
// around it. A claimed page also counts when the run changed something shared
|
|
594
|
+
// by every page: a layer outside all pages (the site-wide layout or a
|
|
595
|
+
// component) or a code file.
|
|
596
|
+
export function claimedTargetProgram(claimed, touched, sharedChange = false) {
|
|
481
597
|
return `
|
|
482
598
|
const { tree } = api;
|
|
483
599
|
const touched = new Set(${JSON.stringify(touched.slice(0, 500))});
|
|
600
|
+
const isPage = (node) => Boolean(node) && node.type === 'WebPageNode';
|
|
601
|
+
let shared = ${sharedChange ? 'true' : 'false'};
|
|
602
|
+
if (!shared) {
|
|
603
|
+
for (const id of touched) {
|
|
604
|
+
const node = tree.get(id);
|
|
605
|
+
if (!node || isPage(node)) continue;
|
|
606
|
+
const ancestors = (await tree.getAncestorsFor(id)) || [];
|
|
607
|
+
if (!ancestors.some(isPage)) { shared = true; break; }
|
|
608
|
+
}
|
|
609
|
+
}
|
|
484
610
|
const verdicts = {};
|
|
485
611
|
for (const id of ${JSON.stringify(claimed.slice(0, 200))}) {
|
|
486
612
|
const node = tree.get(id);
|
|
487
613
|
if (!node) { verdicts[id] = 'missing'; continue; }
|
|
614
|
+
if (shared && isPage(node)) { verdicts[id] = 'related'; continue; }
|
|
488
615
|
const ancestors = (await tree.getAncestorsFor(id)) || [];
|
|
489
616
|
if (ancestors.some((ancestor) => touched.has(ancestor.id))) { verdicts[id] = 'related'; continue; }
|
|
490
617
|
let related = false;
|
|
@@ -547,6 +674,10 @@ export function createFramerAgentToolRuntime({
|
|
|
547
674
|
let closed = false;
|
|
548
675
|
const abortController = new AbortController();
|
|
549
676
|
let pendingRepair = null;
|
|
677
|
+
let consecutiveTimeouts = 0;
|
|
678
|
+
let fatalError = null;
|
|
679
|
+
let signalFatal;
|
|
680
|
+
const fatalInfrastructure = new Promise((resolve) => { signalFatal = resolve; });
|
|
550
681
|
|
|
551
682
|
// Access tokens last minutes; the API refreshes them. A token is reused
|
|
552
683
|
// until a minute before it expires, and replaced at once when Framer
|
|
@@ -613,6 +744,43 @@ export function createFramerAgentToolRuntime({
|
|
|
613
744
|
}
|
|
614
745
|
}
|
|
615
746
|
|
|
747
|
+
// When Framer stops running programs, every call waits out Framer's
|
|
748
|
+
// 60-second limit. Framer sometimes stalls once and recovers, so two
|
|
749
|
+
// timeouts in a row, counting the harness's own setup read, end the run
|
|
750
|
+
// instead of letting it wait through one timeout after another.
|
|
751
|
+
function noteFramerAnswer(error) {
|
|
752
|
+
if (!isFramerTimeout(error)) {
|
|
753
|
+
consecutiveTimeouts = 0;
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
consecutiveTimeouts += 1;
|
|
757
|
+
if (consecutiveTimeouts < 2) return;
|
|
758
|
+
if (!fatalError) {
|
|
759
|
+
fatalError = framerNotRespondingError(mutationCalls > 0);
|
|
760
|
+
signalFatal(fatalError);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Ends the run before any setup when Framer does not answer a no-op
|
|
765
|
+
// program, instead of letting setup and the first read each wait a minute.
|
|
766
|
+
async function probeFramer() {
|
|
767
|
+
const startedAt = Date.now();
|
|
768
|
+
try {
|
|
769
|
+
await client.exec('return true;', { timeoutMs: FRAMER_PROBE_TIMEOUT_MS });
|
|
770
|
+
trace?.info?.('framer_probe', { durationMs: Date.now() - startedAt });
|
|
771
|
+
} catch (error) {
|
|
772
|
+
trace?.warn?.('framer_probe_failed', {
|
|
773
|
+
durationMs: Date.now() - startedAt,
|
|
774
|
+
code: error?.code,
|
|
775
|
+
message: String(error?.message || error).slice(0, 300),
|
|
776
|
+
});
|
|
777
|
+
if (!isFramerTimeout(error)) return;
|
|
778
|
+
fatalError = framerNotRespondingError(false);
|
|
779
|
+
signalFatal(fatalError);
|
|
780
|
+
throw fatalError;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
616
784
|
function recordCall(name, category, status, extra = {}) {
|
|
617
785
|
toolCallCount += 1;
|
|
618
786
|
toolCalls.push({ name, category, status, durationMs: extra.durationMs || 0, ...extra });
|
|
@@ -729,6 +897,7 @@ export function createFramerAgentToolRuntime({
|
|
|
729
897
|
|
|
730
898
|
async function invokeUnsafe(name, args = {}) {
|
|
731
899
|
if (closed) throw toolError('FRAMER_AGENT_RUNTIME_CLOSED', 'This Framer run has finished.');
|
|
900
|
+
if (fatalError) throw fatalError;
|
|
732
901
|
if (!definitions.some((tool) => tool.name === name)) {
|
|
733
902
|
throw toolError('FRAMER_AGENT_TOOL_UNKNOWN', `Unknown Framer tool ${name}.`);
|
|
734
903
|
}
|
|
@@ -783,7 +952,7 @@ export function createFramerAgentToolRuntime({
|
|
|
783
952
|
} else if (name === 'framer_read') {
|
|
784
953
|
const program = validateProgram(args.program, { write: false, allowPublishing });
|
|
785
954
|
await ensureProject();
|
|
786
|
-
const result = await client.exec(program);
|
|
955
|
+
const result = await client.exec(wrapReadProgram(program));
|
|
787
956
|
const applied = applyReceipt(result, []);
|
|
788
957
|
if (applied.changed) {
|
|
789
958
|
throw toolError(
|
|
@@ -791,7 +960,7 @@ export function createFramerAgentToolRuntime({
|
|
|
791
960
|
'A read changed the Framer project. Use framer_edit for changes.',
|
|
792
961
|
);
|
|
793
962
|
}
|
|
794
|
-
const readError = programErrorOf(applied.output);
|
|
963
|
+
const readError = programErrorOf(applied.output, wrappedReadLineOffset(program));
|
|
795
964
|
if (readError) {
|
|
796
965
|
throw toolError('FRAMER_PROGRAM_FAILED', `${readError.message} (${readError.type})`);
|
|
797
966
|
}
|
|
@@ -902,6 +1071,7 @@ export function createFramerAgentToolRuntime({
|
|
|
902
1071
|
throw toolError('FRAMER_AGENT_TOOL_UNKNOWN', `Unknown Framer tool ${name}.`);
|
|
903
1072
|
}
|
|
904
1073
|
recordCall(name, category, 'succeeded', { ...callExtra, durationMs: Date.now() - startedAt });
|
|
1074
|
+
noteFramerAnswer(null);
|
|
905
1075
|
const nudge = category === 'read' ? firstEditNudge() : null;
|
|
906
1076
|
if (nudge) payload = { ...payload, nextStep: nudge };
|
|
907
1077
|
reportActivity({
|
|
@@ -926,7 +1096,8 @@ export function createFramerAgentToolRuntime({
|
|
|
926
1096
|
message: error?.message,
|
|
927
1097
|
durationMs: Date.now() - startedAt,
|
|
928
1098
|
});
|
|
929
|
-
|
|
1099
|
+
noteFramerAnswer(error);
|
|
1100
|
+
throw fatalError || error;
|
|
930
1101
|
}
|
|
931
1102
|
}
|
|
932
1103
|
|
|
@@ -975,7 +1146,10 @@ export function createFramerAgentToolRuntime({
|
|
|
975
1146
|
const [pages, contextPages, activePage] = await Promise.all([
|
|
976
1147
|
readDocsTolerant(GUIDANCE_PATHS),
|
|
977
1148
|
readDocsTolerant([CONTEXT_PATH]),
|
|
978
|
-
readActivePageOutline().catch(() =>
|
|
1149
|
+
readActivePageOutline().catch((error) => {
|
|
1150
|
+
noteFramerAnswer(error);
|
|
1151
|
+
return '';
|
|
1152
|
+
}),
|
|
979
1153
|
]);
|
|
980
1154
|
const inventory = [
|
|
981
1155
|
stripGuidance(contextPages.map((page) => page.content).join('\n\n')),
|
|
@@ -1061,7 +1235,8 @@ export function createFramerAgentToolRuntime({
|
|
|
1061
1235
|
// around it, such as a card whose title was rewritten.
|
|
1062
1236
|
try {
|
|
1063
1237
|
await ensureProject();
|
|
1064
|
-
const
|
|
1238
|
+
const codeFilesChanged = mutationReceipts.some((receipt) => (receipt.codeFiles || []).length > 0);
|
|
1239
|
+
const result = await client.exec(claimedTargetProgram(missing, [...known], codeFilesChanged));
|
|
1065
1240
|
const verdicts = outputOf(result).result;
|
|
1066
1241
|
if (verdicts && typeof verdicts === 'object') {
|
|
1067
1242
|
missing = missing.filter((id) => verdicts[id] !== 'related');
|
|
@@ -1202,6 +1377,7 @@ export function createFramerAgentToolRuntime({
|
|
|
1202
1377
|
toolNames: definitions.map((definition) => definition.name),
|
|
1203
1378
|
preflight: async () => {
|
|
1204
1379
|
await ensureProject();
|
|
1380
|
+
await probeFramer();
|
|
1205
1381
|
return { unresolvedTargets: [] };
|
|
1206
1382
|
},
|
|
1207
1383
|
loadProjectGuidance,
|
|
@@ -1234,8 +1410,8 @@ export function createFramerAgentToolRuntime({
|
|
|
1234
1410
|
files: [...guidance.files],
|
|
1235
1411
|
},
|
|
1236
1412
|
}),
|
|
1237
|
-
fatalInfrastructureError: () =>
|
|
1238
|
-
waitForFatalInfrastructure: () =>
|
|
1413
|
+
fatalInfrastructureError: () => fatalError,
|
|
1414
|
+
waitForFatalInfrastructure: () => fatalInfrastructure,
|
|
1239
1415
|
close: async ({ waitForPending = true } = {}) => {
|
|
1240
1416
|
closed = true;
|
|
1241
1417
|
abortController.abort();
|
package/src/protocol.js
CHANGED
|
@@ -404,6 +404,11 @@ export function normalizeModelTurnCompletion(raw, fallbackModel = 'local-compani
|
|
|
404
404
|
};
|
|
405
405
|
}
|
|
406
406
|
|
|
407
|
+
// Dexter sometimes sends the model back to fix or verify something before it
|
|
408
|
+
// finishes. Its final summary still answers the designer's request, not that
|
|
409
|
+
// note, which the designer never sees.
|
|
410
|
+
const REPAIR_SUMMARY_RULE = 'Write the summary for the designer about the task they asked for: what is now different on their site, in their words. If Dexter sends you back to fix or verify something, that note is invisible to the designer, so never answer it in the summary or mention repairs, property errors, layer ids, replicas, or checks.';
|
|
411
|
+
|
|
407
412
|
export function runSummary(run) {
|
|
408
413
|
return {
|
|
409
414
|
runId: run?.runId,
|
|
@@ -494,6 +499,7 @@ export function buildFramerOutcomePrompt(outcome = {}, product, promptContext =
|
|
|
494
499
|
'You decide which evidence the task needs. Declare visual verification in your task plan when the result must be judged by eye, such as layout or styling work or a build from a reference image, and then inspect a screenshot after your final mutation. When building from a reference attachment or a website read with framer_read_website, compare that final screenshot against the reference images. Claim a visual comparison with a reference only when you saw that reference as an image in this run; if you worked from text descriptions alone, say so in the check. A screenshot before editing is optional; take one when you need to preserve or compare the current look. Use verifies on final reads to establish link, responsive, code, or data checks when your plan requires them.',
|
|
495
500
|
'Confirm declared behaviors with framer_verify_interactions after your final change: for each target node, name the Framer property that carries the behavior and, when it matters, a literal its value must contain, such as a route or SHOW_OVERLAY. This is structural evidence that the property is set, not runtime playback; describe it that way. If a property is not exposed, report the behavior as unverified instead of changing working parts.',
|
|
496
501
|
'For visual work, evaluate hierarchy, spacing, contrast, typography, balance, imagery, interaction states, and responsive integrity.',
|
|
502
|
+
'When the user asks for an exact distance or size, such as a gap in pixels or equal spacing between sections, measure the layers involved with dexter.measure and dexter.gap before changing anything, to find what creates the space now: a stack gap, padding, a fixed height, or a box larger than its content. Change that cause instead of adding spacing on top of it, then measure again, including the neighbouring layers, so nothing overlaps. State a pixel value in your summary only when you measured it; otherwise describe the change without numbers.',
|
|
497
503
|
'If one visual detail, such as a gradient, an effect, or the stacking of layers, still does not look right after two attempts, keep the closest version, finish the rest of the task, and name the difference in your summary. Do not run diagnostic experiments on the user\'s canvas, such as test colours or throwaway layers.',
|
|
498
504
|
'For depth and 3D, set native transforms with api.tree inside framer_edit, as described in the Framer guidance Transforms section: perspective on the parent, preserve3d on a container whose children must stay in 3D space, rotateX, rotateY, rotateZ, translateZ, and transformOrigin on the moving layers, and backfaceVisibility for flip faces. Rotations without a parent perspective render flat. Spend 3D on one signature moment, such as a tilted product mockup, layered card stack, flip card, or hover tilt; keep text readable, reduce or flatten the effect on smaller breakpoints, and confirm it in a screenshot. Do not fake 3D with images or code components when native transforms can achieve it.',
|
|
499
505
|
'You decide when the run is complete. Avoid repeated inspection of unchanged evidence and stop when the requested outcome has been implemented and verified.',
|
|
@@ -501,6 +507,7 @@ export function buildFramerOutcomePrompt(outcome = {}, product, promptContext =
|
|
|
501
507
|
'If Framer authorization or a required user decision is missing, return blocked with a concise explanation. Otherwise return completed only after you have finished and verified the work.',
|
|
502
508
|
'A greeting, thanks, or question that needs no project change is not blocked: answer it in summary without reading or editing the project and return completed.',
|
|
503
509
|
'Set projectChanged to true only when this run applied a change to the Framer project, and list the ids of the layers you changed in changedNodeIds. The summary must describe only changes that were applied; if you could not find or change something, say so plainly instead.',
|
|
510
|
+
REPAIR_SUMMARY_RULE,
|
|
504
511
|
'When finished, return exactly one JSON object and no markdown.',
|
|
505
512
|
'{"status":"completed|blocked|failed","summary":"one or two short, non-technical sentences for the designer","checks":[{"command":"what you verified","status":"passed|failed|skipped","output":"short result"}],"blockedReason":null,"projectChanged":false,"changedNodeIds":[]}',
|
|
506
513
|
doctrine
|
package/LICENSE
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
Dexter Bridge License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 InstaWeb AI. All rights reserved.
|
|
4
|
-
|
|
5
|
-
Permission is granted to download, install, and run this software solely to
|
|
6
|
-
connect your local machine to the Dexter / InstaWeb AI service. You may not
|
|
7
|
-
modify, redistribute, sublicense, or sell this software or use it to build a
|
|
8
|
-
competing service.
|
|
9
|
-
|
|
10
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
11
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
12
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
13
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
14
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
15
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
16
|
-
SOFTWARE.
|