@gakim-digital/dexter-bridge 0.13.7 → 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.7",
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,
@@ -1233,12 +1234,15 @@ function runProcess(
1233
1234
  stdoutProgress,
1234
1235
  } = {},
1235
1236
  ) {
1237
+ // An abort without a reason carries a DOMException, whose code is read-only.
1238
+ const cancellationError = (reason) =>
1239
+ reason instanceof Error && !(reason instanceof DOMException)
1240
+ ? Object.assign(reason, { code: reason.code || 'RUN_CANCELLED' })
1241
+ : Object.assign(new Error('The model process was cancelled.'), { code: 'RUN_CANCELLED' });
1236
1242
  return new Promise((resolve, reject) => {
1237
1243
  const stopped = [signal, bridgeShutdown.signal].find((candidate) => candidate?.aborted);
1238
1244
  if (stopped) {
1239
- const error = stopped.reason instanceof Error ? stopped.reason : new Error('The model process was cancelled.');
1240
- error.code = error.code || 'RUN_CANCELLED';
1241
- reject(error);
1245
+ reject(cancellationError(stopped.reason));
1242
1246
  return;
1243
1247
  }
1244
1248
  const started = Date.now();
@@ -1327,9 +1331,7 @@ function runProcess(
1327
1331
  const abort = (event) => {
1328
1332
  if (settled || terminationError) return;
1329
1333
  clearTimers();
1330
- const reason = event?.target?.reason;
1331
- const error = reason instanceof Error ? reason : new Error('The model process was cancelled.');
1332
- error.code = error.code || 'RUN_CANCELLED';
1334
+ const error = cancellationError(event?.target?.reason);
1333
1335
  terminationError = error;
1334
1336
  terminateChildTree(error);
1335
1337
  trace?.info('agent_process_cancelled', {
@@ -2520,6 +2522,8 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2520
2522
  rejectCancellation(cancellationError());
2521
2523
  return cancelAction;
2522
2524
  };
2525
+ // Framer programs can run for minutes; Stop does not wait for them.
2526
+ const unlessCancelled = (work) => Promise.race([work, cancellation]);
2523
2527
  const postActivity = (payload) => {
2524
2528
  activityChain = activityChain
2525
2529
  .then(async () => {
@@ -2633,12 +2637,15 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2633
2637
  occurredAt: new Date().toISOString(),
2634
2638
  message: `${definition.label} is still working.`,
2635
2639
  });
2640
+ // The run ends on the cancellation itself; a provider that is slow to
2641
+ // stop must not hold up the cancelled event.
2636
2642
  if (controlRequestsCancellation(response)) {
2637
- await requestCancel(controlCancellationSource(response));
2643
+ void requestCancel(controlCancellationSource(response));
2644
+ return;
2638
2645
  }
2639
2646
  } catch (error) {
2640
2647
  if (isCompanionPairingInvalidError(error)) {
2641
- await requestCancel('device_disconnect');
2648
+ void requestCancel('device_disconnect');
2642
2649
  return;
2643
2650
  }
2644
2651
  if (!controlMonitorAbort.signal.aborted && !cancelRequested) {
@@ -2765,6 +2772,17 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2765
2772
  allowInsecureHttp: options.allowInsecureHttp,
2766
2773
  })
2767
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,
2768
2786
  })
2769
2787
  : webflowOutcome
2770
2788
  ? createWebflowToolRuntime({
@@ -3132,7 +3150,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
3132
3150
  let previousRepairPrompt = null;
3133
3151
  for (let round = 0; round < OUTCOME_REPAIR_ROUNDS; round += 1) {
3134
3152
  if (typeof harnessTools.assessCompletion === 'function') {
3135
- await harnessTools.assessCompletion(rawCompletion);
3153
+ await unlessCancelled(harnessTools.assessCompletion(rawCompletion));
3136
3154
  assessedCompletion = rawCompletion;
3137
3155
  }
3138
3156
  const unappliedChangePrompt =
@@ -3205,7 +3223,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
3205
3223
  });
3206
3224
  }
3207
3225
  if (typeof harnessTools.assessCompletion === 'function' && assessedCompletion !== rawCompletion) {
3208
- await harnessTools.assessCompletion(rawCompletion);
3226
+ await unlessCancelled(harnessTools.assessCompletion(rawCompletion));
3209
3227
  }
3210
3228
  const completion =
3211
3229
  typeof harnessTools.finalizeCompletion === 'function'
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}`,
@@ -546,12 +558,140 @@ const __dexterTreeRecorders = {
546
558
  makeExternalComponentLocal: (args, result) => __dexterNote(args[0], result),
547
559
  };
548
560
  const __dexterTreeTarget = { insert: 1 };
561
+ // CMS collections, items, and fields, and pages, are deleted only with the
562
+ // user's permission for this run. The target is read before the call runs.
563
+ const __dexterRefused = [];
564
+ const __dexterContentTypes = new Set(['CollectionNode', 'CollectionItemNode', 'WebPageNode', 'DesignPageNode']);
565
+ const __dexterRefuse = (target, content) => {
566
+ __dexterRefused.push({ target, content });
567
+ throw new Error('Deleting ' + content + ' needs the user\\'s permission. Nothing was deleted.');
568
+ };
569
+ const __dexterTreeGuards = __dexterDeletionAllowed ? {} : {
570
+ remove: (tree, args) => {
571
+ const id = __dexterIdOf(args[0]);
572
+ const node = id ? tree.get(id) : null;
573
+ if (node && __dexterContentTypes.has(node.type)) __dexterRefuse(id, node.type + ' "' + (node.name || id) + '"');
574
+ },
575
+ patch: (tree, args) => {
576
+ const id = __dexterIdOf(args[0]);
577
+ const next = args[1] && args[1].variables;
578
+ if (!id || !Array.isArray(next)) return;
579
+ const node = tree.get(id);
580
+ if (!node || node.type !== 'CollectionNode' || !Array.isArray(node.variables)) return;
581
+ const kept = new Set(next.map((variable) => variable && variable.id));
582
+ const dropped = node.variables.filter((variable) => variable && variable.id && !kept.has(variable.id));
583
+ if (dropped.length) __dexterRefuse(id, 'the fields ' + dropped.map((variable) => '"' + (variable.name || variable.id) + '"').join(', ') + ' of collection "' + (node.name || id) + '"');
584
+ },
585
+ };
549
586
  const __dexterFileRecorders = Object.fromEntries(['create', 'write', 'applyPatch', 'delete'].map((op) => [op, (args, result) => {
550
587
  __dexterFiles.push({ op, path: typeof args[0] === 'string' ? args[0] : '', diagnostics: result && Array.isArray(result.diagnostics) ? result.diagnostics : [] });
551
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
+ };
552
692
  // The proxy sits on an empty stand-in that forwards to the real object, so
553
693
  // methods can be wrapped even when Framer freezes its API objects.
554
- const __dexterRecording = (object, recorders, children = {}, compared = new Set(), targets = {}) => new Proxy({}, {
694
+ const __dexterRecording = (object, recorders, children = {}, compared = new Set(), targets = {}, guards = {}, undo = { before: {}, after: {} }) => new Proxy({}, {
555
695
  has: (_, key) => Reflect.has(object, key),
556
696
  ownKeys: () => Reflect.ownKeys(object),
557
697
  getOwnPropertyDescriptor: (_, key) => {
@@ -564,7 +704,10 @@ const __dexterRecording = (object, recorders, children = {}, compared = new Set(
564
704
  if (typeof value !== 'function') return value;
565
705
  const recorder = recorders[key];
566
706
  const checked = compared.has(key);
567
- 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);
568
711
  return (...args) => {
569
712
  const fail = (error) => {
570
713
  if (__dexterFailed.length < 50) {
@@ -572,28 +715,66 @@ const __dexterRecording = (object, recorders, children = {}, compared = new Set(
572
715
  }
573
716
  throw error;
574
717
  };
718
+ if (guards[key]) guards[key](object, args);
575
719
  const layer = checked ? __dexterLayerOf(args[0]) : null;
576
- const before = checked ? __dexterStateOf(object, layer) : null;
577
- const settle = (result) => {
578
- if (!checked) { recorder(args, result); return result; }
579
- const after = __dexterStateOf(object, layer);
580
- // Without a readable layer, the call's own answer is all there is.
581
- const changed = before !== null || after !== null ? before !== after : result !== false;
582
- if (changed) __dexterNote(layer);
583
- else if (__dexterUnchanged.length < 50) __dexterUnchanged.push({ call: String(key), target: __dexterIdOf(args[0]) || '' });
584
- 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);
585
736
  };
586
- let result;
587
- try { result = value.apply(object, args); } catch (error) { fail(error); }
588
- if (result && typeof result.then === 'function') return result.then(settle, fail);
589
- 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);
590
742
  };
591
743
  },
592
744
  });
593
745
  const __dexterApi = __dexterRecording(api, {}, {
594
- tree: (tree) => __dexterRecording(tree, __dexterTreeRecorders, {}, __dexterCompared, __dexterTreeTarget),
595
- files: (files) => __dexterRecording(files, __dexterFileRecorders),
746
+ tree: (tree) => __dexterRecording(tree, __dexterTreeRecorders, {}, __dexterCompared, __dexterTreeTarget, __dexterTreeGuards, __dexterTreeUndo),
747
+ files: (files) => __dexterRecording(files, __dexterFileRecorders, {}, new Set(), {}, {}, __dexterFileUndo),
596
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
+ };
597
778
  `;
598
779
 
599
780
  // Framer MCP lays out only some pages. On the others getRect returns
@@ -714,12 +895,13 @@ export function wrappedReadLineOffset(program) {
714
895
  return readParts(program).prefix.split('\n').length - 1;
715
896
  }
716
897
 
717
- function wrapperParts(program) {
898
+ function wrapperParts(program, { allowContentDeletion = false } = {}) {
718
899
  const { step, body } = splitStep(program);
719
900
  // A program that throws still keeps the changes it made before the throw,
720
901
  // so the error is caught here and returned with the record of those changes.
721
902
  const prefix = `${step}
722
903
  ${MEASURE_PRELUDE}
904
+ const __dexterDeletionAllowed = ${allowContentDeletion === true};
723
905
  ${RECORDING_PRELUDE}
724
906
  let __dexterResult;
725
907
  let __dexterError = null;
@@ -731,16 +913,83 @@ try {
731
913
  } catch (error) {
732
914
  __dexterError = { name: String((error && error.name) || 'Error'), message: String((error && error.message) || error) };
733
915
  }
734
- return { result: __dexterResult, dexterError: __dexterError, dexterFiles: __dexterFiles, dexterTouched: __dexterTouched, dexterFailed: __dexterFailed, dexterUnchanged: __dexterUnchanged };
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 };
735
919
  `;
736
920
  return { prefix, body, suffix };
737
921
  }
738
922
 
739
- export function wrapProgram(program) {
740
- const { prefix, body, suffix } = wrapperParts(program);
923
+ export function wrapProgram(program, options) {
924
+ const { prefix, body, suffix } = wrapperParts(program, options);
741
925
  return `${prefix}${body}${suffix}`;
742
926
  }
743
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
+
744
993
  // Lines the wrapper adds before the model's program; Framer's compile errors
745
994
  // count from the top of the wrapped program.
746
995
  export function wrappedLineOffset(program) {
@@ -927,6 +1176,7 @@ export function createFramerAgentToolRuntime({
927
1176
  authorizeProject,
928
1177
  readWebsite,
929
1178
  callPanelTool,
1179
+ undoProgram,
930
1180
  mcpClient,
931
1181
  getAccessToken,
932
1182
  trace,
@@ -938,6 +1188,7 @@ export function createFramerAgentToolRuntime({
938
1188
  const projectUrl = String(context.framerProjectUrl || '').trim();
939
1189
  const activePageId = String(context.activePageId || '').trim();
940
1190
  const allowPublishing = context.permissions?.publish === true;
1191
+ const allowContentDeletion = context.permissions?.deleteContent === true;
941
1192
  const allowedTools = Array.isArray(assignment?.toolProtocol?.tools)
942
1193
  ? new Set(assignment.toolProtocol.tools)
943
1194
  : null;
@@ -976,6 +1227,8 @@ export function createFramerAgentToolRuntime({
976
1227
  let firstToolAt = null;
977
1228
  let guidance = { content: '', inventory: '', loaded: false, files: [] };
978
1229
  const mutationReceipts = [];
1230
+ const undoRecord = emptyUndoRecord();
1231
+ const undoneRuns = [];
979
1232
  // Layer id → result of the latest edit call on it ('changed' or 'failed').
980
1233
  const editOutcomes = new Map();
981
1234
  const toolCalls = [];
@@ -1128,6 +1381,7 @@ export function createFramerAgentToolRuntime({
1128
1381
  ...(call.message ? { message: String(call.message).slice(0, 300) } : {}),
1129
1382
  }));
1130
1383
  const failedCalls = calls(inner.dexterFailed);
1384
+ mergeUndoRecord(undoRecord, inner.dexterUndo);
1131
1385
  const receipt = {
1132
1386
  kind: 'framer-mcp-edit',
1133
1387
  changedNodeCount,
@@ -1372,9 +1626,20 @@ export function createFramerAgentToolRuntime({
1372
1626
  callExtra.helpers = helpersUsed(program);
1373
1627
  await ensureProject();
1374
1628
  await snapshotUnchanged();
1375
- const result = await client.exec(wrapProgram(program));
1629
+ const result = await client.exec(wrapProgram(program, { allowContentDeletion }));
1376
1630
  const applied = applyReceipt(result, screenshotNodeIds);
1377
1631
  requirements = renameRequirementNodes(requirements, applied.receipt.renamedIds);
1632
+ const refused = Array.isArray(applied.output?.result?.dexterRefused) ? applied.output.result.dexterRefused : [];
1633
+ if (refused.length) {
1634
+ throw toolError(
1635
+ 'FRAMER_AGENT_DELETE_NOT_AUTHORIZED',
1636
+ [
1637
+ `Deleting ${refused.map((entry) => entry.content).join('; ')} needs the user's clear permission in this conversation, which they have not given, so nothing was deleted.`,
1638
+ applied.changed ? 'The other changes this program made before that point were kept.' : '',
1639
+ 'If deleting it is still the right step, ask the user one plain question naming exactly what you would delete, and return blocked. When they agree, Dexter gives you that permission in the next run.',
1640
+ ].filter(Boolean).join(' '),
1641
+ );
1642
+ }
1378
1643
  const programError = programErrorOf(applied.output, wrappedLineOffset(program));
1379
1644
  // Layers created by this program only get their real ids once it
1380
1645
  // finishes, so screenshots are taken in a second call.
@@ -1413,7 +1678,6 @@ export function createFramerAgentToolRuntime({
1413
1678
  : {}),
1414
1679
  ...(screenshotWarning ? { screenshotWarning } : {}),
1415
1680
  ...(programResult !== undefined ? { result: programResult } : {}),
1416
- changedNodeCount: applied.receipt.changedNodeCount,
1417
1681
  // Framer gives inserted nodes new ids; later programs must use them.
1418
1682
  renamedIds: applied.receipt.renamedIds,
1419
1683
  touchedNodeIds: applied.receipt.touchedNodeIds,
@@ -1458,6 +1722,39 @@ export function createFramerAgentToolRuntime({
1458
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 }];
1459
1723
  })),
1460
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
+ };
1461
1758
  } else if (name === 'framer_verify') {
1462
1759
  requirements = mergeRequirements(requirements, normalizeRequirements(args.requirements));
1463
1760
  const checks = normalizeChecks(args.checks, requirements);
@@ -1952,6 +2249,8 @@ export function createFramerAgentToolRuntime({
1952
2249
  toolCallCount,
1953
2250
  toolCalls: toolCalls.map((call) => ({ ...call })),
1954
2251
  mutationReceipts: mutationReceipts.map((receipt) => ({ ...receipt })),
2252
+ undo: undoRecordForSummary(undoRecord),
2253
+ undoneRuns: undoneRuns.map((entry) => ({ ...entry })),
1955
2254
  branch: branchChange,
1956
2255
  verification: verification(),
1957
2256
  requirements: requirementStatus().requirements.map((item) => ({
package/src/protocol.js CHANGED
@@ -511,12 +511,14 @@ export function buildFramerOutcomePrompt(outcome = {}, product, promptContext =
511
511
  '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.',
512
512
  '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 a requirement is still not met, or you could not find or change something, name it plainly in the summary; never describe it as done.',
513
513
  'Some requests need something beyond the layers you may change, such as photos on other pages, a component shared across the site, or site-wide styles. If the user has not decided about it yet, ask one plain question that names it and what changing it affects, and return blocked; when they agree, Dexter gives you that reach in the next run. If they already said no, or told you what to do in that case, do not ask again: do what you can within what you may change, and explain plainly what is in the way, such as which shared setting causes it. Never mention access, scope, permissions, grants, or authorization.',
514
+ 'Deleting CMS collections, items, or fields, or pages, needs the user\'s clear permission in this conversation; context.permissions.deleteContent is true when they gave it. A request to change, fix, or replace content is not permission to delete it. Without it, ask one plain question naming exactly what you would delete and return blocked, before building any replacement for it.',
514
515
  'If you changed layers before asking a question, say in your summary what you changed.',
515
516
  'When you return blocked, write the question itself in summary, since summary is what the user reads; blockedReason is a short note for Dexter.',
517
+ 'summary is the only text the user reads; checks are for Dexter and are never shown to them. Write summary in Markdown. After an edit, a short, plain answer about what changed is enough. When the user asks a question or asks for a report, audit, review, list, or plan, summary is the full answer, formatted with headings, lists, or tables where they help: never shorten it to a line or put it in checks.',
516
518
  'Write in the language the user wrote their request in: the summary, any question, and the sentences Dexter shows while you work. Keep layer names and quoted text as they are.',
517
519
  REPAIR_SUMMARY_RULE,
518
- 'When finished, return exactly one JSON object and no markdown.',
519
- '{"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":[]}',
520
+ 'When finished, return exactly one JSON object with nothing around it. Markdown belongs only inside the summary string.',
521
+ '{"status":"completed|blocked|failed","summary":"your answer to the designer, in Markdown","checks":[{"command":"what you verified","status":"passed|failed|skipped","output":"short result"}],"blockedReason":null,"projectChanged":false,"changedNodeIds":[]}',
520
522
  doctrine
521
523
  ? `\nDexter design and execution doctrine (${String(promptContext.doctrineVersion || 'current')}):\n${doctrine}`
522
524
  : '',
@@ -581,8 +583,9 @@ export function buildWebflowOutcomePrompt(outcome = {}, product, promptContext =
581
583
  'The summary must describe only changes that were applied. If something the user asked for is not done, name it plainly; never describe it as done.',
582
584
  'Some requests need something beyond the elements you may change, such as content on other pages or a component used across the site. If the user has not decided about it yet, ask one plain question that names it and what changing it affects, and return blocked. If they already said no, or told you what to do in that case, do not ask again: do what you can within what you may change, and explain plainly what is in the way. If you changed elements before asking, say in your summary what you changed. Never mention access, scope, permissions, grants, or authorization.',
583
585
  'Write in the language the user wrote their request in: the summary, any question, and the sentences Dexter shows while you work. Keep element names and quoted text as they are.',
584
- 'When finished, return exactly one JSON object and no markdown.',
585
- '{"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}',
586
+ 'summary is the only text the user reads; checks are for Dexter and are never shown to them. Write summary in Markdown. After an edit, a short, plain answer about what changed is enough. When the user asks a question or asks for a report, audit, review, list, or plan, summary is the full answer, formatted with headings, lists, or tables where they help: never shorten it to a line or put it in checks.',
587
+ 'When finished, return exactly one JSON object with nothing around it. Markdown belongs only inside the summary string.',
588
+ '{"status":"completed|blocked|failed","summary":"your answer to the designer, in Markdown","checks":[{"command":"what you verified","status":"passed|failed|skipped","output":"short result"}],"blockedReason":null}',
586
589
  doctrine
587
590
  ? `\nDexter design and execution doctrine (${String(promptContext.doctrineVersion || 'current')}):\n${doctrine}`
588
591
  : '',
@@ -759,7 +762,7 @@ export function normalizeOutcomeCompletion(
759
762
  status,
760
763
  summary:
761
764
  typeof raw.summary === 'string' && raw.summary.trim()
762
- ? raw.summary.trim().slice(0, 10_000)
765
+ ? raw.summary.trim().slice(0, 40_000)
763
766
  : 'The harness did not provide a summary.',
764
767
  checks,
765
768
  blockedReason:
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.