explorbot 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/bin/explorbot-cli.ts +15 -4
  2. package/boat/doc-collector/src/cli.ts +9 -3
  3. package/boat/doc-collector/src/docbot.ts +11 -2
  4. package/dist/bin/explorbot-cli.js +17 -5
  5. package/dist/boat/doc-collector/src/cli.js +9 -3
  6. package/dist/boat/doc-collector/src/docbot.js +13 -2
  7. package/dist/models.json +7 -6
  8. package/dist/package.json +1 -1
  9. package/dist/src/ai/navigator.d.ts +2 -0
  10. package/dist/src/ai/navigator.js +10 -0
  11. package/dist/src/ai/pilot.js +9 -2
  12. package/dist/src/commands/explore-command.d.ts +5 -0
  13. package/dist/src/commands/explore-command.js +53 -23
  14. package/dist/src/commands/freesail-command.js +9 -1
  15. package/dist/src/commands/init-command.js +4 -1
  16. package/dist/src/commands/recommended-models-command.js +4 -0
  17. package/dist/src/config.js +6 -0
  18. package/dist/src/test-plan.d.ts +1 -0
  19. package/dist/src/test-plan.js +5 -6
  20. package/dist/src/utils/sitemap.d.ts +11 -0
  21. package/dist/src/utils/sitemap.js +106 -0
  22. package/dist/src/utils/spec.d.ts +5 -0
  23. package/dist/src/utils/spec.js +16 -0
  24. package/dist/src/utils/url-filter.d.ts +10 -0
  25. package/dist/src/utils/url-filter.js +52 -0
  26. package/docs/basics/getting-started.md +2 -1
  27. package/docs/basics/providers.md +7 -6
  28. package/docs/workflow/agentic-usage.md +1 -1
  29. package/models.json +7 -6
  30. package/package.json +1 -1
  31. package/src/ai/navigator.ts +13 -1
  32. package/src/ai/pilot.ts +9 -2
  33. package/src/commands/explore-command.ts +48 -23
  34. package/src/commands/freesail-command.ts +10 -1
  35. package/src/commands/init-command.ts +3 -1
  36. package/src/commands/recommended-models-command.ts +2 -0
  37. package/src/config.ts +7 -0
  38. package/src/test-plan.ts +6 -6
  39. package/src/utils/sitemap.ts +108 -0
  40. package/src/utils/spec.ts +21 -0
  41. package/src/utils/url-filter.ts +53 -0
@@ -4,6 +4,7 @@ import { normalizeUrl } from '../state-manager.js';
4
4
  import { Stats } from '../stats.js';
5
5
  import { tag } from '../utils/logger.js';
6
6
  import { loop } from '../utils/loop.js';
7
+ import { UrlFilter } from "../utils/url-filter.js";
7
8
  import { BaseCommand } from './base-command.js';
8
9
  import { DEADLINE_RESERVE_MS, DEADLINE_TEST_ALLOWANCE_MS, ExploreCommand } from './explore-command.js';
9
10
  export class FreesailCommand extends BaseCommand {
@@ -17,6 +18,7 @@ export class FreesailCommand extends BaseCommand {
17
18
  { flags: '--scope <url>', description: 'Limit navigation to URLs starting with this prefix' },
18
19
  { flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
19
20
  { flags: '--max-duration <number>', description: 'Wall-clock budget in minutes for the whole run' },
21
+ { flags: '--url-filter <spec>', description: 'Pick pages to explore: keys filter|exclude|limit, e.g. "filter:/admin/*;exclude:/admin/logs/*;limit:20"' },
20
22
  ];
21
23
  async execute(args) {
22
24
  Stats.mode = 'freesail';
@@ -27,6 +29,7 @@ export class FreesailCommand extends BaseCommand {
27
29
  if (opts.shallow)
28
30
  strategy = 'shallow';
29
31
  const scope = opts.scope;
32
+ const urlFilter = new UrlFilter(opts.urlFilter);
30
33
  const maxTests = opts.maxTests ? Number.parseInt(opts.maxTests, 10) : undefined;
31
34
  const maxDuration = opts.maxDuration ? Number.parseInt(opts.maxDuration, 10) : undefined;
32
35
  let hardDeadlineAt;
@@ -54,6 +57,7 @@ export class FreesailCommand extends BaseCommand {
54
57
  exploreCmd.maxTests = maxTests - testsRun;
55
58
  if (hardDeadlineAt != null)
56
59
  exploreCmd.hardDeadlineAt = hardDeadlineAt;
60
+ exploreCmd.urlFilter = urlFilter;
57
61
  await exploreCmd.execute('');
58
62
  const plan = this.explorBot.getCurrentPlan();
59
63
  if (plan)
@@ -63,7 +67,7 @@ export class FreesailCommand extends BaseCommand {
63
67
  ctx.stop();
64
68
  const navigator = this.explorBot.agentNavigator();
65
69
  const visitedUrls = stateManager.getAllVisitedUrls();
66
- const suggestion = await navigator.freeSail({ strategy, scope, visitedUrls });
70
+ const suggestion = await navigator.freeSail({ strategy, scope, visitedUrls, urlFilter });
67
71
  if (!suggestion) {
68
72
  tag('info').log('No navigation suggestion available');
69
73
  ctx.stop();
@@ -72,6 +76,10 @@ export class FreesailCommand extends BaseCommand {
72
76
  tag('warning').log(`Suggestion ${suggestion.target} is outside scope ${scope}, skipping`);
73
77
  ctx.stop();
74
78
  }
79
+ if (!urlFilter.admit(suggestion.target)) {
80
+ tag('info').log(`URL filter allows no more pages (${suggestion.target} not admitted); stopping exploration`);
81
+ ctx.stop();
82
+ }
75
83
  const currentUrl = stateManager.getCurrentState()?.url;
76
84
  if (currentUrl && normalizeUrl(suggestion.target) === normalizeUrl(currentUrl)) {
77
85
  tag('info').log(`No new navigation target available after ${currentUrl}; stopping exploration`);
@@ -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 = '.';
@@ -121,6 +121,7 @@ export declare class Test extends Task {
121
121
  get isSkipped(): boolean;
122
122
  getCheckedNotes(): Note[];
123
123
  getCheckedExpectations(): string[];
124
+ hasAchieved(expectation: string): boolean;
124
125
  hasAchievedAny(): boolean;
125
126
  getRunResult(): 'success' | 'partial' | 'failed';
126
127
  hasAchievedAll(): boolean;
@@ -272,10 +272,11 @@ export class Test extends Task {
272
272
  return Object.values(this.notes).some((note) => note.message === expectation && !!note.status);
273
273
  });
274
274
  }
275
+ hasAchieved(expectation) {
276
+ return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
277
+ }
275
278
  hasAchievedAny() {
276
- return this.expected.some((expectation) => {
277
- return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
278
- });
279
+ return this.expected.some((expectation) => this.hasAchieved(expectation));
279
280
  }
280
281
  getRunResult() {
281
282
  if (this.isSuccessful)
@@ -285,9 +286,7 @@ export class Test extends Task {
285
286
  return super.getRunResult();
286
287
  }
287
288
  hasAchievedAll() {
288
- return this.expected.every((expectation) => {
289
- return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
290
- });
289
+ return this.expected.every((expectation) => this.hasAchieved(expectation));
291
290
  }
292
291
  isComplete() {
293
292
  return this.expected.every((expectation) => {
@@ -0,0 +1,11 @@
1
+ import type { UrlFilter } from './url-filter.js';
2
+ export declare function readSitemap(source: string, filter?: UrlFilter): Promise<string[]>;
3
+ export declare function readCliSitemap(path: string | undefined, source: string | undefined, filter?: UrlFilter): Promise<string[] | undefined>;
4
+ export declare function keepSiteUrls(urls: string[], siteUrl: string): string[];
5
+ export declare function extractEntries(content: string): SitemapEntry[];
6
+ interface SitemapEntry {
7
+ url: string;
8
+ priority: number;
9
+ lastmod?: number;
10
+ }
11
+ export {};
@@ -0,0 +1,106 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { tag } from './logger.js';
3
+ const ENTRY_PATTERN = /<(url|sitemap)\b[^>]*>(.*?)<\/\1>/gis;
4
+ const XML_ENTITIES = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'" };
5
+ const MAX_NESTED_SITEMAPS = 50;
6
+ const DEFAULT_PRIORITY = 0.5;
7
+ const STDIN_SOURCE = '-';
8
+ export async function readSitemap(source, filter) {
9
+ const entries = [];
10
+ const seen = new Set();
11
+ const pending = [source];
12
+ let fetched = 0;
13
+ while (pending.length > 0) {
14
+ if (fetched >= MAX_NESTED_SITEMAPS) {
15
+ tag('warning').log(`Sitemap index lists more than ${MAX_NESTED_SITEMAPS} sitemaps, the rest are ignored`);
16
+ break;
17
+ }
18
+ const next = pending.shift();
19
+ fetched++;
20
+ const content = await readSource(next);
21
+ const isIndex = content.includes('<sitemapindex');
22
+ for (const entry of extractEntries(content)) {
23
+ if (seen.has(entry.url))
24
+ continue;
25
+ seen.add(entry.url);
26
+ if (isIndex) {
27
+ pending.push(entry.url);
28
+ continue;
29
+ }
30
+ entries.push(entry);
31
+ }
32
+ }
33
+ return applyFilter(entries, filter).map((entry) => entry.url);
34
+ }
35
+ export async function readCliSitemap(path, source, filter) {
36
+ const usage = 'Pass a page path, --sitemap <source>, or pipe a sitemap to stdin';
37
+ let resolved = source;
38
+ if (!resolved && !path && !process.stdin.isTTY)
39
+ resolved = STDIN_SOURCE;
40
+ if (!resolved && !path)
41
+ throw new Error(usage);
42
+ if (!resolved)
43
+ return undefined;
44
+ const urls = await readSitemap(resolved, filter);
45
+ if (urls.length === 0 && !source)
46
+ throw new Error(usage);
47
+ if (urls.length === 0)
48
+ throw new Error(`No URLs left in sitemap ${resolved}`);
49
+ return urls;
50
+ }
51
+ export function keepSiteUrls(urls, siteUrl) {
52
+ const siteOrigin = URL.parse(siteUrl)?.origin;
53
+ const kept = urls.filter((url) => url.startsWith('/') || URL.parse(url)?.origin === siteOrigin);
54
+ if (kept.length < urls.length)
55
+ tag('warning').log(`Skipped ${urls.length - kept.length} sitemap URL(s) outside ${siteOrigin}`);
56
+ return kept;
57
+ }
58
+ export function extractEntries(content) {
59
+ if (!content.trimStart().startsWith('<')) {
60
+ return content
61
+ .split('\n')
62
+ .map((line) => line.trim())
63
+ .filter((line) => line.startsWith('http://') || line.startsWith('https://') || line.startsWith('/'))
64
+ .map((url) => ({ url, priority: DEFAULT_PRIORITY }));
65
+ }
66
+ const entries = [];
67
+ for (const match of content.matchAll(ENTRY_PATTERN)) {
68
+ const url = tagValue(match[2], 'loc');
69
+ if (!url)
70
+ continue;
71
+ const entry = { url, priority: DEFAULT_PRIORITY };
72
+ const priority = Number.parseFloat(tagValue(match[2], 'priority') || '');
73
+ if (!Number.isNaN(priority))
74
+ entry.priority = priority;
75
+ const lastmod = Date.parse(tagValue(match[2], 'lastmod') || '');
76
+ if (!Number.isNaN(lastmod))
77
+ entry.lastmod = lastmod;
78
+ entries.push(entry);
79
+ }
80
+ return entries;
81
+ }
82
+ function applyFilter(entries, filter) {
83
+ if (!filter)
84
+ return entries;
85
+ let result = entries.filter((entry) => filter.allows(entry.url));
86
+ if (filter.sort === 'priority')
87
+ result = result.toSorted((a, b) => b.priority - a.priority);
88
+ if (filter.sort === 'lastmod')
89
+ result = result.toSorted((a, b) => (b.lastmod ?? 0) - (a.lastmod ?? 0));
90
+ return result.slice(0, filter.limit);
91
+ }
92
+ function tagValue(block, name) {
93
+ const match = block.match(new RegExp(`<${name}>\\s*(?:<!\\[CDATA\\[)?\\s*(.*?)\\s*(?:\\]\\]>)?\\s*</${name}>`, 'is'));
94
+ return match?.[1].replace(/&(amp|lt|gt|quot|apos);/g, (entity) => XML_ENTITIES[entity]) || undefined;
95
+ }
96
+ async function readSource(source) {
97
+ if (source === STDIN_SOURCE)
98
+ return readFileSync(0, 'utf8');
99
+ if (source.startsWith('http://') || source.startsWith('https://')) {
100
+ const response = await fetch(source);
101
+ if (!response.ok)
102
+ throw new Error(`Sitemap ${source} returned HTTP ${response.status}`);
103
+ return await response.text();
104
+ }
105
+ return readFileSync(source, 'utf8');
106
+ }
@@ -0,0 +1,5 @@
1
+ export declare function parseSpecPairs(raw: string | undefined): SpecPair[];
2
+ export interface SpecPair {
3
+ key: string;
4
+ value: string;
5
+ }
@@ -0,0 +1,16 @@
1
+ import { tag } from './logger.js';
2
+ export function parseSpecPairs(raw) {
3
+ const pairs = [];
4
+ for (const pair of (raw || '').split(';')) {
5
+ const trimmed = pair.trim();
6
+ if (!trimmed)
7
+ continue;
8
+ const sepMatch = trimmed.match(/^([^:=]+)\s*[:=]\s*(.*)$/);
9
+ if (!sepMatch) {
10
+ tag('warning').log(`Ignoring malformed spec pair: ${trimmed}`);
11
+ continue;
12
+ }
13
+ pairs.push({ key: sepMatch[1].trim().toLowerCase(), value: sepMatch[2].trim() });
14
+ }
15
+ return pairs;
16
+ }
@@ -0,0 +1,10 @@
1
+ export declare class UrlFilter {
2
+ readonly include: string[];
3
+ readonly exclude: string[];
4
+ sort?: 'priority' | 'lastmod';
5
+ limit: number;
6
+ admitted: Set<string>;
7
+ constructor(spec?: string);
8
+ allows(url: string): boolean;
9
+ admit(url: string): boolean;
10
+ }
@@ -0,0 +1,52 @@
1
+ import { tag } from './logger.js';
2
+ import { parseSpecPairs } from "./spec.js";
3
+ import { matchesUrl } from "./url-matcher.js";
4
+ export class UrlFilter {
5
+ include = [];
6
+ exclude = [];
7
+ sort;
8
+ limit = Number.POSITIVE_INFINITY;
9
+ admitted = new Set();
10
+ constructor(spec) {
11
+ for (const { key, value } of parseSpecPairs(spec)) {
12
+ if (key === 'filter') {
13
+ this.include.push(value);
14
+ continue;
15
+ }
16
+ if (key === 'exclude') {
17
+ this.exclude.push(value);
18
+ continue;
19
+ }
20
+ if (key === 'sort' && (value === 'priority' || value === 'lastmod')) {
21
+ this.sort = value;
22
+ continue;
23
+ }
24
+ if (key === 'limit' && Number.parseInt(value, 10) > 0) {
25
+ this.limit = Number.parseInt(value, 10);
26
+ continue;
27
+ }
28
+ tag('warning').log(`Ignoring URL filter pair ${key}:${value} (use filter|exclude:<url pattern>, sort:priority|lastmod, limit:<n>)`);
29
+ }
30
+ }
31
+ allows(url) {
32
+ const path = urlPath(url);
33
+ if (this.include.length > 0 && !this.include.some((pattern) => matchesUrl(pattern, path)))
34
+ return false;
35
+ return !this.exclude.some((pattern) => matchesUrl(pattern, path));
36
+ }
37
+ admit(url) {
38
+ if (!this.allows(url))
39
+ return false;
40
+ const path = urlPath(url);
41
+ if (this.admitted.has(path))
42
+ return true;
43
+ if (this.admitted.size >= this.limit)
44
+ return false;
45
+ this.admitted.add(path);
46
+ return true;
47
+ }
48
+ }
49
+ function urlPath(url) {
50
+ const parsed = new URL(url, 'http://localhost');
51
+ return `${parsed.pathname}${parsed.search}`;
52
+ }
@@ -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
  ```
@@ -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.5",
3
+ "version": "0.5.7",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -20,6 +20,7 @@ import { loop, pause } from '../utils/loop.js';
20
20
  import { RulesLoader } from '../utils/rules-loader.ts';
21
21
  import { normalizeInlineText } from '../utils/strings.ts';
22
22
  import { extractStatePath, isSameHostFamily, matchesNavigationUrl } from '../utils/url-matcher.js';
23
+ import type { UrlFilter } from '../utils/url-filter.ts';
23
24
  import type { Agent, AgentDeps } from './agent.js';
24
25
  import type { Conversation } from './conversation.js';
25
26
  import { type Decision, JUDGE_PAGE_CAP, type Judge, UNDECIDED } from './judge.ts';
@@ -564,7 +565,7 @@ class Navigator implements Agent {
564
565
  return { learnExperience: createLearnExperienceTool({ getExperienceTracker: () => this.experienceTracker, getState }) };
565
566
  }
566
567
 
567
- async freeSail(opts?: { strategy?: 'deep' | 'shallow'; scope?: string; visitedUrls?: Set<string> }, actionResult?: ActionResult): Promise<{ target: string; reason: string } | null> {
568
+ async freeSail(opts?: { strategy?: 'deep' | 'shallow'; scope?: string; visitedUrls?: Set<string>; urlFilter?: UrlFilter }, actionResult?: ActionResult): Promise<{ target: string; reason: string } | null> {
568
569
  const stateManager = this.stateManager;
569
570
  const state = stateManager.getCurrentState();
570
571
  if (!state) {
@@ -612,6 +613,12 @@ class Navigator implements Agent {
612
613
  if (opts?.scope) {
613
614
  scopeInstruction = `IMPORTANT: Only suggest URLs that start with "${opts.scope}". Do not suggest URLs outside this scope.`;
614
615
  }
616
+ if (opts?.urlFilter?.include.length) {
617
+ scopeInstruction += `\nOnly suggest URLs matching one of these patterns: ${opts.urlFilter.include.join(', ')}`;
618
+ }
619
+ if (opts?.urlFilter?.exclude.length) {
620
+ scopeInstruction += `\nNever suggest URLs matching these patterns: ${opts.urlFilter.exclude.join(', ')}`;
621
+ }
615
622
 
616
623
  const prompt = dedent`
617
624
  <research>
@@ -665,6 +672,11 @@ class Navigator implements Agent {
665
672
  return;
666
673
  }
667
674
 
675
+ if (opts?.urlFilter && !opts.urlFilter.allows(target)) {
676
+ conversation.addUserText(`"${target}" does not pass the URL filter. Suggest a URL that does.`);
677
+ return;
678
+ }
679
+
668
680
  if (!target.startsWith('/') && !target.startsWith('http')) {
669
681
  conversation.addUserText(`"${target}" is not a valid URL path. Suggest a URL path starting with "/".`);
670
682
  return;
package/src/ai/pilot.ts CHANGED
@@ -615,7 +615,7 @@ export class Pilot implements Agent {
615
615
  if (finalState?.screenshot && this.provider.hasVision()) image = finalState.screenshot;
616
616
 
617
617
  const decided = (text: string): 'passed' | 'failed' => {
618
- if (task.hasAchievedAny() && !task.getRemainingExpectations().includes(text)) return 'passed';
618
+ if (task.hasAchieved(text)) return 'passed';
619
619
  return 'failed';
620
620
  };
621
621
 
@@ -631,7 +631,7 @@ export class Pilot implements Agent {
631
631
  outcomes: z.array(
632
632
  z.object({
633
633
  expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
634
- status: z.enum(['passed', 'failed', 'unverified', 'contradiction']).describe('passed = the evidence shows it happened, failed = the evidence shows it did not, unverified = the run never established either way, contradiction = the picture and the run disagree'),
634
+ status: z.enum(['passed', 'failed', 'unverified', 'contradiction']).describe('passed = the evidence shows it happened to the thing it names, failed = the evidence shows it did not, unverified = the run never established either way, contradiction = the picture and the run disagree'),
635
635
  evidence: z.string().nullable().describe('What settled it. For a contradiction, what each side shows. Null when there is nothing to add'),
636
636
  })
637
637
  ),
@@ -657,6 +657,10 @@ export class Pilot implements Agent {
657
657
  const userContent = dedent`
658
658
  A test run has finished. Decide, for each expected outcome, what the run established about it.
659
659
 
660
+ <scenario>
661
+ ${task.scenario}
662
+ </scenario>
663
+
660
664
  <expected_outcomes>
661
665
  ${undecided.map((text) => `- ${text}`).join('\n')}
662
666
  </expected_outcomes>
@@ -669,6 +673,9 @@ export class Pilot implements Agent {
669
673
 
670
674
  The log is written in the tester's own words, so an outcome can be satisfied by a step that describes it
671
675
  differently. Judge by what the steps show happened, not by whether the wording matches.
676
+ Wording may differ, the subject may not. An outcome refers to what the scenario names or chooses; it holds
677
+ only when that same thing appears. A different route or a different thing reaching a similar state is "failed",
678
+ and so is an outcome about something the run never managed to choose or create.
672
679
  Choose "unverified" only when the evidence neither shows the outcome happening nor shows it failing —
673
680
  that is a statement about the run, not about the application.
674
681
  `;