explorbot 0.2.4 → 0.3.0
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 +19 -7
- package/boat/api-tester/src/cli.ts +17 -0
- package/boat/doc-collector/src/cli.ts +14 -1
- package/boat/prima/README.md +96 -0
- package/boat/prima/package.json +14 -10
- package/boat/prima/src/cli.ts +29 -12
- package/boat/prima/src/envelope.ts +35 -13
- package/boat/prima/src/prima.ts +78 -45
- package/dist/bin/explorbot-cli.js +19 -7
- package/dist/boat/api-tester/src/cli.js +17 -0
- package/dist/boat/doc-collector/src/cli.js +14 -1
- package/dist/boat/prima/src/cli.js +26 -7
- package/dist/boat/prima/src/envelope.js +32 -8
- package/dist/boat/prima/src/prima.js +75 -43
- 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 +53 -18
- 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/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 +2 -1
- package/dist/src/ai/navigator.d.ts +28 -0
- package/dist/src/ai/navigator.js +223 -175
- package/dist/src/ai/pilot.d.ts +7 -4
- package/dist/src/ai/pilot.js +89 -30
- package/dist/src/ai/planner/subpages.js +2 -16
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.d.ts +2 -2
- package/dist/src/ai/provider.js +28 -22
- package/dist/src/ai/researcher/cache.d.ts +10 -3
- package/dist/src/ai/researcher/cache.js +23 -10
- 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 +6 -4
- package/dist/src/ai/rules.js +1 -5
- package/dist/src/ai/session-analyst.js +2 -0
- package/dist/src/ai/tester.d.ts +6 -3
- package/dist/src/ai/tester.js +30 -35
- package/dist/src/ai/tools.d.ts +8 -5
- package/dist/src/ai/tools.js +83 -57
- package/dist/src/commands/config-command.d.ts +51 -0
- package/dist/src/commands/config-command.js +117 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/init-command.js +13 -20
- package/dist/src/config.d.ts +8 -1
- package/dist/src/config.js +43 -1
- package/dist/src/experience-tracker.d.ts +2 -0
- package/dist/src/experience-tracker.js +12 -0
- package/dist/src/explorbot.js +5 -2
- package/dist/src/playwright-recorder.js +6 -12
- package/dist/src/remote.d.ts +3 -2
- package/dist/src/remote.js +8 -2
- package/dist/src/state-manager.d.ts +1 -1
- package/dist/src/state-manager.js +3 -1
- package/dist/src/test-plan.d.ts +9 -0
- package/dist/src/test-plan.js +30 -0
- package/dist/src/utils/html-diff.d.ts +5 -0
- package/dist/src/utils/html-diff.js +65 -6
- package/dist/src/utils/logger.d.ts +1 -1
- package/dist/src/utils/logger.js +8 -0
- 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/docs/index.json +2 -1
- package/docs/reference/commands.md +3 -0
- package/docs/reference/websocket.md +50 -0
- package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
- package/models.json +4 -4
- package/package.json +6 -2
- package/src/action-result.ts +61 -16
- package/src/action.ts +56 -18
- package/src/ai/captain/web-mode.ts +1 -2
- package/src/ai/captain.ts +9 -1
- package/src/ai/driller.ts +6 -2
- package/src/ai/fisherman-tools.ts +35 -0
- package/src/ai/fisherman.ts +2 -1
- package/src/ai/navigator.ts +238 -179
- package/src/ai/pilot.ts +104 -36
- package/src/ai/planner/subpages.ts +2 -13
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +29 -21
- package/src/ai/researcher/cache.ts +29 -11
- package/src/ai/researcher/deep-analysis.ts +1 -1
- package/src/ai/researcher/fingerprint-worker.ts +23 -5
- package/src/ai/researcher.ts +6 -4
- package/src/ai/rules.ts +1 -5
- package/src/ai/session-analyst.ts +2 -0
- package/src/ai/tester.ts +33 -34
- package/src/ai/tools.ts +88 -61
- package/src/commands/config-command.ts +146 -0
- package/src/commands/index.ts +2 -0
- package/src/commands/init-command.ts +14 -20
- package/src/config.ts +47 -2
- package/src/experience-tracker.ts +13 -0
- package/src/explorbot.ts +4 -2
- package/src/playwright-recorder.ts +6 -11
- package/src/remote.ts +8 -2
- package/src/state-manager.ts +5 -2
- package/src/test-plan.ts +38 -0
- package/src/utils/html-diff.ts +72 -7
- package/src/utils/logger.ts +9 -1
- package/src/utils/strings.ts +36 -0
- package/src/utils/url-matcher.ts +27 -2
package/dist/src/ai/tools.js
CHANGED
|
@@ -2,11 +2,13 @@ import { tool } from 'ai';
|
|
|
2
2
|
import dedent from 'dedent';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { ActionResult } from "../action-result.js";
|
|
5
|
+
import { renderExperienceRecipes } from "../experience-tracker.js";
|
|
5
6
|
import { Stats } from "../stats.js";
|
|
6
7
|
import { TestResult } from '../test-plan.js';
|
|
7
8
|
import { LARGE_ARIA_CHANGE_THRESHOLD } from "../utils/aria.js";
|
|
8
9
|
import { isFatalBrowserError } from "../utils/browser-errors.js";
|
|
9
10
|
import { createDebug, tag } from '../utils/logger.js';
|
|
11
|
+
import { compactErrorMessage } from "../utils/strings.js";
|
|
10
12
|
import { pause } from '../utils/loop.js';
|
|
11
13
|
import { WebElement } from "../utils/web-element.js";
|
|
12
14
|
import { sectionContextRule } from "./rules.js";
|
|
@@ -19,10 +21,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
19
21
|
description: dedent `
|
|
20
22
|
Click an element by trying multiple CodeceptJS commands in order until one succeeds.
|
|
21
23
|
|
|
22
|
-
Use this only for elements the page context gives you no ref for. When the element shows a ref such as [ref=e14],
|
|
23
|
-
call clickRef with that ref instead — composing a locator for an element that already has a ref is wasted work,
|
|
24
|
-
and a locator can match several elements where a ref cannot.
|
|
25
|
-
|
|
26
24
|
Follow <locator_priority> from system prompt for locator selection.
|
|
27
25
|
|
|
28
26
|
I.click(locator) - click element matching locator
|
|
@@ -78,7 +76,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
78
76
|
const success = await action.attempt(command, explanation);
|
|
79
77
|
const attempt = { command, success };
|
|
80
78
|
if (action.lastError)
|
|
81
|
-
attempt.error = action.lastError
|
|
79
|
+
attempt.error = errorText(action.lastError);
|
|
82
80
|
attempts.push(attempt);
|
|
83
81
|
if (success) {
|
|
84
82
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, command);
|
|
@@ -100,7 +98,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
100
98
|
retryCommands.push(`I.click('${disambiguated.xpath.replace(/'/g, "\\'")}')`);
|
|
101
99
|
for (const retryCmd of retryCommands) {
|
|
102
100
|
if (!(await action.attempt(retryCmd, explanation))) {
|
|
103
|
-
attempts.push({ command: retryCmd, success: false, error: action.lastError
|
|
101
|
+
attempts.push({ command: retryCmd, success: false, error: errorText(action.lastError) });
|
|
104
102
|
continue;
|
|
105
103
|
}
|
|
106
104
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, retryCmd);
|
|
@@ -118,38 +116,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
118
116
|
}, action.lastError);
|
|
119
117
|
},
|
|
120
118
|
}),
|
|
121
|
-
clickRef: tool({
|
|
122
|
-
description: dedent `
|
|
123
|
-
Click an element by the ref the page context gave it, e.g. [ref=e14].
|
|
124
|
-
|
|
125
|
-
Prefer this over click() whenever the element you want carries a ref. A ref names one exact element, so it
|
|
126
|
-
cannot match several by mistake and never needs disambiguating — it is the fastest way to click.
|
|
127
|
-
Only pass a ref that appears in the page context you were given. Never invent or guess one.
|
|
128
|
-
If it reports the ref is gone, the page has been rebuilt: get fresh context and use the new ref.
|
|
129
|
-
`,
|
|
130
|
-
inputSchema: z.object({
|
|
131
|
-
ref: z.string().describe('The ref exactly as it appears in the page context, e.g. "e14"'),
|
|
132
|
-
element: z.string().describe('Role and name of the element you are clicking, for the record'),
|
|
133
|
-
}),
|
|
134
|
-
execute: async ({ ref, element }) => {
|
|
135
|
-
const activeNote = task.startNote(`Click ${element}`);
|
|
136
|
-
const previousState = ActionResult.fromState(stateManager.getCurrentState());
|
|
137
|
-
const action = explorer.action();
|
|
138
|
-
const named = await describeRef(explorer, ref);
|
|
139
|
-
const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
|
|
140
|
-
if (!(await action.attempt(run, `Click ${element}`))) {
|
|
141
|
-
activeNote.commit(TestResult.FAILED);
|
|
142
|
-
return failedToolResult('clickRef', `Ref ${ref} could not be clicked: ${errorText(action.lastError)}`, {
|
|
143
|
-
suggestion: 'The ref may belong to an older version of the page. Get fresh context and use the ref it gives, or fall back to click() with a locator.',
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
// a ref belongs to this session only, so the run is reported as the locator a later test can replay
|
|
147
|
-
const code = named ? `I.click(${JSON.stringify(named)})` : run;
|
|
148
|
-
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, code);
|
|
149
|
-
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
150
|
-
return successToolResult('clickRef', { ...toolResult, code }, action);
|
|
151
|
-
},
|
|
152
|
-
}),
|
|
153
119
|
hover: tool({
|
|
154
120
|
description: dedent `
|
|
155
121
|
Move the mouse cursor to an element to reveal hover-only controls.
|
|
@@ -197,7 +163,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
197
163
|
const success = await action.attempt(command, explanation);
|
|
198
164
|
const attempt = { command, success };
|
|
199
165
|
if (action.lastError)
|
|
200
|
-
attempt.error = action.lastError
|
|
166
|
+
attempt.error = errorText(action.lastError);
|
|
201
167
|
attempts.push(attempt);
|
|
202
168
|
if (!success)
|
|
203
169
|
continue;
|
|
@@ -264,7 +230,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
264
230
|
fallback: true,
|
|
265
231
|
}, action);
|
|
266
232
|
}
|
|
267
|
-
const errorMsg = `pressKey fallback to type() failed: ${action.lastError
|
|
233
|
+
const errorMsg = `pressKey fallback to type() failed: ${errorText(action.lastError)}`;
|
|
268
234
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
269
235
|
return failedToolResult('pressKey', errorMsg, {
|
|
270
236
|
...toolResult,
|
|
@@ -300,7 +266,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
300
266
|
code: pressKeyCommand,
|
|
301
267
|
}, action);
|
|
302
268
|
}
|
|
303
|
-
const errorMsg = `pressKey() failed: ${action.lastError
|
|
269
|
+
const errorMsg = `pressKey() failed: ${errorText(action.lastError)}`;
|
|
304
270
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
305
271
|
return failedToolResult('pressKey', errorMsg, {
|
|
306
272
|
...toolResult,
|
|
@@ -321,8 +287,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
321
287
|
Execute raw CodeceptJS code block with multiple commands.
|
|
322
288
|
USE THIS TOOL for typing text into fields: I.fillField, I.type
|
|
323
289
|
|
|
324
|
-
Do not put a click on a ref-bearing element in here — clickRef with its ref is cheaper and cannot mis-target.
|
|
325
|
-
|
|
326
290
|
Follow <actions> from system prompt for available commands.
|
|
327
291
|
Follow <locator_priority> from system prompt for locator selection.
|
|
328
292
|
|
|
@@ -377,7 +341,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
377
341
|
await action.attempt(codeBlock, explanation);
|
|
378
342
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, formLocator);
|
|
379
343
|
if (action.lastError) {
|
|
380
|
-
const message =
|
|
344
|
+
const message = errorText(action.lastError);
|
|
381
345
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
382
346
|
let formSuggestion = 'Look into error message and identify which commands passed and which failed. Continue execution using step-by-step approach using click() and form() tools.';
|
|
383
347
|
if (message.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN)) {
|
|
@@ -419,6 +383,42 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
419
383
|
}),
|
|
420
384
|
};
|
|
421
385
|
}
|
|
386
|
+
export function createRefTools({ explorer, stateManager }, task) {
|
|
387
|
+
return {
|
|
388
|
+
clickRef: tool({
|
|
389
|
+
description: dedent `
|
|
390
|
+
Click an element by the ref the page context gave it, e.g. [ref=e14].
|
|
391
|
+
|
|
392
|
+
Prefer this over click() whenever the element you want carries a ref. A ref names one exact element, so it
|
|
393
|
+
cannot match several by mistake and never needs disambiguating — it is the fastest way to click.
|
|
394
|
+
Only pass a ref that appears in the page context you were given. Never invent or guess one.
|
|
395
|
+
If it reports the ref is gone, the page has been rebuilt: get fresh context and use the new ref.
|
|
396
|
+
`,
|
|
397
|
+
inputSchema: z.object({
|
|
398
|
+
ref: z.string().describe('The ref exactly as it appears in the page context, e.g. "e14"'),
|
|
399
|
+
element: z.string().describe('Role and name of the element you are clicking, for the record'),
|
|
400
|
+
}),
|
|
401
|
+
execute: async ({ ref, element }) => {
|
|
402
|
+
const activeNote = task.startNote(`Click ${element}`);
|
|
403
|
+
const previousState = ActionResult.fromState(stateManager.getCurrentState());
|
|
404
|
+
const action = explorer.action();
|
|
405
|
+
const named = await describeRef(explorer, ref);
|
|
406
|
+
const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
|
|
407
|
+
if (!(await action.attempt(run, `Click ${element}`))) {
|
|
408
|
+
activeNote.commit(TestResult.FAILED);
|
|
409
|
+
return failedToolResult('clickRef', `Ref ${ref} could not be clicked: ${errorText(action.lastError)}`, {
|
|
410
|
+
suggestion: 'The ref may belong to an older version of the page. Get fresh context and use the ref it gives, or fall back to click() with a locator.',
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
// a ref belongs to this session only, so the run is reported as the locator a later test can replay
|
|
414
|
+
const code = named ? `I.click(${JSON.stringify(named)})` : run;
|
|
415
|
+
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, code);
|
|
416
|
+
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
417
|
+
return successToolResult('clickRef', { ...toolResult, code }, action);
|
|
418
|
+
},
|
|
419
|
+
}),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
422
|
export function createIframeTools({ explorer, stateManager }) {
|
|
423
423
|
return {
|
|
424
424
|
exitIframe: tool({
|
|
@@ -535,7 +535,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
535
535
|
|
|
536
536
|
DO NOT call this if:
|
|
537
537
|
- You just performed an action (pageDiff already provided in response)
|
|
538
|
-
- You already have recent <
|
|
538
|
+
- You already have a recent <page_aria> snapshot in context
|
|
539
539
|
- You're about to perform an action (you'll get pageDiff after)
|
|
540
540
|
|
|
541
541
|
Call ONLY when:
|
|
@@ -675,12 +675,18 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
675
675
|
}),
|
|
676
676
|
interact: tool({
|
|
677
677
|
description: dedent `
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
678
|
+
Delegate one step to the Navigator, which reads the full page HTML and tries multiple CodeceptJS strategies.
|
|
679
|
+
Slower than the direct action tools — use it as a fallback, not as the default.
|
|
680
|
+
|
|
681
|
+
Use when:
|
|
682
|
+
- direct action tools failed and you have no better locator to try
|
|
683
|
+
- the step needs a sequence of actions to complete
|
|
684
|
+
- the element is not in the context you have
|
|
685
|
+
|
|
686
|
+
Describe the outcome to reach, not the locator to use.
|
|
681
687
|
`,
|
|
682
688
|
inputSchema: z.object({
|
|
683
|
-
instruction: z.string().describe('
|
|
689
|
+
instruction: z.string().describe('The step to perform on the page, described by its intent'),
|
|
684
690
|
}),
|
|
685
691
|
execute: async ({ instruction }) => {
|
|
686
692
|
try {
|
|
@@ -690,7 +696,8 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
690
696
|
}
|
|
691
697
|
const previousState = ActionResult.fromState(currentState);
|
|
692
698
|
const actionResult = ActionResult.fromState(currentState);
|
|
693
|
-
const
|
|
699
|
+
const experience = renderExperienceRecipes(explorer.activeTest?.getAppliedExperience(actionResult) ?? []);
|
|
700
|
+
const success = await navigator.resolveState(instruction, actionResult, { experience });
|
|
694
701
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, instruction);
|
|
695
702
|
if (success) {
|
|
696
703
|
return successToolResult('interact', {
|
|
@@ -698,7 +705,10 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
698
705
|
message: `Successfully executed: ${instruction}`,
|
|
699
706
|
});
|
|
700
707
|
}
|
|
701
|
-
|
|
708
|
+
let reason = '';
|
|
709
|
+
if (navigator.lastFailureReason)
|
|
710
|
+
reason = `: ${navigator.lastFailureReason}`;
|
|
711
|
+
return failedToolResult('interact', `Failed to execute: ${instruction}${reason}`, {
|
|
702
712
|
...toolResult,
|
|
703
713
|
suggestion: 'The action could not be completed. Try a different instruction or use more specific element descriptions.',
|
|
704
714
|
});
|
|
@@ -821,7 +831,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
821
831
|
}
|
|
822
832
|
const failData = { suggestion: 'Try reset() to return to the starting page.' };
|
|
823
833
|
if (action.lastError)
|
|
824
|
-
failData.error = action.lastError
|
|
834
|
+
failData.error = errorText(action.lastError);
|
|
825
835
|
return failedToolResult('back', `Failed to navigate back to ${targetUrl}`, failData);
|
|
826
836
|
},
|
|
827
837
|
}),
|
|
@@ -878,7 +888,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
878
888
|
}
|
|
879
889
|
if (result.totalFound === 0) {
|
|
880
890
|
return failedToolResult('xpathCheck', `No elements matched XPath: ${xpath}`, {
|
|
881
|
-
suggestion: '
|
|
891
|
+
suggestion: 'Do not guess another expression. Narrow down from what you know about the target: its role, its visible text, its nearest labelled ancestor. Add one constraint at a time.',
|
|
882
892
|
});
|
|
883
893
|
}
|
|
884
894
|
const action = explorer.action();
|
|
@@ -960,7 +970,9 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
960
970
|
withdrawVisionTools(tools);
|
|
961
971
|
return tools;
|
|
962
972
|
}
|
|
963
|
-
const PAGE_DIFF_SUGGESTION = 'Analyze page diff. htmlParts shows what changed and WHERE — each part has a container selector. Use the container as context when clicking elements from the diff.';
|
|
973
|
+
const PAGE_DIFF_SUGGESTION = 'Analyze page diff. htmlParts shows what changed and WHERE — each part has a container selector. Use the container as context when clicking elements from the diff. messages holds text the app showed in response, requests the calls it made and consoleErrors what it logged.';
|
|
974
|
+
const FAILED_REQUEST_SUGGESTION = 'The server rejected a request made by this action (see requests). The UI accepted the interaction but the operation did not complete — read messages and consoleErrors for the reason and report it instead of repeating the action.';
|
|
975
|
+
const NAVIGATED_SUGGESTION = 'The action left the page. Elements are never compared across pages, so this diff carries the move itself and what the app announced in transit — an empty element diff does not mean nothing happened.';
|
|
964
976
|
const ARIA_OUTPUT_CAP = 4000;
|
|
965
977
|
const HTML_OUTPUT_CAP = 6000;
|
|
966
978
|
const ANALYSIS_OUTPUT_CAP = 2000;
|
|
@@ -998,7 +1010,7 @@ function transformContainsCommand(command) {
|
|
|
998
1010
|
}
|
|
999
1011
|
function errorText(error) {
|
|
1000
1012
|
if (error instanceof Error)
|
|
1001
|
-
return error
|
|
1013
|
+
return compactErrorMessage(error);
|
|
1002
1014
|
return 'Unknown error occurred';
|
|
1003
1015
|
}
|
|
1004
1016
|
export async function commitNote(activeNote, result, toolResult, action) {
|
|
@@ -1034,7 +1046,13 @@ export function successToolResult(action, data, source) {
|
|
|
1034
1046
|
const ariaChanges = data.pageDiff.ariaChanges || '';
|
|
1035
1047
|
const urlChanged = data.pageDiff.urlChanged === true;
|
|
1036
1048
|
const hasHtmlParts = Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
|
|
1037
|
-
if (
|
|
1049
|
+
if (hasFailedRequest(data.pageDiff)) {
|
|
1050
|
+
suggestion = `${FAILED_REQUEST_SUGGESTION} ${suggestion}`;
|
|
1051
|
+
}
|
|
1052
|
+
else if (urlChanged) {
|
|
1053
|
+
suggestion = `${NAVIGATED_SUGGESTION} ${suggestion}`;
|
|
1054
|
+
}
|
|
1055
|
+
else if (isMajorPageChange(data.pageDiff)) {
|
|
1038
1056
|
suggestion = `MAJOR PAGE CHANGE. Page entered a different mode. Check htmlParts and iframes in pageDiff before next action. ${suggestion}`;
|
|
1039
1057
|
}
|
|
1040
1058
|
else if (!urlChanged && !ariaChanges && !hasHtmlParts) {
|
|
@@ -1050,6 +1068,9 @@ export function successToolResult(action, data, source) {
|
|
|
1050
1068
|
export function isMajorPageChange(pageDiff) {
|
|
1051
1069
|
return pageDiff.urlChanged !== true && (pageDiff.ariaChangeCount ?? 0) >= LARGE_ARIA_CHANGE_THRESHOLD;
|
|
1052
1070
|
}
|
|
1071
|
+
export function hasFailedRequest(pageDiff) {
|
|
1072
|
+
return (pageDiff.requests ?? []).some((request) => request.status >= 400);
|
|
1073
|
+
}
|
|
1053
1074
|
function hasObservablePageChange(data) {
|
|
1054
1075
|
if (!data?.pageDiff)
|
|
1055
1076
|
return false;
|
|
@@ -1057,6 +1078,8 @@ function hasObservablePageChange(data) {
|
|
|
1057
1078
|
return true;
|
|
1058
1079
|
if (data.pageDiff.ariaChanges)
|
|
1059
1080
|
return true;
|
|
1081
|
+
if (data.pageDiff.messages?.length)
|
|
1082
|
+
return true;
|
|
1060
1083
|
return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
|
|
1061
1084
|
}
|
|
1062
1085
|
export async function failedToolResult(action, message, data, error) {
|
|
@@ -1108,6 +1131,9 @@ export function clickFailureSuggestion(attempts) {
|
|
|
1108
1131
|
if (errors.some((e) => e.includes('is not visible'))) {
|
|
1109
1132
|
return 'Element is in the DOM but not visible. Reveal it first — scroll to it, expand its section, or open the panel holding it.';
|
|
1110
1133
|
}
|
|
1134
|
+
if (errors.some((e) => e.includes('SyntaxError'))) {
|
|
1135
|
+
return 'The command string never parsed as JavaScript — quotes or brackets do not match. No element was looked up, so this tells you nothing about the page. Re-emit the same intent as valid CodeceptJS.';
|
|
1136
|
+
}
|
|
1111
1137
|
const notFound = errors.filter((e) => e.includes('was not found'));
|
|
1112
1138
|
if (notFound.length && notFound.every((e) => e.includes('was not found inside element'))) {
|
|
1113
1139
|
return 'Element was not found inside that container — the container is wrong or stale, and the element may exist elsewhere on the page. Retry the same locator WITHOUT a container, or verify the container with xpathCheck().';
|
|
@@ -1188,7 +1214,7 @@ function getNotFoundSuggestion(errorMessage) {
|
|
|
1188
1214
|
Element was not found. The locator does not exist on this page.
|
|
1189
1215
|
1. Use see() to visually analyze what elements are actually on the page
|
|
1190
1216
|
2. Use context() to get fresh HTML and ARIA snapshot
|
|
1191
|
-
3. Use ONLY locators from <page_aria> or
|
|
1217
|
+
3. Use ONLY locators from <page_aria> or from HTML returned by context()
|
|
1192
1218
|
4. Prefer ARIA locators: { "role": "button", "text": "visible text" }
|
|
1193
1219
|
`;
|
|
1194
1220
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type AIConfig, type ReporterConfig } from '../config.js';
|
|
2
|
+
import { BaseCommand } from './base-command.js';
|
|
3
|
+
export declare class ConfigCommand extends BaseCommand {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
execute(): Promise<void>;
|
|
7
|
+
static summary(options?: {
|
|
8
|
+
config?: string;
|
|
9
|
+
path?: string;
|
|
10
|
+
url?: string;
|
|
11
|
+
json?: boolean;
|
|
12
|
+
}): Promise<string>;
|
|
13
|
+
static data(config: SummarizedConfig, options?: ConfigSummaryOptions): ConfigData;
|
|
14
|
+
static render(config: SummarizedConfig, options?: ConfigSummaryOptions): string;
|
|
15
|
+
}
|
|
16
|
+
interface ConfigSummaryOptions {
|
|
17
|
+
configPath?: string | null;
|
|
18
|
+
root?: string;
|
|
19
|
+
json?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface ConfigData {
|
|
22
|
+
config: string;
|
|
23
|
+
url: string;
|
|
24
|
+
browser: string;
|
|
25
|
+
headless: boolean;
|
|
26
|
+
dirs: Record<string, string>;
|
|
27
|
+
models: Record<string, string>;
|
|
28
|
+
providers: Record<string, string>;
|
|
29
|
+
integrations: {
|
|
30
|
+
langfuse: boolean;
|
|
31
|
+
testomatio: boolean;
|
|
32
|
+
};
|
|
33
|
+
env: Record<string, string>;
|
|
34
|
+
}
|
|
35
|
+
interface SummarizedConfig {
|
|
36
|
+
ai?: AIConfig;
|
|
37
|
+
playwright?: {
|
|
38
|
+
url?: string;
|
|
39
|
+
browser?: string;
|
|
40
|
+
show?: boolean;
|
|
41
|
+
};
|
|
42
|
+
web?: {
|
|
43
|
+
url?: string;
|
|
44
|
+
};
|
|
45
|
+
api?: {
|
|
46
|
+
baseEndpoint?: string;
|
|
47
|
+
};
|
|
48
|
+
dirs?: Record<string, string>;
|
|
49
|
+
reporter?: ReporterConfig;
|
|
50
|
+
}
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { ConfigParser, EXPLORBOT_ENV_VARS, configuredModels } from '../config.js';
|
|
5
|
+
import { listSites } from '../global-config.js';
|
|
6
|
+
import { Reporter } from '../reporter.js';
|
|
7
|
+
import { getCliName } from '../utils/cli-name.js';
|
|
8
|
+
import { tag } from '../utils/logger.js';
|
|
9
|
+
import { BaseCommand } from './base-command.js';
|
|
10
|
+
export class ConfigCommand extends BaseCommand {
|
|
11
|
+
name = 'config';
|
|
12
|
+
description = 'Show models, config file and paths used by this run';
|
|
13
|
+
async execute() {
|
|
14
|
+
const parser = ConfigParser.getInstance();
|
|
15
|
+
tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), root: parser.getProjectRoot() }));
|
|
16
|
+
}
|
|
17
|
+
static async summary(options = {}) {
|
|
18
|
+
const parser = ConfigParser.getInstance();
|
|
19
|
+
const [site] = listSites();
|
|
20
|
+
const load = (baseUrl) => parser.loadConfig({ config: options.config, path: options.path, baseUrl });
|
|
21
|
+
const config = await load(options.url).catch((error) => {
|
|
22
|
+
if (!site)
|
|
23
|
+
throw error;
|
|
24
|
+
return load(site.url);
|
|
25
|
+
});
|
|
26
|
+
return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json });
|
|
27
|
+
}
|
|
28
|
+
static data(config, options = {}) {
|
|
29
|
+
let configPath = '';
|
|
30
|
+
if (options.configPath && existsSync(options.configPath))
|
|
31
|
+
configPath = options.configPath;
|
|
32
|
+
const dirs = {};
|
|
33
|
+
if (options.root) {
|
|
34
|
+
for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
|
|
35
|
+
dirs[name] = path.join(options.root, dir);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const env = {};
|
|
39
|
+
for (const variable of EXPLORBOT_ENV_VARS) {
|
|
40
|
+
const value = process.env[variable.name];
|
|
41
|
+
if (value)
|
|
42
|
+
env[variable.name] = value;
|
|
43
|
+
}
|
|
44
|
+
const models = {};
|
|
45
|
+
const providers = {};
|
|
46
|
+
for (const [role, model] of Object.entries(configuredModels(config.ai))) {
|
|
47
|
+
models[role] = model.name;
|
|
48
|
+
if (model.provider)
|
|
49
|
+
providers[role] = model.provider;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
config: configPath,
|
|
53
|
+
url: config.playwright?.url || config.web?.url || config.api?.baseEndpoint || '',
|
|
54
|
+
browser: config.playwright?.browser || '',
|
|
55
|
+
headless: !config.playwright?.show,
|
|
56
|
+
dirs,
|
|
57
|
+
models,
|
|
58
|
+
providers,
|
|
59
|
+
integrations: {
|
|
60
|
+
langfuse: !!config.ai?.langfuse?.enabled,
|
|
61
|
+
testomatio: Reporter.resolveEnabled(config.reporter),
|
|
62
|
+
},
|
|
63
|
+
env,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
static render(config, options = {}) {
|
|
67
|
+
const data = ConfigCommand.data(config, options);
|
|
68
|
+
if (options.json)
|
|
69
|
+
return JSON.stringify(data, null, 2);
|
|
70
|
+
const lines = [];
|
|
71
|
+
const section = (title, entries) => {
|
|
72
|
+
if (!entries.length)
|
|
73
|
+
return;
|
|
74
|
+
const width = Math.max(...entries.map(([label]) => label.length));
|
|
75
|
+
lines.push(chalk.bold(title));
|
|
76
|
+
for (const [label, value] of entries)
|
|
77
|
+
lines.push(` ${chalk.dim(label.padEnd(width))} ${value}`);
|
|
78
|
+
lines.push('');
|
|
79
|
+
};
|
|
80
|
+
const general = [['config', data.config || 'EXPLORBOT_* environment variables']];
|
|
81
|
+
if (data.url)
|
|
82
|
+
general.push(['url', data.url]);
|
|
83
|
+
if (data.browser) {
|
|
84
|
+
let window = 'visible';
|
|
85
|
+
if (data.headless)
|
|
86
|
+
window = 'headless';
|
|
87
|
+
general.push(['browser', `${data.browser}, ${window}`]);
|
|
88
|
+
}
|
|
89
|
+
for (const [name, dir] of Object.entries(data.dirs))
|
|
90
|
+
general.push([name, dir]);
|
|
91
|
+
section('Config', general);
|
|
92
|
+
const providerWidth = Math.max(0, ...Object.values(data.providers).map((provider) => provider.length));
|
|
93
|
+
const models = Object.entries(data.models).map(([role, model]) => {
|
|
94
|
+
if (!providerWidth)
|
|
95
|
+
return [role, model];
|
|
96
|
+
return [role, `${chalk.dim((data.providers[role] || '').padEnd(providerWidth))} ${model}`];
|
|
97
|
+
});
|
|
98
|
+
if (!models.length)
|
|
99
|
+
models.push(['model', chalk.red(`not configured — run ${getCliName()} init`)]);
|
|
100
|
+
section('Models', models);
|
|
101
|
+
const integrations = [];
|
|
102
|
+
if (data.integrations.langfuse)
|
|
103
|
+
integrations.push(['langfuse', 'traces sent']);
|
|
104
|
+
if (data.integrations.testomatio)
|
|
105
|
+
integrations.push(['testomatio', 'runs reported']);
|
|
106
|
+
section('Integrations', integrations);
|
|
107
|
+
const env = Object.entries(data.env).map(([name, value]) => {
|
|
108
|
+
let shown = value;
|
|
109
|
+
if (shown.length > 60)
|
|
110
|
+
shown = `${shown.slice(0, 57)}...`;
|
|
111
|
+
return [name, shown];
|
|
112
|
+
});
|
|
113
|
+
section('Environment', env);
|
|
114
|
+
lines.push(chalk.dim(`Every EXPLORBOT_* variable: ${getCliName()} --help`));
|
|
115
|
+
return lines.join('\n');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AddRuleCommand } from './add-rule-command.js';
|
|
2
2
|
import { CleanCommand } from './clean-command.js';
|
|
3
3
|
import { CompactCommand } from './compact-command.js';
|
|
4
|
+
import { ConfigCommand } from './config-command.js';
|
|
4
5
|
import { ContextAriaCommand } from './context-aria-command.js';
|
|
5
6
|
import { ContextCommand } from './context-command.js';
|
|
6
7
|
import { ContextDataCommand } from './context-data-command.js';
|
|
@@ -64,6 +65,7 @@ const commandClasses = [
|
|
|
64
65
|
RunsCommand,
|
|
65
66
|
RerunCommand,
|
|
66
67
|
StatusCommand,
|
|
68
|
+
ConfigCommand,
|
|
67
69
|
DebugCommand,
|
|
68
70
|
ExitCommand,
|
|
69
71
|
];
|
|
@@ -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
|
@@ -272,12 +272,19 @@ export declare class ConfigMissingError extends Error {
|
|
|
272
272
|
}
|
|
273
273
|
export declare function envConfigRequested(): boolean;
|
|
274
274
|
export declare function missingConfigMessage(configFile?: string): string;
|
|
275
|
+
export declare function modelName(model: unknown): string;
|
|
276
|
+
export declare function modelProvider(model: unknown): string;
|
|
277
|
+
export declare function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel>;
|
|
275
278
|
export declare function resolveConfigModels(ai?: AIConfig): Promise<void>;
|
|
276
279
|
export declare function resolveOutputRoot(baseUrl?: string): string;
|
|
277
280
|
export declare function resolveStateRoot(baseUrl: string, ephemeral?: boolean): string;
|
|
278
281
|
export declare function materializeKnowledge(outputRoot: string): void;
|
|
279
282
|
export declare function createModel(provider: string, modelId: string): Promise<any>;
|
|
280
283
|
type ModelRole = 'model' | 'visionModel' | 'agenticModel';
|
|
284
|
+
interface ConfiguredModel {
|
|
285
|
+
name: string;
|
|
286
|
+
provider: string;
|
|
287
|
+
}
|
|
281
288
|
interface ProviderInfo {
|
|
282
289
|
envKey: string;
|
|
283
290
|
load: () => Promise<(modelId: string) => any>;
|
|
@@ -287,4 +294,4 @@ interface EnvVar {
|
|
|
287
294
|
description: string;
|
|
288
295
|
required?: boolean;
|
|
289
296
|
}
|
|
290
|
-
export type { ModelRole, EnvVar, ProviderInfo };
|
|
297
|
+
export type { ModelRole, EnvVar, ProviderInfo, ConfiguredModel };
|