@gakim-digital/dexter-bridge 0.13.8 → 0.13.9
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 +12 -0
- package/src/api.js +27 -0
- package/src/framerTools.js +279 -19
- package/LICENSE +0 -16
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
findWebflowImages,
|
|
11
11
|
getFramerProjectAuthorization,
|
|
12
12
|
webflowMcpEndpoint,
|
|
13
|
+
getFramerUndoProgram,
|
|
13
14
|
readFramerWebsite,
|
|
14
15
|
isCompanionPairingInvalidError,
|
|
15
16
|
postRunEvent,
|
|
@@ -2771,6 +2772,17 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
|
|
|
2771
2772
|
allowInsecureHttp: options.allowInsecureHttp,
|
|
2772
2773
|
})
|
|
2773
2774
|
: undefined,
|
|
2775
|
+
undoProgram:
|
|
2776
|
+
options.apiBaseUrl && options.deviceToken
|
|
2777
|
+
? ({ runId: targetRunId }) =>
|
|
2778
|
+
getFramerUndoProgram(options.apiBaseUrl, {
|
|
2779
|
+
deviceToken: options.deviceToken,
|
|
2780
|
+
runId: run.runId,
|
|
2781
|
+
targetRunId,
|
|
2782
|
+
fetchImpl: options.fetchImpl,
|
|
2783
|
+
allowInsecureHttp: options.allowInsecureHttp,
|
|
2784
|
+
})
|
|
2785
|
+
: undefined,
|
|
2774
2786
|
})
|
|
2775
2787
|
: webflowOutcome
|
|
2776
2788
|
? createWebflowToolRuntime({
|
package/src/api.js
CHANGED
|
@@ -267,6 +267,33 @@ export function readFramerWebsite(apiBaseUrl, {
|
|
|
267
267
|
);
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
// The program that puts back what an earlier run in this conversation
|
|
271
|
+
// changed. Dexter writes it from that run's undo record.
|
|
272
|
+
export function getFramerUndoProgram(apiBaseUrl, {
|
|
273
|
+
deviceToken,
|
|
274
|
+
runId,
|
|
275
|
+
targetRunId,
|
|
276
|
+
fetchImpl,
|
|
277
|
+
allowInsecureHttp,
|
|
278
|
+
}) {
|
|
279
|
+
return requestJson(
|
|
280
|
+
apiBaseUrl,
|
|
281
|
+
`/ai/framer/companion/runs/${encodeURIComponent(runId)}/undo-program`,
|
|
282
|
+
{
|
|
283
|
+
method: 'POST',
|
|
284
|
+
deviceToken,
|
|
285
|
+
fetchImpl,
|
|
286
|
+
allowInsecureHttp,
|
|
287
|
+
timeoutMs: 30_000,
|
|
288
|
+
body: {
|
|
289
|
+
targetRunId,
|
|
290
|
+
platform: process.platform,
|
|
291
|
+
companionVersion: BRIDGE_VERSION,
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
270
297
|
// The run's Webflow MCP endpoint on Dexter's API. The Webflow token never leaves the API.
|
|
271
298
|
export function webflowMcpEndpoint(apiBaseUrl, { deviceToken, runId, allowInsecureHttp }) {
|
|
272
299
|
return {
|
package/src/framerTools.js
CHANGED
|
@@ -183,6 +183,18 @@ const TOOL_DEFINITIONS = [
|
|
|
183
183
|
additionalProperties: false,
|
|
184
184
|
},
|
|
185
185
|
},
|
|
186
|
+
{
|
|
187
|
+
name: 'framer_undo_run',
|
|
188
|
+
description: 'Put back exactly what one earlier run in this conversation changed, from context.recentRuns: settings and text return, layers it added are removed, layers it removed come back, and moved layers return to their place. Anything changed again since that run is left as it is and reported. Use it when the user asks to revert or undo earlier work, instead of rebuilding the earlier state by hand. To go further back, undo the later runs first, newest first.',
|
|
189
|
+
inputSchema: {
|
|
190
|
+
type: 'object',
|
|
191
|
+
properties: {
|
|
192
|
+
runId: { type: 'string', minLength: 1, maxLength: 300, description: 'The runId from context.recentRuns.' },
|
|
193
|
+
},
|
|
194
|
+
required: ['runId'],
|
|
195
|
+
additionalProperties: false,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
186
198
|
{
|
|
187
199
|
name: 'framer_read_website',
|
|
188
200
|
description: `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. ${IMAGE_RESULT_NOTE}`,
|
|
@@ -574,9 +586,112 @@ const __dexterTreeGuards = __dexterDeletionAllowed ? {} : {
|
|
|
574
586
|
const __dexterFileRecorders = Object.fromEntries(['create', 'write', 'applyPatch', 'delete'].map((op) => [op, (args, result) => {
|
|
575
587
|
__dexterFiles.push({ op, path: typeof args[0] === 'string' ? args[0] : '', diagnostics: result && Array.isArray(result.diagnostics) ? result.diagnostics : [] });
|
|
576
588
|
}]));
|
|
589
|
+
// What each change replaced, so Dexter can put it back later. A layer keeps
|
|
590
|
+
// its settings from before its first change; a text layer keeps its
|
|
591
|
+
// paragraphs and runs too. A removed layer keeps everything inside it and its
|
|
592
|
+
// copies on the other breakpoints, which Framer removes with it. Calls whose
|
|
593
|
+
// changes Dexter cannot put back are listed by name.
|
|
594
|
+
const __dexterUndo = { layers: {}, created: [], moved: {}, removed: [], files: {}, unsupported: [] };
|
|
595
|
+
const __dexterCreated = new Set();
|
|
596
|
+
const __dexterTextTypes = new Set(['TextBlock', 'TextRun', 'TextLineBreak', 'TextBulletList', 'TextNumberedList', 'TextListItem', 'TextBlockquote', 'TextTable', 'TextTableRow', 'TextTableCell', 'TextComponentInstance']);
|
|
597
|
+
const __dexterReadNode = (tree, id, json) => {
|
|
598
|
+
try {
|
|
599
|
+
const text = json !== undefined ? json : __dexterStateOf(tree, id);
|
|
600
|
+
return text ? JSON.parse(text) : null;
|
|
601
|
+
} catch { return null; }
|
|
602
|
+
};
|
|
603
|
+
const __dexterOwn = (node) => {
|
|
604
|
+
if (!node || node.type === 'RichTextNode') return node;
|
|
605
|
+
const { children, ...own } = node;
|
|
606
|
+
return own;
|
|
607
|
+
};
|
|
608
|
+
const __dexterKeep = (tree, id, json) => {
|
|
609
|
+
if (!id || __dexterCreated.has(id) || __dexterUndo.layers[id]) return;
|
|
610
|
+
const node = __dexterOwn(__dexterReadNode(tree, id, json));
|
|
611
|
+
if (node) __dexterUndo.layers[id] = { before: node };
|
|
612
|
+
};
|
|
613
|
+
const __dexterPlace = (tree, id) => {
|
|
614
|
+
const node = tree.get(id);
|
|
615
|
+
const parent = node && node.$parentId;
|
|
616
|
+
if (!parent) return null;
|
|
617
|
+
const siblings = (tree.get(parent) || {}).children || [];
|
|
618
|
+
return { parent, index: Math.max(0, siblings.findIndex((child) => child && child.id === id)) };
|
|
619
|
+
};
|
|
620
|
+
const __dexterReplicas = (tree, node) => {
|
|
621
|
+
const copies = {};
|
|
622
|
+
if (!node || node.$replicaId || !node.$groundNodeId || !node.$scopeId) return copies;
|
|
623
|
+
const scope = tree.get(node.$scopeId);
|
|
624
|
+
for (const variant of (scope && (scope.$breakpoints || scope.$variants)) || []) {
|
|
625
|
+
const variantId = variant && variant.id;
|
|
626
|
+
if (!variantId || variantId === node.$groundNodeId) continue;
|
|
627
|
+
const copy = __dexterReadNode(tree, variantId + node.id);
|
|
628
|
+
if (copy) copies[variantId] = copy;
|
|
629
|
+
}
|
|
630
|
+
return copies;
|
|
631
|
+
};
|
|
632
|
+
const __dexterKeepLayer = (tree, args, json) => __dexterKeep(tree, __dexterLayerOf(args[0]), json);
|
|
633
|
+
const __dexterNoteCreated = (args, result) => {
|
|
634
|
+
const id = __dexterIdOf(result);
|
|
635
|
+
if (!id || id.startsWith('v:') || __dexterCreated.has(id)) return;
|
|
636
|
+
__dexterCreated.add(id);
|
|
637
|
+
__dexterUndo.created.push(id);
|
|
638
|
+
};
|
|
639
|
+
// An edit to something Dexter could not read first, such as rich text inside
|
|
640
|
+
// a CMS field, cannot be put back; it is listed instead.
|
|
641
|
+
const __dexterUnreadable = (call, args) => {
|
|
642
|
+
const id = __dexterLayerOf(args[0]);
|
|
643
|
+
if (!id || __dexterUndo.layers[id] || __dexterCreated.has(id) || __dexterUndo.unsupported.length >= 20) return;
|
|
644
|
+
__dexterUndo.unsupported.push({ call, target: __dexterIdOf(args[0]) || '' });
|
|
645
|
+
};
|
|
646
|
+
const __dexterTreeUndo = {
|
|
647
|
+
before: {
|
|
648
|
+
patch: __dexterKeepLayer,
|
|
649
|
+
replaceText: __dexterKeepLayer,
|
|
650
|
+
autofit: __dexterKeepLayer,
|
|
651
|
+
resetOverrides: __dexterKeepLayer,
|
|
652
|
+
// Text blocks and runs are inserted into an existing text layer.
|
|
653
|
+
insert: (tree, args) => {
|
|
654
|
+
if (__dexterTextTypes.has(args[0])) __dexterKeep(tree, __dexterLayerOf(args[2] && args[2].parent));
|
|
655
|
+
},
|
|
656
|
+
move: (tree, args) => {
|
|
657
|
+
const id = __dexterIdOf(args[0]);
|
|
658
|
+
if (!id || __dexterCreated.has(id) || __dexterUndo.moved[id]) return;
|
|
659
|
+
const place = __dexterPlace(tree, id);
|
|
660
|
+
if (place) __dexterUndo.moved[id] = { before: place, name: (tree.get(id) || {}).name || null };
|
|
661
|
+
},
|
|
662
|
+
// Removing a layer on another breakpoint only hides it there.
|
|
663
|
+
remove: (tree, args, json) => {
|
|
664
|
+
const id = __dexterIdOf(args[0]);
|
|
665
|
+
if (!id || __dexterCreated.has(id)) return undefined;
|
|
666
|
+
const node = __dexterReadNode(tree, id, json);
|
|
667
|
+
if (!node) return undefined;
|
|
668
|
+
if (node.$replicaId) { __dexterKeep(tree, id, json); return undefined; }
|
|
669
|
+
return { id, node, place: __dexterPlace(tree, id), replicas: __dexterReplicas(tree, node) };
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
after: {
|
|
673
|
+
patch: (args) => __dexterUnreadable('patch', args),
|
|
674
|
+
replaceText: (args) => __dexterUnreadable('replaceText', args),
|
|
675
|
+
insert: (args, result) => { if (!__dexterTextTypes.has(args[0])) __dexterNoteCreated(args, result); },
|
|
676
|
+
duplicate: __dexterNoteCreated,
|
|
677
|
+
createVariant: __dexterNoteCreated,
|
|
678
|
+
remove: (args, result, removal) => { if (removal) __dexterUndo.removed.push(removal); },
|
|
679
|
+
},
|
|
680
|
+
unsupported: new Set(['var', 'changeHomePage', 'createComponentFromFrame', 'flattenComponentInstance', 'makeExternalComponentLocal', 'createABTestVariant', 'setABTestDistribution', 'patchFormDestinations', 'updateTitle']),
|
|
681
|
+
};
|
|
682
|
+
const __dexterFileUndo = {
|
|
683
|
+
before: Object.fromEntries(['create', 'write', 'applyPatch', 'delete'].map((op) => [op, async (files, args) => {
|
|
684
|
+
const path = typeof args[0] === 'string' ? args[0] : '';
|
|
685
|
+
if (!path || Object.prototype.hasOwnProperty.call(__dexterUndo.files, path)) return;
|
|
686
|
+
let source = null;
|
|
687
|
+
try { const file = await files.read(path); source = file && typeof file.source === 'string' ? file.source : null; } catch {}
|
|
688
|
+
__dexterUndo.files[path] = { before: source };
|
|
689
|
+
}])),
|
|
690
|
+
after: {},
|
|
691
|
+
};
|
|
577
692
|
// The proxy sits on an empty stand-in that forwards to the real object, so
|
|
578
693
|
// methods can be wrapped even when Framer freezes its API objects.
|
|
579
|
-
const __dexterRecording = (object, recorders, children = {}, compared = new Set(), targets = {}, guards = {}) => new Proxy({}, {
|
|
694
|
+
const __dexterRecording = (object, recorders, children = {}, compared = new Set(), targets = {}, guards = {}, undo = { before: {}, after: {} }) => new Proxy({}, {
|
|
580
695
|
has: (_, key) => Reflect.has(object, key),
|
|
581
696
|
ownKeys: () => Reflect.ownKeys(object),
|
|
582
697
|
getOwnPropertyDescriptor: (_, key) => {
|
|
@@ -589,7 +704,10 @@ const __dexterRecording = (object, recorders, children = {}, compared = new Set(
|
|
|
589
704
|
if (typeof value !== 'function') return value;
|
|
590
705
|
const recorder = recorders[key];
|
|
591
706
|
const checked = compared.has(key);
|
|
592
|
-
|
|
707
|
+
const undoBefore = undo.before[key];
|
|
708
|
+
const undoAfter = undo.after[key];
|
|
709
|
+
const unsupported = Boolean(undo.unsupported && undo.unsupported.has(key));
|
|
710
|
+
if (!recorder && !checked && !undoBefore && !undoAfter && !unsupported) return (...args) => value.apply(object, args);
|
|
593
711
|
return (...args) => {
|
|
594
712
|
const fail = (error) => {
|
|
595
713
|
if (__dexterFailed.length < 50) {
|
|
@@ -599,27 +717,64 @@ const __dexterRecording = (object, recorders, children = {}, compared = new Set(
|
|
|
599
717
|
};
|
|
600
718
|
if (guards[key]) guards[key](object, args);
|
|
601
719
|
const layer = checked ? __dexterLayerOf(args[0]) : null;
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
720
|
+
const run = (before, memo) => {
|
|
721
|
+
const settle = (result) => {
|
|
722
|
+
if (undoAfter) undoAfter(args, result, memo);
|
|
723
|
+
if (unsupported && __dexterUndo.unsupported.length < 20) __dexterUndo.unsupported.push({ call: String(key), target: __dexterIdOf(args[0]) || '' });
|
|
724
|
+
if (!checked) { if (recorder) recorder(args, result); return result; }
|
|
725
|
+
const after = __dexterStateOf(object, layer);
|
|
726
|
+
// Without a readable layer, the call's own answer is all there is.
|
|
727
|
+
const changed = before !== null || after !== null ? before !== after : result !== false;
|
|
728
|
+
if (changed) __dexterNote(layer);
|
|
729
|
+
else if (__dexterUnchanged.length < 50) __dexterUnchanged.push({ call: String(key), target: __dexterIdOf(args[0]) || '' });
|
|
730
|
+
return result;
|
|
731
|
+
};
|
|
732
|
+
let result;
|
|
733
|
+
try { result = value.apply(object, args); } catch (error) { fail(error); }
|
|
734
|
+
if (result && typeof result.then === 'function') return result.then(settle, fail);
|
|
735
|
+
return settle(result);
|
|
611
736
|
};
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
return
|
|
737
|
+
const before = checked ? __dexterStateOf(object, layer) : null;
|
|
738
|
+
const memo = undoBefore ? undoBefore(object, args, layer === __dexterIdOf(args[0]) && before !== null ? before : undefined) : undefined;
|
|
739
|
+
// Code files are read before they change, which takes a moment.
|
|
740
|
+
if (memo && typeof memo.then === 'function') return memo.then(() => run(checked ? __dexterStateOf(object, layer) : null));
|
|
741
|
+
return run(before, memo);
|
|
616
742
|
};
|
|
617
743
|
},
|
|
618
744
|
});
|
|
619
745
|
const __dexterApi = __dexterRecording(api, {}, {
|
|
620
|
-
tree: (tree) => __dexterRecording(tree, __dexterTreeRecorders, {}, __dexterCompared, __dexterTreeTarget, __dexterTreeGuards),
|
|
621
|
-
files: (files) => __dexterRecording(files, __dexterFileRecorders),
|
|
746
|
+
tree: (tree) => __dexterRecording(tree, __dexterTreeRecorders, {}, __dexterCompared, __dexterTreeTarget, __dexterTreeGuards, __dexterTreeUndo),
|
|
747
|
+
files: (files) => __dexterRecording(files, __dexterFileRecorders, {}, new Set(), {}, {}, __dexterFileUndo),
|
|
622
748
|
});
|
|
749
|
+
// Read once the program ends: how everything it changed reads now, so a later
|
|
750
|
+
// undo can tell whether someone changed it again since.
|
|
751
|
+
const __dexterUndoRecord = async () => {
|
|
752
|
+
const tree = api.tree;
|
|
753
|
+
const layers = {};
|
|
754
|
+
for (const [id, entry] of Object.entries(__dexterUndo.layers)) {
|
|
755
|
+
layers[id] = { before: entry.before, after: __dexterOwn(__dexterReadNode(tree, id)) };
|
|
756
|
+
}
|
|
757
|
+
const created = [];
|
|
758
|
+
for (const id of __dexterUndo.created) {
|
|
759
|
+
const node = __dexterReadNode(tree, id);
|
|
760
|
+
if (!node) continue;
|
|
761
|
+
const ids = [];
|
|
762
|
+
const visit = (inner) => { if (inner && inner.id) ids.push(inner.id); for (const child of (inner && inner.children) || []) visit(child); };
|
|
763
|
+
visit(node);
|
|
764
|
+
created.push({ id, after: __dexterOwn(node), ids });
|
|
765
|
+
}
|
|
766
|
+
const moved = {};
|
|
767
|
+
for (const [id, entry] of Object.entries(__dexterUndo.moved)) {
|
|
768
|
+
moved[id] = { before: entry.before, name: entry.name, after: __dexterPlace(tree, id) };
|
|
769
|
+
}
|
|
770
|
+
const files = {};
|
|
771
|
+
for (const [path, entry] of Object.entries(__dexterUndo.files)) {
|
|
772
|
+
let source = null;
|
|
773
|
+
try { const file = await api.files.read(path); source = file && typeof file.source === 'string' ? file.source : null; } catch {}
|
|
774
|
+
files[path] = { before: entry.before, after: source };
|
|
775
|
+
}
|
|
776
|
+
return { layers, created, moved, removed: __dexterUndo.removed, files, unsupported: __dexterUndo.unsupported };
|
|
777
|
+
};
|
|
623
778
|
`;
|
|
624
779
|
|
|
625
780
|
// Framer MCP lays out only some pages. On the others getRect returns
|
|
@@ -758,7 +913,9 @@ try {
|
|
|
758
913
|
} catch (error) {
|
|
759
914
|
__dexterError = { name: String((error && error.name) || 'Error'), message: String((error && error.message) || error) };
|
|
760
915
|
}
|
|
761
|
-
|
|
916
|
+
let __dexterUndoRecorded = null;
|
|
917
|
+
try { __dexterUndoRecorded = await __dexterUndoRecord(); } catch {}
|
|
918
|
+
return { result: __dexterResult, dexterError: __dexterError, dexterFiles: __dexterFiles, dexterTouched: __dexterTouched, dexterFailed: __dexterFailed, dexterUnchanged: __dexterUnchanged, dexterRefused: __dexterRefused, dexterUndo: __dexterUndoRecorded };
|
|
762
919
|
`;
|
|
763
920
|
return { prefix, body, suffix };
|
|
764
921
|
}
|
|
@@ -768,6 +925,71 @@ export function wrapProgram(program, options) {
|
|
|
768
925
|
return `${prefix}${body}${suffix}`;
|
|
769
926
|
}
|
|
770
927
|
|
|
928
|
+
// Past this size a run's undo record is not kept; Dexter says the run cannot
|
|
929
|
+
// be undone instead.
|
|
930
|
+
const UNDO_RECORD_LIMIT_BYTES = 8_000_000;
|
|
931
|
+
|
|
932
|
+
export function emptyUndoRecord() {
|
|
933
|
+
return { layers: {}, created: [], moved: {}, removed: [], files: {}, unsupported: [] };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Adds one edit program's undo record to the run's. A layer keeps its
|
|
937
|
+
// settings from before the run and its latest reading. Layers the run added
|
|
938
|
+
// and later removed leave nothing to undo.
|
|
939
|
+
export function mergeUndoRecord(run, program) {
|
|
940
|
+
if (!program || typeof program !== 'object') return run;
|
|
941
|
+
const created = new Map(run.created.map((entry) => [entry.id, entry]));
|
|
942
|
+
for (const entry of Array.isArray(program.created) ? program.created : []) {
|
|
943
|
+
if (!entry?.id || created.has(entry.id)) continue;
|
|
944
|
+
run.created.push(entry);
|
|
945
|
+
created.set(entry.id, entry);
|
|
946
|
+
}
|
|
947
|
+
const madeByRun = new Set(run.created.flatMap((entry) => entry.ids || [entry.id]));
|
|
948
|
+
for (const [id, entry] of Object.entries(program.layers || {})) {
|
|
949
|
+
if (madeByRun.has(id)) {
|
|
950
|
+
if (created.has(id)) created.get(id).after = entry.after;
|
|
951
|
+
} else if (run.layers[id]) {
|
|
952
|
+
run.layers[id].after = entry.after;
|
|
953
|
+
} else {
|
|
954
|
+
run.layers[id] = entry;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
for (const [id, entry] of Object.entries(program.moved || {})) {
|
|
958
|
+
if (madeByRun.has(id)) continue;
|
|
959
|
+
if (run.moved[id]) run.moved[id].after = entry.after;
|
|
960
|
+
else run.moved[id] = entry;
|
|
961
|
+
}
|
|
962
|
+
for (const entry of Array.isArray(program.removed) ? program.removed : []) {
|
|
963
|
+
if (!entry?.id) continue;
|
|
964
|
+
if (madeByRun.has(entry.id)) {
|
|
965
|
+
run.created = run.created.filter((made) => made.id !== entry.id);
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
run.removed.push(entry);
|
|
969
|
+
}
|
|
970
|
+
for (const [path, entry] of Object.entries(program.files || {})) {
|
|
971
|
+
if (run.files[path]) run.files[path].after = entry.after;
|
|
972
|
+
else run.files[path] = entry;
|
|
973
|
+
}
|
|
974
|
+
for (const call of Array.isArray(program.unsupported) ? program.unsupported : []) {
|
|
975
|
+
if (run.unsupported.length >= 20) break;
|
|
976
|
+
if (!run.unsupported.some((known) => known.call === call.call && known.target === call.target)) {
|
|
977
|
+
run.unsupported.push({ call: String(call.call || ''), target: String(call.target || '') });
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
return run;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
export function undoRecordForSummary(record) {
|
|
984
|
+
const empty = !Object.keys(record.layers).length && !record.created.length && !Object.keys(record.moved).length
|
|
985
|
+
&& !record.removed.length && !Object.keys(record.files).length && !record.unsupported.length;
|
|
986
|
+
if (empty) return null;
|
|
987
|
+
if (JSON.stringify(record).length > UNDO_RECORD_LIMIT_BYTES) {
|
|
988
|
+
return { tooLarge: true, unsupported: record.unsupported };
|
|
989
|
+
}
|
|
990
|
+
return record;
|
|
991
|
+
}
|
|
992
|
+
|
|
771
993
|
// Lines the wrapper adds before the model's program; Framer's compile errors
|
|
772
994
|
// count from the top of the wrapped program.
|
|
773
995
|
export function wrappedLineOffset(program) {
|
|
@@ -954,6 +1176,7 @@ export function createFramerAgentToolRuntime({
|
|
|
954
1176
|
authorizeProject,
|
|
955
1177
|
readWebsite,
|
|
956
1178
|
callPanelTool,
|
|
1179
|
+
undoProgram,
|
|
957
1180
|
mcpClient,
|
|
958
1181
|
getAccessToken,
|
|
959
1182
|
trace,
|
|
@@ -1004,6 +1227,8 @@ export function createFramerAgentToolRuntime({
|
|
|
1004
1227
|
let firstToolAt = null;
|
|
1005
1228
|
let guidance = { content: '', inventory: '', loaded: false, files: [] };
|
|
1006
1229
|
const mutationReceipts = [];
|
|
1230
|
+
const undoRecord = emptyUndoRecord();
|
|
1231
|
+
const undoneRuns = [];
|
|
1007
1232
|
// Layer id → result of the latest edit call on it ('changed' or 'failed').
|
|
1008
1233
|
const editOutcomes = new Map();
|
|
1009
1234
|
const toolCalls = [];
|
|
@@ -1156,6 +1381,7 @@ export function createFramerAgentToolRuntime({
|
|
|
1156
1381
|
...(call.message ? { message: String(call.message).slice(0, 300) } : {}),
|
|
1157
1382
|
}));
|
|
1158
1383
|
const failedCalls = calls(inner.dexterFailed);
|
|
1384
|
+
mergeUndoRecord(undoRecord, inner.dexterUndo);
|
|
1159
1385
|
const receipt = {
|
|
1160
1386
|
kind: 'framer-mcp-edit',
|
|
1161
1387
|
changedNodeCount,
|
|
@@ -1452,7 +1678,6 @@ export function createFramerAgentToolRuntime({
|
|
|
1452
1678
|
: {}),
|
|
1453
1679
|
...(screenshotWarning ? { screenshotWarning } : {}),
|
|
1454
1680
|
...(programResult !== undefined ? { result: programResult } : {}),
|
|
1455
|
-
changedNodeCount: applied.receipt.changedNodeCount,
|
|
1456
1681
|
// Framer gives inserted nodes new ids; later programs must use them.
|
|
1457
1682
|
renamedIds: applied.receipt.renamedIds,
|
|
1458
1683
|
touchedNodeIds: applied.receipt.touchedNodeIds,
|
|
@@ -1497,6 +1722,39 @@ export function createFramerAgentToolRuntime({
|
|
|
1497
1722
|
return [id, { x: layer.x, y: layer.y, width: layer.width, height: layer.height, frame: layer.frameId ? { id: layer.frameId, name: layer.frameName || null } : null }];
|
|
1498
1723
|
})),
|
|
1499
1724
|
};
|
|
1725
|
+
} else if (name === 'framer_undo_run') {
|
|
1726
|
+
category = 'mutation';
|
|
1727
|
+
if (typeof undoProgram !== 'function') {
|
|
1728
|
+
throw toolError('FRAMER_UNDO_UNAVAILABLE', 'Undo is not available for this run.');
|
|
1729
|
+
}
|
|
1730
|
+
const runId = String(args.runId || '').trim();
|
|
1731
|
+
callExtra.undoRunId = runId;
|
|
1732
|
+
// Dexter writes the undo program from what that run recorded. It runs
|
|
1733
|
+
// like an edit, so this run records it too and can itself be undone.
|
|
1734
|
+
const prepared = await undoProgram({ runId });
|
|
1735
|
+
if (!prepared?.program) {
|
|
1736
|
+
throw toolError(prepared?.code || 'FRAMER_UNDO_UNAVAILABLE', prepared?.error || 'That run cannot be undone.');
|
|
1737
|
+
}
|
|
1738
|
+
await ensureProject();
|
|
1739
|
+
const result = await client.exec(wrapProgram(prepared.program, { allowContentDeletion: true }));
|
|
1740
|
+
const applied = applyReceipt(result, []);
|
|
1741
|
+
const programError = programErrorOf(applied.output, wrappedLineOffset(prepared.program));
|
|
1742
|
+
if (programError) {
|
|
1743
|
+
throw toolError(
|
|
1744
|
+
'FRAMER_UNDO_FAILED',
|
|
1745
|
+
`${programError.message}${applied.changed ? ' Part of the undo was applied; read the project before going on.' : ''}`,
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
const report = applied.output.result && typeof applied.output.result === 'object'
|
|
1749
|
+
? applied.output.result.result
|
|
1750
|
+
: null;
|
|
1751
|
+
undoneRuns.push({ runId, report: boundedResult(report) });
|
|
1752
|
+
payload = {
|
|
1753
|
+
undone: runId,
|
|
1754
|
+
report: boundedResult(report),
|
|
1755
|
+
touchedNodeIds: applied.receipt.touchedNodeIds,
|
|
1756
|
+
note: 'skipped lists what was changed again after that run and was left as it is; failed lists what could not be put back. Tell the user about both.',
|
|
1757
|
+
};
|
|
1500
1758
|
} else if (name === 'framer_verify') {
|
|
1501
1759
|
requirements = mergeRequirements(requirements, normalizeRequirements(args.requirements));
|
|
1502
1760
|
const checks = normalizeChecks(args.checks, requirements);
|
|
@@ -1991,6 +2249,8 @@ export function createFramerAgentToolRuntime({
|
|
|
1991
2249
|
toolCallCount,
|
|
1992
2250
|
toolCalls: toolCalls.map((call) => ({ ...call })),
|
|
1993
2251
|
mutationReceipts: mutationReceipts.map((receipt) => ({ ...receipt })),
|
|
2252
|
+
undo: undoRecordForSummary(undoRecord),
|
|
2253
|
+
undoneRuns: undoneRuns.map((entry) => ({ ...entry })),
|
|
1994
2254
|
branch: branchChange,
|
|
1995
2255
|
verification: verification(),
|
|
1996
2256
|
requirements: requirementStatus().requirements.map((item) => ({
|
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.
|