explorbot 0.2.5 → 0.3.1
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/boat/prima/README.md +96 -0
- package/boat/prima/package.json +14 -10
- package/boat/prima/src/cli.ts +5 -0
- package/boat/prima/src/prima.ts +17 -4
- package/dist/boat/prima/src/cli.js +7 -0
- package/dist/boat/prima/src/prima.js +18 -4
- package/dist/models.json +4 -4
- package/dist/package.json +6 -2
- package/dist/src/action-result.d.ts +13 -0
- package/dist/src/action-result.js +46 -15
- package/dist/src/action.d.ts +5 -2
- package/dist/src/action.js +48 -17
- package/dist/src/ai/captain/web-mode.js +1 -2
- package/dist/src/ai/captain.d.ts +20 -0
- package/dist/src/ai/captain.js +10 -1
- package/dist/src/ai/conversation.d.ts +1 -0
- package/dist/src/ai/conversation.js +3 -0
- package/dist/src/ai/driller.js +6 -2
- package/dist/src/ai/fisherman-tools.d.ts +40 -1
- package/dist/src/ai/fisherman-tools.js +39 -0
- package/dist/src/ai/fisherman.js +3 -2
- package/dist/src/ai/navigator.d.ts +2 -1
- package/dist/src/ai/navigator.js +5 -9
- package/dist/src/ai/pilot.js +51 -29
- package/dist/src/ai/planner/subpages.js +2 -16
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.d.ts +3 -0
- package/dist/src/ai/provider.js +80 -17
- package/dist/src/ai/researcher/cache.d.ts +8 -3
- package/dist/src/ai/researcher/cache.js +13 -8
- package/dist/src/ai/researcher/deep-analysis.js +1 -1
- package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
- package/dist/src/ai/researcher.js +4 -3
- package/dist/src/ai/rules.js +1 -5
- package/dist/src/ai/tester.d.ts +1 -1
- package/dist/src/ai/tester.js +34 -26
- package/dist/src/ai/tools.d.ts +8 -5
- package/dist/src/ai/tools.js +79 -56
- package/dist/src/commands/explore-command.js +22 -17
- package/dist/src/commands/init-command.js +13 -20
- package/dist/src/config.d.ts +2 -1
- package/dist/src/config.js +3 -1
- package/dist/src/experience-tracker.d.ts +2 -0
- package/dist/src/experience-tracker.js +12 -0
- package/dist/src/explorbot.js +1 -1
- package/dist/src/playwright-recorder.js +6 -12
- package/dist/src/test-plan.d.ts +8 -0
- package/dist/src/test-plan.js +11 -0
- package/dist/src/utils/html-diff.d.ts +5 -0
- package/dist/src/utils/html-diff.js +65 -6
- package/dist/src/utils/strings.d.ts +2 -0
- package/dist/src/utils/strings.js +32 -0
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +31 -2
- package/docs/basics/getting-started.md +33 -10
- package/docs/basics/providers.md +6 -4
- package/docs/contributing/npm-package.md +73 -4
- package/models.json +4 -4
- package/package.json +6 -2
- package/src/action-result.ts +61 -16
- package/src/action.ts +51 -17
- package/src/ai/captain/web-mode.ts +1 -2
- package/src/ai/captain.ts +9 -1
- package/src/ai/conversation.ts +3 -0
- package/src/ai/driller.ts +6 -2
- package/src/ai/fisherman-tools.ts +35 -0
- package/src/ai/fisherman.ts +3 -2
- package/src/ai/navigator.ts +6 -10
- package/src/ai/pilot.ts +54 -32
- package/src/ai/planner/subpages.ts +2 -13
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +111 -41
- package/src/ai/researcher/cache.ts +17 -9
- package/src/ai/researcher/deep-analysis.ts +1 -1
- package/src/ai/researcher/fingerprint-worker.ts +23 -5
- package/src/ai/researcher.ts +4 -3
- package/src/ai/rules.ts +1 -5
- package/src/ai/tester.ts +32 -27
- package/src/ai/tools.ts +84 -60
- package/src/commands/explore-command.ts +17 -14
- package/src/commands/init-command.ts +14 -20
- package/src/config.ts +4 -2
- package/src/experience-tracker.ts +13 -0
- package/src/explorbot.ts +1 -1
- package/src/playwright-recorder.ts +6 -11
- package/src/test-plan.ts +18 -0
- package/src/utils/html-diff.ts +72 -7
- package/src/utils/strings.ts +36 -0
- package/src/utils/url-matcher.ts +27 -2
|
@@ -7,15 +7,10 @@ import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "..
|
|
|
7
7
|
import { getCliName } from "../utils/cli-name.js";
|
|
8
8
|
import { log, tag } from '../utils/logger.js';
|
|
9
9
|
import { relativeToCwd } from "../utils/next-steps.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
// Bring your own provider or use OpenRouter (one API key, many providers).
|
|
15
|
-
// https://github.com/testomatio/explorbot/blob/main/docs/providers.md
|
|
16
|
-
const openrouter = createOpenRouter({
|
|
17
|
-
apiKey: process.env.OPENROUTER_API_KEY,
|
|
18
|
-
});
|
|
10
|
+
function defaultConfigTemplate() {
|
|
11
|
+
return `// 'provider/model-id' uses a bundled provider.
|
|
12
|
+
// It is also possible to import provider as a module from Vercel AI SDK.
|
|
13
|
+
// https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
|
|
19
14
|
|
|
20
15
|
const config = {
|
|
21
16
|
web: {
|
|
@@ -24,12 +19,7 @@ const config = {
|
|
|
24
19
|
},
|
|
25
20
|
|
|
26
21
|
ai: {
|
|
27
|
-
|
|
28
|
-
model: openrouter('openai/gpt-oss-20b:nitro'),
|
|
29
|
-
// vision model for screenshot analysis
|
|
30
|
-
visionModel: openrouter('meta-llama/llama-4-scout-17b-16e-instruct'),
|
|
31
|
-
// agentic model for decision making
|
|
32
|
-
agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
|
|
22
|
+
${modelLines('openrouter')}
|
|
33
23
|
},
|
|
34
24
|
|
|
35
25
|
reporter: {
|
|
@@ -44,6 +34,7 @@ const config = {
|
|
|
44
34
|
|
|
45
35
|
export default config;
|
|
46
36
|
`;
|
|
37
|
+
}
|
|
47
38
|
const DEFAULT_ENV_TEMPLATE = dedent `
|
|
48
39
|
# AI provider API keys
|
|
49
40
|
OPENROUTER_API_KEY=
|
|
@@ -127,7 +118,7 @@ export function runInitCommand(options) {
|
|
|
127
118
|
log('Use --force to overwrite existing file');
|
|
128
119
|
process.exit(1);
|
|
129
120
|
}
|
|
130
|
-
writeFileSync(outPath,
|
|
121
|
+
writeFileSync(outPath, defaultConfigTemplate(), 'utf8');
|
|
131
122
|
log(`Created config file: ${relativeToCwd(outPath)}`);
|
|
132
123
|
const envPath = resolve(process.cwd(), '.env');
|
|
133
124
|
if (!existsSync(envPath)) {
|
|
@@ -196,15 +187,17 @@ async function renderInitWizard(mode) {
|
|
|
196
187
|
}), { exitOnCtrlC: false, patchConsole: false });
|
|
197
188
|
});
|
|
198
189
|
}
|
|
199
|
-
function
|
|
200
|
-
const { envKey } = PROVIDERS[provider];
|
|
190
|
+
function modelLines(provider) {
|
|
201
191
|
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
202
192
|
const roles = [
|
|
203
193
|
['model', 'fast model with tool calling capabilities'],
|
|
204
194
|
['visionModel', 'vision model for screenshot analysis'],
|
|
205
195
|
['agenticModel', 'agentic model for decision making'],
|
|
206
196
|
];
|
|
207
|
-
|
|
197
|
+
return roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
|
|
198
|
+
}
|
|
199
|
+
function globalConfigTemplate(provider) {
|
|
200
|
+
const { envKey } = PROVIDERS[provider];
|
|
208
201
|
return `// Global Explorbot configuration — used by every directory without its own explorbot.config.js.
|
|
209
202
|
// Models are written as 'provider/model-id' so they resolve without a local node_modules.
|
|
210
203
|
// The key is read from ${envKey} in ~/.explorbot/.env
|
|
@@ -212,7 +205,7 @@ function globalConfigTemplate(provider) {
|
|
|
212
205
|
// https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
|
|
213
206
|
const config = {
|
|
214
207
|
ai: {
|
|
215
|
-
${
|
|
208
|
+
${modelLines(provider)}
|
|
216
209
|
},
|
|
217
210
|
|
|
218
211
|
reporter: {
|
package/dist/src/config.d.ts
CHANGED
|
@@ -151,8 +151,9 @@ interface AIConfig {
|
|
|
151
151
|
vision?: boolean;
|
|
152
152
|
visionModel?: any;
|
|
153
153
|
agenticModel?: any;
|
|
154
|
-
|
|
154
|
+
retryAttempts?: number;
|
|
155
155
|
retryDelay?: number;
|
|
156
|
+
maxParallelRequests?: number;
|
|
156
157
|
agents?: AgentsConfig;
|
|
157
158
|
}
|
|
158
159
|
interface HtmlConfig {
|
package/dist/src/config.js
CHANGED
|
@@ -157,7 +157,7 @@ export class ConfigParser {
|
|
|
157
157
|
const config = this.getConfig();
|
|
158
158
|
if (!this.configPath)
|
|
159
159
|
throw new Error('Config path not found');
|
|
160
|
-
return
|
|
160
|
+
return this.resolveProjectDir(config.dirs?.output || 'output');
|
|
161
161
|
}
|
|
162
162
|
getProjectRoot() {
|
|
163
163
|
if (this.site)
|
|
@@ -168,6 +168,8 @@ export class ConfigParser {
|
|
|
168
168
|
return process.cwd();
|
|
169
169
|
}
|
|
170
170
|
resolveProjectDir(relativeDir) {
|
|
171
|
+
if (path.isAbsolute(relativeDir))
|
|
172
|
+
return relativeDir;
|
|
171
173
|
if (!this.configPath)
|
|
172
174
|
return relativeDir;
|
|
173
175
|
return path.join(this.getProjectRoot(), relativeDir);
|
|
@@ -53,6 +53,7 @@ export declare class ExperienceTracker {
|
|
|
53
53
|
getExperienceTableOfContents(state: ActionResult, options?: {
|
|
54
54
|
includeDescendantExperience?: boolean;
|
|
55
55
|
}): ExperienceTocEntry[];
|
|
56
|
+
renderExperienceFor(state: ActionResult): string;
|
|
56
57
|
renderExperienceTocFor(state: ActionResult): string;
|
|
57
58
|
getExperienceSection(fileTag: string, sectionIndex: number, state: ActionResult, options?: {
|
|
58
59
|
includeDescendantExperience?: boolean;
|
|
@@ -77,6 +78,7 @@ export declare class ExperienceTracker {
|
|
|
77
78
|
fileHash: string;
|
|
78
79
|
} | null;
|
|
79
80
|
}
|
|
81
|
+
export declare function renderExperienceRecipes(recipes: string[]): string;
|
|
80
82
|
export declare function renderExperienceToc(toc: ExperienceTocEntry[]): string;
|
|
81
83
|
export interface ExperienceFile {
|
|
82
84
|
filePath: string;
|
|
@@ -257,6 +257,13 @@ export class ExperienceTracker {
|
|
|
257
257
|
});
|
|
258
258
|
return this.buildToc(sorted);
|
|
259
259
|
}
|
|
260
|
+
renderExperienceFor(state) {
|
|
261
|
+
const successful = this.getSuccessfulExperience(state);
|
|
262
|
+
if (!successful.length)
|
|
263
|
+
return '';
|
|
264
|
+
tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${state.url}`);
|
|
265
|
+
return renderExperienceRecipes(successful);
|
|
266
|
+
}
|
|
260
267
|
renderExperienceTocFor(state) {
|
|
261
268
|
const toc = this.getExperienceTableOfContents(state);
|
|
262
269
|
if (toc.length === 0)
|
|
@@ -381,6 +388,11 @@ function indexToLetters(index) {
|
|
|
381
388
|
}
|
|
382
389
|
return result;
|
|
383
390
|
}
|
|
391
|
+
export function renderExperienceRecipes(recipes) {
|
|
392
|
+
if (recipes.length === 0)
|
|
393
|
+
return '';
|
|
394
|
+
return `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${recipes.join('\n\n')}\n</experience>`;
|
|
395
|
+
}
|
|
384
396
|
export function renderExperienceToc(toc) {
|
|
385
397
|
if (toc.length === 0)
|
|
386
398
|
return '';
|
package/dist/src/explorbot.js
CHANGED
|
@@ -199,7 +199,7 @@ export class ExplorBot {
|
|
|
199
199
|
this.agents.tester = this.createAgent((deps) => {
|
|
200
200
|
const researcher = this.agentResearcher();
|
|
201
201
|
const navigator = this.agentNavigator();
|
|
202
|
-
const tools = createAgentTools({ ...deps, researcher, navigator });
|
|
202
|
+
const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
|
|
203
203
|
return new Tester(deps, researcher, navigator, tools);
|
|
204
204
|
});
|
|
205
205
|
const qm = this.agentQuartermaster();
|
|
@@ -6,7 +6,7 @@ const RECORDABLE = {
|
|
|
6
6
|
Frame: new Set(['click', 'dblclick', 'fill', 'selectOption', 'press', 'type', 'check', 'uncheck', 'hover', 'tap', 'focus', 'setInputFiles', 'scrollIntoViewIfNeeded', 'dragTo', 'goto', 'setContent']),
|
|
7
7
|
Page: new Set(['goBack', 'goForward', 'reload', 'keyboardPress', 'keyboardType', 'keyboardDown', 'keyboardUp', 'keyboardInsertText', 'mouseClick', 'mouseDblclick', 'mouseMove', 'mouseDown', 'mouseUp', 'mouseWheel']),
|
|
8
8
|
};
|
|
9
|
-
const PLAYWRIGHT_INCOMPATIBLE = "Playwright output
|
|
9
|
+
const PLAYWRIGHT_INCOMPATIBLE = "Playwright output requires playwright-core 1.62 or newer (lib/coreBundle does not expose iso.asLocator). Use output.framework: 'codeceptjs' instead.";
|
|
10
10
|
let cachedAsLocator = null;
|
|
11
11
|
let asLocatorLoadAttempted = false;
|
|
12
12
|
const nodeRequire = typeof require === 'function' ? require : createRequire(import.meta.url);
|
|
@@ -16,17 +16,11 @@ function getAsLocator() {
|
|
|
16
16
|
if (asLocatorLoadAttempted)
|
|
17
17
|
throw new Error(PLAYWRIGHT_INCOMPATIBLE);
|
|
18
18
|
asLocatorLoadAttempted = true;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
// Module not exported or not found
|
|
28
|
-
}
|
|
29
|
-
throw new Error(PLAYWRIGHT_INCOMPATIBLE);
|
|
19
|
+
const asLocator = nodeRequire('playwright-core/lib/coreBundle')?.iso?.asLocator;
|
|
20
|
+
if (typeof asLocator !== 'function')
|
|
21
|
+
throw new Error(PLAYWRIGHT_INCOMPATIBLE);
|
|
22
|
+
cachedAsLocator = asLocator;
|
|
23
|
+
return cachedAsLocator;
|
|
30
24
|
}
|
|
31
25
|
export class PlaywrightRecorder {
|
|
32
26
|
context = null;
|
package/dist/src/test-plan.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ActionResult } from './action-result.js';
|
|
1
2
|
import { WebPageState } from './state-manager.js';
|
|
2
3
|
export declare const TestResult: {
|
|
3
4
|
readonly PASSED: "passed";
|
|
@@ -94,10 +95,13 @@ export declare class Test extends Task {
|
|
|
94
95
|
startTime?: number;
|
|
95
96
|
endTime?: number;
|
|
96
97
|
resetCount: number;
|
|
98
|
+
appliedExperience: AppliedExperience[];
|
|
97
99
|
constructor(scenario: string, priority: 'critical' | 'important' | 'high' | 'normal' | 'low', expectedOutcome: string | string[], startUrl: string, plannedSteps?: string[]);
|
|
98
100
|
getVisitedUrls({ localOnly }?: {
|
|
99
101
|
localOnly?: boolean;
|
|
100
102
|
}): string[];
|
|
103
|
+
applyExperience(recipes: AppliedExperience[]): void;
|
|
104
|
+
getAppliedExperience(state: ActionResult): string[];
|
|
101
105
|
addArtifact(artifact?: string): void;
|
|
102
106
|
get hasFinished(): boolean;
|
|
103
107
|
get isSuccessful(): boolean;
|
|
@@ -166,4 +170,8 @@ interface UrlNoteState {
|
|
|
166
170
|
h2?: string;
|
|
167
171
|
screenshotFile?: string;
|
|
168
172
|
}
|
|
173
|
+
interface AppliedExperience {
|
|
174
|
+
url: string;
|
|
175
|
+
content: string;
|
|
176
|
+
}
|
|
169
177
|
export {};
|
package/dist/src/test-plan.js
CHANGED
|
@@ -190,6 +190,7 @@ export class Test extends Task {
|
|
|
190
190
|
startTime;
|
|
191
191
|
endTime;
|
|
192
192
|
resetCount = 0;
|
|
193
|
+
appliedExperience = [];
|
|
193
194
|
constructor(scenario, priority, expectedOutcome, startUrl, plannedSteps = []) {
|
|
194
195
|
super(scenario, startUrl);
|
|
195
196
|
this.scenario = scenario;
|
|
@@ -208,6 +209,16 @@ export class Test extends Task {
|
|
|
208
209
|
}
|
|
209
210
|
return [...new Set([this.startUrl, ...this.states.map((s) => s.url)].filter((value) => Boolean(value) && value.trim() !== ''))];
|
|
210
211
|
}
|
|
212
|
+
applyExperience(recipes) {
|
|
213
|
+
for (const recipe of recipes) {
|
|
214
|
+
if (this.appliedExperience.some((applied) => applied.content === recipe.content))
|
|
215
|
+
continue;
|
|
216
|
+
this.appliedExperience.push(recipe);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
getAppliedExperience(state) {
|
|
220
|
+
return this.appliedExperience.filter((recipe) => state.isRelevantExperienceRecord({ url: recipe.url })).map((recipe) => recipe.content);
|
|
221
|
+
}
|
|
211
222
|
addArtifact(artifact) {
|
|
212
223
|
if (!artifact)
|
|
213
224
|
return;
|
|
@@ -11,9 +11,14 @@ export interface HtmlDiffResult {
|
|
|
11
11
|
removed: string[];
|
|
12
12
|
similarity: number;
|
|
13
13
|
summary: string;
|
|
14
|
+
messages: string[];
|
|
14
15
|
}
|
|
15
16
|
export declare function computeHtmlFingerprint(html: string): string[];
|
|
16
17
|
/**
|
|
17
18
|
* Compares two HTML documents and returns differences along with a diff subtree.
|
|
18
19
|
*/
|
|
19
20
|
export declare function htmlDiff(originalHtml: string, modifiedHtml: string, htmlConfig?: HtmlConfig): Promise<HtmlDiffResult>;
|
|
21
|
+
/**
|
|
22
|
+
* Text the app announced across a navigation. Only live regions: everything else on a new page is its content, not a message.
|
|
23
|
+
*/
|
|
24
|
+
export declare function liveRegionMessages(originalHtml: string, modifiedHtml: string): string[];
|
|
@@ -2,6 +2,10 @@ import { parse, serialize } from 'parse5';
|
|
|
2
2
|
import { TAILWIND_CLASS_PATTERNS, TRASH_HTML_CLASSES, minifyHtml } from "./html.js";
|
|
3
3
|
import { isDynamicId, isGenericClass } from "./xpath.js";
|
|
4
4
|
const IGNORED_PATHS = new Set(['html[1]', 'html[1]/head[1]', 'html[1]/body[1]']);
|
|
5
|
+
const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log']);
|
|
6
|
+
const TEXT_LINE_PREFIX = 'TEXT:';
|
|
7
|
+
const MESSAGE_MAX_LENGTH = 200;
|
|
8
|
+
const MESSAGE_LIMIT = 8;
|
|
5
9
|
/**
|
|
6
10
|
* Get text content from an element node.
|
|
7
11
|
*/
|
|
@@ -141,7 +145,9 @@ export async function htmlDiff(originalHtml, modifiedHtml, htmlConfig) {
|
|
|
141
145
|
const modifiedLines = flattenHtml(modifiedRoot);
|
|
142
146
|
const similarity = calculateSimilarity(originalLines, modifiedLines);
|
|
143
147
|
const { added, removed } = findDifferences(originalLines, modifiedLines);
|
|
144
|
-
const
|
|
148
|
+
const originalMap = collectElementMap(originalDocument);
|
|
149
|
+
const modifiedMap = collectElementMap(modifiedDocument);
|
|
150
|
+
const parts = await buildDiffParts(originalMap, modifiedMap);
|
|
145
151
|
const structuralAdditions = parts.flatMap((p) => p.added.filter((a) => a.startsWith('ELEMENT:')));
|
|
146
152
|
const allAdded = [...added, ...structuralAdditions];
|
|
147
153
|
const totalChanges = allAdded.length + removed.length;
|
|
@@ -152,8 +158,63 @@ export async function htmlDiff(originalHtml, modifiedHtml, htmlConfig) {
|
|
|
152
158
|
removed,
|
|
153
159
|
similarity,
|
|
154
160
|
summary,
|
|
161
|
+
messages: collectMessages(originalMap, modifiedMap, allAdded),
|
|
155
162
|
};
|
|
156
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Text the app announced while the page stayed the same: live region content first, then any other text that appeared.
|
|
166
|
+
*/
|
|
167
|
+
function collectMessages(originalMap, modifiedMap, added) {
|
|
168
|
+
const appearedText = added.filter((line) => line.startsWith(TEXT_LINE_PREFIX)).map((line) => line.slice(TEXT_LINE_PREFIX.length));
|
|
169
|
+
return limitMessages([...collectLiveRegionTexts(originalMap, modifiedMap), ...appearedText]);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Text the app announced across a navigation. Only live regions: everything else on a new page is its content, not a message.
|
|
173
|
+
*/
|
|
174
|
+
export function liveRegionMessages(originalHtml, modifiedHtml) {
|
|
175
|
+
const originalMap = collectElementMap(parseDocument(originalHtml));
|
|
176
|
+
const modifiedMap = collectElementMap(parseDocument(modifiedHtml));
|
|
177
|
+
return limitMessages(collectLiveRegionTexts(originalMap, modifiedMap));
|
|
178
|
+
}
|
|
179
|
+
function limitMessages(candidates) {
|
|
180
|
+
const messages = [];
|
|
181
|
+
for (const candidate of candidates) {
|
|
182
|
+
const text = candidate.replace(/\s+/g, ' ').trim().slice(0, MESSAGE_MAX_LENGTH);
|
|
183
|
+
if (!text)
|
|
184
|
+
continue;
|
|
185
|
+
if (messages.some((message) => message.includes(text)))
|
|
186
|
+
continue;
|
|
187
|
+
messages.push(text);
|
|
188
|
+
if (messages.length === MESSAGE_LIMIT)
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
return messages;
|
|
192
|
+
}
|
|
193
|
+
function collectLiveRegionTexts(originalMap, modifiedMap) {
|
|
194
|
+
const texts = [];
|
|
195
|
+
for (const [path, element] of modifiedMap) {
|
|
196
|
+
if (!isLiveRegion(element))
|
|
197
|
+
continue;
|
|
198
|
+
const text = getTextContent(element).trim();
|
|
199
|
+
if (!text)
|
|
200
|
+
continue;
|
|
201
|
+
const previous = originalMap.get(path);
|
|
202
|
+
if (previous && getTextContent(previous).trim() === text)
|
|
203
|
+
continue;
|
|
204
|
+
texts.push(text);
|
|
205
|
+
}
|
|
206
|
+
return texts;
|
|
207
|
+
}
|
|
208
|
+
function isLiveRegion(element) {
|
|
209
|
+
if (element.tagName?.toLowerCase() === 'output')
|
|
210
|
+
return true;
|
|
211
|
+
const attrs = element.attrs ?? [];
|
|
212
|
+
const role = attrs.find((attr) => attr.name === 'role')?.value.toLowerCase();
|
|
213
|
+
if (role && LIVE_REGION_ROLES.has(role))
|
|
214
|
+
return true;
|
|
215
|
+
const live = attrs.find((attr) => attr.name === 'aria-live')?.value.toLowerCase();
|
|
216
|
+
return live === 'polite' || live === 'assertive';
|
|
217
|
+
}
|
|
157
218
|
/**
|
|
158
219
|
* Parse HTML into a document, wrapping fragments with html/body for consistency.
|
|
159
220
|
* Uses custom sanitization that removes iframes for diff purposes.
|
|
@@ -354,9 +415,7 @@ function findStableContainer(topLevelPath, originalMap, modifiedMap) {
|
|
|
354
415
|
}
|
|
355
416
|
return { path: 'html[1]/body[1]', selector: 'body' };
|
|
356
417
|
}
|
|
357
|
-
async function buildDiffParts(
|
|
358
|
-
const originalMap = collectElementMap(originalDocument);
|
|
359
|
-
const modifiedMap = collectElementMap(modifiedDocument);
|
|
418
|
+
async function buildDiffParts(originalMap, modifiedMap) {
|
|
360
419
|
const addedPaths = [];
|
|
361
420
|
const changedPaths = [];
|
|
362
421
|
for (const [path, element] of modifiedMap.entries()) {
|
|
@@ -624,7 +683,7 @@ function flattenHtml(node) {
|
|
|
624
683
|
function process(n) {
|
|
625
684
|
if (n.type === 'text' && n.content) {
|
|
626
685
|
if (n.content.length >= 5) {
|
|
627
|
-
lines.push(
|
|
686
|
+
lines.push(`${TEXT_LINE_PREFIX}${n.content}`);
|
|
628
687
|
}
|
|
629
688
|
return;
|
|
630
689
|
}
|
|
@@ -643,7 +702,7 @@ function flattenHtml(node) {
|
|
|
643
702
|
return;
|
|
644
703
|
}
|
|
645
704
|
if (n.content && n.content.length >= 5) {
|
|
646
|
-
lines.push(
|
|
705
|
+
lines.push(`${TEXT_LINE_PREFIX}${n.content}`);
|
|
647
706
|
}
|
|
648
707
|
if (n.children) {
|
|
649
708
|
n.children.forEach((child) => process(child));
|
|
@@ -3,3 +3,5 @@ export declare function slugify(text: string): string;
|
|
|
3
3
|
export declare function normalizeInlineText(text: string): string;
|
|
4
4
|
export declare function sanitizeFilename(name: string): string;
|
|
5
5
|
export declare function safeFilename(name: string, ext?: string, maxBytes?: number): string;
|
|
6
|
+
export declare function truncate(text: string, max: number): string;
|
|
7
|
+
export declare function compactErrorMessage(error: unknown): string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import stripAnsi from 'strip-ansi';
|
|
2
3
|
export function truncateJson(input) {
|
|
3
4
|
if (!input)
|
|
4
5
|
return '';
|
|
@@ -36,3 +37,34 @@ export function safeFilename(name, ext = '', maxBytes = 240) {
|
|
|
36
37
|
}
|
|
37
38
|
return truncated + suffix + ext;
|
|
38
39
|
}
|
|
40
|
+
export function truncate(text, max) {
|
|
41
|
+
if (text.length <= max)
|
|
42
|
+
return text;
|
|
43
|
+
return `${text.slice(0, max - 3)}...`;
|
|
44
|
+
}
|
|
45
|
+
const MAX_COMPACT_ERROR = 400;
|
|
46
|
+
export function compactErrorMessage(error) {
|
|
47
|
+
let text = stripAnsi(String(error));
|
|
48
|
+
for (const strip of STRIP_STRATEGIES) {
|
|
49
|
+
text = strip(text);
|
|
50
|
+
}
|
|
51
|
+
return truncate(text, MAX_COMPACT_ERROR);
|
|
52
|
+
}
|
|
53
|
+
function stripCallLog(text) {
|
|
54
|
+
const CALL_LOG = 'Call log:';
|
|
55
|
+
const NOISE = ['attempting', 'retrying', 'waiting'];
|
|
56
|
+
const [headline, ...log] = text.split(CALL_LOG);
|
|
57
|
+
if (!log.length)
|
|
58
|
+
return text;
|
|
59
|
+
const lines = new Set();
|
|
60
|
+
for (const line of log.join(CALL_LOG).split('\n')) {
|
|
61
|
+
const cleaned = normalizeInlineText(line);
|
|
62
|
+
if (!cleaned)
|
|
63
|
+
continue;
|
|
64
|
+
if (NOISE.some((noise) => cleaned.includes(noise)))
|
|
65
|
+
continue;
|
|
66
|
+
lines.add(cleaned);
|
|
67
|
+
}
|
|
68
|
+
return [headline.trim(), ...lines].join(' ');
|
|
69
|
+
}
|
|
70
|
+
const STRIP_STRATEGIES = [stripCallLog];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare function isDynamicSegment(segment: string): boolean;
|
|
2
2
|
export declare function hasDynamicUrlSegment(url: string): boolean;
|
|
3
|
+
export declare function isSamePageFamily(urlA: string, urlB: string): boolean;
|
|
3
4
|
export declare function generalizeSegment(segment: string): string;
|
|
4
5
|
export declare function generalizeUrl(url: string): string;
|
|
5
6
|
export declare function matchesUrl(pattern: string, path: string): boolean;
|
|
@@ -32,6 +32,23 @@ export function isDynamicSegment(segment) {
|
|
|
32
32
|
export function hasDynamicUrlSegment(url) {
|
|
33
33
|
return url.split('/').some((seg) => seg.length > 0 && isDynamicSegment(seg));
|
|
34
34
|
}
|
|
35
|
+
export function isSamePageFamily(urlA, urlB) {
|
|
36
|
+
const partsA = new URL(urlA, 'http://localhost').pathname.toLowerCase().split('/').filter(Boolean);
|
|
37
|
+
const partsB = new URL(urlB, 'http://localhost').pathname.toLowerCase().split('/').filter(Boolean);
|
|
38
|
+
if (partsA.length !== partsB.length)
|
|
39
|
+
return false;
|
|
40
|
+
let diffCount = 0;
|
|
41
|
+
for (let i = 0; i < partsA.length; i++) {
|
|
42
|
+
if (partsA[i] === partsB[i])
|
|
43
|
+
continue;
|
|
44
|
+
diffCount++;
|
|
45
|
+
if (diffCount > 1)
|
|
46
|
+
return false;
|
|
47
|
+
if (!isDynamicSegment(partsA[i]) || !isDynamicSegment(partsB[i]))
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
35
52
|
export function generalizeSegment(segment) {
|
|
36
53
|
if (/^\d+$/.test(segment))
|
|
37
54
|
return '\\d+';
|
|
@@ -111,6 +128,18 @@ export function matchesNavigationUrl(expected, current) {
|
|
|
111
128
|
if (!expectedPath.includes('?')) {
|
|
112
129
|
currentPath = currentPath.split('?')[0];
|
|
113
130
|
}
|
|
114
|
-
const normalize = (value) => value.replace(/^\/+|\/+$/g, '')
|
|
115
|
-
|
|
131
|
+
const normalize = (value) => value.replace(/^\/+|\/+$/g, '');
|
|
132
|
+
const expectedNormalized = normalize(expectedPath);
|
|
133
|
+
const currentNormalized = normalize(currentPath);
|
|
134
|
+
const expectedKey = expectedNormalized.toLowerCase();
|
|
135
|
+
const currentKey = currentNormalized.toLowerCase();
|
|
136
|
+
if (expectedKey === currentKey)
|
|
137
|
+
return true;
|
|
138
|
+
if (!currentKey.startsWith(`${expectedKey}/`))
|
|
139
|
+
return false;
|
|
140
|
+
const recordSegments = currentNormalized
|
|
141
|
+
.slice(expectedKey.length + 1)
|
|
142
|
+
.split('/')
|
|
143
|
+
.filter(Boolean);
|
|
144
|
+
return recordSegments.length > 0 && recordSegments.every(isDynamicSegment);
|
|
116
145
|
}
|
|
@@ -32,30 +32,53 @@ OPENROUTER_API_KEY=sk-...
|
|
|
32
32
|
Then open `explorbot.config.js` and set your app's base URL — the host only, no path:
|
|
33
33
|
|
|
34
34
|
```javascript
|
|
35
|
-
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
|
36
|
-
|
|
37
|
-
const openrouter = createOpenRouter({
|
|
38
|
-
apiKey: process.env.OPENROUTER_API_KEY,
|
|
39
|
-
});
|
|
40
|
-
|
|
41
35
|
export default {
|
|
42
36
|
web: {
|
|
43
37
|
url: 'http://localhost:3000',
|
|
44
38
|
},
|
|
45
39
|
ai: {
|
|
46
|
-
model: openrouter
|
|
47
|
-
visionModel: openrouter
|
|
48
|
-
agenticModel: openrouter
|
|
40
|
+
model: 'openrouter/openai/gpt-oss-20b:nitro',
|
|
41
|
+
visionModel: 'openrouter/openai/gpt-5.6-luna',
|
|
42
|
+
agenticModel: 'openrouter/openai/gpt-5.6-luna',
|
|
49
43
|
},
|
|
50
44
|
};
|
|
51
45
|
```
|
|
52
46
|
|
|
47
|
+
That shorthand — `'provider/model-id'` — uses a provider package Explorbot ships, so nothing extra is installed. Bundled providers:
|
|
48
|
+
|
|
49
|
+
- `openai`
|
|
50
|
+
- `anthropic`
|
|
51
|
+
- `google`
|
|
52
|
+
- `groq`
|
|
53
|
+
- `mistral`
|
|
54
|
+
- `openrouter`
|
|
55
|
+
- `sambanova`
|
|
56
|
+
|
|
57
|
+
The other style is explicit: install a Vercel AI SDK package (`npm i @ai-sdk/openai`) and build the client yourself. It works for the providers above too, and it is the only way to reach one that isn't bundled, a custom `baseURL`, or extra client options:
|
|
58
|
+
|
|
59
|
+
```javascript
|
|
60
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
61
|
+
|
|
62
|
+
const poolside = createOpenAI({
|
|
63
|
+
apiKey: process.env.POOLSIDE_API_KEY,
|
|
64
|
+
baseURL: 'https://inference.poolside.ai/v1',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export default {
|
|
68
|
+
ai: {
|
|
69
|
+
model: poolside('poolside/laguna-xs-2.1'),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Both styles mix freely across the three keys. See [Providers](./providers.md).
|
|
75
|
+
|
|
53
76
|
Explorbot uses three models. Pick each one for speed and cost:
|
|
54
77
|
|
|
55
78
|
| Model | Config key | Used by | Pick |
|
|
56
79
|
|-------|-----------|---------|------|
|
|
57
80
|
| `model` | `ai.model` | Tester, Navigator, Researcher — they read HTML and ARIA on every step | a fast, cheap model (e.g. `openai/gpt-oss-20b:nitro`) |
|
|
58
|
-
| `visionModel` | `ai.visionModel` | screenshot analysis | a vision model (e.g. `
|
|
81
|
+
| `visionModel` | `ai.visionModel` | screenshot analysis | a vision model (e.g. `openai/gpt-5.6-luna`) |
|
|
59
82
|
| `agenticModel` | `ai.agenticModel` | Captain and Pilot — they read short action logs and make the big decisions | a smarter model (e.g. MiniMax 2.5, Grok Fast) |
|
|
60
83
|
|
|
61
84
|
Captain and Pilot barely use tokens, so a smarter `agenticModel` improves results for almost no extra cost. OpenRouter is the simplest start — one key, many models. To use OpenAI, Anthropic, Groq, or others, see [Providers](./providers.md). For every config option, see [Configuration](../reference/configuration.md).
|
package/docs/basics/providers.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Explorbot connects to AI providers through the [Vercel AI SDK](https://sdk.vercel.ai/). Use any supported provider, and mix providers across different models.
|
|
4
4
|
|
|
5
|
+
Every provider below is set up the classical way: install its package, import it, build the client. Explorbot bundles some of these packages — for those you can skip the install and name the model as `'provider/model-id'` instead. See [Getting Started](./getting-started.md#2-configure) for that list and the two styles side by side.
|
|
6
|
+
|
|
5
7
|
> The `export default` config block inside each `<!-- START/END provider -->` marker is generated from [`models.json`](../../models.json). After editing that file, run `bunosh docs:sync`. Everything else — including the import blocks — is hand-written.
|
|
6
8
|
|
|
7
9
|
## Requirements
|
|
@@ -51,8 +53,8 @@ Set the recommended models in the exported config:
|
|
|
51
53
|
export default {
|
|
52
54
|
ai: {
|
|
53
55
|
model: openrouter('openai/gpt-oss-20b:nitro'),
|
|
54
|
-
visionModel: openrouter('
|
|
55
|
-
agenticModel: openrouter('
|
|
56
|
+
visionModel: openrouter('openai/gpt-5.6-luna'),
|
|
57
|
+
agenticModel: openrouter('openai/gpt-5.6-luna'),
|
|
56
58
|
},
|
|
57
59
|
};
|
|
58
60
|
```
|
|
@@ -118,8 +120,8 @@ Set the recommended models in the exported config:
|
|
|
118
120
|
```javascript
|
|
119
121
|
export default {
|
|
120
122
|
ai: {
|
|
121
|
-
model: openai('gpt-5
|
|
122
|
-
visionModel: openai('gpt-5.
|
|
123
|
+
model: openai('gpt-5-nano'),
|
|
124
|
+
visionModel: openai('gpt-5.6-luna'),
|
|
123
125
|
agenticModel: openai('gpt-5.6-luna'),
|
|
124
126
|
},
|
|
125
127
|
};
|