explorbot 0.4.1 → 0.4.2
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 +8 -3
- package/boat/api-tester/src/ai/curler-tools.ts +1 -1
- package/boat/api-tester/src/cli.ts +3 -0
- package/boat/doc-collector/src/ai/documentarian.ts +57 -5
- package/boat/doc-collector/src/ai/tools.ts +1 -1
- package/boat/doc-collector/src/cli.ts +18 -2
- package/boat/doc-collector/src/config.ts +3 -0
- package/boat/doc-collector/src/docbot.ts +56 -11
- package/boat/doc-collector/src/docs-renderer.ts +79 -55
- package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
- package/boat/doc-collector/src/screenshots.ts +106 -3
- package/boat/doc-collector/src/template-dedup.ts +31 -0
- package/boat/prima/src/cli.ts +3 -0
- package/boat/prima/src/prima.ts +3 -4
- package/dist/bin/explorbot-cli.js +9 -3
- package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
- package/dist/boat/api-tester/src/cli.js +2 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
- package/dist/boat/doc-collector/src/ai/tools.js +1 -1
- package/dist/boat/doc-collector/src/cli.js +15 -2
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +52 -10
- package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
- package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
- package/dist/boat/doc-collector/src/screenshots.js +104 -1
- package/dist/boat/doc-collector/src/template-dedup.js +27 -0
- package/dist/boat/prima/src/cli.js +2 -0
- package/dist/boat/prima/src/prima.js +3 -4
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +2 -2
- package/dist/src/action-result.js +15 -17
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +15 -15
- package/dist/src/ai/fisherman/request-haul.d.ts +11 -0
- package/dist/src/ai/fisherman/request-haul.js +28 -0
- package/dist/src/ai/fisherman-tools.d.ts +10 -1
- package/dist/src/ai/fisherman-tools.js +35 -31
- package/dist/src/ai/fisherman.d.ts +3 -2
- package/dist/src/ai/fisherman.js +8 -6
- package/dist/src/ai/pilot.js +21 -16
- package/dist/src/ai/provider.js +8 -14
- package/dist/src/ai/researcher/coordinates.d.ts +2 -0
- package/dist/src/ai/researcher/coordinates.js +13 -1
- package/dist/src/ai/researcher/deep-analysis.js +9 -9
- package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
- package/dist/src/ai/researcher.js +1 -1
- package/dist/src/ai/rules.js +8 -0
- package/dist/src/ai/tester.js +20 -20
- package/dist/src/ai/tools.d.ts +1 -1
- package/dist/src/ai/tools.js +18 -74
- package/dist/src/api/request-store.d.ts +1 -5
- package/dist/src/api/request-store.js +12 -22
- package/dist/src/commands/config-command.js +2 -9
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/init-command.js +3 -7
- package/dist/src/commands/plans-command.d.ts +4 -1
- package/dist/src/commands/plans-command.js +4 -4
- package/dist/src/commands/recommended-models-command.d.ts +9 -0
- package/dist/src/commands/recommended-models-command.js +85 -0
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +7 -2
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.js +3 -3
- package/dist/src/global-config.d.ts +2 -0
- package/dist/src/global-config.js +6 -0
- package/dist/src/state-manager.d.ts +3 -3
- package/dist/src/state-manager.js +8 -8
- package/dist/src/test-plan.d.ts +1 -0
- package/dist/src/test-plan.js +13 -9
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +23 -2
- package/dist/src/utils/cli-section.d.ts +1 -0
- package/dist/src/utils/cli-section.js +12 -0
- package/dist/src/utils/html-diff.d.ts +1 -0
- package/dist/src/utils/html-diff.js +5 -5
- package/dist/src/utils/html.d.ts +2 -0
- package/dist/src/utils/html.js +5 -4
- package/dist/src/utils/overlay.d.ts +19 -55
- package/dist/src/utils/overlay.js +94 -148
- package/dist/src/utils/region.d.ts +41 -0
- package/dist/src/utils/region.js +99 -0
- package/dist/src/utils/similarity.d.ts +1 -0
- package/dist/src/utils/similarity.js +11 -0
- package/dist/src/utils/url-matcher.d.ts +1 -1
- package/dist/src/utils/url-matcher.js +4 -4
- package/docs/doc-collection/basics.md +11 -5
- package/docs/doc-collection/crawling.md +21 -1
- package/docs/reference/commands.md +3 -0
- package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
- package/docs/workflow/application-spec.md +4 -0
- package/package.json +1 -1
- package/src/action-result.ts +15 -16
- package/src/action.ts +16 -15
- package/src/ai/fisherman/request-haul.ts +32 -0
- package/src/ai/fisherman-tools.ts +38 -32
- package/src/ai/fisherman.ts +8 -7
- package/src/ai/pilot.ts +19 -14
- package/src/ai/provider.ts +8 -12
- package/src/ai/researcher/coordinates.ts +13 -1
- package/src/ai/researcher/deep-analysis.ts +9 -9
- package/src/ai/researcher/fingerprint-worker.ts +1 -10
- package/src/ai/researcher.ts +1 -1
- package/src/ai/rules.ts +8 -0
- package/src/ai/tester.ts +18 -18
- package/src/ai/tools.ts +18 -86
- package/src/api/request-store.ts +13 -27
- package/src/commands/config-command.ts +2 -7
- package/src/commands/index.ts +2 -0
- package/src/commands/init-command.ts +4 -11
- package/src/commands/plans-command.ts +5 -5
- package/src/commands/recommended-models-command.ts +92 -0
- package/src/config.ts +9 -2
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +3 -3
- package/src/global-config.ts +8 -0
- package/src/state-manager.ts +10 -9
- package/src/test-plan.ts +12 -10
- package/src/utils/aria.ts +26 -3
- package/src/utils/cli-section.ts +12 -0
- package/src/utils/html-diff.ts +6 -6
- package/src/utils/html.ts +6 -4
- package/src/utils/overlay.ts +105 -192
- package/src/utils/region.ts +132 -0
- package/src/utils/similarity.ts +9 -0
- package/src/utils/url-matcher.ts +3 -3
- package/dist/src/utils/request-map.d.ts +0 -7
- package/dist/src/utils/request-map.js +0 -16
- package/src/utils/request-map.ts +0 -19
package/dist/src/ai/tools.js
CHANGED
|
@@ -16,7 +16,7 @@ import { sectionContextRule } from "./rules.js";
|
|
|
16
16
|
import { isInteractive } from "./task-agent.js";
|
|
17
17
|
const debugLog = createDebug('explorbot:tools');
|
|
18
18
|
export const ASSERTION_TOOLS = ['verify'];
|
|
19
|
-
export function createCodeceptJSTools({ explorer, stateManager
|
|
19
|
+
export function createCodeceptJSTools({ explorer, stateManager }, task) {
|
|
20
20
|
return {
|
|
21
21
|
click: tool({
|
|
22
22
|
description: dedent `
|
|
@@ -29,6 +29,12 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
29
29
|
Container narrows search area. Use when page has multiple matching elements.
|
|
30
30
|
Example: Page has 3 "Delete" buttons in different rows:
|
|
31
31
|
I.click("Delete", ".row-1") - clicks Delete inside element with class row-1
|
|
32
|
+
I.click(locator, step.opts({ elementIndex: N })) - click the Nth element the locator matches
|
|
33
|
+
Use after a result reported several matches. N is the "Element N" number from that list.
|
|
34
|
+
step.opts() always goes LAST, after the container when there is one.
|
|
35
|
+
Example: the result listed 3 "Delete" buttons and you want the second:
|
|
36
|
+
I.click("Delete", step.opts({ elementIndex: 2 }))
|
|
37
|
+
I.click("Delete", ".table", step.opts({ elementIndex: 2 })) - same, narrowed to a container
|
|
32
38
|
|
|
33
39
|
IMPORTANT: This tool ONLY accepts click commands. For typing text, use form() tool.
|
|
34
40
|
CRITICAL: All commands MUST target the SAME element using different locators.
|
|
@@ -47,6 +53,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
47
53
|
3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
|
|
48
54
|
4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
|
|
49
55
|
5. I.clickXY(x, y) - coordinates fallback
|
|
56
|
+
After a result reporting multiple matches, reuse that locator with step.opts({ elementIndex: N }) as the last argument.
|
|
50
57
|
`),
|
|
51
58
|
explanation: z.string().describe('Why you are clicking this element'),
|
|
52
59
|
}),
|
|
@@ -72,6 +79,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
72
79
|
const previousState = ActionResult.fromState(stateManager.getCurrentState());
|
|
73
80
|
const action = explorer.action();
|
|
74
81
|
const attempts = [];
|
|
82
|
+
let ambiguityError = null;
|
|
75
83
|
for (let i = 0; i < commands.length; i++) {
|
|
76
84
|
const command = transformContainsCommand(commands[i]);
|
|
77
85
|
const success = await action.attempt(command, explanation);
|
|
@@ -79,34 +87,14 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
79
87
|
if (action.lastError)
|
|
80
88
|
attempt.error = errorText(action.lastError);
|
|
81
89
|
attempts.push(attempt);
|
|
90
|
+
if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound')
|
|
91
|
+
ambiguityError = action.lastError;
|
|
82
92
|
if (success) {
|
|
83
93
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, command);
|
|
84
94
|
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
85
95
|
return successToolResult('click', { ...toolResult, attempts, code: command }, action);
|
|
86
96
|
}
|
|
87
97
|
}
|
|
88
|
-
let disambiguated = null;
|
|
89
|
-
if (attempts.some((a) => a.error?.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
|
|
90
|
-
disambiguated = await disambiguateElements(action.lastError, explanation, ai);
|
|
91
|
-
}
|
|
92
|
-
if (disambiguated) {
|
|
93
|
-
debugLog('Disambiguation picked element %d', disambiguated.position);
|
|
94
|
-
const failedCommand = attempts.find((a) => a.error?.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))?.command;
|
|
95
|
-
const retryCommands = [];
|
|
96
|
-
if (failedCommand) {
|
|
97
|
-
retryCommands.push(failedCommand.replace(/\)$/, `, step.opts({ elementIndex: ${disambiguated.position} }))`));
|
|
98
|
-
}
|
|
99
|
-
retryCommands.push(`I.click('${disambiguated.xpath.replace(/'/g, "\\'")}')`);
|
|
100
|
-
for (const retryCmd of retryCommands) {
|
|
101
|
-
if (!(await action.attempt(retryCmd, explanation))) {
|
|
102
|
-
attempts.push({ command: retryCmd, success: false, error: errorText(action.lastError) });
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, retryCmd);
|
|
106
|
-
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
107
|
-
return successToolResult('click', { ...toolResult, attempts, code: retryCmd, disambiguated: true }, action);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
98
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, commands[0]);
|
|
111
99
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
112
100
|
const suggestion = clickFailureSuggestion(attempts);
|
|
@@ -114,7 +102,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
114
102
|
...toolResult,
|
|
115
103
|
attempts,
|
|
116
104
|
suggestion,
|
|
117
|
-
}, action.lastError);
|
|
105
|
+
}, ambiguityError || action.lastError);
|
|
118
106
|
},
|
|
119
107
|
}),
|
|
120
108
|
hover: tool({
|
|
@@ -344,13 +332,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
|
|
|
344
332
|
if (action.lastError) {
|
|
345
333
|
const message = errorText(action.lastError);
|
|
346
334
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
347
|
-
|
|
348
|
-
if (message.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN)) {
|
|
349
|
-
const disambiguated = await disambiguateElements(action.lastError, explanation, ai);
|
|
350
|
-
if (disambiguated) {
|
|
351
|
-
formSuggestion = `Multiple elements matched. Add step.opts({ elementIndex: ${disambiguated.position} }) to the failing command. Fallback locator: ${disambiguated.xpath}`;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
335
|
+
const formSuggestion = 'Commands after the failing one never ran. Retry only those, using click() or form().';
|
|
354
336
|
return failedToolResult('form', `Form execution FAILED! ${message}\n${formatExecutedSteps(action.executedSteps, codeLines.length)}`, {
|
|
355
337
|
...toolResult,
|
|
356
338
|
code: codeBlock,
|
|
@@ -1123,13 +1105,11 @@ export async function failedToolResult(action, message, data, error) {
|
|
|
1123
1105
|
}
|
|
1124
1106
|
function getMultipleElementsSuggestion() {
|
|
1125
1107
|
return dedent `
|
|
1126
|
-
Multiple elements matched your locator
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
5. Use xpathCheck() to inspect matched elements and pick the correct one
|
|
1132
|
-
6. Use visualClick() to click the right element by visual appearance
|
|
1108
|
+
Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
|
|
1109
|
+
Read the numbered elements list and click the one you meant by its number:
|
|
1110
|
+
reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
|
|
1111
|
+
If none of them is the element you want, narrow the locator with a container or its full unique text.
|
|
1112
|
+
If the list is missing, call xpathCheck() to see what the locator matches.
|
|
1133
1113
|
`;
|
|
1134
1114
|
}
|
|
1135
1115
|
export function withdrawVisionTools(tools) {
|
|
@@ -1194,42 +1174,6 @@ export async function formatMatchedElements(error) {
|
|
|
1194
1174
|
return 'Could not fetch element details. Repeat the action to get better info.';
|
|
1195
1175
|
return formatElementList(details);
|
|
1196
1176
|
}
|
|
1197
|
-
async function disambiguateElements(error, explanation, provider) {
|
|
1198
|
-
const elementDetails = await extractWebElements(error);
|
|
1199
|
-
if (!elementDetails)
|
|
1200
|
-
return null;
|
|
1201
|
-
const elementList = formatElementList(elementDetails);
|
|
1202
|
-
const schema = z.object({
|
|
1203
|
-
position: z.number().nullable().describe('1-based position of the correct element, or null if none match'),
|
|
1204
|
-
});
|
|
1205
|
-
try {
|
|
1206
|
-
const result = await provider.generateObject([
|
|
1207
|
-
{
|
|
1208
|
-
role: 'user',
|
|
1209
|
-
content: dedent `
|
|
1210
|
-
A click action failed because multiple elements matched the locator.
|
|
1211
|
-
The intended action was: ${explanation}
|
|
1212
|
-
|
|
1213
|
-
Here are the matched elements:
|
|
1214
|
-
|
|
1215
|
-
${elementList}
|
|
1216
|
-
|
|
1217
|
-
Which element (1-${elementDetails.length}) best matches the intended action?
|
|
1218
|
-
Return the position number, or null if none of them match.
|
|
1219
|
-
`,
|
|
1220
|
-
},
|
|
1221
|
-
], schema, provider.getModelForAgent(), { agentName: 'disambiguator', timeout: 15000 });
|
|
1222
|
-
const position = result?.object?.position;
|
|
1223
|
-
if (position && position >= 1 && position <= elementDetails.length) {
|
|
1224
|
-
return { position, xpath: elementDetails[position - 1].xpath };
|
|
1225
|
-
}
|
|
1226
|
-
return null;
|
|
1227
|
-
}
|
|
1228
|
-
catch (e) {
|
|
1229
|
-
debugLog('Element disambiguation AI call failed: %s', e);
|
|
1230
|
-
return null;
|
|
1231
|
-
}
|
|
1232
|
-
}
|
|
1233
1177
|
function getNotFoundSuggestion(errorMessage) {
|
|
1234
1178
|
if (!errorMessage.includes('not found')) {
|
|
1235
1179
|
return null;
|
|
@@ -12,14 +12,9 @@ export declare class RequestStore {
|
|
|
12
12
|
getFailedRequests(): RequestResult[];
|
|
13
13
|
onFailedRequest(cb: (r: RequestResult) => void): () => void;
|
|
14
14
|
addMadeRequest(result: RequestResult): void;
|
|
15
|
-
addRequest(result: RequestResult): void;
|
|
16
15
|
getCapturedRequests(): RequestResult[];
|
|
17
16
|
getMadeRequests(): RequestResult[];
|
|
18
|
-
getRequests(): RequestResult[];
|
|
19
17
|
getLastRequest(): RequestResult | undefined;
|
|
20
|
-
getRequestsByEndpoint(pathPrefix: string): RequestResult[];
|
|
21
|
-
getRequestsByMethod(method: string): RequestResult[];
|
|
22
|
-
getRequestsByStatus(status: number): RequestResult[];
|
|
23
18
|
toEndpointList(scopePath?: string): string;
|
|
24
19
|
extractAuthHeaders(): Record<string, string>;
|
|
25
20
|
findCapturedRequest(method: string, searchPath: string): RequestResult | undefined;
|
|
@@ -28,3 +23,4 @@ export declare class RequestStore {
|
|
|
28
23
|
getWriteRequestsForScope(scopePath: string): RequestResult[];
|
|
29
24
|
clear(): void;
|
|
30
25
|
}
|
|
26
|
+
export declare function isFailedRequest(request: RequestResult): boolean;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { isDynamicSegment } from "../utils/url-matcher.js";
|
|
3
|
+
import { generalizeUrl, isDynamicSegment } from "../utils/url-matcher.js";
|
|
4
4
|
import { RequestResult } from "./request-result.js";
|
|
5
5
|
const AUTH_HEADERS = ['authorization', 'x-api-key', 'x-csrf-token'];
|
|
6
6
|
export class RequestStore {
|
|
@@ -38,31 +38,15 @@ export class RequestStore {
|
|
|
38
38
|
this.madeRequests.push(result);
|
|
39
39
|
result.save(this.outputDir);
|
|
40
40
|
}
|
|
41
|
-
addRequest(result) {
|
|
42
|
-
this.addMadeRequest(result);
|
|
43
|
-
}
|
|
44
41
|
getCapturedRequests() {
|
|
45
42
|
return this.capturedRequests;
|
|
46
43
|
}
|
|
47
44
|
getMadeRequests() {
|
|
48
45
|
return this.madeRequests;
|
|
49
46
|
}
|
|
50
|
-
getRequests() {
|
|
51
|
-
return this.madeRequests;
|
|
52
|
-
}
|
|
53
47
|
getLastRequest() {
|
|
54
48
|
return this.madeRequests[this.madeRequests.length - 1];
|
|
55
49
|
}
|
|
56
|
-
getRequestsByEndpoint(pathPrefix) {
|
|
57
|
-
return this.madeRequests.filter((r) => r.path.startsWith(pathPrefix));
|
|
58
|
-
}
|
|
59
|
-
getRequestsByMethod(method) {
|
|
60
|
-
const upper = method.toUpperCase();
|
|
61
|
-
return this.madeRequests.filter((r) => r.method === upper);
|
|
62
|
-
}
|
|
63
|
-
getRequestsByStatus(status) {
|
|
64
|
-
return this.madeRequests.filter((r) => r.status === status);
|
|
65
|
-
}
|
|
66
50
|
toEndpointList(scopePath) {
|
|
67
51
|
let requests = this.capturedRequests;
|
|
68
52
|
if (scopePath)
|
|
@@ -70,7 +54,7 @@ export class RequestStore {
|
|
|
70
54
|
const seen = new Set();
|
|
71
55
|
const lines = [];
|
|
72
56
|
for (const req of requests) {
|
|
73
|
-
const key = `${req.method} ${
|
|
57
|
+
const key = `${req.method} ${generalizeUrl(req.path, () => '{id}')}`;
|
|
74
58
|
if (seen.has(key))
|
|
75
59
|
continue;
|
|
76
60
|
seen.add(key);
|
|
@@ -92,13 +76,17 @@ export class RequestStore {
|
|
|
92
76
|
}
|
|
93
77
|
findCapturedRequest(method, searchPath) {
|
|
94
78
|
const upper = method.toUpperCase();
|
|
95
|
-
const search =
|
|
79
|
+
const search = generalizeUrl(searchPath, () => '{id}')
|
|
80
|
+
.split('/')
|
|
81
|
+
.filter(Boolean);
|
|
96
82
|
let best;
|
|
97
83
|
let bestScore = -1;
|
|
98
84
|
for (const req of this.capturedRequests) {
|
|
99
85
|
if (req.method !== upper)
|
|
100
86
|
continue;
|
|
101
|
-
const segments =
|
|
87
|
+
const segments = generalizeUrl(req.path, () => '{id}')
|
|
88
|
+
.split('/')
|
|
89
|
+
.filter(Boolean);
|
|
102
90
|
if (segments.length < search.length)
|
|
103
91
|
continue;
|
|
104
92
|
if (!search.every((segment, i) => segment === segments[i]))
|
|
@@ -139,8 +127,7 @@ export class RequestStore {
|
|
|
139
127
|
}
|
|
140
128
|
}
|
|
141
129
|
getWriteRequestsForScope(scopePath) {
|
|
142
|
-
const
|
|
143
|
-
const writes = this.capturedRequests.filter((r) => writeMethods.has(r.method));
|
|
130
|
+
const writes = this.capturedRequests.filter((r) => r.isWrite);
|
|
144
131
|
const scopeSegments = scopePath.split('/').filter(Boolean);
|
|
145
132
|
if (scopeSegments.length === 0)
|
|
146
133
|
return writes;
|
|
@@ -172,6 +159,9 @@ export class RequestStore {
|
|
|
172
159
|
this.failedRequests = [];
|
|
173
160
|
}
|
|
174
161
|
}
|
|
162
|
+
export function isFailedRequest(request) {
|
|
163
|
+
return request.status >= 400 || Boolean(request.error);
|
|
164
|
+
}
|
|
175
165
|
function normalizePathPattern(urlPath) {
|
|
176
166
|
return urlPath
|
|
177
167
|
.split('/')
|
|
@@ -5,6 +5,7 @@ import { ConfigParser, EXPLORBOT_ENV_VARS, configuredModels } from '../config.js
|
|
|
5
5
|
import { listSites } from '../global-config.js';
|
|
6
6
|
import { Reporter } from '../reporter.js';
|
|
7
7
|
import { getCliName } from '../utils/cli-name.js';
|
|
8
|
+
import { renderSection } from '../utils/cli-section.js';
|
|
8
9
|
import { tag } from '../utils/logger.js';
|
|
9
10
|
import { BaseCommand } from './base-command.js';
|
|
10
11
|
export class ConfigCommand extends BaseCommand {
|
|
@@ -68,15 +69,7 @@ export class ConfigCommand extends BaseCommand {
|
|
|
68
69
|
if (options.json)
|
|
69
70
|
return JSON.stringify(data, null, 2);
|
|
70
71
|
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
|
-
};
|
|
72
|
+
const section = (title, entries) => lines.push(...renderSection(title, entries));
|
|
80
73
|
const general = [['config', data.config || 'EXPLORBOT_* environment variables']];
|
|
81
74
|
if (data.url)
|
|
82
75
|
general.push(['url', data.url]);
|
|
@@ -26,6 +26,7 @@ import { PlanLoadCommand } from './plan-load-command.js';
|
|
|
26
26
|
import { PlanReloadCommand } from './plan-reload-command.js';
|
|
27
27
|
import { PlanSaveCommand } from './plan-save-command.js';
|
|
28
28
|
import { PlansCommand } from './plans-command.js';
|
|
29
|
+
import { RecommendedModelsCommand } from './recommended-models-command.js';
|
|
29
30
|
import { RerunCommand } from './rerun-command.js';
|
|
30
31
|
import { ResearchCommand } from './research-command.js';
|
|
31
32
|
import { RunsCommand } from './runs-command.js';
|
|
@@ -66,6 +67,7 @@ const commandClasses = [
|
|
|
66
67
|
RerunCommand,
|
|
67
68
|
StatusCommand,
|
|
68
69
|
ConfigCommand,
|
|
70
|
+
RecommendedModelsCommand,
|
|
69
71
|
DebugCommand,
|
|
70
72
|
ExitCommand,
|
|
71
73
|
];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, extname, join, resolve } from 'node:path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
-
import { ConfigParser, PROVIDERS } from "../config.js";
|
|
4
|
+
import { ConfigParser, PROVIDERS, missingModelRoles } from "../config.js";
|
|
5
5
|
import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "../global-config.js";
|
|
6
6
|
import { getCliName } from "../utils/cli-name.js";
|
|
7
7
|
import { log, tag } from '../utils/logger.js';
|
|
@@ -82,7 +82,7 @@ export function writeGlobalConfig(provider, apiKey) {
|
|
|
82
82
|
const envKey = PROVIDERS[provider].envKey;
|
|
83
83
|
writeEnvKey(envKey, apiKey || '');
|
|
84
84
|
log(`Stored ${envKey} in ${globalEnvPath()}`);
|
|
85
|
-
const missing =
|
|
85
|
+
const missing = missingModelRoles(provider);
|
|
86
86
|
if (missing.length) {
|
|
87
87
|
tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${globalConfigPath()}`);
|
|
88
88
|
}
|
|
@@ -139,7 +139,7 @@ export function runInitCommand(options) {
|
|
|
139
139
|
else {
|
|
140
140
|
log(`Env file already exists: ${relativeToCwd(envPath)}`);
|
|
141
141
|
}
|
|
142
|
-
const missing =
|
|
142
|
+
const missing = missingModelRoles(provider);
|
|
143
143
|
if (missing.length) {
|
|
144
144
|
tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${relativeToCwd(outPath)}`);
|
|
145
145
|
}
|
|
@@ -269,10 +269,6 @@ function isModuleProject(configDir) {
|
|
|
269
269
|
currentDir = parentDir;
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
|
-
function missingRoles(provider) {
|
|
273
|
-
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
274
|
-
return ['model', 'visionModel', 'agenticModel'].filter((role) => !recommended[role]);
|
|
275
|
-
}
|
|
276
272
|
function writeEnvKey(key, value) {
|
|
277
273
|
const envPath = globalEnvPath();
|
|
278
274
|
let content = '# AI provider API keys';
|
|
@@ -11,7 +11,10 @@ export declare class PlansCommand extends BaseCommand {
|
|
|
11
11
|
getPlanFiles(): PlanFile[];
|
|
12
12
|
printPlans(files: PlanFile[]): void;
|
|
13
13
|
printPlanDetails(plan: Plan, file: PlanFile): void;
|
|
14
|
-
resolvePlanFile(target: string, files: PlanFile[]):
|
|
14
|
+
resolvePlanFile(target: string, files: PlanFile[]): {
|
|
15
|
+
plan: Plan;
|
|
16
|
+
file: PlanFile;
|
|
17
|
+
};
|
|
15
18
|
}
|
|
16
19
|
interface PlanFile {
|
|
17
20
|
name: string;
|
|
@@ -17,8 +17,7 @@ export class PlansCommand extends BaseCommand {
|
|
|
17
17
|
this.printPlans(files);
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
|
-
const file = this.resolvePlanFile(target, files);
|
|
21
|
-
const plan = Plan.fromMarkdown(file.path);
|
|
20
|
+
const { plan, file } = this.resolvePlanFile(target, files);
|
|
22
21
|
this.printPlanDetails(plan, file);
|
|
23
22
|
}
|
|
24
23
|
getPlanFiles() {
|
|
@@ -68,16 +67,17 @@ export class PlansCommand extends BaseCommand {
|
|
|
68
67
|
const file = files[index - 1];
|
|
69
68
|
if (!file)
|
|
70
69
|
throw new Error(`Plan #${target} not found. Available: 1-${files.length}`);
|
|
71
|
-
return file;
|
|
70
|
+
return { plan: Plan.fromMarkdown(file.path), file };
|
|
72
71
|
}
|
|
73
72
|
const plan = Plan.loadFromFile(target, this.explorBot.getPlansDir());
|
|
74
73
|
if (!plan?.filePath) {
|
|
75
74
|
throw new Error(`Plan file not found: ${target}`);
|
|
76
75
|
}
|
|
77
|
-
|
|
76
|
+
const file = {
|
|
78
77
|
name: path.basename(plan.filePath),
|
|
79
78
|
path: plan.filePath,
|
|
80
79
|
modifiedAt: statSync(plan.filePath).mtimeMs,
|
|
81
80
|
};
|
|
81
|
+
return { plan, file };
|
|
82
82
|
}
|
|
83
83
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { BaseCommand } from './base-command.js';
|
|
3
|
+
export declare class RecommendedModelsCommand extends BaseCommand {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
execute(): Promise<void>;
|
|
7
|
+
static register(program: Command): void;
|
|
8
|
+
static render(json?: boolean): string;
|
|
9
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { ConfigParser, EXPLORBOT_ENV_VARS, MODEL_ROLES, PROVIDERS } from '../config.js';
|
|
3
|
+
import { getCliName } from '../utils/cli-name.js';
|
|
4
|
+
import { renderSection } from '../utils/cli-section.js';
|
|
5
|
+
import { tag } from '../utils/logger.js';
|
|
6
|
+
import { BaseCommand } from './base-command.js';
|
|
7
|
+
const DESCRIPTION = 'Show the models recommended for every AI provider';
|
|
8
|
+
const ROLES = {
|
|
9
|
+
model: { hint: 'fast', env: 'EXPLORBOT_AI_MODEL' },
|
|
10
|
+
visionModel: { hint: 'screenshots', env: 'EXPLORBOT_VISION_MODEL' },
|
|
11
|
+
agenticModel: { hint: 'smart', env: 'EXPLORBOT_AGENTIC_MODEL' },
|
|
12
|
+
};
|
|
13
|
+
const MODEL_ENV_VARS = MODEL_ROLES.map((role) => ROLES[role].env);
|
|
14
|
+
export class RecommendedModelsCommand extends BaseCommand {
|
|
15
|
+
name = 'recommended-models';
|
|
16
|
+
description = DESCRIPTION;
|
|
17
|
+
async execute() {
|
|
18
|
+
tag('info').log(RecommendedModelsCommand.render());
|
|
19
|
+
}
|
|
20
|
+
static register(program) {
|
|
21
|
+
program
|
|
22
|
+
.command('recommended-models')
|
|
23
|
+
.description(DESCRIPTION)
|
|
24
|
+
.option('--json', 'Print the recommendations as JSON')
|
|
25
|
+
.action((options) => console.log(RecommendedModelsCommand.render(options.json)));
|
|
26
|
+
}
|
|
27
|
+
static render(json = false) {
|
|
28
|
+
const recommended = ConfigParser.recommendedModels();
|
|
29
|
+
if (json)
|
|
30
|
+
return JSON.stringify(recommended, null, 2);
|
|
31
|
+
const lines = [
|
|
32
|
+
chalk.bold('Export the provider key, then pick one of two ways to select models:'),
|
|
33
|
+
` ${chalk.bold('EXPLORBOT_AI_PROVIDER')} — every role takes that provider's recommendation below`,
|
|
34
|
+
` ${chalk.bold(MODEL_ENV_VARS.join(' + '))} — no provider variable, pin every role yourself as ${chalk.dim('provider/model-id')}`,
|
|
35
|
+
'',
|
|
36
|
+
];
|
|
37
|
+
for (const [provider, roles] of Object.entries(recommended)) {
|
|
38
|
+
lines.push(chalk.bold.yellow(`${provider}:`));
|
|
39
|
+
const models = [];
|
|
40
|
+
for (const role of MODEL_ROLES) {
|
|
41
|
+
const label = `${role} (${ROLES[role].hint})`;
|
|
42
|
+
if (roles[role])
|
|
43
|
+
models.push([label, roles[role]]);
|
|
44
|
+
if (!roles[role])
|
|
45
|
+
models.push([label, chalk.dim('not served, pair with another provider')]);
|
|
46
|
+
}
|
|
47
|
+
lines.push(...renderSection('models', models, 2));
|
|
48
|
+
const envKey = PROVIDERS[provider]?.envKey;
|
|
49
|
+
if (!envKey) {
|
|
50
|
+
lines.push(` ${chalk.dim('not bundled — import the provider in your config')}`, '');
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const env = [
|
|
54
|
+
['key', envKey],
|
|
55
|
+
['provider', `EXPLORBOT_AI_PROVIDER=${provider}`],
|
|
56
|
+
];
|
|
57
|
+
let label = 'or models';
|
|
58
|
+
for (const role of MODEL_ROLES) {
|
|
59
|
+
if (!roles[role])
|
|
60
|
+
continue;
|
|
61
|
+
env.push([label, `${ROLES[role].env}=${provider}/${roles[role]}`]);
|
|
62
|
+
label = '';
|
|
63
|
+
}
|
|
64
|
+
lines.push(...renderSection('env variables', env, 2));
|
|
65
|
+
}
|
|
66
|
+
const modelVars = ['EXPLORBOT_AI_PROVIDER', ...MODEL_ENV_VARS];
|
|
67
|
+
const current = [];
|
|
68
|
+
for (const variable of EXPLORBOT_ENV_VARS) {
|
|
69
|
+
if (!modelVars.includes(variable.name))
|
|
70
|
+
continue;
|
|
71
|
+
if (process.env[variable.name])
|
|
72
|
+
current.push([variable.name, process.env[variable.name]]);
|
|
73
|
+
}
|
|
74
|
+
for (const [provider, { envKey }] of Object.entries(PROVIDERS)) {
|
|
75
|
+
if (process.env[envKey])
|
|
76
|
+
current.push([envKey, chalk.dim(`set, ${provider} usable`)]);
|
|
77
|
+
}
|
|
78
|
+
if (!current.length)
|
|
79
|
+
lines.push(chalk.dim('Nothing set — export one of the variables above, or write models into your config'), '');
|
|
80
|
+
if (current.length)
|
|
81
|
+
lines.push(...renderSection('Currently set', current));
|
|
82
|
+
lines.push(chalk.bold('Example using OpenRouter:'), ` OPENROUTER_API_KEY=sk-... EXPLORBOT_AI_PROVIDER=openrouter ${getCliName()} <command>`);
|
|
83
|
+
return lines.join('\n');
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/src/config.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type SiteRecord } from './global-config.js';
|
|
2
2
|
export declare const PROVIDERS: Record<string, ProviderInfo>;
|
|
3
|
+
export declare const MODEL_ROLES: ModelRole[];
|
|
3
4
|
interface PlaywrightConfig {
|
|
4
5
|
browser: 'chromium' | 'firefox' | 'webkit';
|
|
5
6
|
url: string;
|
|
@@ -270,6 +271,7 @@ export declare class ConfigParser {
|
|
|
270
271
|
}
|
|
271
272
|
export declare function outputPath(...segments: string[]): string;
|
|
272
273
|
export declare function resolveModel(spec: string, role?: ModelRole): Promise<any>;
|
|
274
|
+
export declare function missingModelRoles(provider: string): ModelRole[];
|
|
273
275
|
export declare class ConfigMissingError extends Error {
|
|
274
276
|
}
|
|
275
277
|
export declare function envConfigRequested(): boolean;
|
package/dist/src/config.js
CHANGED
|
@@ -25,6 +25,7 @@ export const PROVIDERS = {
|
|
|
25
25
|
openrouter: { envKey: 'OPENROUTER_API_KEY', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
|
|
26
26
|
sambanova: { envKey: 'SAMBANOVA_API_KEY', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
|
|
27
27
|
};
|
|
28
|
+
export const MODEL_ROLES = ['model', 'visionModel', 'agenticModel'];
|
|
28
29
|
let cachedOutputRoot = null;
|
|
29
30
|
const config = {
|
|
30
31
|
playwright: {
|
|
@@ -458,6 +459,10 @@ export async function resolveModel(spec, role = 'model') {
|
|
|
458
459
|
}
|
|
459
460
|
return createModel(spec, modelId);
|
|
460
461
|
}
|
|
462
|
+
export function missingModelRoles(provider) {
|
|
463
|
+
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
464
|
+
return MODEL_ROLES.filter((role) => !recommended[role]);
|
|
465
|
+
}
|
|
461
466
|
export class ConfigMissingError extends Error {
|
|
462
467
|
}
|
|
463
468
|
export function envConfigRequested() {
|
|
@@ -478,6 +483,7 @@ export function missingConfigMessage(configFile = 'explorbot.config.js') {
|
|
|
478
483
|
EXPLORBOT_AI_PROVIDER=openrouter EXPLORBOT_URL=https://your-app.example.com ${cli} ...
|
|
479
484
|
|
|
480
485
|
Providers: ${Object.keys(PROVIDERS).join(', ')}
|
|
486
|
+
See the models each one recommends: ${cli} recommended-models
|
|
481
487
|
`;
|
|
482
488
|
}
|
|
483
489
|
export function modelName(model) {
|
|
@@ -511,8 +517,7 @@ export function configuredModels(ai) {
|
|
|
511
517
|
export async function resolveConfigModels(ai) {
|
|
512
518
|
if (!ai)
|
|
513
519
|
return;
|
|
514
|
-
const
|
|
515
|
-
for (const role of roles) {
|
|
520
|
+
for (const role of MODEL_ROLES) {
|
|
516
521
|
if (typeof ai[role] === 'string')
|
|
517
522
|
ai[role] = await resolveModel(ai[role], role);
|
|
518
523
|
}
|
|
@@ -105,7 +105,7 @@ export class ExperienceTracker {
|
|
|
105
105
|
url: state.url ? extractStatePath(state.url) : '',
|
|
106
106
|
title: state.title,
|
|
107
107
|
};
|
|
108
|
-
if (state.overlay.
|
|
108
|
+
if (state.overlay.isOpen && state.overlay.name) {
|
|
109
109
|
frontmatter.region = state.overlay.name;
|
|
110
110
|
if (state.overlay.root)
|
|
111
111
|
frontmatter.root = state.overlay.root;
|
package/dist/src/explorbot.js
CHANGED
|
@@ -399,10 +399,10 @@ export class ExplorBot {
|
|
|
399
399
|
return plan;
|
|
400
400
|
}
|
|
401
401
|
loadPlans(filename) {
|
|
402
|
-
const
|
|
403
|
-
if (!
|
|
402
|
+
const filePath = Plan.resolveFile(filename, this.getPlansDir());
|
|
403
|
+
if (!filePath)
|
|
404
404
|
throw new Error(`Plan file not found: ${filename}`);
|
|
405
|
-
return parsePlansFromMarkdown(
|
|
405
|
+
return parsePlansFromMarkdown(filePath);
|
|
406
406
|
}
|
|
407
407
|
setCurrentPlan(plan) {
|
|
408
408
|
this.currentPlan = plan;
|
|
@@ -6,6 +6,8 @@ export declare function isGlobalConfigPath(configPath: string): boolean;
|
|
|
6
6
|
export declare function sitesDir(): string;
|
|
7
7
|
export declare function siteFolderName(url: string): string;
|
|
8
8
|
export declare function listSites(): SiteRecord[];
|
|
9
|
+
export declare function findSiteWith(subpath: string): SiteRecord | undefined;
|
|
10
|
+
export declare function listSitePlanDirs(): string[];
|
|
9
11
|
export declare function registerSite(baseUrl: string): SiteRecord;
|
|
10
12
|
export declare function resolveSiteTarget(target?: string, defaultBaseUrl?: string): SiteTarget;
|
|
11
13
|
interface SiteRecord {
|
|
@@ -38,6 +38,12 @@ export function listSites() {
|
|
|
38
38
|
.filter((site) => !!site)
|
|
39
39
|
.sort((a, b) => b.lastRunAt.localeCompare(a.lastRunAt));
|
|
40
40
|
}
|
|
41
|
+
export function findSiteWith(subpath) {
|
|
42
|
+
return listSites().find((site) => existsSync(join(site.dir, subpath)));
|
|
43
|
+
}
|
|
44
|
+
export function listSitePlanDirs() {
|
|
45
|
+
return listSites().map((site) => join(site.dir, 'output', 'plans'));
|
|
46
|
+
}
|
|
41
47
|
export function registerSite(baseUrl) {
|
|
42
48
|
const folder = siteFolderName(baseUrl);
|
|
43
49
|
const dir = join(sitesDir(), folder);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ActionResult, type FocusedElement } from './action-result.js';
|
|
2
2
|
import type { ExperienceTracker } from './experience-tracker.js';
|
|
3
3
|
import type { Knowledge, KnowledgeTracker } from './knowledge-tracker.js';
|
|
4
|
-
import {
|
|
4
|
+
import type { Region } from './utils/region.js';
|
|
5
5
|
export interface Link {
|
|
6
6
|
title: string;
|
|
7
7
|
url: string;
|
|
@@ -41,7 +41,7 @@ export interface WebPageState {
|
|
|
41
41
|
focusedElement?: FocusedElement | null;
|
|
42
42
|
links?: Link[];
|
|
43
43
|
verifications?: Record<string, boolean>;
|
|
44
|
-
overlay?:
|
|
44
|
+
overlay?: Region;
|
|
45
45
|
/** Region name, the persisted scalar form of overlay.name used in experience frontmatter */
|
|
46
46
|
region?: string;
|
|
47
47
|
/** Region root selector, the persisted scalar form of overlay.root used in experience frontmatter */
|
|
@@ -95,7 +95,7 @@ export declare class StateManager {
|
|
|
95
95
|
* Update state from basic data (for navigation events)
|
|
96
96
|
*/
|
|
97
97
|
updateStateFromBasic(url: string, title?: string, trigger?: 'manual' | 'navigation' | 'automatic'): WebPageState;
|
|
98
|
-
|
|
98
|
+
regionOpened(previousState: WebPageState | null, newState: WebPageState): boolean;
|
|
99
99
|
/**
|
|
100
100
|
* Generate a basic hash for state comparison
|
|
101
101
|
*/
|