@gakim-digital/dexter-bridge 0.13.0 → 0.13.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gakim-digital/dexter-bridge",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
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
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import crossSpawn from 'cross-spawn';
8
8
  import {
9
- callWebflowPanelTool,
9
+ callPanelTool,
10
10
  findWebflowImages,
11
11
  getFramerProjectAuthorization,
12
12
  webflowMcpEndpoint,
@@ -2385,6 +2385,10 @@ async function executeModelTurn(run, send, agent, options = {}) {
2385
2385
  });
2386
2386
  }
2387
2387
 
2388
+ // Rounds in which a model that finished with unmet checks is sent back to fix
2389
+ // them, within the run's time limit.
2390
+ const OUTCOME_REPAIR_ROUNDS = 3;
2391
+
2388
2392
  async function executeOutcomeRun(run, send, agent, options = {}) {
2389
2393
  const product = normalizeBridgeProduct(run?.product || options.product);
2390
2394
  const definition = definitionForRuntimeProfile(run?.runtimeProfile, agent);
@@ -2745,6 +2749,20 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2745
2749
  allowInsecureHttp: options.allowInsecureHttp,
2746
2750
  })
2747
2751
  : undefined,
2752
+ callPanelTool:
2753
+ options.apiBaseUrl && options.deviceToken
2754
+ ? ({ callId, name, arguments: toolArguments, signal }) =>
2755
+ callPanelTool(options.apiBaseUrl, {
2756
+ deviceToken: options.deviceToken,
2757
+ runId: run.runId,
2758
+ callId,
2759
+ name,
2760
+ arguments: toolArguments,
2761
+ fetchImpl: options.fetchImpl,
2762
+ signal,
2763
+ allowInsecureHttp: options.allowInsecureHttp,
2764
+ })
2765
+ : undefined,
2748
2766
  })
2749
2767
  : webflowOutcome
2750
2768
  ? createWebflowToolRuntime({
@@ -2762,7 +2780,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
2762
2780
  callPanelTool:
2763
2781
  options.apiBaseUrl && options.deviceToken
2764
2782
  ? ({ callId, name, arguments: toolArguments, signal }) =>
2765
- callWebflowPanelTool(options.apiBaseUrl, {
2783
+ callPanelTool(options.apiBaseUrl, {
2766
2784
  deviceToken: options.deviceToken,
2767
2785
  runId: run.runId,
2768
2786
  callId,
@@ -3072,32 +3090,34 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
3072
3090
  ) {
3073
3091
  synchronization = await harnessTools.synchronize();
3074
3092
  }
3075
- if (typeof harnessTools.assessCompletion === 'function') {
3076
- await harnessTools.assessCompletion(rawCompletion);
3077
- }
3078
- const unappliedChangePrompt =
3079
- rawCompletion.status === successStatus
3080
- ? harnessTools.unappliedChangePrompt?.(rawCompletion) || null
3081
- : null;
3082
- const repairPrompt =
3083
- rawCompletion.status === successStatus
3084
- ? harnessTools.verificationRepairPrompt?.() || unappliedChangePrompt
3085
- : harnessTools.unblockedFailurePrompt?.(rawCompletion) || null;
3086
- if (
3087
- repairPrompt
3088
- && Date.now() - callStartedAt < outcomeMaxDurationMs - 5_000
3089
- ) {
3093
+ // The model sees what Dexter's reads found and decides what to do with it.
3094
+ // Findings it has already weighed are not sent again: finishing with the
3095
+ // same findings is its judgment, and the outcome reports them as they are.
3096
+ let assessedCompletion = null;
3097
+ let previousRepairPrompt = null;
3098
+ for (let round = 0; round < OUTCOME_REPAIR_ROUNDS; round += 1) {
3099
+ if (typeof harnessTools.assessCompletion === 'function') {
3100
+ await harnessTools.assessCompletion(rawCompletion);
3101
+ assessedCompletion = rawCompletion;
3102
+ }
3103
+ const unappliedChangePrompt =
3104
+ rawCompletion.status === successStatus
3105
+ ? harnessTools.unappliedChangePrompt?.(rawCompletion) || null
3106
+ : null;
3107
+ const repairPrompt =
3108
+ rawCompletion.status === successStatus
3109
+ ? harnessTools.verificationRepairPrompt?.() || unappliedChangePrompt
3110
+ : harnessTools.unblockedFailurePrompt?.(rawCompletion) || null;
3111
+ if (
3112
+ !repairPrompt
3113
+ || repairPrompt === previousRepairPrompt
3114
+ || Date.now() - callStartedAt >= outcomeMaxDurationMs - 5_000
3115
+ ) break;
3116
+ previousRepairPrompt = repairPrompt;
3090
3117
  progress({
3091
3118
  kind: 'status_update',
3092
3119
  source: 'harness',
3093
- message:
3094
- repairPrompt === unappliedChangePrompt
3095
- ? `Nothing has changed in ${webflowOutcome ? 'Webflow' : 'Framer'} yet, so I’m making the change before I finish.`
3096
- : rawCompletion.status === successStatus
3097
- ? (webflowOutcome
3098
- ? 'I’m completing the final Webflow verification before I finish.'
3099
- : 'I’m completing the final Framer verification before I finish.')
3100
- : 'Nothing is blocking this change, so I’m continuing the work.',
3120
+ message: 'I’m checking the result against your request before I finish.',
3101
3121
  });
3102
3122
  const repairResult = await Promise.race([
3103
3123
  executeProviderOutcome(
@@ -3138,7 +3158,7 @@ async function executeOutcomeRun(run, send, agent, options = {}) {
3138
3158
  updatedAt: Date.now(),
3139
3159
  });
3140
3160
  }
3141
- if (typeof harnessTools.assessCompletion === 'function') {
3161
+ if (typeof harnessTools.assessCompletion === 'function' && assessedCompletion !== rawCompletion) {
3142
3162
  await harnessTools.assessCompletion(rawCompletion);
3143
3163
  }
3144
3164
  const completion =
package/src/api.js CHANGED
@@ -279,7 +279,7 @@ export function webflowMcpEndpoint(apiBaseUrl, { deviceToken, runId, allowInsecu
279
279
  };
280
280
  }
281
281
 
282
- export function callWebflowPanelTool(apiBaseUrl, {
282
+ export function callPanelTool(apiBaseUrl, {
283
283
  deviceToken,
284
284
  runId,
285
285
  callId,
package/src/config.js CHANGED
@@ -22,7 +22,7 @@ export const BRIDGE_BUILD_FINGERPRINT = crypto
22
22
  './harnessMcpServer.js',
23
23
  './framerTools.js',
24
24
  './framerMcpClient.js',
25
- './framerBehaviorContract.js',
25
+ './framerRequirementContract.js',
26
26
  './webflowTools.js',
27
27
  './taskEvidence.js',
28
28
  './outcomeWorkspace.js',
@@ -47,7 +47,7 @@ export const BRIDGE_CAPABILITIES = [
47
47
  'framer-mcp-v1',
48
48
  'framer-project-bootstrap-v1',
49
49
  'framer-project-verification-v1',
50
- 'framer-interaction-verification-v4',
50
+ 'framer-requirement-verification-v1',
51
51
  'framer-read-website-v1',
52
52
  'webflow-site-v2',
53
53
  'project-shell-v1',
@@ -0,0 +1,332 @@
1
+ // The model turns the user's request into requirements it can check: a value
2
+ // a layer must carry, a link, a size, a distance, a layer that must stay
3
+ // inside its parent. This module checks them against live Framer data, so a
4
+ // requirement counts as met because Framer shows it, not because the model
5
+ // says so. Dexter does not model Framer's features: the model names the layer
6
+ // and the property, and this module reads what is there.
7
+
8
+ const MAX_SEARCH_DEPTH = 6;
9
+ const MAX_OBSERVED_CHARS = 1000;
10
+ const DEFAULT_TOLERANCE_PX = 2;
11
+ export const MEASURES = ['width', 'height', 'gap', 'insideParent'];
12
+
13
+ function isRecord(value) {
14
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
15
+ }
16
+
17
+ function text(value, maximum = 500) {
18
+ return typeof value === 'string'
19
+ ? value.trim().slice(0, maximum)
20
+ : '';
21
+ }
22
+
23
+ function uniqueStrings(values, maximum = 50) {
24
+ return Array.from(new Set(
25
+ (Array.isArray(values) ? values : [])
26
+ .map((value) => text(value, 500))
27
+ .filter(Boolean),
28
+ )).slice(0, maximum);
29
+ }
30
+
31
+ function contractError(code, message) {
32
+ return Object.assign(new Error(message), { code, statusCode: 400 });
33
+ }
34
+
35
+ function normalizeRequirement(raw, index) {
36
+ const id = text(raw?.id, 120);
37
+ const description = text(raw?.text, 500);
38
+ if (!isRecord(raw) || !id || description.length < 3) {
39
+ throw contractError(
40
+ 'FRAMER_REQUIREMENT_INVALID',
41
+ `Requirement ${index + 1} needs an id and the text of what must be true.`,
42
+ );
43
+ }
44
+ return { id, text: description, nodeIds: uniqueStrings(raw.nodeIds, 50) };
45
+ }
46
+
47
+ export function normalizeRequirements(rawRequirements) {
48
+ const requirements = (Array.isArray(rawRequirements) ? rawRequirements : [])
49
+ .slice(0, 40)
50
+ .map(normalizeRequirement);
51
+ const ids = new Set();
52
+ for (const requirement of requirements) {
53
+ if (ids.has(requirement.id)) {
54
+ throw contractError(
55
+ 'FRAMER_REQUIREMENT_INVALID',
56
+ `Requirement ids must be unique; "${requirement.id}" is duplicated.`,
57
+ );
58
+ }
59
+ ids.add(requirement.id);
60
+ }
61
+ return requirements;
62
+ }
63
+
64
+ export function mergeRequirements(current, next) {
65
+ const byId = new Map(current.map((item) => [item.id, item]));
66
+ next.forEach((item) => byId.set(item.id, item));
67
+ return [...byId.values()];
68
+ }
69
+
70
+ function finiteNumber(value) {
71
+ const number = Number(value);
72
+ return Number.isFinite(number) ? number : null;
73
+ }
74
+
75
+ export function normalizeChecks(rawChecks, requirements) {
76
+ const requirementIds = new Set(requirements.map((item) => item.id));
77
+ const checks = (Array.isArray(rawChecks) ? rawChecks : []).slice(0, 80);
78
+ if (!checks.length) {
79
+ throw contractError(
80
+ 'FRAMER_CHECKS_REQUIRED',
81
+ 'Send at least one check: a requirement id, a layer id, and either a property or a measure.',
82
+ );
83
+ }
84
+ return checks.map((raw, index) => {
85
+ const requirementId = text(raw?.requirementId, 120);
86
+ const nodeId = text(raw?.nodeId, 200);
87
+ const property = text(raw?.property, 120);
88
+ const measure = text(raw?.measure, 40);
89
+ const invalid = (reason) => contractError(
90
+ 'FRAMER_CHECK_INVALID',
91
+ `Check ${index + 1} ${reason}`,
92
+ );
93
+ if (!isRecord(raw) || !requirementIds.has(requirementId) || !nodeId) {
94
+ throw invalid(`must name a requirement from your plan (${[...requirementIds].join(', ') || 'none planned'}) and a layer id.`);
95
+ }
96
+ if (Boolean(property) === Boolean(measure)) {
97
+ throw invalid('needs either a property or a measure, not both.');
98
+ }
99
+ if (property) {
100
+ return raw.absent === true
101
+ ? { requirementId, nodeId, property, absent: true }
102
+ : { requirementId, nodeId, property, contains: text(raw.contains, 500) };
103
+ }
104
+ if (!MEASURES.includes(measure)) {
105
+ throw invalid(`has an unknown measure; use one of ${MEASURES.join(', ')}.`);
106
+ }
107
+ const otherNodeId = text(raw.otherNodeId, 200);
108
+ if (measure === 'gap' && !otherNodeId) throw invalid('measures a gap, so it needs otherNodeId.');
109
+ const expected = finiteNumber(raw.expected);
110
+ if (measure !== 'insideParent' && expected === null) {
111
+ throw invalid(`measures ${measure}, so it needs the expected number of pixels.`);
112
+ }
113
+ return {
114
+ requirementId,
115
+ nodeId,
116
+ measure,
117
+ ...(otherNodeId ? { otherNodeId } : {}),
118
+ ...(expected !== null ? { expected } : {}),
119
+ tolerance: Math.max(0, finiteNumber(raw.tolerance) ?? DEFAULT_TOLERANCE_PX),
120
+ };
121
+ });
122
+ }
123
+
124
+ export function checkNodeIds(checks) {
125
+ return uniqueStrings(checks.filter((check) => check.property).map((check) => check.nodeId), 200);
126
+ }
127
+
128
+ export function measureChecks(checks) {
129
+ return checks.filter((check) => check.measure);
130
+ }
131
+
132
+ function hasContent(value) {
133
+ if (value === undefined || value === null || value === '') return false;
134
+ if (Array.isArray(value)) return value.length > 0;
135
+ if (isRecord(value)) return Object.keys(value).length > 0;
136
+ return true;
137
+ }
138
+
139
+ // Finds the property on the node itself. Child nodes are skipped so a
140
+ // descendant cannot satisfy a claim about its parent. A dotted name such as
141
+ // `link.href` or `hoverEffect.opacity` reads the field inside the property's value.
142
+ function propertyValues(node, property) {
143
+ const values = keyValues(node, property);
144
+ const [head, ...path] = property.split('.');
145
+ if (values.length > 0 || path.length === 0) return values;
146
+ return keyValues(node, head)
147
+ .map((value) => path.reduce(
148
+ (current, key) => (isRecord(current) ? current[key] : undefined),
149
+ value,
150
+ ))
151
+ .filter(hasContent);
152
+ }
153
+
154
+ function keyValues(node, property) {
155
+ const values = [];
156
+ const visit = (current, depth) => {
157
+ if (depth > MAX_SEARCH_DEPTH || values.length >= 10) return;
158
+ if (Array.isArray(current)) {
159
+ current.forEach((item) => visit(item, depth + 1));
160
+ return;
161
+ }
162
+ if (!isRecord(current)) return;
163
+ for (const [key, nested] of Object.entries(current)) {
164
+ if (key === 'children') continue;
165
+ if (key === property) {
166
+ if (hasContent(nested)) values.push(nested);
167
+ } else {
168
+ visit(nested, depth + 1);
169
+ }
170
+ }
171
+ };
172
+ visit(node, 0);
173
+ return values;
174
+ }
175
+
176
+ function serialized(value) {
177
+ try {
178
+ return JSON.stringify(value);
179
+ } catch {
180
+ return '';
181
+ }
182
+ }
183
+
184
+ function propertyVerdict(check, nodes) {
185
+ const node = nodes instanceof Map ? nodes.get(check.nodeId) : nodes?.[check.nodeId];
186
+ if (!node) return { ...check, verdict: 'unknown', reason: `layer ${check.nodeId} was not found` };
187
+ const values = propertyValues(node, check.property);
188
+ if (check.absent) {
189
+ return values.length === 0
190
+ ? { ...check, verdict: 'verified', observedValue: 'not set' }
191
+ : {
192
+ ...check,
193
+ verdict: 'contradicted',
194
+ reason: `${check.property} on ${check.nodeId} is set to ${serialized(values.length === 1 ? values[0] : values).slice(0, 200)}`,
195
+ observedValue: serialized(values.length === 1 ? values[0] : values).slice(0, MAX_OBSERVED_CHARS),
196
+ };
197
+ }
198
+ if (values.length === 0) {
199
+ return { ...check, verdict: 'unknown', reason: `${check.property} is not set on ${check.nodeId}` };
200
+ }
201
+ const observed = serialized(values.length === 1 ? values[0] : values);
202
+ if (check.contains && !observed.toLowerCase().includes(check.contains.toLowerCase())) {
203
+ return {
204
+ ...check,
205
+ verdict: 'contradicted',
206
+ reason: `${check.property} on ${check.nodeId} is ${observed.slice(0, 200)}, not ${check.contains}`,
207
+ observedValue: observed.slice(0, MAX_OBSERVED_CHARS),
208
+ };
209
+ }
210
+ return { ...check, verdict: 'verified', observedValue: observed.slice(0, MAX_OBSERVED_CHARS) };
211
+ }
212
+
213
+ // `measured` holds what the measuring program found for each measure check,
214
+ // in the same order: { value } in pixels, { inside, overflow } for
215
+ // insideParent, { unmeasured, note } when Framer could not lay the layer out
216
+ // for Dexter, or { error }.
217
+ function measureVerdict(check, measured) {
218
+ if (measured?.unmeasured) {
219
+ return { ...check, verdict: 'unmeasured', reason: measured.note || `${check.nodeId} could not be measured on this page` };
220
+ }
221
+ if (!measured || measured.error) {
222
+ return { ...check, verdict: 'unknown', reason: measured?.error || `${check.nodeId} could not be measured` };
223
+ }
224
+ if (check.measure === 'insideParent') {
225
+ return measured.inside
226
+ ? { ...check, verdict: 'verified', observedValue: 'inside its parent' }
227
+ : {
228
+ ...check,
229
+ verdict: 'contradicted',
230
+ reason: `${check.nodeId} sticks out of its parent by ${measured.overflow}px`,
231
+ observedValue: `overflows by ${measured.overflow}px`,
232
+ };
233
+ }
234
+ const value = Number(measured.value);
235
+ const label = check.measure === 'gap' ? `gap between ${check.nodeId} and ${check.otherNodeId}` : `${check.measure} of ${check.nodeId}`;
236
+ if (Math.abs(value - check.expected) <= check.tolerance) {
237
+ return { ...check, verdict: 'verified', observedValue: `${value}px` };
238
+ }
239
+ return {
240
+ ...check,
241
+ verdict: 'contradicted',
242
+ reason: `${label} measures ${value}px, not ${check.expected}px`,
243
+ observedValue: `${value}px`,
244
+ };
245
+ }
246
+
247
+ export function verifyChecks({ checks, nodes, measurements = [] }) {
248
+ let measureIndex = 0;
249
+ return checks.map((check) => (check.property
250
+ ? propertyVerdict(check, nodes)
251
+ : measureVerdict(check, measurements[measureIndex++])));
252
+ }
253
+
254
+ // Summarizes the latest check results for every requirement. A requirement is
255
+ // met once every layer it names has a verified check (or, naming none, once
256
+ // one check verified it) and no check contradicts it. A measure Dexter could
257
+ // not take is neither met nor failed: the requirement is unmeasured, and the
258
+ // model judges it another way, such as from a screenshot.
259
+ // status: missing (a requirement or one of its layers was never checked),
260
+ // contradicted (Framer shows something else), unverified (a property or
261
+ // layer could not be found), or verified.
262
+ export function summarizeRequirements(requirements, checkResults) {
263
+ const items = requirements.map((requirement) => {
264
+ const results = checkResults.filter((result) => result.requirementId === requirement.id);
265
+ const verifiedNodeIds = new Set(
266
+ results.filter((result) => result.verdict === 'verified').map((result) => result.nodeId),
267
+ );
268
+ const contradictions = results
269
+ .filter((result) => result.verdict === 'contradicted')
270
+ .map((result) => result.reason);
271
+ const checkedNodeIds = new Set(results.map((result) => result.nodeId));
272
+ const uncheckedNodeIds = requirement.nodeIds.filter((nodeId) => !checkedNodeIds.has(nodeId));
273
+ const covered = requirement.nodeIds.length > 0
274
+ ? requirement.nodeIds.every((nodeId) => verifiedNodeIds.has(nodeId))
275
+ : verifiedNodeIds.size > 0;
276
+ const unmeasuredOnly = results.length > 0
277
+ && results.every((result) => result.verdict === 'verified' || result.verdict === 'unmeasured');
278
+ return {
279
+ requirementId: requirement.id,
280
+ text: requirement.text,
281
+ checked: results.length > 0 && uncheckedNodeIds.length === 0,
282
+ verdict: contradictions.length > 0
283
+ ? 'contradicted'
284
+ : covered ? 'verified' : unmeasuredOnly ? 'unmeasured' : 'unknown',
285
+ uncheckedNodeIds,
286
+ contradictions: uniqueStrings(contradictions, 20),
287
+ unknowns: uniqueStrings(
288
+ results.filter((result) => result.verdict === 'unknown').map((result) => result.reason),
289
+ 20,
290
+ ),
291
+ unmeasured: uniqueStrings(
292
+ results.filter((result) => result.verdict === 'unmeasured').map((result) => result.reason),
293
+ 20,
294
+ ),
295
+ };
296
+ });
297
+ const status = items.some((item) => !item.checked)
298
+ ? 'missing'
299
+ : items.some((item) => item.verdict === 'contradicted')
300
+ ? 'contradicted'
301
+ : items.some((item) => item.verdict === 'unknown')
302
+ ? 'unverified'
303
+ : 'verified';
304
+ return { status, requirements: items };
305
+ }
306
+
307
+ // Compares two snapshots of layers that had to stay as they were. A snapshot
308
+ // maps a layer id to { name, rect, props } where props is the serialized
309
+ // layer without children and rect is null when Dexter could not measure the
310
+ // layer, so only its settings are compared. Boxes are compared only when both
311
+ // readings came from the same source (the user's editor or Framer MCP).
312
+ export function unchangedViolations(before, after, tolerance = 1) {
313
+ const changes = [];
314
+ for (const [id, earlier] of Object.entries(before || {})) {
315
+ const later = after?.[id];
316
+ const name = earlier.name || id;
317
+ if (!later) {
318
+ changes.push(`${name} was removed`);
319
+ continue;
320
+ }
321
+ const a = earlier.rect;
322
+ const b = later.rect;
323
+ const comparable = a && b && (a.source || null) === (b.source || null);
324
+ if (comparable && ['x', 'y', 'width', 'height'].some((key) => Math.abs(a[key] - b[key]) > tolerance)) {
325
+ changes.push(`${name} moved or resized (${a.width}×${a.height} at ${a.x},${a.y} → ${b.width}×${b.height} at ${b.x},${b.y})`);
326
+ } else if (earlier.props !== later.props) {
327
+ changes.push(`${name} has different settings`);
328
+ }
329
+ if (changes.length >= 20) break;
330
+ }
331
+ return changes;
332
+ }