@enricai/barnacle 1.6.14 → 1.7.1

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.
Files changed (67) hide show
  1. package/README.md +3 -5
  2. package/dist/api/schemas/submissions.d.ts +8 -8
  3. package/dist/config.d.ts +23 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +2 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/lib/bedrock.d.ts +2 -2
  8. package/dist/lib/bedrock.d.ts.map +1 -1
  9. package/dist/lib/bedrock.js.map +1 -1
  10. package/dist/lib/llm/anthropic-client.d.ts +12 -0
  11. package/dist/lib/llm/anthropic-client.d.ts.map +1 -1
  12. package/dist/lib/llm/anthropic-client.js +25 -0
  13. package/dist/lib/llm/anthropic-client.js.map +1 -1
  14. package/dist/lib/telemetry/beacon-capture.d.ts +4 -3
  15. package/dist/lib/telemetry/beacon-capture.d.ts.map +1 -1
  16. package/dist/lib/telemetry/beacon-capture.js.map +1 -1
  17. package/dist/lib/telemetry/reconciliation-record.d.ts +15 -33
  18. package/dist/lib/telemetry/reconciliation-record.d.ts.map +1 -1
  19. package/dist/lib/telemetry/reconciliation-record.js +11 -49
  20. package/dist/lib/telemetry/reconciliation-record.js.map +1 -1
  21. package/dist/lib/telemetry/submission-capture.d.ts +8 -13
  22. package/dist/lib/telemetry/submission-capture.d.ts.map +1 -1
  23. package/dist/lib/telemetry/submission-capture.js +2 -4
  24. package/dist/lib/telemetry/submission-capture.js.map +1 -1
  25. package/dist/lib/telemetry/submission-reader.d.ts +3 -4
  26. package/dist/lib/telemetry/submission-reader.d.ts.map +1 -1
  27. package/dist/lib/telemetry/submission-reader.js +3 -4
  28. package/dist/lib/telemetry/submission-reader.js.map +1 -1
  29. package/dist/plugins/config-plugin.d.ts.map +1 -1
  30. package/dist/plugins/config-plugin.js +22 -0
  31. package/dist/plugins/config-plugin.js.map +1 -1
  32. package/dist/plugins/loader.d.ts +0 -7
  33. package/dist/plugins/loader.d.ts.map +1 -1
  34. package/dist/plugins/loader.js +0 -9
  35. package/dist/plugins/loader.js.map +1 -1
  36. package/dist/scraper/deep-locator-actuate.d.ts +24 -19
  37. package/dist/scraper/deep-locator-actuate.d.ts.map +1 -1
  38. package/dist/scraper/deep-locator-actuate.js +68 -23
  39. package/dist/scraper/deep-locator-actuate.js.map +1 -1
  40. package/dist/scraper/deep-locator-scan.d.ts +20 -0
  41. package/dist/scraper/deep-locator-scan.d.ts.map +1 -1
  42. package/dist/scraper/deep-locator-scan.js +25 -0
  43. package/dist/scraper/deep-locator-scan.js.map +1 -1
  44. package/dist/scraper/flow-runner.d.ts +50 -10
  45. package/dist/scraper/flow-runner.d.ts.map +1 -1
  46. package/dist/scraper/flow-runner.js +239 -97
  47. package/dist/scraper/flow-runner.js.map +1 -1
  48. package/dist/scripts/recon-browser.d.ts +5 -4
  49. package/dist/scripts/recon-browser.d.ts.map +1 -1
  50. package/dist/scripts/recon-browser.js +8 -5
  51. package/dist/scripts/recon-browser.js.map +1 -1
  52. package/dist/scripts/recon-generate.d.ts +2 -2
  53. package/dist/scripts/recon-generate.d.ts.map +1 -1
  54. package/dist/scripts/recon-generate.js +18 -85
  55. package/dist/scripts/recon-generate.js.map +1 -1
  56. package/dist/scripts/recon-shared.d.ts +0 -10
  57. package/dist/scripts/recon-shared.d.ts.map +1 -1
  58. package/dist/scripts/recon-shared.js +0 -11
  59. package/dist/scripts/recon-shared.js.map +1 -1
  60. package/dist/site-plugin.d.ts +3 -3
  61. package/dist/testing/coverage-guard-suite.d.mts +7 -7
  62. package/dist/testing/coverage-guard-suite.mjs +5 -5
  63. package/package.json +3 -1
  64. package/dist/scripts/migrate-telemetry-dir-names.d.ts +0 -57
  65. package/dist/scripts/migrate-telemetry-dir-names.d.ts.map +0 -1
  66. package/dist/scripts/migrate-telemetry-dir-names.js +0 -156
  67. package/dist/scripts/migrate-telemetry-dir-names.js.map +0 -1
@@ -77,7 +77,7 @@ const node_crypto_1 = require("node:crypto");
77
77
  const node_fs_1 = require("node:fs");
78
78
  const node_os_1 = require("node:os");
79
79
  const node_path_1 = require("node:path");
80
- const zod_1 = require("@anthropic-ai/sdk/helpers/zod");
80
+ const ai_1 = require("ai");
81
81
  const config_1 = require("../config");
82
82
  const errors_1 = require("../lib/errors");
83
83
  const error_messages_1 = require("../lib/llm/judges/error-messages");
@@ -480,7 +480,7 @@ exports.TRAILING_GRACE_WINDOW = 2;
480
480
  * means no injection surface. Runs in browser context and returns a typed-narrow
481
481
  * shape via Runtime.callFunctionOn.
482
482
  */
483
- const DOM_SNAPSHOT_EXPR = `(() => { const b = document.body; if (!b) return { html: 0, text: "" }; const t = b.innerText || ""; return { html: (b.outerHTML || "").length, text: t.length + ":" + t.slice(0, 200) }; })()`;
483
+ const DOM_SNAPSHOT_EXPR = `(() => { const b = document.body; if (!b) return { html: 0, text: "", values: "" }; const t = b.innerText || ""; const controls = Array.from(b.querySelectorAll("input, textarea, select")).filter((el) => el.offsetParent !== null); const values = controls.map((el) => { if (el.type === "checkbox" || el.type === "radio") return el.checked ? "1" : "0"; return el.value || ""; }).join("|").slice(0, 2000); return { html: (b.outerHTML || "").length, text: t.length + ":" + t.slice(0, 200), values }; })()`;
484
484
  /**
485
485
  * Captures the pre/post signal triple the submit-verify cascade diffs.
486
486
  * Accepts the optional `page` so a resolved child `FrameTarget` whose
@@ -491,6 +491,7 @@ const DOM_SNAPSHOT_EXPR = `(() => { const b = document.body; if (!b) return { ht
491
491
  async function snapshotPage(target, signalCounter, page) {
492
492
  let bodyHtmlLength = 0;
493
493
  let visibleTextSignature = "";
494
+ let formValueSignature = "";
494
495
  try {
495
496
  const result = await target.evaluate(DOM_SNAPSHOT_EXPR);
496
497
  if (result !== null &&
@@ -501,6 +502,8 @@ async function snapshotPage(target, signalCounter, page) {
501
502
  typeof result.text === "string") {
502
503
  bodyHtmlLength = result.html;
503
504
  visibleTextSignature = result.text;
505
+ const rawValues = result.values;
506
+ formValueSignature = typeof rawValues === "string" ? rawValues : "";
504
507
  }
505
508
  }
506
509
  catch {
@@ -519,6 +522,7 @@ async function snapshotPage(target, signalCounter, page) {
519
522
  url,
520
523
  bodyHtmlLength,
521
524
  visibleTextSignature,
525
+ formValueSignature,
522
526
  };
523
527
  }
524
528
  /**
@@ -906,11 +910,15 @@ function isDomOnlyAdvanceVerified(params) {
906
910
  * to the network/URL signal — which correctly verifies navigation but
907
911
  * false-negatives client-side view swaps. This gate fills that gap.
908
912
  *
909
- * **Threshold rationale:** 5000B is 10× the trivial boundary (500B) and
910
- * safely distinguishes real view swaps (measured case: +49518B) from
913
+ * **Threshold rationale:** default 5000B is 10× the trivial boundary (500B)
914
+ * and safely distinguishes real view swaps (measured case: +49518B) from
911
915
  * validation-error reflows (typically < 2KB per codebase evidence at
912
916
  * line 2040-2044: error text capped at 200 chars, container markup adds
913
- * 150-350B per error, worst-case ~10 errors = ~3500B max).
917
+ * 150-350B per error, worst-case ~10 errors = ~3500B max). Some sites render
918
+ * much smaller popups/comboboxes whose open-click grows the DOM by only a
919
+ * few hundred bytes; `config.scraper.viewSwapMinBytesThreshold`
920
+ * (`VIEW_SWAP_MIN_BYTES` env var) lets a deployment lower the threshold
921
+ * without regressing the shared default for other sites.
914
922
  *
915
923
  * **Scope guards:** Excludes final/submit steps (those require real network
916
924
  * per isSubmitRevealedInvalid + LLM submit judge) and advance-pattern steps
@@ -918,7 +926,7 @@ function isDomOnlyAdvanceVerified(params) {
918
926
  * to avoid wizard-ATS autosave-vs-transition ambiguity).
919
927
  */
920
928
  function isClickViewSwapVerified(params) {
921
- const VIEW_SWAP_MIN_BYTES = 5000;
929
+ const VIEW_SWAP_MIN_BYTES = config_1.config.scraper.viewSwapMinBytesThreshold;
922
930
  const { resolvedAction, isFinalStep, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, } = params;
923
931
  if (resolvedAction?.method !== "click")
924
932
  return false;
@@ -972,6 +980,7 @@ function describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLengt
972
980
  const bytesDelta = post.bodyHtmlLength - pre.bodyHtmlLength;
973
981
  const networkDelta = post.networkCount - pre.networkCount;
974
982
  const textChanged = post.visibleTextSignature !== pre.visibleTextSignature;
983
+ const valueChanged = post.formValueSignature !== pre.formValueSignature;
975
984
  const windowCaptures = recentCaptureMeta.slice(preMetaLength);
976
985
  const observations = [];
977
986
  if (networkDelta === 0 && bytesDelta >= 500) {
@@ -986,6 +995,9 @@ function describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLengt
986
995
  if (textChanged && networkDelta === 0 && bytesDelta < 500) {
987
996
  observations.push("visible-text-changed-without-network: page reflowed slightly (likely tooltip/focus state)");
988
997
  }
998
+ if (valueChanged && !textChanged && networkDelta === 0) {
999
+ observations.push("form-value-changed-without-network: a visible input/textarea/select value changed with no other observable effect (plain field fill)");
1000
+ }
989
1001
  return observations.join(" | ");
990
1002
  }
991
1003
  /**
@@ -1036,7 +1048,7 @@ function shouldSkipTechnique(params) {
1036
1048
  // structured-click / observe-act-exclude re-resolves the SAME
1037
1049
  // light-DOM-only view of the page and would no-op identically, so skip
1038
1050
  // straight to deep-submit-locator (attempt 2) instead. llm-rephrase
1039
- // (attempt 5) is never skipped — a differently-worded instruction is still
1051
+ // (attempt 4) is never skipped — a differently-worded instruction is still
1040
1052
  // a distinct attempt worth trying if the deep locator also fails. Gated on
1041
1053
  // submitShapedStep: the deep submit-control locator ranks submit-shaped
1042
1054
  // candidates only, so on a non-submit control (e.g. a radio/checkbox) it
@@ -1143,8 +1155,8 @@ function isAdvanceStalled(params) {
1143
1155
  return networkFired;
1144
1156
  }
1145
1157
  /**
1146
- * Lazy Anthropic client for attempt 4's rephrase. Returns null when the
1147
- * deployment is Bedrock-only (no ANTHROPIC_API_KEY) — attempt 4 then becomes
1158
+ * Lazy Anthropic client for attempt 5's rephrase. Returns null when the
1159
+ * deployment is Bedrock-only (no ANTHROPIC_API_KEY) — attempt 5 then becomes
1148
1160
  * a no-op and the executor escalates straight to the failure dump. The other
1149
1161
  * three attempts already cover the lion's share of recovery.
1150
1162
  */
@@ -1165,7 +1177,7 @@ function anthropicModelName() {
1165
1177
  */
1166
1178
  const REPHRASE_SYSTEM_PROMPT = "You rewrite a failed Stagehand act() instruction so the next attempt targets a different element than those already tried. If no different element exists on the page that could plausibly unblock the flow, return outcome=impossible. Never re-propose a selector or wording from the lists labeled SELECTORS ALREADY TRIED or INSTRUCTION TEXT ALREADY TRIED.";
1167
1179
  /**
1168
- * Attempt-4 of the step-healing cascade: when three mechanical retry variations
1180
+ * Attempt-5 of the step-healing cascade: when three mechanical retry variations
1169
1181
  * all fail, this is the last resort before the step is declared terminal. Exported
1170
1182
  * so tests can inject a fake capture sink without touching the browser session.
1171
1183
  */
@@ -1219,13 +1231,21 @@ priorAttempts,
1219
1231
  * "click harder" into "fill the actually-missing field." Telemetry
1220
1232
  * the engine already captures but never showed the LLM until now.
1221
1233
  */
1222
- gaEventEvidence) {
1234
+ gaEventEvidence,
1235
+ /**
1236
+ * Anthropic client for the modal-priority judge inside
1237
+ * `renderUnfocusedObserve`. Separate from `client` (the ai-SDK model used
1238
+ * for the rephrase call itself) because that judge pipeline is unrelated
1239
+ * to which provider generates the rephrase, and null on a Bedrock-only
1240
+ * deployment falls back to `renderUnfocusedObserve`'s source-order default.
1241
+ */
1242
+ judgeClient) {
1223
1243
  const candidateList = observeCandidates
1224
1244
  .slice(0, 12)
1225
1245
  .map((a, i) => `${i + 1}. ${a.description} — ${a.selector}`)
1226
1246
  .join("\n");
1227
1247
  const unfocusedList = unfocusedObserve
1228
- ? await renderUnfocusedObserve(unfocusedObserve, { client, captureFn })
1248
+ ? await renderUnfocusedObserve(unfocusedObserve, { client: judgeClient, captureFn })
1229
1249
  : "";
1230
1250
  const triedList = triedSelectors.length > 0 ? triedSelectors.join("\n") : "(none)";
1231
1251
  const reasonList = failureReasons.map((r, i) => `attempt ${i + 1}: ${r}`).join("\n");
@@ -1297,35 +1317,28 @@ PAGE TRANSITION + VALIDATOR TELEMETRY (parsed from Google Analytics Measurement
1297
1317
  ${gaEventEvidence || "(none)"}
1298
1318
 
1299
1319
  Rewrite the instruction so a Stagehand act() call can resolve it unambiguously to a different element than the ones already tried. Keep it short — one sentence, natural language, no quotes around it. If the invalid-fields section above names a field AND the interactive-targets section below lists clickable options inside that same container, your rewrite picks one of those options (e.g. for a yes/no question rendered as two radio labels, choose the candidate-favorable answer — 'No' for "do you have a non-compete", 'Yes' for "are you authorized to work", 'Prefer not to say' for demographic disclosures) rather than retrying the original click. If the unfocused observe section shows an open modal/dialog with a Save or Close action, your rewrite invokes that action so the underlying form can clear its blocking state. Set outcome to "impossible" with a one-line reason only when the original instruction's element does not exist on the current page and no redirect target is available.`;
1300
- const model = anthropicModelName();
1320
+ const model = client.modelId;
1301
1321
  const t0 = performance.now();
1302
1322
  try {
1303
- const response = await client.messages.parse({
1304
- model,
1305
- max_tokens: 400,
1323
+ const response = await (0, ai_1.generateObject)({
1324
+ model: client,
1325
+ maxOutputTokens: 400,
1306
1326
  system: REPHRASE_SYSTEM_PROMPT,
1307
- messages: [{ role: "user", content: prompt }],
1308
- output_config: {
1309
- format: (0, zod_1.zodOutputFormat)(schemas_1.REPHRASE_RESPONSE_SCHEMA),
1310
- },
1327
+ prompt,
1328
+ schema: schemas_1.REPHRASE_RESPONSE_SCHEMA,
1311
1329
  });
1312
1330
  const latencyMs = performance.now() - t0;
1313
- const parsed = response.parsed_output;
1314
- if (parsed === null) {
1315
- throw new Error("structured-output enabled but parsed_output is null");
1316
- }
1317
- const textBlock = response.content.find((b) => b.type === "text");
1318
- const rawText = textBlock?.type === "text" ? textBlock.text : "";
1331
+ const parsed = response.object;
1319
1332
  await captureFn({
1320
1333
  callId: (0, node_crypto_1.randomUUID)(),
1321
1334
  callType: call_types_1.CALL_TYPE_RECON_REPHRASE,
1322
1335
  model,
1323
1336
  systemPrompt: REPHRASE_SYSTEM_PROMPT,
1324
1337
  userContent: prompt,
1325
- responseContent: rawText,
1338
+ responseContent: JSON.stringify(parsed),
1326
1339
  parsedOk: true,
1327
- inputTokens: response.usage?.input_tokens ?? null,
1328
- outputTokens: response.usage?.output_tokens ?? null,
1340
+ inputTokens: response.usage.inputTokens ?? null,
1341
+ outputTokens: response.usage.outputTokens ?? null,
1329
1342
  latencyMs,
1330
1343
  success: true,
1331
1344
  errorMessage: null,
@@ -2273,9 +2286,13 @@ const UPLOAD_NETWORK_POLL_INTERVAL_MS = 250;
2273
2286
  * wizard lands on the Apply page, so a single probe races that mount and the
2274
2287
  * primitive wrongly falls into the click-to-surface path (or skips). Only
2275
2288
  * reached on `upload:true` steps, so it never slows a page with no upload step.
2289
+ * The poll exits on first success, so raising the attempt count via
2290
+ * `config.scraper.uploadWidgetRenderAttempts` (`UPLOAD_WIDGET_RENDER_ATTEMPTS`
2291
+ * env var) costs nothing extra for sites that mount promptly — only sites
2292
+ * whose upload field mounts conditionally (e.g. only after a prior step
2293
+ * commits) and needs a longer window should override the default.
2276
2294
  */
2277
2295
  const UPLOAD_WIDGET_RENDER_INTERVAL_MS = 600;
2278
- const UPLOAD_WIDGET_RENDER_ATTEMPTS = 17;
2279
2296
  /**
2280
2297
  * URL substrings that mark a POST as an actual resume/attachment upload rather
2281
2298
  * than coincidental traffic (analytics beacons, `/interruption_check`,
@@ -2385,7 +2402,10 @@ async function tryUploadPrimitive(params) {
2385
2402
  // already rendered it pay nothing.
2386
2403
  let inputCount = 0;
2387
2404
  try {
2388
- inputCount = await pollEnumerate(page, target, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0, { attempts: UPLOAD_WIDGET_RENDER_ATTEMPTS, intervalMs: UPLOAD_WIDGET_RENDER_INTERVAL_MS });
2405
+ inputCount = await pollEnumerate(page, target, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0, {
2406
+ attempts: config_1.config.scraper.uploadWidgetRenderAttempts,
2407
+ intervalMs: UPLOAD_WIDGET_RENDER_INTERVAL_MS,
2408
+ });
2389
2409
  }
2390
2410
  catch (err) {
2391
2411
  logger.warn(`upload primitive: file-input probe threw: ${(0, errors_1.toErrorMessage)(err)}`);
@@ -2534,7 +2554,7 @@ async function surfaceAndUpload(params) {
2534
2554
  return { present: false };
2535
2555
  })()`;
2536
2556
  const gate = await pollEnumerate(page, target, targetExpr, (r) => r?.present === true, {
2537
- attempts: UPLOAD_WIDGET_RENDER_ATTEMPTS,
2557
+ attempts: config_1.config.scraper.uploadWidgetRenderAttempts,
2538
2558
  intervalMs: UPLOAD_WIDGET_RENDER_INTERVAL_MS,
2539
2559
  });
2540
2560
  if (!gate?.present) {
@@ -2843,6 +2863,24 @@ function resolveDeepLocatorActuation(step) {
2843
2863
  function normalizeFieldLabel(text) {
2844
2864
  return text.replace(/\s+/g, " ").trim().toLowerCase();
2845
2865
  }
2866
+ /**
2867
+ * Parses a fill/select step into the field it names plus the value to write,
2868
+ * or `null` for any other step shape (click, a select whose question is
2869
+ * un-quoted, etc). Shared by both the observe-empty deepLocator fallback and
2870
+ * the field-label-first routing ahead of Stagehand's own act()/observe()
2871
+ * resolution — see `findDeepLocatorCandidateByFieldLabel`'s docblock for why
2872
+ * the field LABEL (not the value) is what a caller matches candidates
2873
+ * against.
2874
+ */
2875
+ function parseFieldLabelTarget(step) {
2876
+ const fillStep = parseFillStep(step);
2877
+ if (fillStep)
2878
+ return { kind: "fill", fieldLabel: fillStep.fieldLabel, value: fillStep.value };
2879
+ const selectStep = parseSelectStep(step);
2880
+ return selectStep?.questionLabel
2881
+ ? { kind: "select", fieldLabel: selectStep.questionLabel, value: selectStep.option }
2882
+ : null;
2883
+ }
2846
2884
  /**
2847
2885
  * Finds the candidate whose accessible name identifies the named field —
2848
2886
  * `fieldLabel` (from {@link parseFillStep}/{@link parseSelectStep}), NOT the
@@ -3675,15 +3713,15 @@ async function tryRadioPrimitive(params) {
3675
3713
  const { option, questionLabel } = parsed;
3676
3714
  const optLabel = `option "${option.slice(0, 40)}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
3677
3715
  // Phase 1 (browser): find radio GROUPS and their options. A group is a
3678
- // `<fieldset>` / `[role=radiogroup]` / `[class*='RadioGroup']` container with
3679
- // radios. Question label = the group's legend / associated label; each option
3716
+ // `<fieldset>` / `[role=radiogroup]` / `[role=group]` / `[class*='RadioGroup']`
3717
+ // container with radios. Question label = the group's legend / associated label; each option
3680
3718
  // = the `<label for=radio-id>` text. Deterministic unique option-text match
3681
3719
  // commits immediately (no LLM). Else return groups for the LLM picker.
3682
3720
  // `commit` uses the React-safe native `checked` setter so MUI/React registers
3683
3721
  // the change, then reports `ok` = post-commit `radio.checked && !invalid`.
3684
3722
  const enumerateExpr = `((option) => {
3685
3723
  const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
3686
- let groupEls = Array.from(document.querySelectorAll("fieldset,[role='radiogroup'],[class*='RadioGroup']"))
3724
+ const groupEls = Array.from(document.querySelectorAll("fieldset,[role='radiogroup'],[role='group'],[class*='RadioGroup']"))
3687
3725
  .filter((g) => g.querySelector("input[type=radio]"));
3688
3726
  if (groupEls.length === 0) return { groupPresent: false };
3689
3727
  const groupLabel = (g) => {
@@ -3881,7 +3919,7 @@ async function applyRadioSelection(target, gi, ri, hint) {
3881
3919
  // Tier C — legacy synthetic-events backstop (native setter + bubbling events).
3882
3920
  const applyExpr = `((gi, ri) => {
3883
3921
  const isInvalid = ${INVALID_MARKER_EL_EXPR};
3884
- const groupEls = Array.from(document.querySelectorAll("fieldset,[role='radiogroup'],[class*='RadioGroup']"))
3922
+ const groupEls = Array.from(document.querySelectorAll("fieldset,[role='radiogroup'],[role='group'],[class*='RadioGroup']"))
3885
3923
  .filter((g) => g.querySelector("input[type=radio]"));
3886
3924
  const grp = groupEls[gi];
3887
3925
  if (!grp) return { ok: false };
@@ -4174,10 +4212,15 @@ async function verifyDomEffect(target, action) {
4174
4212
  // Same trust-boundary rationale as the click case below: xpath is from
4175
4213
  // Stagehand's resolved selector, JSON.stringify produces a safe JS string
4176
4214
  // literal, and the expression cannot inject behavior through xpath content.
4177
- const expr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); const el = r.singleNodeValue; if (!el || el.tagName !== "SELECT") return null; const opt = el.options[el.selectedIndex]; if (!opt) return { value: "", label: "", text: "" }; return { value: (opt.value || "").trim(), label: (opt.label || "").trim(), text: (opt.textContent || "").trim() }; })()`;
4215
+ // Custom (non-native-<select>) comboboxes MUI/React-Select/Radix/Headless
4216
+ // UI-style widgets — always fail the native-<select>-only check below,
4217
+ // misreporting genuinely-successful selections as unverified. Fall back to
4218
+ // reading the combobox's own displayed/aria value when the resolved element
4219
+ // isn't a real <select>, mirroring what a real ARIA combobox exposes.
4220
+ const exprNative = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); const el = r.singleNodeValue; if (!el || el.tagName !== "SELECT") return null; const opt = el.options[el.selectedIndex]; if (!opt) return { value: "", label: "", text: "" }; return { value: (opt.value || "").trim(), label: (opt.label || "").trim(), text: (opt.textContent || "").trim() }; })()`;
4178
4221
  let selected = null;
4179
4222
  try {
4180
- const result = await target.evaluate(expr);
4223
+ const result = await target.evaluate(exprNative);
4181
4224
  if (result !== null &&
4182
4225
  typeof result === "object" &&
4183
4226
  "value" in result &&
@@ -4189,6 +4232,42 @@ async function verifyDomEffect(target, action) {
4189
4232
  catch {
4190
4233
  return false;
4191
4234
  }
4235
+ if (!selected) {
4236
+ // MUI-style comboboxes render role="combobox" on a wrapper element with a
4237
+ // nested (often hidden/readonly) `<input value="...">` carrying the real
4238
+ // selected value — neither the wrapper nor the resolved element necessarily
4239
+ // has a usable `.value` itself, so search the element, its combobox
4240
+ // ancestor, and — since some resolved xpaths land on a sibling label rather
4241
+ // than the combobox itself — the nearest combobox within the same
4242
+ // field-group container, for a nested input value, falling back to visible
4243
+ // text or an aria label/valuetext.
4244
+ const exprCustom = `(() => {
4245
+ const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
4246
+ const el = r.singleNodeValue;
4247
+ if (!el) return null;
4248
+ let combo = (el.closest && el.closest('[role="combobox"]')) || null;
4249
+ if (!combo) {
4250
+ const container = el.closest && (el.closest('[class*="Container"]') || el.parentElement);
4251
+ combo = container && container.querySelector && container.querySelector('[role="combobox"]');
4252
+ }
4253
+ combo = combo || el;
4254
+ const nestedInput = combo.querySelector && combo.querySelector('input');
4255
+ const value = (nestedInput && nestedInput.value) || el.value || "";
4256
+ const text = (combo.textContent || el.textContent || "").trim();
4257
+ const ariaText = (el.getAttribute && (el.getAttribute('aria-label') || el.getAttribute('aria-valuetext'))) || "";
4258
+ return { value: String(value).trim(), text: String(text || ariaText).trim() };
4259
+ })()`;
4260
+ try {
4261
+ const customResult = await target.evaluate(exprCustom);
4262
+ if (customResult !== null && typeof customResult === "object") {
4263
+ const custom = customResult;
4264
+ selected = { value: custom.value ?? "", label: "", text: custom.text ?? "" };
4265
+ }
4266
+ }
4267
+ catch {
4268
+ return false;
4269
+ }
4270
+ }
4192
4271
  if (!selected)
4193
4272
  return false;
4194
4273
  const want = expected.toLowerCase();
@@ -4564,6 +4643,36 @@ async function resolveDeepLocatorCandidatesWithWidening(page, frameSelector, ins
4564
4643
  const widened = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameSelector, "*", instruction, timeoutOptions);
4565
4644
  return { candidates: widened, innerSelector: "*" };
4566
4645
  }
4646
+ /**
4647
+ * Resolves and actuates a fill/select step's NAMED FIELD through the
4648
+ * deterministic accessible-name match (`findDeepLocatorCandidateByFieldLabel`)
4649
+ * against the frame's own deepLocator scan, independent of what Stagehand's
4650
+ * `act()`/`observe()` resolved. A same-frame accessible-name label match
4651
+ * cannot confuse a City input for an unrelated header search box the way
4652
+ * Stagehand's semantic resolution can — Stagehand's own candidate is only a
4653
+ * fallback for the caller to use when this returns `no-match` or
4654
+ * `not-fill-or-select`, never the first attempt for a step that names a
4655
+ * field. `triedSelectors` (already-attempted selectors from prior
4656
+ * cascade attempts) is excluded so a step that already tried and failed on
4657
+ * this candidate doesn't just re-pick it.
4658
+ */
4659
+ async function tryDeterministicFieldLabelActuation(params) {
4660
+ const { page, frameTarget, step, triedSelectors, timeoutOptions } = params;
4661
+ const fieldTarget = parseFieldLabelTarget(step);
4662
+ if (!fieldTarget)
4663
+ return { kind: "not-fill-or-select" };
4664
+ const { candidates, innerSelector } = await resolveDeepLocatorCandidatesWithWidening(page, frameTarget.frameSelector, step, timeoutOptions);
4665
+ const unexcluded = candidates.filter((c) => !triedSelectors.includes(c.selector));
4666
+ const matched = findDeepLocatorCandidateByFieldLabel(unexcluded, fieldTarget.fieldLabel);
4667
+ if (!matched)
4668
+ return { kind: "no-match", fieldTarget };
4669
+ const actuated = fieldTarget.kind === "fill"
4670
+ ? await (0, deep_locator_actuate_1.fillDeepLocatorCandidate)(page, frameTarget.frameSelector, innerSelector, matched.index, fieldTarget.value, { frameTarget })
4671
+ : await (0, deep_locator_actuate_1.selectDeepLocatorCandidateOption)(page, frameTarget.frameSelector, innerSelector, matched.index, fieldTarget.value, { frameTarget });
4672
+ return actuated
4673
+ ? { kind: "actuated", matched, fieldTarget }
4674
+ : { kind: "actuation-failed", matched, fieldTarget };
4675
+ }
4567
4676
  /**
4568
4677
  * Adapts `resolveDeepLocatorCandidatesWithWidening` results into
4569
4678
  * `Action`-shaped evidence for `rephraseWithLLM`, which only knows about
@@ -4674,7 +4783,7 @@ async function probeStepBeforeAttempts(params) {
4674
4783
  }
4675
4784
  }
4676
4785
  async function executeStepWithHealing(params) {
4677
- const { stagehand, page, step, optional, upload, submitStep, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, logger, captureFn, uploadFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, } = params;
4786
+ const { stagehand, page, step, optional, upload, submitStep, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, rephraseModel, logger, captureFn, uploadFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, } = params;
4678
4787
  // Mutable (not the destructured const above) so a lost frame-attach race
4679
4788
  // can be upgraded in place once the OOPIF attaches later in the cascade —
4680
4789
  // see reresolveFrameTargetIfLost below. Every existing reference in this
@@ -4988,6 +5097,7 @@ async function executeStepWithHealing(params) {
4988
5097
  url: page.url(),
4989
5098
  bodyHtmlLength: 0,
4990
5099
  visibleTextSignature: "",
5100
+ formValueSignature: "",
4991
5101
  };
4992
5102
  attempts.push({
4993
5103
  attempt: 0,
@@ -5089,6 +5199,57 @@ async function executeStepWithHealing(params) {
5089
5199
  // from DOM re-read. Captured here so both branches of the cascade write to it.
5090
5200
  let resolvedAction = null;
5091
5201
  try {
5202
+ // Field-label-first routing: for a frame-scoped fill/select step,
5203
+ // Stagehand's own act()/observe() can resolve a real, in-DOM, plausible-
5204
+ // looking control that is nonetheless the WRONG one (a City field
5205
+ // resolving to an unrelated header search box whose accessible name
5206
+ // overlaps semantically with "city/location" is the motivating case).
5207
+ // A same-frame accessible-name match against the field's own label
5208
+ // can't make that mistake, so it's tried BEFORE any Stagehand
5209
+ // act/observe call for every attempt, not only once observe() has
5210
+ // already come back empty (bugfix-001's finding: observe() returning a
5211
+ // wrong-but-nonempty candidate was exactly what let the phantom-fill
5212
+ // through the old empty-candidates-only gate). Falls through to the
5213
+ // existing Stagehand-driven attempt when no candidate names the field
5214
+ // at all — this only preempts Stagehand's resolution, it never expands
5215
+ // what counts as a match.
5216
+ const fieldLabelOutcome = attempt !== 3 && frameTarget?.frame
5217
+ ? await tryDeterministicFieldLabelActuation({
5218
+ page,
5219
+ frameTarget,
5220
+ step,
5221
+ triedSelectors,
5222
+ timeoutOptions: { frameTarget },
5223
+ })
5224
+ : { kind: "not-fill-or-select" };
5225
+ if (fieldLabelOutcome.kind === "actuated") {
5226
+ record.technique = "deep-locator-field-label";
5227
+ record.instruction = `deepLocator: ${fieldLabelOutcome.matched.accessibleText || "(no accessible text)"}`;
5228
+ triedSelectors.push(fieldLabelOutcome.matched.selector);
5229
+ record.triedSelectors = [fieldLabelOutcome.matched.selector];
5230
+ record.actResultSuccess = true;
5231
+ record.actResultDescription = `deepLocator ${fieldLabelOutcome.fieldTarget.kind === "fill" ? "filled" : "selected"} "${fieldLabelOutcome.matched.accessibleText || fieldLabelOutcome.matched.selector}" with "${fieldLabelOutcome.fieldTarget.value}"`;
5232
+ // Same rationale as the pre-existing candidates===0 branch below:
5233
+ // the write + read-back the actuation helper already performed IS
5234
+ // the verification signal — no resolvedAction/network/url verifier
5235
+ // can score a `deeplocator=` selector.
5236
+ record.verifiedBy = "dom";
5237
+ attempts.push(record);
5238
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.actResultDescription}`);
5239
+ trajectory?.push({ stepIndex, verifiedBy: "dom" });
5240
+ return "completed";
5241
+ }
5242
+ if (fieldLabelOutcome.kind === "actuation-failed") {
5243
+ triedSelectors.push(fieldLabelOutcome.matched.selector);
5244
+ record.triedSelectors = [fieldLabelOutcome.matched.selector];
5245
+ const failureMessage = `deepLocator: ${fieldLabelOutcome.fieldTarget.kind} on "${fieldLabelOutcome.matched.accessibleText || fieldLabelOutcome.matched.selector}" did not verify (read-back mismatch or rejected write)`;
5246
+ record.actResultSuccess = false;
5247
+ record.errorMessage = failureMessage;
5248
+ attempts.push(record);
5249
+ failureReasons.push(failureMessage);
5250
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
5251
+ continue;
5252
+ }
5092
5253
  if (attempt === 1) {
5093
5254
  record.technique = "act-string";
5094
5255
  record.instruction = step;
@@ -5267,58 +5428,19 @@ async function executeStepWithHealing(params) {
5267
5428
  const deepLocatorInnerSelector = deepLocatorResolution?.innerSelector ?? deep_locator_scan_1.INTERACTIVE_CANDIDATE_SELECTOR;
5268
5429
  const deepLocatorCandidates = (deepLocatorResolution?.candidates ?? []).filter((c) => !triedSelectors.includes(c.selector));
5269
5430
  if (candidates.length === 0 && deepLocatorCandidates.length > 0) {
5270
- // A fill/select step must actuate the NAMED FIELD through the
5271
- // fill/select seam, never the click-only walk below: candidate
5272
- // ranking scores by the instruction's quoted VALUE (see
5273
- // parseFillStep's docblock) for "Fill in the First Name field
5274
- // with 'Reginald'" no control's accessible name is ever 'Reginald',
5275
- // so every candidate ties at score 0 and the click walk would fire
5276
- // on whatever sits first in DOM order (the bug report's wizard
5277
- // 'Close' mis-click). Detect that shape first and route
5278
- // deterministically to the candidate matching the field's own
5279
- // label, refusing to click when no candidate names that field.
5280
- const fillStep = parseFillStep(step);
5281
- const selectStep = fillStep ? null : parseSelectStep(step);
5282
- const fieldTarget = fillStep
5283
- ? { kind: "fill", fieldLabel: fillStep.fieldLabel, value: fillStep.value }
5284
- : selectStep?.questionLabel
5285
- ? { kind: "select", fieldLabel: selectStep.questionLabel, value: selectStep.option }
5286
- : null;
5431
+ // Field-label routing for a fill/select-shaped step already ran
5432
+ // (and returned/continued on a MATCH) in the field-label-first
5433
+ // pre-check above, before this attempt's own act/observe call —
5434
+ // see `tryDeterministicFieldLabelActuation`. Reaching this
5435
+ // `if (fieldTarget)` means the pre-check found no candidate
5436
+ // naming the field either the walk below scores by the
5437
+ // instruction's quoted VALUE (see parseFillStep's docblock), so
5438
+ // every candidate ties at score 0 and it would otherwise click
5439
+ // whichever sits first in DOM order (the bug report's wizard
5440
+ // 'Close' mis-click). Refuse instead of guessing.
5441
+ const fieldTarget = parseFieldLabelTarget(step);
5287
5442
  if (fieldTarget) {
5288
- const matched = findDeepLocatorCandidateByFieldLabel(deepLocatorCandidates, fieldTarget.fieldLabel);
5289
- if (!matched) {
5290
- const failureMessage = `deepLocator: no candidate matched field "${fieldTarget.fieldLabel}" (refusing to click an unrelated control)`;
5291
- record.actResultSuccess = false;
5292
- record.errorMessage = failureMessage;
5293
- attempts.push(record);
5294
- failureReasons.push(failureMessage);
5295
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
5296
- continue;
5297
- }
5298
- triedSelectors.push(matched.selector);
5299
- record.triedSelectors = [matched.selector];
5300
- const actuated = fieldTarget.kind === "fill"
5301
- ? await (0, deep_locator_actuate_1.fillDeepLocatorCandidate)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, matched.index, fieldTarget.value, { frameTarget })
5302
- : await (0, deep_locator_actuate_1.selectDeepLocatorCandidateOption)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, matched.index, fieldTarget.value, { frameTarget });
5303
- if (actuated) {
5304
- record.instruction = `deepLocator: ${matched.accessibleText || "(no accessible text)"}`;
5305
- record.actResultSuccess = true;
5306
- record.actResultDescription = `deepLocator ${fieldTarget.kind === "fill" ? "filled" : "selected"} "${matched.accessibleText || matched.selector}" with "${fieldTarget.value}"`;
5307
- // verifyDomEffect can't resolve a `deeplocator=` selector (see
5308
- // deep-locator-actuate.ts's module docblock: target.locator()
5309
- // has no meaning for cross-origin OOPIF hop notation) — the
5310
- // write + read-back fillDeepLocatorCandidate/
5311
- // selectDeepLocatorCandidateOption already performed IS the
5312
- // verification signal, so record it directly instead of
5313
- // synthesizing a resolvedAction that a verifier can only ever
5314
- // score false for.
5315
- record.verifiedBy = "dom";
5316
- attempts.push(record);
5317
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.actResultDescription}`);
5318
- trajectory?.push({ stepIndex, verifiedBy: "dom" });
5319
- return "completed";
5320
- }
5321
- const failureMessage = `deepLocator: ${fieldTarget.kind} on "${matched.accessibleText || matched.selector}" did not verify (read-back mismatch or rejected write)`;
5443
+ const failureMessage = `deepLocator: no candidate matched field "${fieldTarget.fieldLabel}" (refusing to click an unrelated control)`;
5322
5444
  record.actResultSuccess = false;
5323
5445
  record.errorMessage = failureMessage;
5324
5446
  attempts.push(record);
@@ -5326,6 +5448,7 @@ async function executeStepWithHealing(params) {
5326
5448
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
5327
5449
  continue;
5328
5450
  }
5451
+ const selectStep = parseSelectStep(step);
5329
5452
  // A select step whose question is phrased un-quoted (parseSelectStep
5330
5453
  // returns `questionLabel: null`) has no fieldLabel to route through
5331
5454
  // findDeepLocatorCandidateByFieldLabel above, so ranking below falls
@@ -5709,8 +5832,8 @@ async function executeStepWithHealing(params) {
5709
5832
  }
5710
5833
  else {
5711
5834
  record.technique = "llm-rephrase";
5712
- if (!anthropic) {
5713
- record.errorMessage = "no anthropic client (bedrock-only deployment); skipping rephrase";
5835
+ if (!rephraseModel) {
5836
+ record.errorMessage = "no rephrase model configured; skipping rephrase";
5714
5837
  }
5715
5838
  else if (hasBillingErrorBeenLogged()) {
5716
5839
  // Telemetry-driven skip: when Anthropic billing has already been
@@ -5758,7 +5881,7 @@ async function executeStepWithHealing(params) {
5758
5881
  instruction: a.instruction,
5759
5882
  verdict: a.errorMessage ?? failureReasons[i] ?? null,
5760
5883
  }));
5761
- const rephrased = await rephraseWithLLM(anthropic, step, triedSelectors, candidates, failureReasons, captureFn, livePageEvidence, unfocused, submitFailureList, priorAttemptsForPrompt, gaEventList);
5884
+ const rephrased = await rephraseWithLLM(rephraseModel, step, triedSelectors, candidates, failureReasons, captureFn, livePageEvidence, unfocused, submitFailureList, priorAttemptsForPrompt, gaEventList, anthropic);
5762
5885
  if (!rephrased) {
5763
5886
  record.errorMessage = "llm declined to rephrase or returned outcome=impossible";
5764
5887
  }
@@ -5889,7 +6012,19 @@ async function executeStepWithHealing(params) {
5889
6012
  networkDelta: post.networkCount - pre.networkCount,
5890
6013
  bytesDelta: post.bodyHtmlLength - pre.bodyHtmlLength,
5891
6014
  });
5892
- let verified = networkIsRealAdvance || urlChanged || domVerifiedForStep || clickViewSwapVerified;
6015
+ // Form-value-diff signal: `visibleTextSignature` never reflects a plain
6016
+ // <input>/<textarea>/<select>'s `value` property, so a fill with no
6017
+ // secondary UI side effect (no toggle, no formatted display) had ZERO
6018
+ // verification signal — every cascade attempt reported "no observable
6019
+ // effect" even though the value genuinely landed. Scoped to state-class
6020
+ // actions (fill/check/etc.), same as domVerified, so it never lets an
6021
+ // advance/submit step ride a stray value mutation elsewhere on the page.
6022
+ const formValueVerified = isStateClass && post.formValueSignature !== pre.formValueSignature;
6023
+ let verified = networkIsRealAdvance ||
6024
+ urlChanged ||
6025
+ domVerifiedForStep ||
6026
+ clickViewSwapVerified ||
6027
+ formValueVerified;
5893
6028
  // Final-step submit-verification gate. Replaces the deterministic
5894
6029
  // submitEndpointPattern regex with a Haiku 4.5 LLM judgment over
5895
6030
  // multi-signal evidence (network captures, page URL/title, DOM
@@ -6008,7 +6143,11 @@ async function executeStepWithHealing(params) {
6008
6143
  ? "url"
6009
6144
  : networkFired
6010
6145
  ? "network"
6011
- : "dom";
6146
+ : domVerifiedForStep
6147
+ ? "dom"
6148
+ : formValueVerified
6149
+ ? "form-value"
6150
+ : "dom";
6012
6151
  }
6013
6152
  // N+16 probe: Stagehand's CDP click sometimes lands on the button without
6014
6153
  // triggering React's SyntheticEvent layer (or jQuery delegated handlers).
@@ -6095,6 +6234,7 @@ async function executeStepWithHealing(params) {
6095
6234
  const retryUrlChanged = retryPost.url !== pre.url;
6096
6235
  const retryHtmlDelta = retryPost.bodyHtmlLength - pre.bodyHtmlLength;
6097
6236
  const retryTextChanged = retryPost.visibleTextSignature !== pre.visibleTextSignature;
6237
+ const retryFormValueChanged = retryPost.formValueSignature !== pre.formValueSignature;
6098
6238
  // RC2: an advance/`kind=click` "Next" that only grew the DOM
6099
6239
  // (validation errors rendered) with NO network/URL change is a
6100
6240
  // validation-blocked no-op, not a real transition — but the
@@ -6150,6 +6290,7 @@ async function executeStepWithHealing(params) {
6150
6290
  retryUrlChanged ||
6151
6291
  retryHtmlDelta !== 0 ||
6152
6292
  retryTextChanged ||
6293
+ retryFormValueChanged ||
6153
6294
  checkboxStateVerified);
6154
6295
  // Apply the same submit-endpoint gate the primary verifier uses.
6155
6296
  // Without this, the n+16 fallback would still ride past a
@@ -6223,7 +6364,7 @@ async function executeStepWithHealing(params) {
6223
6364
  failureReasons.push(`n+16 fallback: submit-judge-rejected: ${judgeVerdict.reason}`);
6224
6365
  }
6225
6366
  }
6226
- logger.info(`n+16 probe: step=${stepIndex + 1}/${totalSteps?.() ?? "?"} attempt=${attempt} el.click() fallback fired=${fired === true} kind=${probeResult.kind ?? "none"} checkboxStateVerified=${checkboxStateVerified} ancestorStillInvalid=${ancestorStillInvalid}; network=${retryNetworkFired} url=${retryUrlChanged} htmlDelta=${retryHtmlDelta} textChanged=${retryTextChanged} verified=${retryVerified}`);
6367
+ logger.info(`n+16 probe: step=${stepIndex + 1}/${totalSteps?.() ?? "?"} attempt=${attempt} el.click() fallback fired=${fired === true} kind=${probeResult.kind ?? "none"} checkboxStateVerified=${checkboxStateVerified} ancestorStillInvalid=${ancestorStillInvalid}; network=${retryNetworkFired} url=${retryUrlChanged} htmlDelta=${retryHtmlDelta} textChanged=${retryTextChanged} formValueChanged=${retryFormValueChanged} verified=${retryVerified}`);
6227
6368
  if (retryVerified) {
6228
6369
  if (record.verifiedBy === null) {
6229
6370
  record.verifiedBy = retryUrlChanged ? "url" : retryNetworkFired ? "network" : "dom";
@@ -6488,7 +6629,7 @@ async function waitForSpaReady(page, logger, opts = {}) {
6488
6629
  * than let the flow report phantom success.
6489
6630
  */
6490
6631
  async function runHealingFlow(deps) {
6491
- const { stagehand, page, steps, logger, anthropic, uploadFixture, maxFlowMs } = deps;
6632
+ const { stagehand, page, steps, logger, anthropic, rephraseModel, uploadFixture, maxFlowMs } = deps;
6492
6633
  const counter = { n: 0 };
6493
6634
  const signalCounter = { n: 0 };
6494
6635
  const recentCaptures = [];
@@ -6540,6 +6681,7 @@ async function runHealingFlow(deps) {
6540
6681
  recentCaptures,
6541
6682
  recentCaptureMeta,
6542
6683
  anthropic,
6684
+ rephraseModel,
6543
6685
  logger,
6544
6686
  uploadFixture,
6545
6687
  frameTarget,