@trusty-squire/mcp 0.1.11 → 0.1.12

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.
@@ -1,4 +1,4 @@
1
- import type { BrowserController, CaptchaVariant } from "./browser.js";
1
+ import type { BrowserController, CaptchaVariant, InteractiveElement } from "./browser.js";
2
2
  import { type LLMClient, type LLMPair } from "./llm-client.js";
3
3
  export interface AgentInbox {
4
4
  waitForEmail(input: {
@@ -95,7 +95,9 @@ export type PostVerifyStep = {
95
95
  };
96
96
  declare const FILL_VALUE_KINDS: readonly ["email", "password", "name", "username", "company", "literal"];
97
97
  type FillValueKind = (typeof FILL_VALUE_KINDS)[number];
98
- export declare function parseSignupPlan(raw: string): SignupPlan;
98
+ export declare function parseSignupPlan(raw: string, allowedSelectors?: ReadonlySet<string>): SignupPlan;
99
+ export declare function formatInventory(inventory: readonly InteractiveElement[]): string;
100
+ export declare function isOauthOnlyChooser(inventory: readonly InteractiveElement[]): boolean;
99
101
  export declare function parsePostVerifyStep(raw: string): PostVerifyStep;
100
102
  export declare function extractApiKeyFromText(text: string): string | null;
101
103
  export declare function pickVerificationLink(links: readonly string[]): string | null;
@@ -108,6 +110,9 @@ export declare class SignupAgent {
108
110
  private resultTail;
109
111
  private runCaptchaGate;
110
112
  private executePlan;
113
+ private planExecuteWithRetry;
114
+ private buildInventory;
115
+ private verifyPlan;
111
116
  constructor(browser: BrowserController, llm?: LLMClient | LLMPair);
112
117
  get backends(): readonly string[];
113
118
  private callLLM;
@@ -116,7 +121,6 @@ export declare class SignupAgent {
116
121
  signup(task: SignupTask): Promise<SignupResult>;
117
122
  private runSignup;
118
123
  private planSignupForm;
119
- private extractFormHtml;
120
124
  private looksLikeValidationFailure;
121
125
  private pickVerificationLink;
122
126
  private waitForVerificationEmail;
@@ -1 +1 @@
1
- {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../src/bot/agent.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../src/bot/agent.ts"],"names":[],"mappings":"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"}
package/dist/bot/agent.js CHANGED
@@ -7,6 +7,7 @@
7
7
  // The "plan" is a small JSON schema Claude emits, so this file stays a thin
8
8
  // executor; the prompt is the contract. If a service breaks we tweak the
9
9
  // prompt rather than threading service-specific logic through the agent.
10
+ import { rankAndCapInventory, scoreSignupButton } from "./browser.js";
10
11
  import { saveDebugSnapshot } from "./debug.js";
11
12
  import { wasRecentlyPrewarmed, recordPrewarmSuccess } from "./prewarm-cache.js";
12
13
  import { pickLLMPair, } from "./llm-client.js";
@@ -134,7 +135,7 @@ function validateAction(value, index) {
134
135
  throw new Error(`action[${index}]: unknown kind ${JSON.stringify(kind)}`);
135
136
  }
136
137
  }
137
- export function parseSignupPlan(raw) {
138
+ export function parseSignupPlan(raw, allowedSelectors) {
138
139
  const obj = extractJsonObject(raw);
139
140
  const rawActions = obj["actions"];
140
141
  if (!Array.isArray(rawActions)) {
@@ -146,11 +147,77 @@ export function parseSignupPlan(raw) {
146
147
  if (confidence !== "high" && confidence !== "medium" && confidence !== "low") {
147
148
  throw new Error(`signup plan: invalid confidence ${JSON.stringify(confidence)}`);
148
149
  }
150
+ // F3 T4: when the page inventory is supplied, every selector the
151
+ // planner emits must be one the bot computed and put in the
152
+ // inventory. This makes selector hallucination a parse-time
153
+ // rejection (the throw triggers a re-plan) before any DOM
154
+ // round-trip — and an invalid selector like `:contains()` simply
155
+ // isn't in the inventory, so it is caught here too.
156
+ if (allowedSelectors !== undefined) {
157
+ for (const a of actions) {
158
+ if (!allowedSelectors.has(a.selector)) {
159
+ throw new Error(`signup plan: action selector ${JSON.stringify(a.selector)} is not in the page inventory`);
160
+ }
161
+ }
162
+ if (!allowedSelectors.has(submitSelector)) {
163
+ throw new Error(`signup plan: submit_selector ${JSON.stringify(submitSelector)} is not in the page inventory`);
164
+ }
165
+ }
149
166
  const notes = typeof obj["notes"] === "string" ? obj["notes"] : undefined;
150
167
  return notes !== undefined
151
168
  ? { actions, submit_selector: submitSelector, confidence, notes }
152
169
  : { actions, submit_selector: submitSelector, confidence };
153
170
  }
171
+ // Render the element inventory as a compact text block for the
172
+ // planner — one line per element, ending with the verified
173
+ // `selector=` the planner must copy verbatim (F3 T3).
174
+ export function formatInventory(inventory) {
175
+ if (inventory.length === 0)
176
+ return "(no interactive elements found on the page)";
177
+ return inventory
178
+ .map((e) => {
179
+ const bits = [`[${e.index}] ${e.tag}`];
180
+ if (e.type !== null)
181
+ bits.push(`type=${e.type}`);
182
+ if (e.name !== null)
183
+ bits.push(`name=${e.name}`);
184
+ if (e.placeholder !== null) {
185
+ bits.push(`placeholder=${JSON.stringify(e.placeholder)}`);
186
+ }
187
+ const label = e.labelText ?? e.ariaLabel;
188
+ if (label !== null && label !== undefined) {
189
+ bits.push(`label=${JSON.stringify(label)}`);
190
+ }
191
+ if (e.tag !== "input" &&
192
+ e.tag !== "textarea" &&
193
+ e.tag !== "select" &&
194
+ e.visibleText !== null) {
195
+ bits.push(`text=${JSON.stringify(e.visibleText)}`);
196
+ }
197
+ if (e.inConsentWidget)
198
+ bits.push("[cookie-consent — avoid]");
199
+ bits.push(`selector=${e.selector}`);
200
+ return bits.join(" ");
201
+ })
202
+ .join("\n");
203
+ }
204
+ // True when the page has no fillable text input AND no button that
205
+ // reads as an email-signup option — a genuinely OAuth/SSO-only
206
+ // service with no form to automate (F3 Issue 4).
207
+ export function isOauthOnlyChooser(inventory) {
208
+ const TEXTLIKE = new Set([
209
+ "text",
210
+ "email",
211
+ "password",
212
+ "tel",
213
+ null,
214
+ ]);
215
+ const hasFillableInput = inventory.some((e) => (e.tag === "input" && TEXTLIKE.has(e.type)) || e.tag === "textarea");
216
+ if (hasFillableInput)
217
+ return false;
218
+ const hasEmailOption = inventory.some((e) => scoreSignupButton(`${e.visibleText ?? ""} ${e.ariaLabel ?? ""} ${e.labelText ?? ""}`) > 0);
219
+ return !hasEmailOption;
220
+ }
154
221
  export function parsePostVerifyStep(raw) {
155
222
  const obj = extractJsonObject(raw);
156
223
  const kind = obj["kind"];
@@ -363,16 +430,31 @@ export class SignupAgent {
363
430
  // the initial-plan and the validation re-plan paths so the step
364
431
  // logging stays consistent (the re-plan copy historically dropped
365
432
  // the per-action steps.push entries the initial path had).
366
- async executePlan(plan, fillValues, steps) {
433
+ async executePlan(plan, fillValues, steps, bySelector) {
367
434
  for (const action of plan.actions) {
435
+ const el = bySelector.get(action.selector);
436
+ // Belt-and-suspenders: the planner is told to skip
437
+ // cookie-consent elements; never act on one even if it slips
438
+ // through (Render's TOS check hit an Osano consent toggle).
439
+ if (el !== undefined && el.inConsentWidget) {
440
+ steps.push(`Skip ${action.kind} ${action.selector} — inside a cookie-consent widget`);
441
+ continue;
442
+ }
368
443
  try {
369
444
  if (action.kind === "fill") {
370
445
  // `literal` is per-action; everything else is a fixed value.
371
446
  const value = action.value_kind === "literal"
372
447
  ? action.literal ?? ""
373
448
  : fillValues[action.value_kind];
374
- steps.push(`Fill ${action.value_kind} ${action.selector}`);
375
- await this.browser.type(action.selector, value);
449
+ if (el !== undefined && el.tag === "select") {
450
+ // A <select> needs selectOption, not type() (Sentry bug).
451
+ steps.push(`Select ${action.selector}`);
452
+ await this.browser.selectOption(action.selector);
453
+ }
454
+ else {
455
+ steps.push(`Fill ${action.value_kind} → ${action.selector}`);
456
+ await this.browser.type(action.selector, value);
457
+ }
376
458
  }
377
459
  else if (action.kind === "check") {
378
460
  steps.push(`Check ${action.selector} (${action.reason})`);
@@ -389,6 +471,153 @@ export class SignupAgent {
389
471
  }
390
472
  }
391
473
  }
474
+ // F3 T5: the verify-and-replan loop. Builds a DOM-grounded element
475
+ // inventory, has the planner pick from it, verifies the picks
476
+ // resolve, executes, and submits. A bad pick re-plans instead of
477
+ // cascading through 10s timeouts. Replan caps are split (Tension
478
+ // 3): a selector-miss ("the bot erred") is capped tight; a reveal
479
+ // click or a post-submit validation error ("the page advanced")
480
+ // gets more headroom. All bounded by the 15-call LLM breaker + the
481
+ // F2 top-level deadline.
482
+ async planExecuteWithRetry(task, fillValues, steps) {
483
+ const MAX_ERROR_REPLANS = 2;
484
+ const MAX_PROGRESS_REPLANS = 4;
485
+ let errorReplans = 0;
486
+ let progressReplans = 0;
487
+ let hint;
488
+ for (;;) {
489
+ await this.browser.waitForFormReady();
490
+ await saveDebugSnapshot(this.browser, "before-fill");
491
+ const state = await this.browser.getState();
492
+ const inventory = await this.buildInventory(steps);
493
+ // OAuth-only: no fillable input AND no button that reads as an
494
+ // email-signup option — nothing to automate (Issue 4).
495
+ if (isOauthOnlyChooser(inventory)) {
496
+ return { kind: "oauth_required" };
497
+ }
498
+ steps.push("Asking Claude to plan the signup form fill...");
499
+ let plan;
500
+ try {
501
+ plan = await this.planSignupForm({
502
+ service: task.service,
503
+ url: state.url,
504
+ inventory,
505
+ screenshot: state.screenshot,
506
+ ...(hint !== undefined ? { hint } : {}),
507
+ });
508
+ }
509
+ catch (err) {
510
+ // Parse/validation failure — includes a hallucinated selector
511
+ // rejected by the inventory check. An error replan.
512
+ const reason = err instanceof Error ? err.message : String(err);
513
+ if (++errorReplans > MAX_ERROR_REPLANS) {
514
+ return { kind: "planning_failed", reason: `planner output never validated: ${reason}` };
515
+ }
516
+ steps.push(`⚠ plan rejected (${reason}) — re-planning`);
517
+ hint =
518
+ "Your previous plan used a selector not in the inventory. Use ONLY selectors copied verbatim from a `selector=` field.";
519
+ continue;
520
+ }
521
+ steps.push(`Plan: ${plan.actions.length} action(s), confidence=${plan.confidence}` +
522
+ (plan.notes !== undefined ? ` — ${plan.notes}` : ""));
523
+ // Verify the picks resolve on the live page — also catches a
524
+ // stale selector resolving to a recycled wrong node (Tension 4).
525
+ const bySelector = new Map(inventory.map((e) => [e.selector, e]));
526
+ const miss = await this.verifyPlan(plan, bySelector);
527
+ if (miss !== null) {
528
+ if (++errorReplans > MAX_ERROR_REPLANS) {
529
+ return { kind: "planning_failed", reason: `planned selectors kept missing: ${miss}` };
530
+ }
531
+ steps.push(`⚠ planned selectors did not verify (${miss}) — re-planning`);
532
+ hint = `These selectors did not resolve correctly: ${miss}. Pick different inventory entries.`;
533
+ continue;
534
+ }
535
+ await this.executePlan(plan, fillValues, steps, bySelector);
536
+ // A plan with no fill actions only revealed/advanced the page (a
537
+ // cookie banner, a two-stage "sign up with email" chooser) — the
538
+ // real form should now be present. Re-extract and plan it.
539
+ const hadFill = plan.actions.some((a) => a.kind === "fill");
540
+ if (!hadFill) {
541
+ if (++progressReplans > MAX_PROGRESS_REPLANS) {
542
+ return { kind: "planning_failed", reason: "never reached a fillable form" };
543
+ }
544
+ steps.push("Plan only revealed the page — re-planning the now-visible form");
545
+ hint =
546
+ "The previous step revealed or advanced the page. Plan the signup form that should now be visible.";
547
+ continue;
548
+ }
549
+ // Captcha gate + submit.
550
+ const preGate = await this.runCaptchaGate("Pre-submit", steps);
551
+ if (preGate.blocked)
552
+ return { kind: "captcha_blocked", captchaKind: preGate.kind };
553
+ steps.push(`Submit → ${plan.submit_selector}`);
554
+ try {
555
+ await this.browser.clickSubmit(plan.submit_selector);
556
+ }
557
+ catch (err) {
558
+ const reason = err instanceof Error ? err.message : String(err);
559
+ steps.push(`⚠ submit click failed: ${reason}`);
560
+ return { kind: "submit_failed", reason };
561
+ }
562
+ await this.browser.wait(5);
563
+ const postGate = await this.runCaptchaGate("Post-submit", steps);
564
+ if (postGate.blocked)
565
+ return { kind: "captcha_blocked", captchaKind: postGate.kind };
566
+ if (postGate.found && postGate.solved) {
567
+ // Re-click submit so the populated token ships with the form.
568
+ try {
569
+ await this.browser.click(plan.submit_selector);
570
+ await this.browser.wait(3);
571
+ }
572
+ catch (err) {
573
+ steps.push(`⚠ post-captcha submit retry failed: ${err instanceof Error ? err.message : String(err)}`);
574
+ }
575
+ }
576
+ // Post-submit validation errors → the page advanced; re-plan
577
+ // against the new state (a progress replan).
578
+ const afterText = (await this.browser.extractText()).slice(0, 4000);
579
+ if (this.looksLikeValidationFailure(afterText)) {
580
+ if (++progressReplans > MAX_PROGRESS_REPLANS) {
581
+ // Out of replan headroom — proceed; credential extraction
582
+ // confirms whether the signup actually went through.
583
+ return { kind: "submitted" };
584
+ }
585
+ steps.push("Post-submit validation errors — re-planning");
586
+ hint = `The previous submit produced validation errors. Visible page text: ${afterText.slice(0, 600)}`;
587
+ continue;
588
+ }
589
+ return { kind: "submitted" };
590
+ }
591
+ }
592
+ // Extract + rank the page's interactive elements (F3 T1/T2).
593
+ async buildInventory(steps) {
594
+ const raw = await this.browser.extractInteractiveElements();
595
+ const { inventory, buttonsDropped } = rankAndCapInventory(raw);
596
+ steps.push(`Inventory: ${inventory.length} element(s)` +
597
+ (buttonsDropped > 0 ? ` (${buttonsDropped} low-ranked button(s) dropped)` : ""));
598
+ return inventory;
599
+ }
600
+ // Verify every selector the plan references still resolves on the
601
+ // live page, and — when the inventory entry had an id — that it
602
+ // resolves to that same element (Tension 4: a stale structural
603
+ // selector can resolve to a recycled wrong node). Returns a
604
+ // human-readable miss list, or null when every selector is good.
605
+ async verifyPlan(plan, bySelector) {
606
+ const selectors = [...plan.actions.map((a) => a.selector), plan.submit_selector];
607
+ const misses = [];
608
+ for (const sel of new Set(selectors)) {
609
+ const info = await this.browser.inspectSelector(sel);
610
+ if (info.count === 0) {
611
+ misses.push(sel);
612
+ continue;
613
+ }
614
+ const inv = bySelector.get(sel);
615
+ if (inv !== undefined && inv.id !== null && info.id !== inv.id) {
616
+ misses.push(`${sel} (resolved to the wrong element)`);
617
+ }
618
+ }
619
+ return misses.length > 0 ? misses.join(", ") : null;
620
+ }
392
621
  constructor(browser, llm) {
393
622
  this.browser = browser;
394
623
  if (llm === undefined) {
@@ -587,136 +816,53 @@ export class SignupAgent {
587
816
  await this.browser.wait(2);
588
817
  }
589
818
  }
590
- // Wait for the form to actually render before planning (F1) —
591
- // SPA and two-stage signup pages render late, and screenshotting
592
- // a skeleton makes the planner emit selectors that don't exist.
593
- steps.push("Waiting for the signup form to render...");
594
- await this.browser.waitForFormReady();
595
- // Step 2: Plan the form fill with Claude.
596
- steps.push("Asking Claude to plan the signup form fill...");
597
- await saveDebugSnapshot(this.browser, "before-fill");
598
- const state = await this.browser.getState();
599
- const plan = await this.planSignupForm({
600
- service: task.service,
601
- url: state.url,
602
- html: state.html,
603
- screenshot: state.screenshot,
604
- });
605
- steps.push(`Plan: ${plan.actions.length} action(s), confidence=${plan.confidence}${plan.notes !== undefined ? ` — ${plan.notes}` : ""}`);
606
- // Step 3: Execute the plan.
819
+ // Steps 2-5: plan the form, fill it, submit via the
820
+ // verify-and-replan loop (F3). The planner picks selectors from
821
+ // a DOM-grounded element inventory; a bad pick re-plans instead
822
+ // of cascading through 10s timeouts to total failure.
607
823
  const fillValues = {
608
824
  email: task.email,
609
825
  password,
610
826
  name: displayName,
611
827
  username,
612
828
  company: "Trusty Squire",
613
- // `literal` has no fixed value — resolved per-action below.
829
+ // `literal` has no fixed value — resolved per-action.
614
830
  literal: "",
615
831
  };
616
- await this.executePlan(plan, fillValues, steps);
617
- // Tier 2 captcha (pre-submit): check for a visible
618
- // Turnstile/reCAPTCHA widget rendered inline with the form.
619
- // Many sites render the widget alongside the form on first
620
- // load; failing to interact with it leaves cf-turnstile-response
621
- // empty and the submit gets server-side-rejected with a generic
622
- // validation error we'd waste a re-plan trying to debug.
623
- const preSubmitGate = await this.runCaptchaGate("Pre-submit", steps);
624
- if (preSubmitGate.blocked) {
625
- return {
626
- success: false,
627
- error: `captcha_blocked: visible ${preSubmitGate.kind} challenge did not resolve. The site flagged this session.`,
628
- steps,
629
- ...this.resultTail(),
630
- };
631
- }
632
- // Step 4: Submit. clickSubmit() disambiguates when the planned
633
- // selector matches several button[type=submit] (OAuth buttons are
634
- // submit-typed too). A submit click that fails means the form was
635
- // never submitted — fail fast here rather than fall through into
636
- // the multi-minute verification-email poll for an email that can
637
- // never arrive.
638
- steps.push(`Submit → ${plan.submit_selector}`);
639
- try {
640
- await this.browser.clickSubmit(plan.submit_selector);
641
- }
642
- catch (err) {
643
- const reason = err instanceof Error ? err.message : String(err);
644
- steps.push(`⚠ submit click failed: ${reason}`);
645
- return {
646
- success: false,
647
- error: `submit_failed: could not click the signup button — ${reason}`,
648
- steps,
649
- ...this.resultTail(),
650
- };
651
- }
652
- await this.browser.wait(5);
653
- // Tier 2 captcha (post-submit): some services only render the
654
- // challenge after form submission (deferred rendering). Same
655
- // shape as the pre-submit check.
656
- const postSubmitGate = await this.runCaptchaGate("Post-submit", steps);
657
- if (postSubmitGate.blocked) {
658
- return {
659
- success: false,
660
- error: `captcha_blocked: post-submit ${postSubmitGate.kind} challenge did not resolve.`,
661
- steps,
662
- ...this.resultTail(),
663
- };
664
- }
665
- if (postSubmitGate.found && postSubmitGate.solved) {
666
- // Re-click submit so the populated token ships with the form.
667
- try {
668
- await this.browser.click(plan.submit_selector);
669
- await this.browser.wait(3);
670
- }
671
- catch (err) {
672
- steps.push(`⚠ post-captcha submit retry failed: ${err instanceof Error ? err.message : String(err)}`);
673
- }
674
- }
675
- await saveDebugSnapshot(this.browser, "after-submit");
676
- // Step 5: Detect post-submit validation errors — if visible text contains
677
- // hints like "required", "must be between", "please accept", we re-plan
678
- // once with the new state. This handles the Postmark-style server-side
679
- // validation case.
680
- const afterSubmitText = (await this.browser.extractText()).slice(0, 4000);
681
- if (this.looksLikeValidationFailure(afterSubmitText)) {
682
- steps.push("Post-submit text suggests validation errors — re-planning...");
683
- const state2 = await this.browser.getState();
684
- const plan2 = await this.planSignupForm({
685
- service: task.service,
686
- url: state2.url,
687
- html: state2.html,
688
- screenshot: state2.screenshot,
689
- hint: `Previous submit produced validation errors. Visible page text snippet: ${afterSubmitText.slice(0, 800)}`,
690
- });
691
- await this.executePlan(plan2, fillValues, steps);
692
- // Re-plan path: same captcha guard as initial submit. If the
693
- // first submit triggered captcha rendering, the second pass
694
- // sees it inline.
695
- const replanGate = await this.runCaptchaGate("Re-plan", steps);
696
- if (replanGate.blocked) {
832
+ const outcome = await this.planExecuteWithRetry(task, fillValues, steps);
833
+ switch (outcome.kind) {
834
+ case "captcha_blocked":
697
835
  return {
698
836
  success: false,
699
- error: `captcha_blocked: re-plan ${replanGate.kind} challenge did not resolve.`,
837
+ error: `captcha_blocked: ${outcome.captchaKind} challenge did not resolve. The site flagged this session.`,
700
838
  steps,
701
839
  ...this.resultTail(),
702
840
  };
703
- }
704
- try {
705
- await this.browser.clickSubmit(plan2.submit_selector);
706
- }
707
- catch (err) {
708
- const reason = err instanceof Error ? err.message : String(err);
709
- steps.push(`⚠ re-plan submit click failed: ${reason}`);
841
+ case "submit_failed":
710
842
  return {
711
843
  success: false,
712
- error: `submit_failed: re-plan submit could not click the signup button — ${reason}`,
844
+ error: `submit_failed: could not click the signup button — ${outcome.reason}`,
713
845
  steps,
714
846
  ...this.resultTail(),
715
847
  };
716
- }
717
- await this.browser.wait(5);
718
- await saveDebugSnapshot(this.browser, "after-resubmit");
848
+ case "planning_failed":
849
+ return {
850
+ success: false,
851
+ error: `planning_failed: ${outcome.reason}`,
852
+ steps,
853
+ ...this.resultTail(),
854
+ };
855
+ case "oauth_required":
856
+ return {
857
+ success: false,
858
+ error: `oauth_required: ${task.service} offers only OAuth/SSO signup — there is no email/password form to automate.`,
859
+ steps,
860
+ ...this.resultTail(),
861
+ };
862
+ case "submitted":
863
+ break;
719
864
  }
865
+ await saveDebugSnapshot(this.browser, "after-submit");
720
866
  // Step 6: Extract creds from page.
721
867
  steps.push("Extracting credentials from page...");
722
868
  let credentials = await this.extractCredentials();
@@ -794,62 +940,62 @@ export class SignupAgent {
794
940
  }
795
941
  // ------------ Claude planner ------------
796
942
  async planSignupForm(input) {
797
- // Trim HTML to just <form>...</form> regions if possible the prompt
798
- // budget matters and most pages have a lot of marketing chrome.
799
- const trimmedHtml = this.extractFormHtml(input.html);
800
- const systemPrompt = `You analyze a web signup form and emit a JSON plan describing how to fill it.
943
+ const systemPrompt = `You plan how to fill a web signup form.
944
+
945
+ You are given a screenshot of the page and an INVENTORY of its
946
+ interactive elements each line carries a precise \`selector=\` the
947
+ bot has already verified resolves. Your job: pick which inventory
948
+ elements to fill / check / click.
949
+
801
950
  Output rules:
802
951
  - Reply with ONE JSON object only. No prose, no markdown.
803
952
  - Schema:
804
953
  {
805
954
  "actions": [
806
- {"kind":"fill","selector":"CSS_SELECTOR","value_kind":"email|password|name|username|company|literal","literal":"only when value_kind=literal","reason":"why"},
807
- {"kind":"check","selector":"CSS_SELECTOR","reason":"TOS / marketing-opt-in / etc."},
808
- {"kind":"click","selector":"CSS_SELECTOR","reason":"e.g. accept cookies before form is reachable"}
955
+ {"kind":"fill","selector":"<a selector= copied verbatim from the inventory>","value_kind":"email|password|name|username|company|literal","literal":"only when value_kind=literal","reason":"why"},
956
+ {"kind":"check","selector":"<from inventory>","reason":"TOS checkbox etc."},
957
+ {"kind":"click","selector":"<from inventory>","reason":"e.g. reveal the email form"}
809
958
  ],
810
- "submit_selector": "CSS_SELECTOR for the primary signup button",
959
+ "submit_selector": "<a selector= from the inventory — the primary signup button>",
811
960
  "confidence": "high|medium|low",
812
- "notes": "optional caveats"
961
+ "notes": "optional"
813
962
  }
814
- - Prefer stable selectors: name attributes, id, then aria-label. Avoid nth-child unless unavoidable.
815
- - Include the TOS/agree checkbox if one is required.
816
- - If a cookie banner is blocking the form, click "Accept" first.
817
- - Do NOT include password confirmation as a separate action unless the form has a visible second password field.
818
- - Skip optional/marketing-opt-in checkboxes.
819
- - For "name" use a realistic full name. For "username" generate a plausible 7-15 char handle.`;
963
+ - CRITICAL: every "selector" you emit MUST be copied verbatim from a
964
+ \`selector=\` field in the inventory below. Never invent, guess, or
965
+ modify a selector. A selector not in the inventory is rejected and
966
+ you will be asked to re-plan.
967
+ - Include the TOS/agree checkbox if the form has one.
968
+ - Skip elements marked [cookie-consent avoid], and skip optional
969
+ marketing-opt-in checkboxes.
970
+ - Do NOT add a separate password-confirmation fill unless the
971
+ inventory shows a second password field.
972
+ - Two-stage pages: if the inventory has only buttons (e.g. "Sign up
973
+ with email" / "Continue with Google") and no input fields, emit a
974
+ single click action on the EMAIL-signup button, and set
975
+ submit_selector to that same button.
976
+ - For "name" use a realistic full name; for "username" a plausible
977
+ 7-15 char handle.`;
978
+ const hintLine = input.hint !== undefined ? `\nHint: ${input.hint}` : "";
820
979
  const userBlocks = [
821
980
  { kind: "image", media_type: "image/png", data_base64: input.screenshot },
822
981
  {
823
982
  kind: "text",
824
983
  text: `Service: ${input.service}
825
- URL: ${input.url}
826
- ${input.hint !== undefined ? `Hint: ${input.hint}\n` : ""}
827
- Form HTML (trimmed):
828
- ${trimmedHtml}`,
984
+ URL: ${input.url}${hintLine}
985
+
986
+ Interactive element inventory:
987
+ ${formatInventory(input.inventory)}`,
829
988
  },
830
989
  ];
990
+ // F3 T4: the planner may only pick selectors the bot supplied.
991
+ const allowed = new Set(input.inventory.map((e) => e.selector));
831
992
  return this.callLLM({
832
993
  system: systemPrompt,
833
994
  userBlocks,
834
995
  maxTokens: 1500,
835
- parse: parseSignupPlan,
996
+ parse: (raw) => parseSignupPlan(raw, allowed),
836
997
  });
837
998
  }
838
- // Extract just <form> elements from the HTML — drops marketing + scripts.
839
- extractFormHtml(html) {
840
- const forms = [];
841
- const re = /<form\b[\s\S]*?<\/form>/gi;
842
- let m;
843
- while ((m = re.exec(html)) !== null)
844
- forms.push(m[0]);
845
- const joined = forms.join("\n");
846
- if (joined.length === 0) {
847
- // No <form> — fall back to body but cap aggressively
848
- return html.slice(0, 20000);
849
- }
850
- // Cap at 20k chars (~5k tokens) to leave room for the screenshot and reply
851
- return joined.slice(0, 20000);
852
- }
853
999
  looksLikeValidationFailure(text) {
854
1000
  const t = text.toLowerCase();
855
1001
  return (t.includes("must be between") ||