@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gakim-digital/dexter-bridge",
3
- "version": "0.13.8",
3
+ "version": "0.13.9",
4
4
  "description": "Local bridge for InstaWebAI and Dexter — runs Codex, Claude Code, or OpenCode on your machine.",
5
5
  "type": "module",
6
6
  "bin": {
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 {
@@ -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
- if (!recorder && !checked) return (...args) => value.apply(object, args);
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 before = checked ? __dexterStateOf(object, layer) : null;
603
- const settle = (result) => {
604
- if (!checked) { recorder(args, result); return result; }
605
- const after = __dexterStateOf(object, layer);
606
- // Without a readable layer, the call's own answer is all there is.
607
- const changed = before !== null || after !== null ? before !== after : result !== false;
608
- if (changed) __dexterNote(layer);
609
- else if (__dexterUnchanged.length < 50) __dexterUnchanged.push({ call: String(key), target: __dexterIdOf(args[0]) || '' });
610
- return result;
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
- let result;
613
- try { result = value.apply(object, args); } catch (error) { fail(error); }
614
- if (result && typeof result.then === 'function') return result.then(settle, fail);
615
- return settle(result);
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
- return { result: __dexterResult, dexterError: __dexterError, dexterFiles: __dexterFiles, dexterTouched: __dexterTouched, dexterFailed: __dexterFailed, dexterUnchanged: __dexterUnchanged, dexterRefused: __dexterRefused };
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.