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.
- package/bin/explorbot-cli.ts +15 -4
- package/boat/doc-collector/src/cli.ts +9 -3
- package/boat/doc-collector/src/docbot.ts +11 -2
- package/dist/bin/explorbot-cli.js +17 -5
- package/dist/boat/doc-collector/src/cli.js +9 -3
- package/dist/boat/doc-collector/src/docbot.js +13 -2
- package/dist/models.json +7 -6
- package/dist/package.json +1 -1
- package/dist/src/ai/navigator.d.ts +2 -0
- package/dist/src/ai/navigator.js +10 -0
- package/dist/src/ai/pilot.js +9 -2
- package/dist/src/commands/explore-command.d.ts +5 -0
- package/dist/src/commands/explore-command.js +53 -23
- package/dist/src/commands/freesail-command.js +9 -1
- package/dist/src/commands/init-command.js +4 -1
- package/dist/src/commands/recommended-models-command.js +4 -0
- package/dist/src/config.js +6 -0
- package/dist/src/test-plan.d.ts +1 -0
- package/dist/src/test-plan.js +5 -6
- package/dist/src/utils/sitemap.d.ts +11 -0
- package/dist/src/utils/sitemap.js +106 -0
- package/dist/src/utils/spec.d.ts +5 -0
- package/dist/src/utils/spec.js +16 -0
- package/dist/src/utils/url-filter.d.ts +10 -0
- package/dist/src/utils/url-filter.js +52 -0
- package/docs/basics/getting-started.md +2 -1
- package/docs/basics/providers.md +7 -6
- package/docs/workflow/agentic-usage.md +1 -1
- package/models.json +7 -6
- package/package.json +1 -1
- package/src/ai/navigator.ts +13 -1
- package/src/ai/pilot.ts +9 -2
- package/src/commands/explore-command.ts +48 -23
- package/src/commands/freesail-command.ts +10 -1
- package/src/commands/init-command.ts +3 -1
- package/src/commands/recommended-models-command.ts +2 -0
- package/src/config.ts +7 -0
- package/src/test-plan.ts +6 -6
- package/src/utils/sitemap.ts +108 -0
- package/src/utils/spec.ts +21 -0
- 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
|
-
|
|
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];
|
package/dist/src/config.js
CHANGED
|
@@ -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 = '.';
|
package/dist/src/test-plan.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/test-plan.js
CHANGED
|
@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
|
|
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,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/
|
|
42
|
+
agenticModel: 'openrouter/anthropic/claude-haiku-5.5',
|
|
43
|
+
decisionModel: { provider: 'openrouter', model: 'typesafe/jev-latest' },
|
|
43
44
|
},
|
|
44
45
|
};
|
|
45
46
|
```
|
package/docs/basics/providers.md
CHANGED
|
@@ -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('
|
|
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-
|
|
127
|
-
agenticModel: openai('gpt-
|
|
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-
|
|
160
|
-
visionModel: anthropic('claude-haiku-
|
|
161
|
-
agenticModel: anthropic('claude-haiku-
|
|
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-
|
|
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": "
|
|
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-
|
|
18
|
-
"agenticModel": "gpt-
|
|
18
|
+
"visionModel": "gpt-6-luna",
|
|
19
|
+
"agenticModel": "gpt-6-luna"
|
|
19
20
|
},
|
|
20
21
|
"anthropic": {
|
|
21
|
-
"model": "claude-haiku-
|
|
22
|
-
"visionModel": "claude-haiku-
|
|
23
|
-
"agenticModel": "claude-haiku-
|
|
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
package/src/ai/navigator.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
`;
|