@enricai/barnacle 1.9.1 → 1.9.3

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.
@@ -2393,11 +2393,21 @@ async function fillTextDatepickerInput(target, selector, value) {
2393
2393
  * Returns null for non-fillable elements (clicks, selects, etc.) — caller
2394
2394
  * knows to skip the check.
2395
2395
  *
2396
+ * Accepts any selector a call site holds: Stagehand's `xpath=…`, an
2397
+ * already-stripped bare XPath, or a non-xpath form (`deeplocator=…`,
2398
+ * `deep-index:N`). Non-xpath forms can't be read via `document.evaluate`, so
2399
+ * the selector is normalized through {@link xpathBodyForEvaluate} and a form
2400
+ * this reader can't resolve returns null (same "couldn't verify — skip" contract
2401
+ * as a non-fillable element) rather than evaluating an invalid XPath expression.
2402
+ *
2396
2403
  * Site-agnostic: works on any <input>, <textarea>, or [contenteditable]
2397
2404
  * element regardless of framework wrapping. Industry-standard pattern
2398
2405
  * (react-testing-library's `getByDisplayValue` does the same readback).
2399
2406
  */
2400
- async function verifyFillReadback(target, xpath, expectedValue) {
2407
+ async function verifyFillReadback(target, selector, expectedValue) {
2408
+ const xpath = xpathBodyForEvaluate(selector);
2409
+ if (xpath === null)
2410
+ return null;
2401
2411
  const expr = `(() => {
2402
2412
  const xpath = ${JSON.stringify(xpath)};
2403
2413
  const expected = ${JSON.stringify(expectedValue)};
@@ -2518,6 +2528,22 @@ const STATE_CLASS_METHODS = new Set([
2518
2528
  function xpathBody(selector) {
2519
2529
  return selector.startsWith("xpath=") ? selector.slice("xpath=".length) : null;
2520
2530
  }
2531
+ /**
2532
+ * Resolve any selector a caller might hold into a bare XPath body for
2533
+ * `document.evaluate`. `verifyFillReadback` is shared across call sites that
2534
+ * carry different selector forms — Stagehand's `xpath=…` (act path), an
2535
+ * already-stripped body (the datepicker primitive's internal readback), and
2536
+ * non-xpath forms like `deeplocator=… >> nth=N` / `deep-index:N` (deep-locator
2537
+ * cascades) — so a single `xpath=`-strip is insufficient and feeding a
2538
+ * prefixed string straight to `document.evaluate` yields an invalid expression.
2539
+ * Accepts an `xpath=`-prefixed selector or a bare XPath (starts with `/` or
2540
+ * `(`); returns null for every other form so the reader resolves nothing rather
2541
+ * than evaluating garbage.
2542
+ */
2543
+ function xpathBodyForEvaluate(selector) {
2544
+ const stripped = selector.startsWith("xpath=") ? selector.slice("xpath=".length) : selector;
2545
+ return stripped.startsWith("/") || stripped.startsWith("(") ? stripped : null;
2546
+ }
2521
2547
  /** How long the upload primitive waits for a post-setInputFiles network POST. */
2522
2548
  const UPLOAD_NETWORK_TIMEOUT_MS = 5_000;
2523
2549
  /** Polling interval while waiting for the upload's network signal. */
@@ -3046,23 +3072,31 @@ function parseFillStep(instruction) {
3046
3072
  * canonical `<label> field with '<value>'` shape, so real-world prose that
3047
3073
  * inserts words between the field noun and `with` — e.g. "Fill in the Start
3048
3074
  * Date field FOR WORK EXPERIENCE with '01/2020'" — parses to `null`, missing
3049
- * the fill intent entirely. This looser parser recognizes a fill verb plus a
3050
- * `with '<value>'` clause and returns just the value, so the act-success
3051
- * datepicker guard can key off intent that survives that phrasing drift.
3075
+ * the fill intent entirely. This looser parser recognizes a value-carrying
3076
+ * entry verb and returns just the value, so the act-success datepicker guard
3077
+ * can key off intent that survives that phrasing drift. Two shapes are
3078
+ * accepted: `fill … with '<value>'` and the replanner's own escalation
3079
+ * `type '<value>' into …` — the replanner rewords a stuck date fill as
3080
+ * "Type '01/2020' into the Start Date input", which carries no `fill` verb, so
3081
+ * without the `type` shape the guard could never fire on any replanned step.
3052
3082
  *
3053
3083
  * Deliberately excludes select steps (checked first, mirroring
3054
3084
  * `resolveDeepLocatorActuation`'s precedence) so a `select '…'` step whose
3055
3085
  * prose happens to trail a quoted value is never mistaken for a fill. Returns
3056
- * `null` for clicks, selects, and any step without a quoted `with '…'` value.
3086
+ * `null` for clicks, selects, and any step without a quoted value.
3057
3087
  */
3058
3088
  function parseFillValueIntent(instruction) {
3059
3089
  if (parseSelectStep(instruction))
3060
3090
  return null;
3061
- if (!/\bfill(?:\s+in)?\b/i.test(instruction))
3062
- return null;
3063
- const match = instruction.match(/\bwith\s+'([^']+)'/i);
3064
- const value = match?.[1]?.trim();
3065
- return value ? { value } : null;
3091
+ if (/\bfill(?:\s+in)?\b/i.test(instruction)) {
3092
+ const value = instruction.match(/\bwith\s+'([^']+)'/i)?.[1]?.trim();
3093
+ return value ? { value } : null;
3094
+ }
3095
+ if (/\btype\b/i.test(instruction)) {
3096
+ const value = instruction.match(/\btype\s+'([^']+)'/i)?.[1]?.trim();
3097
+ return value ? { value } : null;
3098
+ }
3099
+ return null;
3066
3100
  }
3067
3101
  /**
3068
3102
  * Parse a single-choice RADIO flow step into the option to click and (when
@@ -3316,11 +3350,17 @@ async function applySelectValue(page, target, selIdx, value) {
3316
3350
  const stillInvalid = (await target.evaluate(invalidExpr).catch(() => false));
3317
3351
  return { ok: true, stillInvalid };
3318
3352
  }
3353
+ /**
3354
+ * Returns the resolved `<select>`'s DOM id on success (empty string when the
3355
+ * element has no id), or `null` when the step wasn't handled here (no matching
3356
+ * select, no commit) and should fall through to the cascade. The id becomes the
3357
+ * step's stable `targetId` for cross-run convergence.
3358
+ */
3319
3359
  async function trySelectPrimitive(params) {
3320
3360
  const { page, target, instruction, logger, anthropic, captureFn } = params;
3321
3361
  const parsed = parseSelectStep(instruction);
3322
3362
  if (!parsed)
3323
- return false;
3363
+ return null;
3324
3364
  const { option, questionLabel } = parsed;
3325
3365
  const optLabel = `option "${option.slice(0, 40)}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
3326
3366
  // Phase 1 (browser, no mutation): find the target select — the one whose
@@ -3349,7 +3389,7 @@ async function trySelectPrimitive(params) {
3349
3389
  for (let i = 0; i < selects.length; i++) {
3350
3390
  const opts = Array.from(selects[i].options || []);
3351
3391
  const m = opts.find((o) => norm(o.textContent) === wantOpt || norm(o.value) === wantOpt);
3352
- if (m) detMatches.push({ selIdx: i, value: m.value, text: m.textContent });
3392
+ if (m) detMatches.push({ selIdx: i, value: m.value, text: m.textContent, id: selects[i].id || "" });
3353
3393
  }
3354
3394
  if (detMatches.length === 1) {
3355
3395
  return { selectPresent: true, detMatch: detMatches[0] };
@@ -3394,7 +3434,7 @@ async function trySelectPrimitive(params) {
3394
3434
  const options = Array.from(sel.options || [])
3395
3435
  .filter((o) => !o.disabled && (o.value || (o.textContent || "").trim()))
3396
3436
  .map((o) => ({ text: (o.textContent || "").replace(/\\s+/g, " ").trim(), value: o.value }));
3397
- if (options.length > 0) candidates.push({ selIdx: i, label: selLabelText(sel), options });
3437
+ if (options.length > 0) candidates.push({ selIdx: i, id: sel.id || "", label: selLabelText(sel), options });
3398
3438
  }
3399
3439
  if (candidates.length === 0) return { selectPresent: true, candidates: [] };
3400
3440
  return { selectPresent: true, candidates };
@@ -3407,7 +3447,7 @@ async function trySelectPrimitive(params) {
3407
3447
  // fall through to the cascade unchanged; the LLM picker can't help here.
3408
3448
  if (!enumResult?.selectPresent) {
3409
3449
  logger.info(`select primitive: no <select> on page for ${optLabel}; falling through to cascade`);
3410
- return false;
3450
+ return null;
3411
3451
  }
3412
3452
  // Deterministic unique-option match: set it, settle, and confirm it committed
3413
3453
  // (cleared the required/invalid marker). A set that doesn't clear the marker
@@ -3417,15 +3457,15 @@ async function trySelectPrimitive(params) {
3417
3457
  const { ok, stillInvalid } = await applySelectValue(page, target, enumResult.detMatch.selIdx, enumResult.detMatch.value);
3418
3458
  if (ok && !stillInvalid) {
3419
3459
  logger.info(`select primitive: set dropdown to "${enumResult.detMatch.text.trim().slice(0, 40)}" (${optLabel})`);
3420
- return true;
3460
+ return enumResult.detMatch.id;
3421
3461
  }
3422
3462
  logger.info(`select primitive: dropdown value for ${optLabel} did not commit (ok=${ok} stillInvalid=${stillInvalid}); falling through to cascade`);
3423
- return false;
3463
+ return null;
3424
3464
  }
3425
3465
  const candidates = enumResult.candidates ?? [];
3426
3466
  if (anthropic === null || candidates.length === 0) {
3427
3467
  logger.info(`select primitive: no unique option match for ${optLabel}${anthropic === null ? " (no LLM client)" : ""}; falling through to cascade`);
3428
- return false;
3468
+ return null;
3429
3469
  }
3430
3470
  // LLM picks WHICH dropdown answers the question and which option in it.
3431
3471
  const verdict = await (0, select_option_1.judgeSelectOptionWithLLM)({
@@ -3442,7 +3482,7 @@ async function trySelectPrimitive(params) {
3442
3482
  });
3443
3483
  if (!verdict || verdict.selectIndex === null || verdict.optionIndex === null) {
3444
3484
  logger.info(`select primitive: LLM found no matching dropdown for ${optLabel}${verdict ? ` (${verdict.reason})` : ""}; falling through to cascade`);
3445
- return false;
3485
+ return null;
3446
3486
  }
3447
3487
  // biome-ignore lint/style/noNonNullAssertion: guarded above by the verdict.selectIndex === null early-return
3448
3488
  const chosenCandidate = candidates[verdict.selectIndex];
@@ -3454,14 +3494,14 @@ async function trySelectPrimitive(params) {
3454
3494
  const { ok, stillInvalid } = await applySelectValue(page, target, chosenCandidate.selIdx, chosenOption.value);
3455
3495
  if (ok && !stillInvalid) {
3456
3496
  logger.info(`select primitive: LLM chose "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
3457
- return true;
3497
+ return chosenCandidate.id;
3458
3498
  }
3459
3499
  logger.info(`select primitive: LLM-chosen value for ${optLabel} did not commit (ok=${ok} stillInvalid=${stillInvalid}); falling through`);
3460
- return false;
3500
+ return null;
3461
3501
  }
3462
3502
  catch (err) {
3463
3503
  logger.warn(`select primitive: evaluate threw: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
3464
- return false;
3504
+ return null;
3465
3505
  }
3466
3506
  }
3467
3507
  /** Option texts that answer a required select without volunteering info — used
@@ -3645,11 +3685,16 @@ async function tryFillRequiredSelectsPrimitive(params) {
3645
3685
  * no checkbox groups, or no option fits (fall through to the cascade — which is
3646
3686
  * also where `<select>`-only pages go, since trySelectPrimitive runs first).
3647
3687
  */
3688
+ /**
3689
+ * Returns the resolved checkbox's DOM id on success (empty string when it has
3690
+ * no id), or `null` when the step wasn't handled here. See trySelectPrimitive
3691
+ * for the id-as-targetId rationale.
3692
+ */
3648
3693
  async function tryCheckboxPrimitive(params) {
3649
3694
  const { page, target, instruction, logger, anthropic, captureFn } = params;
3650
3695
  const parsed = parseSelectStep(instruction);
3651
3696
  if (!parsed)
3652
- return false;
3697
+ return null;
3653
3698
  const { option, questionLabel } = parsed;
3654
3699
  const optLabel = `option "${option.slice(0, 40)}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
3655
3700
  // Phase 1 (browser, no mutation): find checkbox GROUPS and their options.
@@ -3723,7 +3768,7 @@ async function tryCheckboxPrimitive(params) {
3723
3768
  const grpEl = groupEls[h.gi];
3724
3769
  const cb = grpEl.querySelectorAll("input[type=checkbox]")[h.bi];
3725
3770
  const ok = cb ? setChecked(cb) : false;
3726
- return { groupPresent: true, applied: true, ok, chosen: h.text };
3771
+ return { groupPresent: true, applied: true, ok, chosen: h.text, id: cb ? (cb.id || "") : "" };
3727
3772
  }
3728
3773
  // LLM path: return groups (label + option texts) for the picker.
3729
3774
  return { groupPresent: true, applied: false, groups: groups.map((g) => ({ gi: g.gi, label: g.label, options: g.options })) };
@@ -3733,15 +3778,15 @@ async function tryCheckboxPrimitive(params) {
3733
3778
  // (SPA render-lag); poll until it appears or the cap is hit.
3734
3779
  const enumResult = await pollEnumerate(page, target, enumerateExpr, (r) => r?.groupPresent === true);
3735
3780
  if (!enumResult?.groupPresent)
3736
- return false; // no checkbox groups → cascade
3781
+ return null; // no checkbox groups → cascade
3737
3782
  if (enumResult.applied && enumResult.ok) {
3738
3783
  logger.info(`checkbox primitive: checked "${(enumResult.chosen || "").trim().slice(0, 40)}" (${optLabel})`);
3739
- return true;
3784
+ return enumResult.id ?? "";
3740
3785
  }
3741
3786
  const groups = enumResult.groups ?? [];
3742
3787
  if (anthropic === null || groups.length === 0) {
3743
3788
  logger.info(`checkbox primitive: no unique option match for ${optLabel}${anthropic === null ? " (no LLM client)" : ""}; falling through to cascade`);
3744
- return false;
3789
+ return null;
3745
3790
  }
3746
3791
  // LLM picks which group answers the question + which option. Reuse the
3747
3792
  // select-option judge (candidate "dropdowns" == checkbox groups here).
@@ -3759,7 +3804,7 @@ async function tryCheckboxPrimitive(params) {
3759
3804
  });
3760
3805
  if (!verdict || verdict.selectIndex === null || verdict.optionIndex === null) {
3761
3806
  logger.info(`checkbox primitive: LLM found no matching group for ${optLabel}${verdict ? ` (${verdict.reason})` : ""}; falling through to cascade`);
3762
- return false;
3807
+ return null;
3763
3808
  }
3764
3809
  // biome-ignore lint/style/noNonNullAssertion: guarded above by the verdict.selectIndex === null early-return
3765
3810
  const chosenGroup = groups[verdict.selectIndex];
@@ -3778,19 +3823,19 @@ async function tryCheckboxPrimitive(params) {
3778
3823
  cb.dispatchEvent(new Event("input", { bubbles: true }));
3779
3824
  cb.dispatchEvent(new Event("change", { bubbles: true }));
3780
3825
  }
3781
- return { ok: cb.checked === true };
3826
+ return { ok: cb.checked === true, id: cb.id || "" };
3782
3827
  })(${JSON.stringify(chosenGroup.gi)}, ${JSON.stringify(chosenOption.bi)})`;
3783
3828
  const applyResult = (await target.evaluate(applyExpr));
3784
3829
  if (applyResult?.ok) {
3785
3830
  logger.info(`checkbox primitive: LLM checked "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
3786
- return true;
3831
+ return applyResult.id;
3787
3832
  }
3788
3833
  logger.info(`checkbox primitive: LLM-chosen checkbox did not stick for ${optLabel}; falling through`);
3789
- return false;
3834
+ return null;
3790
3835
  }
3791
3836
  catch (err) {
3792
3837
  logger.warn(`checkbox primitive: evaluate threw: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
3793
- return false;
3838
+ return null;
3794
3839
  }
3795
3840
  }
3796
3841
  /**
@@ -3976,7 +4021,7 @@ async function tryRadioPrimitive(params) {
3976
4021
  const { page, target, instruction, logger, anthropic, captureFn } = params;
3977
4022
  const parsed = parseRadioStep(instruction);
3978
4023
  if (!parsed)
3979
- return false;
4024
+ return null;
3980
4025
  const { option, questionLabel } = parsed;
3981
4026
  const optLabel = `option "${option.slice(0, 40)}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
3982
4027
  // Phase 1 (browser): find radio GROUPS and their options. A group is a
@@ -4048,10 +4093,10 @@ async function tryRadioPrimitive(params) {
4048
4093
  try {
4049
4094
  const enumResult = await pollEnumerate(page, target, enumerateExpr, (r) => r?.groupPresent === true);
4050
4095
  if (!enumResult?.groupPresent)
4051
- return false; // no radio group → cascade
4096
+ return null; // no radio group → cascade
4052
4097
  const groups = enumResult.groups ?? [];
4053
4098
  if (groups.length === 0)
4054
- return false;
4099
+ return null;
4055
4100
  // Deterministic + positional selection (excludes already-answered groups,
4056
4101
  // fixes the empty-label universal-match bug). Only genuine labeled ambiguity
4057
4102
  // defers to the LLM.
@@ -4064,19 +4109,19 @@ async function tryRadioPrimitive(params) {
4064
4109
  });
4065
4110
  if (applied) {
4066
4111
  logger.info(`radio primitive: selected "${(chosenOpt?.text ?? "").trim().slice(0, 40)}" (${optLabel})`);
4067
- return true;
4112
+ return chosenOpt?.id ?? "";
4068
4113
  }
4069
4114
  logger.info(`radio primitive: chosen radio did not stick for ${optLabel}; falling through`);
4070
- return false;
4115
+ return null;
4071
4116
  }
4072
4117
  if (selection === null) {
4073
4118
  logger.info(`radio primitive: no group offers option for ${optLabel}; falling through to cascade`);
4074
- return false;
4119
+ return null;
4075
4120
  }
4076
4121
  // selection === "ambiguous": multiple labeled groups match → let the LLM pick.
4077
4122
  if (anthropic === null) {
4078
4123
  logger.info(`radio primitive: ambiguous labeled match for ${optLabel} (no LLM client); falling through to cascade`);
4079
- return false;
4124
+ return null;
4080
4125
  }
4081
4126
  // LLM picks which group answers the question + which option. Reuse the
4082
4127
  // select-option judge (candidate "dropdowns" == radio groups here). Only
@@ -4096,7 +4141,7 @@ async function tryRadioPrimitive(params) {
4096
4141
  });
4097
4142
  if (!verdict || verdict.selectIndex === null || verdict.optionIndex === null) {
4098
4143
  logger.info(`radio primitive: LLM found no matching group for ${optLabel}${verdict ? ` (${verdict.reason})` : ""}; falling through to cascade`);
4099
- return false;
4144
+ return null;
4100
4145
  }
4101
4146
  // biome-ignore lint/style/noNonNullAssertion: guarded above by the verdict.selectIndex === null early-return
4102
4147
  const chosenGroup = llmGroups[verdict.selectIndex];
@@ -4110,14 +4155,14 @@ async function tryRadioPrimitive(params) {
4110
4155
  };
4111
4156
  if (applyResult?.ok) {
4112
4157
  logger.info(`radio primitive: LLM selected "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${verdict.reason.slice(0, 60)})`);
4113
- return true;
4158
+ return chosenOption.id ?? "";
4114
4159
  }
4115
4160
  logger.info(`radio primitive: LLM-chosen radio did not stick for ${optLabel}; falling through`);
4116
- return false;
4161
+ return null;
4117
4162
  }
4118
4163
  catch (err) {
4119
4164
  logger.warn(`radio primitive: evaluate threw: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
4120
- return false;
4165
+ return null;
4121
4166
  }
4122
4167
  }
4123
4168
  /**
@@ -5152,16 +5197,17 @@ async function executeStepWithHealing(params) {
5152
5197
  // falls back to the main-frame target when no frameSelector is set, so this
5153
5198
  // bridge stays behavior-identical for every existing site.
5154
5199
  const selectFrameTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
5155
- if (await trySelectPrimitive({
5200
+ const selectTargetId = await trySelectPrimitive({
5156
5201
  page,
5157
5202
  target: selectFrameTarget,
5158
5203
  instruction: step,
5159
5204
  logger,
5160
5205
  anthropic,
5161
5206
  captureFn,
5162
- })) {
5207
+ });
5208
+ if (selectTargetId !== null) {
5163
5209
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by select primitive`);
5164
- trajectory?.push({ stepIndex, verifiedBy: "dom" });
5210
+ trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: selectTargetId });
5165
5211
  return "completed";
5166
5212
  }
5167
5213
  // When the step is a "select 'X'" against a multi-select CHECKBOX group
@@ -5169,16 +5215,17 @@ async function executeStepWithHealing(params) {
5169
5215
  // screening questions this way — answer it directly in the DOM. Runs AFTER
5170
5216
  // trySelectPrimitive (which handles <select> and no-ops on checkbox-only
5171
5217
  // pages). No-op (falls through) when there's no checkbox group or no match.
5172
- if (await tryCheckboxPrimitive({
5218
+ const checkboxTargetId = await tryCheckboxPrimitive({
5173
5219
  page,
5174
5220
  target: selectFrameTarget,
5175
5221
  instruction: step,
5176
5222
  logger,
5177
5223
  anthropic,
5178
5224
  captureFn,
5179
- })) {
5225
+ });
5226
+ if (checkboxTargetId !== null) {
5180
5227
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by checkbox primitive`);
5181
- trajectory?.push({ stepIndex, verifiedBy: "dom" });
5228
+ trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: checkboxTargetId });
5182
5229
  return "completed";
5183
5230
  }
5184
5231
  // When the step is a single-choice RADIO answer ("Click the 'Yes' answer for
@@ -5187,16 +5234,17 @@ async function executeStepWithHealing(params) {
5187
5234
  // reach the observe cascade's el.click() fallback that fails to commit MUI/
5188
5235
  // React controlled state (the wizard ATS's Basic-Info Step-2 wall). No-op (falls
5189
5236
  // through) when there's no radio group or no confident option match.
5190
- if (await tryRadioPrimitive({
5237
+ const radioTargetId = await tryRadioPrimitive({
5191
5238
  page,
5192
5239
  target: frameTarget ?? (0, frame_target_1.mainFrameTarget)(page),
5193
5240
  instruction: step,
5194
5241
  logger,
5195
5242
  anthropic,
5196
5243
  captureFn,
5197
- })) {
5244
+ });
5245
+ if (radioTargetId !== null) {
5198
5246
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} resolved by radio primitive`);
5199
- trajectory?.push({ stepIndex, verifiedBy: "dom" });
5247
+ trajectory?.push({ stepIndex, verifiedBy: "dom", targetId: radioTargetId });
5200
5248
  return "completed";
5201
5249
  }
5202
5250
  // On a CATCH-ALL step ("for any remaining … question"), fill every