explorbot 0.5.4 → 0.5.6

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.
@@ -17,25 +17,20 @@ npm install -g prima-cli # or install it
17
17
 
18
18
  Prima also ships inside [explorbot](https://www.npmjs.com/package/explorbot), so `npx explorbot prima <command>` runs the same tool if you already have it.
19
19
 
20
- Requires Node.js 24+. Playwright browsers come from `npx playwright install chromium`.
20
+ Requires Node.js 22.13+. Playwright browsers come from `npx playwright install chromium`.
21
21
 
22
22
  ## Session
23
23
 
24
- Prima needs a browser to drive. Either attach to a `playwright-cli` session:
24
+ Prima drives a browser opened by `playwright-cli` and never launches one itself:
25
25
 
26
26
  ```bash
27
27
  playwright-cli open https://app.example.com
28
+ prima-cli browser
28
29
  prima-cli check "the settings page saves a changed theme"
29
30
  playwright-cli close
30
31
  ```
31
32
 
32
- Or let prima own the browser:
33
-
34
- ```bash
35
- prima-cli browser start --url https://app.example.com
36
- prima-cli do "open the account menu" "choose the settings entry"
37
- prima-cli browser stop
38
- ```
33
+ `browser` checks that a `playwright-cli` session is open and fails with the command to open one when it is not.
39
34
 
40
35
  ## Commands
41
36
 
@@ -50,7 +45,7 @@ prima-cli browser stop
50
45
  | `pw <fn>` | Run a Playwright function expression against the open page |
51
46
  | `status <hash>` | Show the artifacts and page detail recorded for an earlier command |
52
47
  | `report` | Turn every command of a session into one html and markdown report |
53
- | `browser start\|stop\|status\|list` | Manage the browsers prima drives |
48
+ | `browser` | Check that a playwright-cli session is open, and fail with the command to open one when it is not |
54
49
  | `config` | Show models, config file and paths used by this run |
55
50
 
56
51
  `check` takes an outcome rather than a click path. It runs on the page you are already on and never reloads it, so an open dialog survives the check. Each `--expected` outcome comes back as PASSED, FAILED, CONTRADICTION or not verified — settled against a screenshot of the whole page, because what a user can see is the proof.
@@ -15,6 +15,6 @@
15
15
  "directory": "boat/prima"
16
16
  },
17
17
  "engines": {
18
- "node": ">=24.0.0"
18
+ "node": ">=22.13.0"
19
19
  }
20
20
  }
@@ -1,5 +1,4 @@
1
1
  import { Command } from 'commander';
2
- import { keepServerRunning } from '../../../src/browser-server.ts';
3
2
  import { RecommendedModelsCommand } from '../../../src/commands/recommended-models-command.ts';
4
3
  import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
5
4
  import { isVerboseMode, setQuietMode } from '../../../src/utils/logger.ts';
@@ -22,8 +21,6 @@ function buildOptions(subcommand: any): PrimaOptions {
22
21
  noVision: options.vision === false,
23
22
  url: options.url,
24
23
  baseUrl: options.baseUrl,
25
- show: options.show,
26
- headless: options.headless,
27
24
  endpoint: options.endpoint,
28
25
  pwSession: options.pwSession,
29
26
  };
@@ -89,18 +86,6 @@ async function runPrima(options: any, command: string, run: (prima: Prima) => Pr
89
86
  process.exit(envelope.ok ? 0 : 1);
90
87
  }
91
88
 
92
- async function runBrowser(options: any, run: (prima: Prima) => Promise<boolean>): Promise<void> {
93
- let ok = false;
94
- try {
95
- ok = await run(primaFor(options));
96
- } catch (error) {
97
- console.error(browserErrorMessage(error));
98
- process.exit(1);
99
- }
100
-
101
- process.exit(ok ? 0 : 1);
102
- }
103
-
104
89
  export function createPrimaCommands(name = 'prima'): Command {
105
90
  const cmd = new Command(name);
106
91
  cmd.description('Drives a web app through described behaviour, not locators');
@@ -179,41 +164,14 @@ export function createPrimaCommands(name = 'prima'): Command {
179
164
  process.exit(0);
180
165
  });
181
166
 
182
- const browser = cmd.command('browser').description('Manage the browsers prima drives');
183
-
184
- addBrowserOptions(browser.command('start').description('Start a prima-owned browser and hold it open until Ctrl+C'))
185
- .option('-s, --show', 'Launch the browser in a visible window')
186
- .option('--headless', 'Launch the browser without a window')
187
- .action(async (options) => {
188
- await runBrowser(options, async (prima) => {
189
- await prima.browserStart();
190
- console.log(await prima.browserStatus());
191
- return keepServerRunning(() => prima.browserStop());
192
- });
193
- });
194
-
195
- addBrowserOptions(browser.command('stop').description('Stop the browser of this instance'))
196
- .option('--all', 'Stop every running instance')
197
- .action(async (options) => {
198
- await runBrowser(options, async (prima) => {
199
- const stopped = await prima.browserStop(options.all);
200
- console.log(await prima.browserStatus());
201
- return stopped;
202
- });
203
- });
204
-
205
- addBrowserOptions(browser.command('status').description('Report the browser of this instance')).action(async (options) => {
206
- await runBrowser(options, async (prima) => {
207
- console.log(await prima.browserStatus());
208
- return true;
209
- });
210
- });
211
-
212
- addBrowserOptions(browser.command('list').description('List every browser instance that is running')).action(async (options) => {
213
- await runBrowser(options, async (prima) => {
214
- console.log(await prima.browserList());
215
- return true;
216
- });
167
+ addBrowserOptions(cmd.command('browser').description('Check that a playwright-cli session is open for prima to drive')).action(async (options) => {
168
+ try {
169
+ console.log(await primaFor(options).browserCheck());
170
+ } catch (error) {
171
+ console.error(browserErrorMessage(error));
172
+ process.exit(1);
173
+ }
174
+ process.exit(0);
217
175
  });
218
176
 
219
177
  return cmd;
@@ -12,7 +12,7 @@ import { JUDGE_PAGE_CAP, UNDECIDED } from '../../../src/ai/judge.ts';
12
12
  import { getPreviousResearch } from '../../../src/ai/researcher/cache.ts';
13
13
  import { actionRule, locatorRule } from '../../../src/ai/rules.ts';
14
14
  import { createAgentTools, createCodeceptJSTools, createRefTools } from '../../../src/ai/tools.ts';
15
- import { getAliveEndpoint, launchServer, listInstances, stopServer } from '../../../src/browser-server.ts';
15
+ import { getAliveEndpoint, listInstances } from '../../../src/browser-server.ts';
16
16
  import { ConfigCommand } from '../../../src/commands/config-command.ts';
17
17
  import { ConfigMissingError, ConfigParser, EXPLORBOT_ENV_VARS, type ExplorbotConfig, outputPath } from '../../../src/config.ts';
18
18
  import { ExplorBot } from '../../../src/explorbot.ts';
@@ -73,7 +73,6 @@ export class Prima {
73
73
  private artifactsDir?: string;
74
74
  private hash?: string;
75
75
  private sessionUrl?: string;
76
- private server: { close: () => Promise<void> } | null = null;
77
76
  private attached: string | null = null;
78
77
  private session: SessionRun | null = null;
79
78
 
@@ -503,25 +502,12 @@ export class Prima {
503
502
  return envelope;
504
503
  }
505
504
 
506
- async browserStart(): Promise<void> {
507
- const config = await this.loadConfig();
508
- let show = config.playwright.show || false;
509
- if (this.options.show) show = true;
510
- if (this.options.headless) show = false;
511
- this.server = await this.launchOwnServer({ browser: config.playwright.browser, show }, this.instanceName());
512
- }
513
-
514
- async browserStop(all = false): Promise<boolean> {
505
+ async browserCheck(): Promise<string> {
515
506
  await this.loadConfig();
516
- if (this.attached) return false;
517
-
518
- if (!all) return this.stopInstance(this.instanceName());
519
-
520
- let stopped = false;
521
- for (const instance of listInstances()) {
522
- if (await this.stopInstance(instance.name)) stopped = true;
523
- }
524
- return stopped;
507
+ const { match, candidates, browser } = await this.discover();
508
+ await browser?.close().catch(() => {});
509
+ if (!match) throw this.missingSessionError(candidates);
510
+ return `browser: ${this.attachmentLabel(match)} at ${match.endpoint}`;
525
511
  }
526
512
 
527
513
  async config(json?: boolean): Promise<string> {
@@ -572,37 +558,6 @@ export class Prima {
572
558
  ].join('\n');
573
559
  }
574
560
 
575
- async browserStatus(): Promise<string> {
576
- await this.loadConfig();
577
- const info = await this.instanceInfo();
578
- const endpoint = await getAliveEndpoint(info.name);
579
- const others = info.others.map((other) => other.name).join(', ') || 'none';
580
- const lines = [`instance: ${info.name} (${info.tabs} ${pluralize(info.tabs, 'tab')}) | other instances: ${others}`];
581
- if (!endpoint) lines.push('browser: not running');
582
- if (endpoint) lines.push(`browser: running at ${endpoint}`);
583
- return lines.join('\n');
584
- }
585
-
586
- async browserList(): Promise<string> {
587
- await this.loadConfig();
588
-
589
- const lines: string[] = [];
590
- for (const instance of listInstances()) {
591
- const endpoint = await getAliveEndpoint(instance.name);
592
- if (!endpoint) continue;
593
- lines.push(`prima --instance ${instance.name} ${endpoint}`);
594
- }
595
-
596
- const discovery = await this.discover();
597
- await discovery.browser?.close().catch(() => {});
598
- for (const descriptor of discovery.candidates) {
599
- lines.push(`playwright-cli --pw-session ${descriptor.title} ${descriptor.endpoint}`);
600
- }
601
-
602
- if (!lines.length) return 'no browser instances running';
603
- return lines.join('\n');
604
- }
605
-
606
561
  async instanceInfo(): Promise<InstanceInfo> {
607
562
  const name = this.instanceName();
608
563
  const others = listInstances()
@@ -666,22 +621,27 @@ export class Prima {
666
621
  const { match, candidates, browser } = discovered || (await this.discover());
667
622
  if (match && (await this.attachToEndpoint(match, browser))) return;
668
623
 
669
- if (!match && candidates.length) {
624
+ if (!match && candidates.length) throw this.missingSessionError(candidates);
625
+
626
+ if (await this.connectOwnInstance()) return;
627
+
628
+ throw this.missingSessionError(candidates);
629
+ }
630
+
631
+ private missingSessionError(candidates: PwServerDescriptor[]): Error {
632
+ if (candidates.length) {
670
633
  const titles = candidates.map((candidate) => candidate.title).join(', ');
671
- throw new Error(dedent`
634
+ return new Error(dedent`
672
635
  Several playwright-cli sessions are open: ${titles}
673
636
  Pick one with --pw-session <title>.
674
637
  `);
675
638
  }
676
639
 
677
- if (await this.connectOwnInstance()) return;
678
-
679
- throw new Error(dedent`
680
- No browser to drive for instance "${this.instanceName()}".
681
- Open one first:
682
- playwright-cli open <url> prima attaches to this workspace session by default
683
- prima browser start starts a prima-owned browser instead
684
- Prima never launches a browser implicitly.
640
+ return new Error(dedent`
641
+ No playwright-cli session is open for prima to drive.
642
+ Start one first:
643
+ playwright-cli open ${this.options.url || '<url>'}
644
+ Prima attaches to this workspace session by default and never launches a browser itself.
685
645
  `);
686
646
  }
687
647
 
@@ -751,20 +711,6 @@ export class Prima {
751
711
  return true;
752
712
  }
753
713
 
754
- private async launchOwnServer(opts: { browser?: string; show?: boolean }, instance: string): Promise<{ close: () => Promise<void> }> {
755
- return launchServer(opts, instance);
756
- }
757
-
758
- private async stopInstance(instance: string): Promise<boolean> {
759
- if (instance === this.instanceName()) {
760
- const server = this.server;
761
- this.server = null;
762
- await server?.close();
763
- }
764
-
765
- return stopServer(instance);
766
- }
767
-
768
714
  private isUrlTarget(target: string): boolean {
769
715
  const value = target.trim();
770
716
  if (value.startsWith('/')) return true;
@@ -1229,8 +1175,6 @@ export interface PrimaOptions {
1229
1175
  noVision?: boolean;
1230
1176
  url?: string;
1231
1177
  baseUrl?: string;
1232
- show?: boolean;
1233
- headless?: boolean;
1234
1178
  endpoint?: string;
1235
1179
  pwSession?: string;
1236
1180
  }
@@ -1,5 +1,4 @@
1
1
  import { Command } from 'commander';
2
- import { keepServerRunning } from "../../../src/browser-server.js";
3
2
  import { RecommendedModelsCommand } from "../../../src/commands/recommended-models-command.js";
4
3
  import { browserErrorMessage } from "../../../src/utils/browser-errors.js";
5
4
  import { isVerboseMode, setQuietMode } from "../../../src/utils/logger.js";
@@ -20,8 +19,6 @@ function buildOptions(subcommand) {
20
19
  noVision: options.vision === false,
21
20
  url: options.url,
22
21
  baseUrl: options.baseUrl,
23
- show: options.show,
24
- headless: options.headless,
25
22
  endpoint: options.endpoint,
26
23
  pwSession: options.pwSession,
27
24
  };
@@ -87,17 +84,6 @@ async function runPrima(options, command, run, opts = {}) {
87
84
  await prima.stop().catch(() => { });
88
85
  process.exit(envelope.ok ? 0 : 1);
89
86
  }
90
- async function runBrowser(options, run) {
91
- let ok = false;
92
- try {
93
- ok = await run(primaFor(options));
94
- }
95
- catch (error) {
96
- console.error(browserErrorMessage(error));
97
- process.exit(1);
98
- }
99
- process.exit(ok ? 0 : 1);
100
- }
101
87
  export function createPrimaCommands(name = 'prima') {
102
88
  const cmd = new Command(name);
103
89
  cmd.description('Drives a web app through described behaviour, not locators');
@@ -163,37 +149,15 @@ export function createPrimaCommands(name = 'prima') {
163
149
  .catch((error) => browserErrorMessage(error)));
164
150
  process.exit(0);
165
151
  });
166
- const browser = cmd.command('browser').description('Manage the browsers prima drives');
167
- addBrowserOptions(browser.command('start').description('Start a prima-owned browser and hold it open until Ctrl+C'))
168
- .option('-s, --show', 'Launch the browser in a visible window')
169
- .option('--headless', 'Launch the browser without a window')
170
- .action(async (options) => {
171
- await runBrowser(options, async (prima) => {
172
- await prima.browserStart();
173
- console.log(await prima.browserStatus());
174
- return keepServerRunning(() => prima.browserStop());
175
- });
176
- });
177
- addBrowserOptions(browser.command('stop').description('Stop the browser of this instance'))
178
- .option('--all', 'Stop every running instance')
179
- .action(async (options) => {
180
- await runBrowser(options, async (prima) => {
181
- const stopped = await prima.browserStop(options.all);
182
- console.log(await prima.browserStatus());
183
- return stopped;
184
- });
185
- });
186
- addBrowserOptions(browser.command('status').description('Report the browser of this instance')).action(async (options) => {
187
- await runBrowser(options, async (prima) => {
188
- console.log(await prima.browserStatus());
189
- return true;
190
- });
191
- });
192
- addBrowserOptions(browser.command('list').description('List every browser instance that is running')).action(async (options) => {
193
- await runBrowser(options, async (prima) => {
194
- console.log(await prima.browserList());
195
- return true;
196
- });
152
+ addBrowserOptions(cmd.command('browser').description('Check that a playwright-cli session is open for prima to drive')).action(async (options) => {
153
+ try {
154
+ console.log(await primaFor(options).browserCheck());
155
+ }
156
+ catch (error) {
157
+ console.error(browserErrorMessage(error));
158
+ process.exit(1);
159
+ }
160
+ process.exit(0);
197
161
  });
198
162
  return cmd;
199
163
  }
@@ -11,7 +11,7 @@ import { JUDGE_PAGE_CAP, UNDECIDED } from "../../../src/ai/judge.js";
11
11
  import { getPreviousResearch } from "../../../src/ai/researcher/cache.js";
12
12
  import { actionRule, locatorRule } from "../../../src/ai/rules.js";
13
13
  import { createAgentTools, createCodeceptJSTools, createRefTools } from "../../../src/ai/tools.js";
14
- import { getAliveEndpoint, launchServer, listInstances, stopServer } from "../../../src/browser-server.js";
14
+ import { getAliveEndpoint, listInstances } from "../../../src/browser-server.js";
15
15
  import { ConfigCommand } from "../../../src/commands/config-command.js";
16
16
  import { ConfigMissingError, ConfigParser, EXPLORBOT_ENV_VARS, outputPath } from "../../../src/config.js";
17
17
  import { ExplorBot } from "../../../src/explorbot.js";
@@ -67,7 +67,6 @@ export class Prima {
67
67
  artifactsDir;
68
68
  hash;
69
69
  sessionUrl;
70
- server = null;
71
70
  attached = null;
72
71
  session = null;
73
72
  constructor(options = {}) {
@@ -460,27 +459,13 @@ export class Prima {
460
459
  envelope.page.redirectedFrom = target;
461
460
  return envelope;
462
461
  }
463
- async browserStart() {
464
- const config = await this.loadConfig();
465
- let show = config.playwright.show || false;
466
- if (this.options.show)
467
- show = true;
468
- if (this.options.headless)
469
- show = false;
470
- this.server = await this.launchOwnServer({ browser: config.playwright.browser, show }, this.instanceName());
471
- }
472
- async browserStop(all = false) {
462
+ async browserCheck() {
473
463
  await this.loadConfig();
474
- if (this.attached)
475
- return false;
476
- if (!all)
477
- return this.stopInstance(this.instanceName());
478
- let stopped = false;
479
- for (const instance of listInstances()) {
480
- if (await this.stopInstance(instance.name))
481
- stopped = true;
482
- }
483
- return stopped;
464
+ const { match, candidates, browser } = await this.discover();
465
+ await browser?.close().catch(() => { });
466
+ if (!match)
467
+ throw this.missingSessionError(candidates);
468
+ return `browser: ${this.attachmentLabel(match)} at ${match.endpoint}`;
484
469
  }
485
470
  async config(json) {
486
471
  const [site] = listSites();
@@ -529,36 +514,6 @@ export class Prima {
529
514
  `upload: TESTOMATIO=<apiKey> npx @testomatio/reporter replay ${file}`,
530
515
  ].join('\n');
531
516
  }
532
- async browserStatus() {
533
- await this.loadConfig();
534
- const info = await this.instanceInfo();
535
- const endpoint = await getAliveEndpoint(info.name);
536
- const others = info.others.map((other) => other.name).join(', ') || 'none';
537
- const lines = [`instance: ${info.name} (${info.tabs} ${pluralize(info.tabs, 'tab')}) | other instances: ${others}`];
538
- if (!endpoint)
539
- lines.push('browser: not running');
540
- if (endpoint)
541
- lines.push(`browser: running at ${endpoint}`);
542
- return lines.join('\n');
543
- }
544
- async browserList() {
545
- await this.loadConfig();
546
- const lines = [];
547
- for (const instance of listInstances()) {
548
- const endpoint = await getAliveEndpoint(instance.name);
549
- if (!endpoint)
550
- continue;
551
- lines.push(`prima --instance ${instance.name} ${endpoint}`);
552
- }
553
- const discovery = await this.discover();
554
- await discovery.browser?.close().catch(() => { });
555
- for (const descriptor of discovery.candidates) {
556
- lines.push(`playwright-cli --pw-session ${descriptor.title} ${descriptor.endpoint}`);
557
- }
558
- if (!lines.length)
559
- return 'no browser instances running';
560
- return lines.join('\n');
561
- }
562
517
  async instanceInfo() {
563
518
  const name = this.instanceName();
564
519
  const others = listInstances()
@@ -621,21 +576,25 @@ export class Prima {
621
576
  const { match, candidates, browser } = discovered || (await this.discover());
622
577
  if (match && (await this.attachToEndpoint(match, browser)))
623
578
  return;
624
- if (!match && candidates.length) {
579
+ if (!match && candidates.length)
580
+ throw this.missingSessionError(candidates);
581
+ if (await this.connectOwnInstance())
582
+ return;
583
+ throw this.missingSessionError(candidates);
584
+ }
585
+ missingSessionError(candidates) {
586
+ if (candidates.length) {
625
587
  const titles = candidates.map((candidate) => candidate.title).join(', ');
626
- throw new Error(dedent `
588
+ return new Error(dedent `
627
589
  Several playwright-cli sessions are open: ${titles}
628
590
  Pick one with --pw-session <title>.
629
591
  `);
630
592
  }
631
- if (await this.connectOwnInstance())
632
- return;
633
- throw new Error(dedent `
634
- No browser to drive for instance "${this.instanceName()}".
635
- Open one first:
636
- playwright-cli open <url> prima attaches to this workspace session by default
637
- prima browser start starts a prima-owned browser instead
638
- Prima never launches a browser implicitly.
593
+ return new Error(dedent `
594
+ No playwright-cli session is open for prima to drive.
595
+ Start one first:
596
+ playwright-cli open ${this.options.url || '<url>'}
597
+ Prima attaches to this workspace session by default and never launches a browser itself.
639
598
  `);
640
599
  }
641
600
  async discover(descriptors = readDescriptors()) {
@@ -703,17 +662,6 @@ export class Prima {
703
662
  this.session = { key: this.instanceName(), endpoint, title: `prima instance "${this.instanceName()}"` };
704
663
  return true;
705
664
  }
706
- async launchOwnServer(opts, instance) {
707
- return launchServer(opts, instance);
708
- }
709
- async stopInstance(instance) {
710
- if (instance === this.instanceName()) {
711
- const server = this.server;
712
- this.server = null;
713
- await server?.close();
714
- }
715
- return stopServer(instance);
716
- }
717
665
  isUrlTarget(target) {
718
666
  const value = target.trim();
719
667
  if (value.startsWith('/'))
package/dist/models.json CHANGED
@@ -2,7 +2,8 @@
2
2
  "openrouter": {
3
3
  "model": "openai/gpt-oss-20b:nitro",
4
4
  "visionModel": "openai/gpt-5.6-luna",
5
- "agenticModel": "openai/gpt-5.6-luna"
5
+ "agenticModel": "anthropic/claude-haiku-5.5",
6
+ "decisionModel": "typesafe/jev-latest"
6
7
  },
7
8
  "poolside": {
8
9
  "model": "poolside/laguna-xs-2.1"
@@ -14,13 +15,13 @@
14
15
  },
15
16
  "openai": {
16
17
  "model": "gpt-5-nano",
17
- "visionModel": "gpt-5.6-luna",
18
- "agenticModel": "gpt-5.6-luna"
18
+ "visionModel": "gpt-6-luna",
19
+ "agenticModel": "gpt-6-luna"
19
20
  },
20
21
  "anthropic": {
21
- "model": "claude-haiku-4-5-20251001",
22
- "visionModel": "claude-haiku-4-5-20251001",
23
- "agenticModel": "claude-haiku-4-5-20251001"
22
+ "model": "claude-haiku-5-5",
23
+ "visionModel": "claude-haiku-5-5",
24
+ "agenticModel": "claude-haiku-5-5"
24
25
  },
25
26
  "mistral": {
26
27
  "model": "mistral-small-latest",
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -7,6 +7,7 @@ import { normalizeUrl } from '../state-manager.js';
7
7
  import { isFatalBrowserError } from "../utils/browser-errors.js";
8
8
  import { getCliName } from "../utils/cli-name.js";
9
9
  import { extractCodeBlocks } from '../utils/code-extractor.js';
10
+ import { isLoginPage } from "../utils/error-page.js";
10
11
  import { HooksRunner } from "../utils/hooks-runner.js";
11
12
  import { createDebug, pluralize, tag } from '../utils/logger.js';
12
13
  import { loop, pause } from '../utils/loop.js';
@@ -15,7 +16,7 @@ import { normalizeInlineText } from "../utils/strings.js";
15
16
  import { extractStatePath, isSameHostFamily, matchesNavigationUrl } from '../utils/url-matcher.js';
16
17
  import { JUDGE_PAGE_CAP, UNDECIDED } from "./judge.js";
17
18
  import { Researcher } from "./researcher.js";
18
- import { actionRule, locatorRule, unexpectedPopupRule } from './rules.js';
19
+ import { actionRule, credentialsRule, locatorRule, unexpectedPopupRule } from './rules.js';
19
20
  import { isInteractive } from './task-agent.js';
20
21
  import { createLearnExperienceTool } from "./tools.js";
21
22
  const debugLog = createDebug('explorbot:navigator');
@@ -46,7 +47,7 @@ class Navigator {
46
47
  NEVER use executeScript, executeAsyncScript, or any JS evaluation to change the URL, bypass redirects, or fake the page state.
47
48
  If the target URL redirects to an authentication/login page, DO NOT try to force the original URL. Instead:
48
49
  1. Look for credentials in the provided knowledge/hint context and perform a real login through the form.
49
- 2. If no credentials are available, ask the user for credentials or ask the user to log in manually.
50
+ 2. If no credentials are available, stop and name the credentials that are missing. Never guess them.
50
51
  A redirect to /login, /sign_in, /auth, or similar is a signal that authentication is required — treat it as such, never as an obstacle to bypass.
51
52
  </constraints>
52
53
  `;
@@ -344,6 +345,9 @@ class Navigator {
344
345
  if (!experience && !actionResult.isInsideIframe) {
345
346
  experience = this.experienceTracker.renderExperienceFor(actionResult);
346
347
  }
348
+ let credentials = '';
349
+ if (isLoginPage(actionResult))
350
+ credentials = credentialsRule;
347
351
  return dedent `
348
352
  <message>
349
353
  ${message}
@@ -369,6 +373,8 @@ class Navigator {
369
373
 
370
374
  ${unexpectedPopupRule}
371
375
 
376
+ ${credentials}
377
+
372
378
  ${RulesLoader.loadRules('navigator', ['multiple-locator', 'output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.MAX_ATTEMPTS))}
373
379
 
374
380
  ${experience}
@@ -3,6 +3,7 @@ export declare const locatorRule: string;
3
3
  export declare const drillLocatorRule: string;
4
4
  export declare const fileUploadRule: string;
5
5
  export declare const formRequirementsRule: string;
6
+ export declare const credentialsRule: string;
6
7
  export declare const protectionRule: string;
7
8
  export declare const dataProtectionRules: string;
8
9
  export declare const capabilityGroundingRule: string;
@@ -138,6 +138,13 @@ export const formRequirementsRule = dedent `
138
138
  Before filling a form that persists data (create/update), read each control's requirements (required, type/format, length, placeholder/aria-describedby hints) from <page_aria> and page HTML — call context() if not visible — and enter values that satisfy them. Search/filter/sort forms that only change the view do not need this.
139
139
  </form_requirements>
140
140
  `;
141
+ export const credentialsRule = dedent `
142
+ <credentials>
143
+ Sign in to an existing account only with credentials present in the provided context (message, knowledge, experience).
144
+ Never invent, guess, or try default, common, or placeholder credentials for an existing account.
145
+ If the required credentials are not provided, call stop() and name what is missing — do not submit the form.
146
+ </credentials>
147
+ `;
141
148
  // in rage mode we do not protect from irreversible actions
142
149
  export const protectionRule = dedent `
143
150
  <important>
@@ -225,7 +225,10 @@ export function modelLines(provider, only) {
225
225
  let selected = roles;
226
226
  if (only)
227
227
  selected = roles.filter(([role]) => only.includes(role));
228
- return selected.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
228
+ const lines = selected.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`);
229
+ if (!only && recommended.decisionModel)
230
+ lines.push(` // decision model judging ambiguous outcomes\n decisionModel: { provider: '${provider}', model: '${recommended.decisionModel}' },`);
231
+ return lines.join('\n');
229
232
  }
230
233
  function globalConfigTemplate(provider) {
231
234
  const { envKey } = PROVIDERS[provider];
@@ -44,6 +44,8 @@ export class RecommendedModelsCommand extends BaseCommand {
44
44
  if (!roles[role])
45
45
  models.push([label, chalk.dim('not served, pair with another provider')]);
46
46
  }
47
+ if (roles.decisionModel)
48
+ models.push(['decisionModel (judge)', roles.decisionModel]);
47
49
  lines.push(...renderSection('models', models, 2));
48
50
  const envKey = PROVIDERS[provider]?.envKey;
49
51
  if (!envKey) {
@@ -61,6 +63,8 @@ export class RecommendedModelsCommand extends BaseCommand {
61
63
  env.push([label, `${ROLES[role].env}=${provider}/${roles[role]}`]);
62
64
  label = '';
63
65
  }
66
+ if (roles.decisionModel)
67
+ env.push(['decision', `EXPLORBOT_DECISION_MODEL=${provider}/${roles.decisionModel}`]);
64
68
  lines.push(...renderSection('env variables', env, 2));
65
69
  }
66
70
  const modelVars = ['EXPLORBOT_AI_PROVIDER', ...MODEL_ENV_VARS];
@@ -137,6 +137,10 @@ export class ConfigParser {
137
137
  sourcePath = join(outputRoot, 'explorbot.config.js');
138
138
  log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
139
139
  }
140
+ const configuredUrl = loadedConfig?.playwright?.url || loadedConfig?.web?.url || '';
141
+ if (!this.runBase && resolvedPath && !isGlobalConfigPath(resolvedPath) && URL.canParse(configuredUrl)) {
142
+ this.runBase = parseRunBase(configuredUrl);
143
+ }
140
144
  let config = this.resolveConfig(loadedConfig);
141
145
  await resolveConfigModels(config.ai);
142
146
  this.site = null;
@@ -377,6 +381,8 @@ export class ConfigParser {
377
381
  ai.agenticModel = await resolveModel(agenticSpec, 'agenticModel');
378
382
  if (!agenticSpec && recommended.agenticModel)
379
383
  ai.agenticModel = await resolveModel(provider, 'agenticModel');
384
+ if (recommended.decisionModel)
385
+ ai.decisionModel = { provider: provider, model: recommended.decisionModel };
380
386
  const dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
381
387
  if (process.env.EXPLORBOT_OUTPUT)
382
388
  dirs.output = '.';
@@ -3,6 +3,7 @@ import type { WebPageState } from '../state-manager.js';
3
3
  export type PageCondition = 'ok' | 'loading' | 'error';
4
4
  export declare function detectPageCondition(actionResult: ActionResult): PageCondition;
5
5
  export declare function isErrorPage(actionResult: ActionResult): boolean;
6
+ export declare function isLoginPage(actionResult: ActionResult): boolean;
6
7
  export declare function getStateErrorPageError(state: WebPageState | null | undefined): ErrorPageError | null;
7
8
  export declare class ErrorPageError extends Error {
8
9
  readonly url: string;
@@ -1,8 +1,11 @@
1
1
  import { ActionResult } from '../action-result.js';
2
2
  import { isBodyEmpty } from './html.js';
3
+ import { extractStatePath } from './url-matcher.js';
3
4
  const HTTP_ERRORS = ['400 Bad Request', '401 Unauthorized', '403 Forbidden', '404 Not Found', '405 Method Not Allowed', '408 Request Timeout', '500 Internal Server Error', '502 Bad Gateway', '503 Service Unavailable', '504 Gateway Timeout'];
4
5
  const SMALL_PAGE_THRESHOLD = 500;
5
6
  const LOADING_WORD = /\bloading\b/i;
7
+ const PASSWORD_INPUT = /<input\b[^>]*\btype\s*=\s*["']?password\b/i;
8
+ const AUTH_PATH = /\b(log[-_]?in|sign[-_]?in|auth)\b/i;
6
9
  export function detectPageCondition(actionResult) {
7
10
  if (actionResult.httpStatus && actionResult.httpStatus >= 400)
8
11
  return 'error';
@@ -32,6 +35,11 @@ export function detectPageCondition(actionResult) {
32
35
  export function isErrorPage(actionResult) {
33
36
  return detectPageCondition(actionResult) === 'error';
34
37
  }
38
+ export function isLoginPage(actionResult) {
39
+ if (PASSWORD_INPUT.test(actionResult.html || ''))
40
+ return true;
41
+ return AUTH_PATH.test(extractStatePath(actionResult.url || '').split('?')[0]);
42
+ }
35
43
  export function getStateErrorPageError(state) {
36
44
  if (!state)
37
45
  return null;
@@ -57,6 +57,8 @@ export declare class MarkdownQuery {
57
57
  sectionsOf(candidates: MatchedRange[], segment: QuerySegment): MatchedRange[];
58
58
  itemsOf(candidates: MatchedRange[]): MatchedRange[];
59
59
  expandSections(matches: MatchedRange[]): MatchedRange[];
60
+ evaluate(candidates: MatchedRange[], expression: QueryExpression): MatchedRange[];
61
+ union(results: MatchedRange[][]): MatchedRange[];
60
62
  narrow(matches: MatchedRange[], segment: QuerySegment): MatchedRange[];
61
63
  filterByText(matches: MatchedRange[], segment: QuerySegment): MatchedRange[];
62
64
  matchText(text: string, matcher: TextMatcher): boolean;
@@ -77,7 +79,7 @@ export declare class MdqOperationError extends MdqError {
77
79
  constructor(message: string);
78
80
  }
79
81
  export declare function mdq(source: Markdown): MarkdownQuery;
80
- export declare function parseQuery(input: string): QuerySegment[];
82
+ export declare function parseQuery(input: string): QueryExpression;
81
83
  export declare function buildTokenIndex(source: string): MatchedRange[];
82
84
  export type Markdown = string | MarkdownQuery;
83
85
  export type Matcher = string | RegExp | ((text: string) => boolean);
@@ -92,6 +94,7 @@ export interface TextMatcher {
92
94
  flags?: string;
93
95
  predicate?: (text: string) => boolean;
94
96
  }
97
+ export type QueryExpression = QuerySegment[][][];
95
98
  export interface QuerySegment {
96
99
  selector: SelectorType;
97
100
  textMatch?: TextMatcher;
@@ -9,10 +9,13 @@ export class MarkdownQuery {
9
9
  this.matches = matches || buildTokenIndex(source);
10
10
  }
11
11
  query(selector, matcher) {
12
- const segments = parseQuery(selector);
13
- if (matcher !== undefined && segments.length > 0)
14
- segments[segments.length - 1].textMatch = this.valueMatcher(matcher);
15
- return new MarkdownQuery(this.source, this.run(this.expandSections(this.matches), segments));
12
+ const expression = parseQuery(selector);
13
+ for (const chain of expression.flat()) {
14
+ const last = chain.at(-1);
15
+ if (matcher !== undefined && last)
16
+ last.textMatch = this.valueMatcher(matcher);
17
+ }
18
+ return new MarkdownQuery(this.source, this.evaluate(this.expandSections(this.matches), expression));
16
19
  }
17
20
  section(matcher, options) {
18
21
  return this.query(`section${options?.depth || ''}`, matcher);
@@ -299,6 +302,27 @@ export class MarkdownQuery {
299
302
  }
300
303
  return expanded;
301
304
  }
305
+ evaluate(candidates, expression) {
306
+ const results = [];
307
+ for (const operands of expression) {
308
+ const matched = operands.map((chain) => this.run(candidates, chain));
309
+ if (matched.some((ranges) => ranges.length === 0))
310
+ continue;
311
+ results.push(...matched);
312
+ }
313
+ return this.union(results);
314
+ }
315
+ union(results) {
316
+ if (results.length === 1)
317
+ return results[0];
318
+ const unique = new Map();
319
+ for (const range of results.flat()) {
320
+ const key = `${range.start}:${range.length}:${Boolean(range.innerTokens)}`;
321
+ if (!unique.has(key))
322
+ unique.set(key, range);
323
+ }
324
+ return [...unique.values()].sort((a, b) => a.start - b.start || b.length - a.length);
325
+ }
302
326
  narrow(matches, segment) {
303
327
  if (segment.index !== null) {
304
328
  let index = segment.index;
@@ -390,10 +414,24 @@ export function mdq(source) {
390
414
  return new MarkdownQuery(String(source));
391
415
  }
392
416
  export function parseQuery(input) {
393
- const segments = [];
417
+ const expression = [[[]]];
418
+ let chain = expression[0][0];
394
419
  SEGMENT.lastIndex = 0;
395
420
  while (SEGMENT.lastIndex < input.length) {
396
421
  const at = SEGMENT.lastIndex;
422
+ OPERATOR.lastIndex = at;
423
+ const operator = OPERATOR.exec(input);
424
+ if (operator) {
425
+ if (chain.length === 0)
426
+ throw new MdqSelectorError(`Empty selector before ${operator[1]}`, at);
427
+ chain = [];
428
+ if (operator[1] === '||')
429
+ expression.push([chain]);
430
+ if (operator[1] === '&&')
431
+ expression[expression.length - 1].push(chain);
432
+ SEGMENT.lastIndex = OPERATOR.lastIndex;
433
+ continue;
434
+ }
397
435
  const match = SEGMENT.exec(input);
398
436
  if (!match)
399
437
  throw new MdqSelectorError(`Unexpected character "${input[at]}" in selector`, at);
@@ -424,9 +462,11 @@ export function parseQuery(input) {
424
462
  if (to)
425
463
  segment.slice.to = Number.parseInt(to, 10);
426
464
  }
427
- segments.push(segment);
465
+ chain.push(segment);
428
466
  }
429
- return segments;
467
+ if (expression.flat().length > 1 && chain.length === 0)
468
+ throw new MdqSelectorError('Empty selector after operator', input.length);
469
+ return expression;
430
470
  }
431
471
  export function buildTokenIndex(source) {
432
472
  const { body, offset } = MarkdownEditor.splitFrontmatter(source);
@@ -449,3 +489,4 @@ export function buildTokenIndex(source) {
449
489
  const SELECTOR_NAMES = new Set(['section', 'heading', 'paragraph', 'table', 'list', 'item', 'code', 'blockquote', 'hr', 'html', 'comment']);
450
490
  const TOKEN_ALIASES = { item: 'list_item' };
451
491
  const SEGMENT = /(\s*\.?)([A-Za-z]\w*)(?:\((!?)(~?)(?:"((?:[^"\\]|\\.)*)"|\/((?:[^/\\]|\\.)*)\/([a-z]*))\))?((?:\[[^\]]*\])*)\s*/y;
492
+ const OPERATOR = /\s*(&&|\|\|)\s*/y;
@@ -39,7 +39,8 @@ export default {
39
39
  ai: {
40
40
  model: 'openrouter/openai/gpt-oss-20b:nitro',
41
41
  visionModel: 'openrouter/openai/gpt-5.6-luna',
42
- agenticModel: 'openrouter/openai/gpt-5.6-luna',
42
+ agenticModel: 'openrouter/anthropic/claude-haiku-5.5',
43
+ decisionModel: { provider: 'openrouter', model: 'typesafe/jev-latest' },
43
44
  },
44
45
  };
45
46
  ```
@@ -56,7 +56,8 @@ export default {
56
56
  ai: {
57
57
  model: openrouter('openai/gpt-oss-20b:nitro'),
58
58
  visionModel: openrouter('openai/gpt-5.6-luna'),
59
- agenticModel: openrouter('openai/gpt-5.6-luna'),
59
+ agenticModel: openrouter('anthropic/claude-haiku-5.5'),
60
+ decisionModel: { provider: 'openrouter', model: 'typesafe/jev-latest' },
60
61
  },
61
62
  };
62
63
  ```
@@ -123,8 +124,8 @@ Set the recommended models in the exported config:
123
124
  export default {
124
125
  ai: {
125
126
  model: openai('gpt-5-nano'),
126
- visionModel: openai('gpt-5.6-luna'),
127
- agenticModel: openai('gpt-5.6-luna'),
127
+ visionModel: openai('gpt-6-luna'),
128
+ agenticModel: openai('gpt-6-luna'),
128
129
  },
129
130
  };
130
131
  ```
@@ -156,9 +157,9 @@ Set the recommended model in the exported config:
156
157
  ```javascript
157
158
  export default {
158
159
  ai: {
159
- model: anthropic('claude-haiku-4-5-20251001'),
160
- visionModel: anthropic('claude-haiku-4-5-20251001'),
161
- agenticModel: anthropic('claude-haiku-4-5-20251001'),
160
+ model: anthropic('claude-haiku-5-5'),
161
+ visionModel: anthropic('claude-haiku-5-5'),
162
+ agenticModel: anthropic('claude-haiku-5-5'),
162
163
  },
163
164
  };
164
165
  ```
@@ -733,16 +733,16 @@ html: /home/you/.explorbot/sites/app.example.com/output/prima/2026-08-04T10-04-2
733
733
 
734
734
  ### Browsers and sessions
735
735
 
736
- Prima never launches a browser implicitly. Open one first:
736
+ Prima never launches a browser. Open a playwright-cli session first, and check prima sees it:
737
737
 
738
738
  ```bash
739
739
  playwright-cli open https://app.example.com # the session prima attaches to by default
740
- npx explorbot prima browser start # a prima-owned browser instead
740
+ npx explorbot prima browser # prints the session, or fails with the command to open one
741
741
  ```
742
742
 
743
- By default prima attaches to the playwright-cli browser of the current workspace and works on the tabs it already has open — driving the same session from both tools is the intended usage. Stopping prima disconnects from an attached browser; it never closes it. `prima browser list` shows both kinds of browser, and the `### Instance` block names the one you are on.
743
+ By default prima attaches to the playwright-cli browser of the current workspace and works on the tabs it already has open — driving the same session from both tools is the intended usage. Stopping prima disconnects from an attached browser; it never closes it. The `### Instance` block names the browser you are on.
744
744
 
745
- Prima reaches every browser over a Playwright browser-server endpoint — a playwright-cli session, an `--endpoint`, or a `prima browser start` instance — and that client needs the Node build, which is what `npx explorbot prima` and the published `prima` bin run on. Driving a browser by running the CLI from source under Bun does not connect.
745
+ Prima reaches every browser over a Playwright browser-server endpoint — a playwright-cli session, an `--endpoint`, or an `explorbot browser start` instance — and that client needs the Node build, which is what `npx explorbot prima` and the published `prima` bin run on. Driving a browser by running the CLI from source under Bun does not connect.
746
746
 
747
747
  Every command takes these:
748
748
 
@@ -769,8 +769,6 @@ A few commands add their own:
769
769
  | `research` | `--data` | Include data extraction in the map |
770
770
  | `research` | `--deep` | Expand hidden elements for a deeper map |
771
771
  | `research` | `--fresh` | Ignore the cached map and research the page again |
772
- | `browser start` | `-s, --show` / `--headless` | Launch the browser with or without a window |
773
- | `browser stop` | `--all` | Stop every running instance |
774
772
 
775
773
  Prima carries no logging flags of its own. `DEBUG` in front of a command prints everything the run does — config and browser attachment, every step as it executes, and the debug stream of the agents behind it:
776
774
 
@@ -103,7 +103,7 @@ On its own, without a provider, `EXPLORBOT_AI_MODEL` must carry the provider as
103
103
  ```
104
104
  openrouter/openai/gpt-oss-120b:nitro → openrouter, model "openai/gpt-oss-120b:nitro"
105
105
  groq/openai/gpt-oss-20b → groq, model "openai/gpt-oss-20b"
106
- anthropic/claude-haiku-4-5-20251001 → anthropic, model "claude-haiku-4-5-20251001"
106
+ anthropic/claude-haiku-5-5 → anthropic, model "claude-haiku-5-5"
107
107
  ```
108
108
 
109
109
  `EXPLORBOT_VISION_MODEL` and `EXPLORBOT_AGENTIC_MODEL` override those roles the same way — a provider name for its recommendation, or `provider/model-id` to pin one. Mix the forms to take a provider's recommendations and override one role:
package/models.json CHANGED
@@ -2,7 +2,8 @@
2
2
  "openrouter": {
3
3
  "model": "openai/gpt-oss-20b:nitro",
4
4
  "visionModel": "openai/gpt-5.6-luna",
5
- "agenticModel": "openai/gpt-5.6-luna"
5
+ "agenticModel": "anthropic/claude-haiku-5.5",
6
+ "decisionModel": "typesafe/jev-latest"
6
7
  },
7
8
  "poolside": {
8
9
  "model": "poolside/laguna-xs-2.1"
@@ -14,13 +15,13 @@
14
15
  },
15
16
  "openai": {
16
17
  "model": "gpt-5-nano",
17
- "visionModel": "gpt-5.6-luna",
18
- "agenticModel": "gpt-5.6-luna"
18
+ "visionModel": "gpt-6-luna",
19
+ "agenticModel": "gpt-6-luna"
19
20
  },
20
21
  "anthropic": {
21
- "model": "claude-haiku-4-5-20251001",
22
- "visionModel": "claude-haiku-4-5-20251001",
23
- "agenticModel": "claude-haiku-4-5-20251001"
22
+ "model": "claude-haiku-5-5",
23
+ "visionModel": "claude-haiku-5-5",
24
+ "agenticModel": "claude-haiku-5-5"
24
25
  },
25
26
  "mistral": {
26
27
  "model": "mistral-small-latest",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -13,6 +13,7 @@ import { type StateManager, normalizeUrl } from '../state-manager.js';
13
13
  import { isFatalBrowserError } from '../utils/browser-errors.ts';
14
14
  import { getCliName } from '../utils/cli-name.ts';
15
15
  import { extractCodeBlocks } from '../utils/code-extractor.js';
16
+ import { isLoginPage } from '../utils/error-page.ts';
16
17
  import { HooksRunner } from '../utils/hooks-runner.ts';
17
18
  import { createDebug, pluralize, tag } from '../utils/logger.js';
18
19
  import { loop, pause } from '../utils/loop.js';
@@ -24,7 +25,7 @@ import type { Conversation } from './conversation.js';
24
25
  import { type Decision, JUDGE_PAGE_CAP, type Judge, UNDECIDED } from './judge.ts';
25
26
  import type { Provider } from './provider.js';
26
27
  import { Researcher } from './researcher.ts';
27
- import { actionRule, locatorRule, unexpectedPopupRule } from './rules.js';
28
+ import { actionRule, credentialsRule, locatorRule, unexpectedPopupRule } from './rules.js';
28
29
  import { isInteractive } from './task-agent.js';
29
30
  import { createLearnExperienceTool } from './tools.ts';
30
31
 
@@ -59,7 +60,7 @@ class Navigator implements Agent {
59
60
  NEVER use executeScript, executeAsyncScript, or any JS evaluation to change the URL, bypass redirects, or fake the page state.
60
61
  If the target URL redirects to an authentication/login page, DO NOT try to force the original URL. Instead:
61
62
  1. Look for credentials in the provided knowledge/hint context and perform a real login through the form.
62
- 2. If no credentials are available, ask the user for credentials or ask the user to log in manually.
63
+ 2. If no credentials are available, stop and name the credentials that are missing. Never guess them.
63
64
  A redirect to /login, /sign_in, /auth, or similar is a signal that authentication is required — treat it as such, never as an obstacle to bypass.
64
65
  </constraints>
65
66
  `;
@@ -381,6 +382,9 @@ class Navigator implements Agent {
381
382
  experience = this.experienceTracker.renderExperienceFor(actionResult);
382
383
  }
383
384
 
385
+ let credentials = '';
386
+ if (isLoginPage(actionResult)) credentials = credentialsRule;
387
+
384
388
  return dedent`
385
389
  <message>
386
390
  ${message}
@@ -406,6 +410,8 @@ class Navigator implements Agent {
406
410
 
407
411
  ${unexpectedPopupRule}
408
412
 
413
+ ${credentials}
414
+
409
415
  ${RulesLoader.loadRules('navigator', ['multiple-locator', 'output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.MAX_ATTEMPTS))}
410
416
 
411
417
  ${experience}
package/src/ai/rules.ts CHANGED
@@ -147,6 +147,14 @@ export const formRequirementsRule = dedent`
147
147
  </form_requirements>
148
148
  `;
149
149
 
150
+ export const credentialsRule = dedent`
151
+ <credentials>
152
+ Sign in to an existing account only with credentials present in the provided context (message, knowledge, experience).
153
+ Never invent, guess, or try default, common, or placeholder credentials for an existing account.
154
+ If the required credentials are not provided, call stop() and name what is missing — do not submit the form.
155
+ </credentials>
156
+ `;
157
+
150
158
  // in rage mode we do not protect from irreversible actions
151
159
  export const protectionRule = dedent`
152
160
  <important>
@@ -260,7 +260,9 @@ export function modelLines(provider: string, only?: ModelRole[]): string {
260
260
  let selected = roles;
261
261
  if (only) selected = roles.filter(([role]) => only.includes(role));
262
262
 
263
- return selected.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
263
+ const lines = selected.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`);
264
+ if (!only && recommended.decisionModel) lines.push(` // decision model judging ambiguous outcomes\n decisionModel: { provider: '${provider}', model: '${recommended.decisionModel}' },`);
265
+ return lines.join('\n');
264
266
  }
265
267
 
266
268
  function globalConfigTemplate(provider: string): string {
@@ -52,6 +52,7 @@ export class RecommendedModelsCommand extends BaseCommand {
52
52
  if (roles[role]) models.push([label, roles[role]]);
53
53
  if (!roles[role]) models.push([label, chalk.dim('not served, pair with another provider')]);
54
54
  }
55
+ if (roles.decisionModel) models.push(['decisionModel (judge)', roles.decisionModel]);
55
56
  lines.push(...renderSection('models', models, 2));
56
57
 
57
58
  const envKey = PROVIDERS[provider]?.envKey;
@@ -70,6 +71,7 @@ export class RecommendedModelsCommand extends BaseCommand {
70
71
  env.push([label, `${ROLES[role].env}=${provider}/${roles[role]}`]);
71
72
  label = '';
72
73
  }
74
+ if (roles.decisionModel) env.push(['decision', `EXPLORBOT_DECISION_MODEL=${provider}/${roles.decisionModel}`]);
73
75
  lines.push(...renderSection('env variables', env, 2));
74
76
  }
75
77
 
package/src/config.ts CHANGED
@@ -420,6 +420,11 @@ export class ConfigParser {
420
420
  log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
421
421
  }
422
422
 
423
+ const configuredUrl = loadedConfig?.playwright?.url || loadedConfig?.web?.url || '';
424
+ if (!this.runBase && resolvedPath && !isGlobalConfigPath(resolvedPath) && URL.canParse(configuredUrl)) {
425
+ this.runBase = parseRunBase(configuredUrl);
426
+ }
427
+
423
428
  let config = this.resolveConfig(loadedConfig as ExplorbotConfig);
424
429
  await resolveConfigModels(config.ai);
425
430
  this.site = null;
@@ -675,6 +680,8 @@ export class ConfigParser {
675
680
  if (agenticSpec) ai.agenticModel = await resolveModel(agenticSpec, 'agenticModel');
676
681
  if (!agenticSpec && recommended.agenticModel) ai.agenticModel = await resolveModel(provider!, 'agenticModel');
677
682
 
683
+ if (recommended.decisionModel) ai.decisionModel = { provider: provider!, model: recommended.decisionModel };
684
+
678
685
  const dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
679
686
  if (process.env.EXPLORBOT_OUTPUT) dirs.output = '.';
680
687
 
@@ -1,11 +1,14 @@
1
1
  import { ActionResult } from '../action-result.js';
2
2
  import type { WebPageState } from '../state-manager.js';
3
3
  import { isBodyEmpty } from './html.js';
4
+ import { extractStatePath } from './url-matcher.js';
4
5
 
5
6
  const HTTP_ERRORS = ['400 Bad Request', '401 Unauthorized', '403 Forbidden', '404 Not Found', '405 Method Not Allowed', '408 Request Timeout', '500 Internal Server Error', '502 Bad Gateway', '503 Service Unavailable', '504 Gateway Timeout'];
6
7
 
7
8
  const SMALL_PAGE_THRESHOLD = 500;
8
9
  const LOADING_WORD = /\bloading\b/i;
10
+ const PASSWORD_INPUT = /<input\b[^>]*\btype\s*=\s*["']?password\b/i;
11
+ const AUTH_PATH = /\b(log[-_]?in|sign[-_]?in|auth)\b/i;
9
12
 
10
13
  export type PageCondition = 'ok' | 'loading' | 'error';
11
14
 
@@ -40,6 +43,11 @@ export function isErrorPage(actionResult: ActionResult): boolean {
40
43
  return detectPageCondition(actionResult) === 'error';
41
44
  }
42
45
 
46
+ export function isLoginPage(actionResult: ActionResult): boolean {
47
+ if (PASSWORD_INPUT.test(actionResult.html || '')) return true;
48
+ return AUTH_PATH.test(extractStatePath(actionResult.url || '').split('?')[0]);
49
+ }
50
+
43
51
  export function getStateErrorPageError(state: WebPageState | null | undefined): ErrorPageError | null {
44
52
  if (!state) return null;
45
53
  const actionResult = ActionResult.fromState(state);
@@ -13,9 +13,12 @@ export class MarkdownQuery {
13
13
  }
14
14
 
15
15
  query(selector: string, matcher?: Matcher): MarkdownQuery {
16
- const segments = parseQuery(selector);
17
- if (matcher !== undefined && segments.length > 0) segments[segments.length - 1].textMatch = this.valueMatcher(matcher);
18
- return new MarkdownQuery(this.source, this.run(this.expandSections(this.matches), segments));
16
+ const expression = parseQuery(selector);
17
+ for (const chain of expression.flat()) {
18
+ const last = chain.at(-1);
19
+ if (matcher !== undefined && last) last.textMatch = this.valueMatcher(matcher);
20
+ }
21
+ return new MarkdownQuery(this.source, this.evaluate(this.expandSections(this.matches), expression));
19
22
  }
20
23
 
21
24
  section(matcher?: Matcher, options?: SelectorOptions): MarkdownQuery {
@@ -364,6 +367,27 @@ export class MarkdownQuery {
364
367
  return expanded;
365
368
  }
366
369
 
370
+ private evaluate(candidates: MatchedRange[], expression: QueryExpression): MatchedRange[] {
371
+ const results: MatchedRange[][] = [];
372
+ for (const operands of expression) {
373
+ const matched = operands.map((chain) => this.run(candidates, chain));
374
+ if (matched.some((ranges) => ranges.length === 0)) continue;
375
+ results.push(...matched);
376
+ }
377
+ return this.union(results);
378
+ }
379
+
380
+ private union(results: MatchedRange[][]): MatchedRange[] {
381
+ if (results.length === 1) return results[0];
382
+
383
+ const unique = new Map<string, MatchedRange>();
384
+ for (const range of results.flat()) {
385
+ const key = `${range.start}:${range.length}:${Boolean(range.innerTokens)}`;
386
+ if (!unique.has(key)) unique.set(key, range);
387
+ }
388
+ return [...unique.values()].sort((a, b) => a.start - b.start || b.length - a.length);
389
+ }
390
+
367
391
  private narrow(matches: MatchedRange[], segment: QuerySegment): MatchedRange[] {
368
392
  if (segment.index !== null) {
369
393
  let index = segment.index;
@@ -449,12 +473,24 @@ export function mdq(source: Markdown): MarkdownQuery {
449
473
  return new MarkdownQuery(String(source));
450
474
  }
451
475
 
452
- export function parseQuery(input: string): QuerySegment[] {
453
- const segments: QuerySegment[] = [];
476
+ export function parseQuery(input: string): QueryExpression {
477
+ const expression: QueryExpression = [[[]]];
478
+ let chain = expression[0][0];
454
479
  SEGMENT.lastIndex = 0;
455
480
 
456
481
  while (SEGMENT.lastIndex < input.length) {
457
482
  const at = SEGMENT.lastIndex;
483
+ OPERATOR.lastIndex = at;
484
+ const operator = OPERATOR.exec(input);
485
+ if (operator) {
486
+ if (chain.length === 0) throw new MdqSelectorError(`Empty selector before ${operator[1]}`, at);
487
+ chain = [];
488
+ if (operator[1] === '||') expression.push([chain]);
489
+ if (operator[1] === '&&') expression[expression.length - 1].push(chain);
490
+ SEGMENT.lastIndex = OPERATOR.lastIndex;
491
+ continue;
492
+ }
493
+
458
494
  const match = SEGMENT.exec(input);
459
495
  if (!match) throw new MdqSelectorError(`Unexpected character "${input[at]}" in selector`, at);
460
496
 
@@ -484,10 +520,11 @@ export function parseQuery(input: string): QuerySegment[] {
484
520
  if (to) segment.slice.to = Number.parseInt(to, 10);
485
521
  }
486
522
 
487
- segments.push(segment);
523
+ chain.push(segment);
488
524
  }
489
525
 
490
- return segments;
526
+ if (expression.flat().length > 1 && chain.length === 0) throw new MdqSelectorError('Empty selector after operator', input.length);
527
+ return expression;
491
528
  }
492
529
 
493
530
  export function buildTokenIndex(source: string): MatchedRange[] {
@@ -516,6 +553,8 @@ const TOKEN_ALIASES: Record<string, string> = { item: 'list_item' };
516
553
 
517
554
  const SEGMENT = /(\s*\.?)([A-Za-z]\w*)(?:\((!?)(~?)(?:"((?:[^"\\]|\\.)*)"|\/((?:[^/\\]|\\.)*)\/([a-z]*))\))?((?:\[[^\]]*\])*)\s*/y;
518
555
 
556
+ const OPERATOR = /\s*(&&|\|\|)\s*/y;
557
+
519
558
  export type Markdown = string | MarkdownQuery;
520
559
 
521
560
  export type Matcher = string | RegExp | ((text: string) => boolean);
@@ -534,6 +573,8 @@ export interface TextMatcher {
534
573
  predicate?: (text: string) => boolean;
535
574
  }
536
575
 
576
+ export type QueryExpression = QuerySegment[][][];
577
+
537
578
  export interface QuerySegment {
538
579
  selector: SelectorType;
539
580
  textMatch?: TextMatcher;