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/src/ai/tools.ts
CHANGED
|
@@ -15,7 +15,6 @@ import { pause } from '../utils/loop.js';
|
|
|
15
15
|
import { WebElement } from '../utils/web-element.ts';
|
|
16
16
|
import type { ToolDeps } from './agent.ts';
|
|
17
17
|
import { Navigator } from './navigator.ts';
|
|
18
|
-
import type { AIProvider } from './provider.ts';
|
|
19
18
|
import { Researcher } from './researcher.ts';
|
|
20
19
|
import { sectionContextRule } from './rules.ts';
|
|
21
20
|
import { isInteractive } from './task-agent.ts';
|
|
@@ -31,7 +30,7 @@ interface AgentToolDeps extends ToolDeps {
|
|
|
31
30
|
|
|
32
31
|
export const ASSERTION_TOOLS = ['verify'] as const;
|
|
33
32
|
|
|
34
|
-
export function createCodeceptJSTools({ explorer, stateManager
|
|
33
|
+
export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task: Task) {
|
|
35
34
|
return {
|
|
36
35
|
click: tool({
|
|
37
36
|
description: dedent`
|
|
@@ -44,6 +43,12 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
44
43
|
Container narrows search area. Use when page has multiple matching elements.
|
|
45
44
|
Example: Page has 3 "Delete" buttons in different rows:
|
|
46
45
|
I.click("Delete", ".row-1") - clicks Delete inside element with class row-1
|
|
46
|
+
I.click(locator, step.opts({ elementIndex: N })) - click the Nth element the locator matches
|
|
47
|
+
Use after a result reported several matches. N is the "Element N" number from that list.
|
|
48
|
+
step.opts() always goes LAST, after the container when there is one.
|
|
49
|
+
Example: the result listed 3 "Delete" buttons and you want the second:
|
|
50
|
+
I.click("Delete", step.opts({ elementIndex: 2 }))
|
|
51
|
+
I.click("Delete", ".table", step.opts({ elementIndex: 2 })) - same, narrowed to a container
|
|
47
52
|
|
|
48
53
|
IMPORTANT: This tool ONLY accepts click commands. For typing text, use form() tool.
|
|
49
54
|
CRITICAL: All commands MUST target the SAME element using different locators.
|
|
@@ -62,6 +67,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
62
67
|
3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
|
|
63
68
|
4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
|
|
64
69
|
5. I.clickXY(x, y) - coordinates fallback
|
|
70
|
+
After a result reporting multiple matches, reuse that locator with step.opts({ elementIndex: N }) as the last argument.
|
|
65
71
|
`),
|
|
66
72
|
explanation: z.string().describe('Why you are clicking this element'),
|
|
67
73
|
}),
|
|
@@ -91,6 +97,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
91
97
|
const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
|
|
92
98
|
const action = explorer.action();
|
|
93
99
|
const attempts: Array<{ command: string; success: boolean; error?: string }> = [];
|
|
100
|
+
let ambiguityError: Error | null = null;
|
|
94
101
|
|
|
95
102
|
for (let i = 0; i < commands.length; i++) {
|
|
96
103
|
const command = transformContainsCommand(commands[i]);
|
|
@@ -100,6 +107,8 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
100
107
|
if (action.lastError) attempt.error = errorText(action.lastError);
|
|
101
108
|
attempts.push(attempt);
|
|
102
109
|
|
|
110
|
+
if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound') ambiguityError = action.lastError;
|
|
111
|
+
|
|
103
112
|
if (success) {
|
|
104
113
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
|
|
105
114
|
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
@@ -107,31 +116,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
107
116
|
}
|
|
108
117
|
}
|
|
109
118
|
|
|
110
|
-
let disambiguated = null;
|
|
111
|
-
if (attempts.some((a) => a.error?.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
|
|
112
|
-
disambiguated = await disambiguateElements(action.lastError, explanation, ai);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (disambiguated) {
|
|
116
|
-
debugLog('Disambiguation picked element %d', disambiguated.position);
|
|
117
|
-
const failedCommand = attempts.find((a) => a.error?.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))?.command;
|
|
118
|
-
const retryCommands = [];
|
|
119
|
-
if (failedCommand) {
|
|
120
|
-
retryCommands.push(failedCommand.replace(/\)$/, `, step.opts({ elementIndex: ${disambiguated.position} }))`));
|
|
121
|
-
}
|
|
122
|
-
retryCommands.push(`I.click('${disambiguated.xpath.replace(/'/g, "\\'")}')`);
|
|
123
|
-
|
|
124
|
-
for (const retryCmd of retryCommands) {
|
|
125
|
-
if (!(await action.attempt(retryCmd, explanation))) {
|
|
126
|
-
attempts.push({ command: retryCmd, success: false, error: errorText(action.lastError) });
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, retryCmd);
|
|
130
|
-
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
131
|
-
return successToolResult('click', { ...toolResult, attempts, code: retryCmd, disambiguated: true }, action);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
119
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, commands[0]);
|
|
136
120
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
137
121
|
|
|
@@ -145,7 +129,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
145
129
|
attempts,
|
|
146
130
|
suggestion,
|
|
147
131
|
},
|
|
148
|
-
action.lastError
|
|
132
|
+
ambiguityError || action.lastError
|
|
149
133
|
);
|
|
150
134
|
},
|
|
151
135
|
}),
|
|
@@ -413,13 +397,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
413
397
|
const message = errorText(action.lastError);
|
|
414
398
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
415
399
|
|
|
416
|
-
|
|
417
|
-
if (message.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN)) {
|
|
418
|
-
const disambiguated = await disambiguateElements(action.lastError, explanation, ai);
|
|
419
|
-
if (disambiguated) {
|
|
420
|
-
formSuggestion = `Multiple elements matched. Add step.opts({ elementIndex: ${disambiguated.position} }) to the failing command. Fallback locator: ${disambiguated.xpath}`;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
400
|
+
const formSuggestion = 'Commands after the failing one never ran. Retry only those, using click() or form().';
|
|
423
401
|
|
|
424
402
|
return failedToolResult(
|
|
425
403
|
'form',
|
|
@@ -1284,13 +1262,11 @@ export async function failedToolResult(action: string, message: string, data?: R
|
|
|
1284
1262
|
|
|
1285
1263
|
function getMultipleElementsSuggestion(): string {
|
|
1286
1264
|
return dedent`
|
|
1287
|
-
Multiple elements matched your locator
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
5. Use xpathCheck() to inspect matched elements and pick the correct one
|
|
1293
|
-
6. Use visualClick() to click the right element by visual appearance
|
|
1265
|
+
Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
|
|
1266
|
+
Read the numbered elements list and click the one you meant by its number:
|
|
1267
|
+
reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
|
|
1268
|
+
If none of them is the element you want, narrow the locator with a container or its full unique text.
|
|
1269
|
+
If the list is missing, call xpathCheck() to see what the locator matches.
|
|
1294
1270
|
`;
|
|
1295
1271
|
}
|
|
1296
1272
|
|
|
@@ -1367,50 +1343,6 @@ export async function formatMatchedElements(error: Error | null | undefined): Pr
|
|
|
1367
1343
|
return formatElementList(details);
|
|
1368
1344
|
}
|
|
1369
1345
|
|
|
1370
|
-
async function disambiguateElements(error: Error | null | undefined, explanation: string, provider: AIProvider): Promise<{ position: number; xpath: string } | null> {
|
|
1371
|
-
const elementDetails = await extractWebElements(error);
|
|
1372
|
-
if (!elementDetails) return null;
|
|
1373
|
-
|
|
1374
|
-
const elementList = formatElementList(elementDetails);
|
|
1375
|
-
|
|
1376
|
-
const schema = z.object({
|
|
1377
|
-
position: z.number().nullable().describe('1-based position of the correct element, or null if none match'),
|
|
1378
|
-
});
|
|
1379
|
-
|
|
1380
|
-
try {
|
|
1381
|
-
const result = await provider.generateObject(
|
|
1382
|
-
[
|
|
1383
|
-
{
|
|
1384
|
-
role: 'user' as const,
|
|
1385
|
-
content: dedent`
|
|
1386
|
-
A click action failed because multiple elements matched the locator.
|
|
1387
|
-
The intended action was: ${explanation}
|
|
1388
|
-
|
|
1389
|
-
Here are the matched elements:
|
|
1390
|
-
|
|
1391
|
-
${elementList}
|
|
1392
|
-
|
|
1393
|
-
Which element (1-${elementDetails.length}) best matches the intended action?
|
|
1394
|
-
Return the position number, or null if none of them match.
|
|
1395
|
-
`,
|
|
1396
|
-
},
|
|
1397
|
-
],
|
|
1398
|
-
schema,
|
|
1399
|
-
provider.getModelForAgent(),
|
|
1400
|
-
{ agentName: 'disambiguator', timeout: 15000 }
|
|
1401
|
-
);
|
|
1402
|
-
|
|
1403
|
-
const position = result?.object?.position;
|
|
1404
|
-
if (position && position >= 1 && position <= elementDetails.length) {
|
|
1405
|
-
return { position, xpath: elementDetails[position - 1].xpath };
|
|
1406
|
-
}
|
|
1407
|
-
return null;
|
|
1408
|
-
} catch (e) {
|
|
1409
|
-
debugLog('Element disambiguation AI call failed: %s', e);
|
|
1410
|
-
return null;
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
1346
|
function getNotFoundSuggestion(errorMessage: string): string | null {
|
|
1415
1347
|
if (!errorMessage.includes('not found')) {
|
|
1416
1348
|
return null;
|
package/src/api/request-store.ts
CHANGED
|
@@ -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.ts';
|
|
3
|
+
import { generalizeUrl, isDynamicSegment } from '../utils/url-matcher.ts';
|
|
4
4
|
import { RequestResult } from './request-result.ts';
|
|
5
5
|
|
|
6
6
|
const AUTH_HEADERS = ['authorization', 'x-api-key', 'x-csrf-token'];
|
|
@@ -46,10 +46,6 @@ export class RequestStore {
|
|
|
46
46
|
result.save(this.outputDir);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
addRequest(result: RequestResult): void {
|
|
50
|
-
this.addMadeRequest(result);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
49
|
getCapturedRequests(): RequestResult[] {
|
|
54
50
|
return this.capturedRequests;
|
|
55
51
|
}
|
|
@@ -58,27 +54,10 @@ export class RequestStore {
|
|
|
58
54
|
return this.madeRequests;
|
|
59
55
|
}
|
|
60
56
|
|
|
61
|
-
getRequests(): RequestResult[] {
|
|
62
|
-
return this.madeRequests;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
57
|
getLastRequest(): RequestResult | undefined {
|
|
66
58
|
return this.madeRequests[this.madeRequests.length - 1];
|
|
67
59
|
}
|
|
68
60
|
|
|
69
|
-
getRequestsByEndpoint(pathPrefix: string): RequestResult[] {
|
|
70
|
-
return this.madeRequests.filter((r) => r.path.startsWith(pathPrefix));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
getRequestsByMethod(method: string): RequestResult[] {
|
|
74
|
-
const upper = method.toUpperCase();
|
|
75
|
-
return this.madeRequests.filter((r) => r.method === upper);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
getRequestsByStatus(status: number): RequestResult[] {
|
|
79
|
-
return this.madeRequests.filter((r) => r.status === status);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
61
|
toEndpointList(scopePath?: string): string {
|
|
83
62
|
let requests = this.capturedRequests;
|
|
84
63
|
if (scopePath) requests = this.getWriteRequestsForScope(scopePath);
|
|
@@ -87,7 +66,7 @@ export class RequestStore {
|
|
|
87
66
|
const lines: string[] = [];
|
|
88
67
|
|
|
89
68
|
for (const req of requests) {
|
|
90
|
-
const key = `${req.method} ${
|
|
69
|
+
const key = `${req.method} ${generalizeUrl(req.path, () => '{id}')}`;
|
|
91
70
|
if (seen.has(key)) continue;
|
|
92
71
|
seen.add(key);
|
|
93
72
|
lines.push(key);
|
|
@@ -113,14 +92,18 @@ export class RequestStore {
|
|
|
113
92
|
|
|
114
93
|
findCapturedRequest(method: string, searchPath: string): RequestResult | undefined {
|
|
115
94
|
const upper = method.toUpperCase();
|
|
116
|
-
const search =
|
|
95
|
+
const search = generalizeUrl(searchPath, () => '{id}')
|
|
96
|
+
.split('/')
|
|
97
|
+
.filter(Boolean);
|
|
117
98
|
|
|
118
99
|
let best: RequestResult | undefined;
|
|
119
100
|
let bestScore = -1;
|
|
120
101
|
|
|
121
102
|
for (const req of this.capturedRequests) {
|
|
122
103
|
if (req.method !== upper) continue;
|
|
123
|
-
const segments =
|
|
104
|
+
const segments = generalizeUrl(req.path, () => '{id}')
|
|
105
|
+
.split('/')
|
|
106
|
+
.filter(Boolean);
|
|
124
107
|
if (segments.length < search.length) continue;
|
|
125
108
|
if (!search.every((segment, i) => segment === segments[i])) continue;
|
|
126
109
|
|
|
@@ -159,8 +142,7 @@ export class RequestStore {
|
|
|
159
142
|
}
|
|
160
143
|
|
|
161
144
|
getWriteRequestsForScope(scopePath: string): RequestResult[] {
|
|
162
|
-
const
|
|
163
|
-
const writes = this.capturedRequests.filter((r) => writeMethods.has(r.method));
|
|
145
|
+
const writes = this.capturedRequests.filter((r) => r.isWrite);
|
|
164
146
|
const scopeSegments = scopePath.split('/').filter(Boolean);
|
|
165
147
|
if (scopeSegments.length === 0) return writes;
|
|
166
148
|
|
|
@@ -191,6 +173,10 @@ export class RequestStore {
|
|
|
191
173
|
}
|
|
192
174
|
}
|
|
193
175
|
|
|
176
|
+
export function isFailedRequest(request: RequestResult): boolean {
|
|
177
|
+
return request.status >= 400 || Boolean(request.error);
|
|
178
|
+
}
|
|
179
|
+
|
|
194
180
|
function normalizePathPattern(urlPath: string): string {
|
|
195
181
|
return urlPath
|
|
196
182
|
.split('/')
|
|
@@ -5,6 +5,7 @@ import { type AIConfig, ConfigParser, EXPLORBOT_ENV_VARS, type ReporterConfig, c
|
|
|
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
|
|
|
@@ -75,13 +76,7 @@ export class ConfigCommand extends BaseCommand {
|
|
|
75
76
|
if (options.json) return JSON.stringify(data, null, 2);
|
|
76
77
|
|
|
77
78
|
const lines: string[] = [];
|
|
78
|
-
const section = (title: string, entries: [string, string][]) =>
|
|
79
|
-
if (!entries.length) return;
|
|
80
|
-
const width = Math.max(...entries.map(([label]) => label.length));
|
|
81
|
-
lines.push(chalk.bold(title));
|
|
82
|
-
for (const [label, value] of entries) lines.push(` ${chalk.dim(label.padEnd(width))} ${value}`);
|
|
83
|
-
lines.push('');
|
|
84
|
-
};
|
|
79
|
+
const section = (title: string, entries: [string, string][]) => lines.push(...renderSection(title, entries));
|
|
85
80
|
|
|
86
81
|
const general: [string, string][] = [['config', data.config || 'EXPLORBOT_* environment variables']];
|
|
87
82
|
if (data.url) general.push(['url', data.url]);
|
package/src/commands/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { PlanLoadCommand } from './plan-load-command.js';
|
|
|
28
28
|
import { PlanReloadCommand } from './plan-reload-command.js';
|
|
29
29
|
import { PlanSaveCommand } from './plan-save-command.js';
|
|
30
30
|
import { PlansCommand } from './plans-command.js';
|
|
31
|
+
import { RecommendedModelsCommand } from './recommended-models-command.js';
|
|
31
32
|
import { RerunCommand } from './rerun-command.js';
|
|
32
33
|
import { ResearchCommand } from './research-command.js';
|
|
33
34
|
import { RunsCommand } from './runs-command.js';
|
|
@@ -72,6 +73,7 @@ const commandClasses: CommandClass[] = [
|
|
|
72
73
|
RerunCommand,
|
|
73
74
|
StatusCommand,
|
|
74
75
|
ConfigCommand,
|
|
76
|
+
RecommendedModelsCommand,
|
|
75
77
|
DebugCommand,
|
|
76
78
|
ExitCommand,
|
|
77
79
|
];
|
|
@@ -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.ts';
|
|
4
|
+
import { ConfigParser, type ModelRole, PROVIDERS, missingModelRoles } from '../config.ts';
|
|
5
5
|
import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from '../global-config.ts';
|
|
6
6
|
import { getCliName } from '../utils/cli-name.ts';
|
|
7
7
|
import { log, tag } from '../utils/logger.js';
|
|
@@ -91,7 +91,7 @@ export function writeGlobalConfig(provider: string, apiKey?: string): void {
|
|
|
91
91
|
writeEnvKey(envKey, apiKey || '');
|
|
92
92
|
log(`Stored ${envKey} in ${globalEnvPath()}`);
|
|
93
93
|
|
|
94
|
-
const missing =
|
|
94
|
+
const missing = missingModelRoles(provider);
|
|
95
95
|
if (missing.length) {
|
|
96
96
|
tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${globalConfigPath()}`);
|
|
97
97
|
}
|
|
@@ -156,7 +156,7 @@ export function runInitCommand(options: InitCommandOptions): void {
|
|
|
156
156
|
log(`Env file already exists: ${relativeToCwd(envPath)}`);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
const missing =
|
|
159
|
+
const missing = missingModelRoles(provider);
|
|
160
160
|
if (missing.length) {
|
|
161
161
|
tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${relativeToCwd(outPath)}`);
|
|
162
162
|
}
|
|
@@ -255,7 +255,7 @@ async function renderLocalProviderWizard(): Promise<string | null> {
|
|
|
255
255
|
|
|
256
256
|
function modelLines(provider: string): string {
|
|
257
257
|
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
258
|
-
const roles: Array<[
|
|
258
|
+
const roles: Array<[ModelRole, string]> = [
|
|
259
259
|
['model', 'fast model with tool calling capabilities'],
|
|
260
260
|
['visionModel', 'vision model for screenshot analysis'],
|
|
261
261
|
['agenticModel', 'agentic model for decision making'],
|
|
@@ -308,11 +308,6 @@ function isModuleProject(configDir: string): boolean {
|
|
|
308
308
|
}
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
-
function missingRoles(provider: string): string[] {
|
|
312
|
-
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
313
|
-
return ['model', 'visionModel', 'agenticModel'].filter((role) => !recommended[role]);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
311
|
function writeEnvKey(key: string, value: string): void {
|
|
317
312
|
const envPath = globalEnvPath();
|
|
318
313
|
let content = '# AI provider API keys';
|
|
@@ -327,8 +322,6 @@ function writeEnvKey(key: string, value: string): void {
|
|
|
327
322
|
writeFileSync(envPath, `${lines.join('\n').trimEnd()}\n`, 'utf8');
|
|
328
323
|
}
|
|
329
324
|
|
|
330
|
-
type ModelRoleName = 'model' | 'visionModel' | 'agenticModel';
|
|
331
|
-
|
|
332
325
|
type InitCommandOptions = {
|
|
333
326
|
configPath?: string;
|
|
334
327
|
force?: boolean;
|
|
@@ -21,8 +21,7 @@ export class PlansCommand extends BaseCommand {
|
|
|
21
21
|
return;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const file = this.resolvePlanFile(target, files);
|
|
25
|
-
const plan = Plan.fromMarkdown(file.path);
|
|
24
|
+
const { plan, file } = this.resolvePlanFile(target, files);
|
|
26
25
|
this.printPlanDetails(plan, file);
|
|
27
26
|
}
|
|
28
27
|
|
|
@@ -71,12 +70,12 @@ export class PlansCommand extends BaseCommand {
|
|
|
71
70
|
tag('info').log(`${getCliName()} test 1 --from-plan ${file.name}`);
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
private resolvePlanFile(target: string, files: PlanFile[]): PlanFile {
|
|
73
|
+
private resolvePlanFile(target: string, files: PlanFile[]): { plan: Plan; file: PlanFile } {
|
|
75
74
|
const index = Number.parseInt(target, 10);
|
|
76
75
|
if (!Number.isNaN(index) && String(index) === target) {
|
|
77
76
|
const file = files[index - 1];
|
|
78
77
|
if (!file) throw new Error(`Plan #${target} not found. Available: 1-${files.length}`);
|
|
79
|
-
return file;
|
|
78
|
+
return { plan: Plan.fromMarkdown(file.path), file };
|
|
80
79
|
}
|
|
81
80
|
|
|
82
81
|
const plan = Plan.loadFromFile(target, this.explorBot.getPlansDir());
|
|
@@ -84,11 +83,12 @@ export class PlansCommand extends BaseCommand {
|
|
|
84
83
|
throw new Error(`Plan file not found: ${target}`);
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
|
|
86
|
+
const file = {
|
|
88
87
|
name: path.basename(plan.filePath),
|
|
89
88
|
path: plan.filePath,
|
|
90
89
|
modifiedAt: statSync(plan.filePath).mtimeMs,
|
|
91
90
|
};
|
|
91
|
+
return { plan, file };
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import type { Command } from 'commander';
|
|
3
|
+
import { ConfigParser, EXPLORBOT_ENV_VARS, MODEL_ROLES, PROVIDERS } from '../config.js';
|
|
4
|
+
import { getCliName } from '../utils/cli-name.js';
|
|
5
|
+
import { renderSection } from '../utils/cli-section.js';
|
|
6
|
+
import { tag } from '../utils/logger.js';
|
|
7
|
+
import { BaseCommand } from './base-command.js';
|
|
8
|
+
|
|
9
|
+
const DESCRIPTION = 'Show the models recommended for every AI provider';
|
|
10
|
+
|
|
11
|
+
const ROLES: Record<string, { hint: string; env: string }> = {
|
|
12
|
+
model: { hint: 'fast', env: 'EXPLORBOT_AI_MODEL' },
|
|
13
|
+
visionModel: { hint: 'screenshots', env: 'EXPLORBOT_VISION_MODEL' },
|
|
14
|
+
agenticModel: { hint: 'smart', env: 'EXPLORBOT_AGENTIC_MODEL' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const MODEL_ENV_VARS = MODEL_ROLES.map((role) => ROLES[role].env);
|
|
18
|
+
|
|
19
|
+
export class RecommendedModelsCommand extends BaseCommand {
|
|
20
|
+
name = 'recommended-models';
|
|
21
|
+
description = DESCRIPTION;
|
|
22
|
+
|
|
23
|
+
async execute(): Promise<void> {
|
|
24
|
+
tag('info').log(RecommendedModelsCommand.render());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static register(program: Command): void {
|
|
28
|
+
program
|
|
29
|
+
.command('recommended-models')
|
|
30
|
+
.description(DESCRIPTION)
|
|
31
|
+
.option('--json', 'Print the recommendations as JSON')
|
|
32
|
+
.action((options) => console.log(RecommendedModelsCommand.render(options.json)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static render(json = false): string {
|
|
36
|
+
const recommended = ConfigParser.recommendedModels();
|
|
37
|
+
if (json) return JSON.stringify(recommended, null, 2);
|
|
38
|
+
|
|
39
|
+
const lines = [
|
|
40
|
+
chalk.bold('Export the provider key, then pick one of two ways to select models:'),
|
|
41
|
+
` ${chalk.bold('EXPLORBOT_AI_PROVIDER')} — every role takes that provider's recommendation below`,
|
|
42
|
+
` ${chalk.bold(MODEL_ENV_VARS.join(' + '))} — no provider variable, pin every role yourself as ${chalk.dim('provider/model-id')}`,
|
|
43
|
+
'',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
for (const [provider, roles] of Object.entries(recommended)) {
|
|
47
|
+
lines.push(chalk.bold.yellow(`${provider}:`));
|
|
48
|
+
|
|
49
|
+
const models: [string, string][] = [];
|
|
50
|
+
for (const role of MODEL_ROLES) {
|
|
51
|
+
const label = `${role} (${ROLES[role].hint})`;
|
|
52
|
+
if (roles[role]) models.push([label, roles[role]]);
|
|
53
|
+
if (!roles[role]) models.push([label, chalk.dim('not served, pair with another provider')]);
|
|
54
|
+
}
|
|
55
|
+
lines.push(...renderSection('models', models, 2));
|
|
56
|
+
|
|
57
|
+
const envKey = PROVIDERS[provider]?.envKey;
|
|
58
|
+
if (!envKey) {
|
|
59
|
+
lines.push(` ${chalk.dim('not bundled — import the provider in your config')}`, '');
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const env: [string, string][] = [
|
|
64
|
+
['key', envKey],
|
|
65
|
+
['provider', `EXPLORBOT_AI_PROVIDER=${provider}`],
|
|
66
|
+
];
|
|
67
|
+
let label = 'or models';
|
|
68
|
+
for (const role of MODEL_ROLES) {
|
|
69
|
+
if (!roles[role]) continue;
|
|
70
|
+
env.push([label, `${ROLES[role].env}=${provider}/${roles[role]}`]);
|
|
71
|
+
label = '';
|
|
72
|
+
}
|
|
73
|
+
lines.push(...renderSection('env variables', env, 2));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const modelVars = ['EXPLORBOT_AI_PROVIDER', ...MODEL_ENV_VARS];
|
|
77
|
+
const current: [string, string][] = [];
|
|
78
|
+
for (const variable of EXPLORBOT_ENV_VARS) {
|
|
79
|
+
if (!modelVars.includes(variable.name)) continue;
|
|
80
|
+
if (process.env[variable.name]) current.push([variable.name, process.env[variable.name]!]);
|
|
81
|
+
}
|
|
82
|
+
for (const [provider, { envKey }] of Object.entries(PROVIDERS)) {
|
|
83
|
+
if (process.env[envKey]) current.push([envKey, chalk.dim(`set, ${provider} usable`)]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!current.length) lines.push(chalk.dim('Nothing set — export one of the variables above, or write models into your config'), '');
|
|
87
|
+
if (current.length) lines.push(...renderSection('Currently set', current));
|
|
88
|
+
|
|
89
|
+
lines.push(chalk.bold('Example using OpenRouter:'), ` OPENROUTER_API_KEY=sk-... EXPLORBOT_AI_PROVIDER=openrouter ${getCliName()} <command>`);
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -19,6 +19,8 @@ export const PROVIDERS: Record<string, ProviderInfo> = {
|
|
|
19
19
|
sambanova: { envKey: 'SAMBANOVA_API_KEY', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
export const MODEL_ROLES: ModelRole[] = ['model', 'visionModel', 'agenticModel'];
|
|
23
|
+
|
|
22
24
|
let cachedOutputRoot: string | null = null;
|
|
23
25
|
|
|
24
26
|
interface PlaywrightConfig {
|
|
@@ -765,6 +767,11 @@ export async function resolveModel(spec: string, role: ModelRole = 'model'): Pro
|
|
|
765
767
|
return createModel(spec, modelId);
|
|
766
768
|
}
|
|
767
769
|
|
|
770
|
+
export function missingModelRoles(provider: string): ModelRole[] {
|
|
771
|
+
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
772
|
+
return MODEL_ROLES.filter((role) => !recommended[role]);
|
|
773
|
+
}
|
|
774
|
+
|
|
768
775
|
export class ConfigMissingError extends Error {}
|
|
769
776
|
|
|
770
777
|
export function envConfigRequested(): boolean {
|
|
@@ -786,6 +793,7 @@ export function missingConfigMessage(configFile = 'explorbot.config.js'): string
|
|
|
786
793
|
EXPLORBOT_AI_PROVIDER=openrouter EXPLORBOT_URL=https://your-app.example.com ${cli} ...
|
|
787
794
|
|
|
788
795
|
Providers: ${Object.keys(PROVIDERS).join(', ')}
|
|
796
|
+
See the models each one recommends: ${cli} recommended-models
|
|
789
797
|
`;
|
|
790
798
|
}
|
|
791
799
|
|
|
@@ -818,8 +826,7 @@ export function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel>
|
|
|
818
826
|
export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
|
|
819
827
|
if (!ai) return;
|
|
820
828
|
|
|
821
|
-
const
|
|
822
|
-
for (const role of roles) {
|
|
829
|
+
for (const role of MODEL_ROLES) {
|
|
823
830
|
if (typeof ai[role] === 'string') ai[role] = await resolveModel(ai[role], role);
|
|
824
831
|
}
|
|
825
832
|
|
|
@@ -127,7 +127,7 @@ export class ExperienceTracker {
|
|
|
127
127
|
url: state.url ? extractStatePath(state.url) : '',
|
|
128
128
|
title: state.title,
|
|
129
129
|
};
|
|
130
|
-
if (state.overlay.
|
|
130
|
+
if (state.overlay.isOpen && state.overlay.name) {
|
|
131
131
|
frontmatter.region = state.overlay.name;
|
|
132
132
|
if (state.overlay.root) frontmatter.root = state.overlay.root;
|
|
133
133
|
}
|
package/src/explorbot.ts
CHANGED
|
@@ -465,9 +465,9 @@ export class ExplorBot {
|
|
|
465
465
|
}
|
|
466
466
|
|
|
467
467
|
loadPlans(filename: string): Plan[] {
|
|
468
|
-
const
|
|
469
|
-
if (!
|
|
470
|
-
return parsePlansFromMarkdown(
|
|
468
|
+
const filePath = Plan.resolveFile(filename, this.getPlansDir());
|
|
469
|
+
if (!filePath) throw new Error(`Plan file not found: ${filename}`);
|
|
470
|
+
return parsePlansFromMarkdown(filePath);
|
|
471
471
|
}
|
|
472
472
|
|
|
473
473
|
setCurrentPlan(plan?: Plan): void {
|
package/src/global-config.ts
CHANGED
|
@@ -47,6 +47,14 @@ export function listSites(): SiteRecord[] {
|
|
|
47
47
|
.sort((a, b) => b.lastRunAt.localeCompare(a.lastRunAt));
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export function findSiteWith(subpath: string): SiteRecord | undefined {
|
|
51
|
+
return listSites().find((site) => existsSync(join(site.dir, subpath)));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function listSitePlanDirs(): string[] {
|
|
55
|
+
return listSites().map((site) => join(site.dir, 'output', 'plans'));
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
export function registerSite(baseUrl: string): SiteRecord {
|
|
51
59
|
const folder = siteFolderName(baseUrl);
|
|
52
60
|
const dir = join(sitesDir(), folder);
|
package/src/state-manager.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ExperienceTracker } from './experience-tracker.js';
|
|
|
3
3
|
import type { Knowledge, KnowledgeTracker } from './knowledge-tracker.js';
|
|
4
4
|
import { createDebug, tag } from './utils/logger.js';
|
|
5
5
|
import { Overlay } from './utils/overlay.js';
|
|
6
|
+
import type { Region } from './utils/region.js';
|
|
6
7
|
import { slugify } from './utils/strings.js';
|
|
7
8
|
import { extractStatePath } from './utils/url-matcher.js';
|
|
8
9
|
|
|
@@ -49,7 +50,7 @@ export interface WebPageState {
|
|
|
49
50
|
focusedElement?: FocusedElement | null;
|
|
50
51
|
links?: Link[];
|
|
51
52
|
verifications?: Record<string, boolean>;
|
|
52
|
-
overlay?:
|
|
53
|
+
overlay?: Region;
|
|
53
54
|
/** Region name, the persisted scalar form of overlay.name used in experience frontmatter */
|
|
54
55
|
region?: string;
|
|
55
56
|
/** Region root selector, the persisted scalar form of overlay.root used in experience frontmatter */
|
|
@@ -124,7 +125,7 @@ export class StateManager {
|
|
|
124
125
|
private emitStateChange(event: StateTransition): void {
|
|
125
126
|
const state = event.toState;
|
|
126
127
|
const payload: Record<string, unknown> = { url: state.fullUrl || state.url, path: state.url, title: state.title, h1: state.h1 };
|
|
127
|
-
if (state.overlay?.
|
|
128
|
+
if (state.overlay?.isOpen) payload.region = state.overlay.name || state.overlay.type;
|
|
128
129
|
tag('data').log('state', payload);
|
|
129
130
|
|
|
130
131
|
this.stateChangeListeners.forEach((listener) => {
|
|
@@ -149,9 +150,9 @@ export class StateManager {
|
|
|
149
150
|
if (newState.url) this.allVisitedUrls.add(normalizeUrl(newState.url));
|
|
150
151
|
|
|
151
152
|
const hashChanged = actionResult.hash !== previousHash;
|
|
152
|
-
const
|
|
153
|
+
const regionOpened = !hashChanged && this.regionOpened(previousState, newState);
|
|
153
154
|
|
|
154
|
-
if (hashChanged ||
|
|
155
|
+
if (hashChanged || regionOpened) {
|
|
155
156
|
const transition: StateTransition = {
|
|
156
157
|
fromState: previousState,
|
|
157
158
|
toState: newState,
|
|
@@ -162,7 +163,7 @@ export class StateManager {
|
|
|
162
163
|
this.stateHistory.push(transition);
|
|
163
164
|
this.emitStateChange(transition);
|
|
164
165
|
|
|
165
|
-
if (
|
|
166
|
+
if (regionOpened) {
|
|
166
167
|
debugLog('State change detected: region of interest appeared');
|
|
167
168
|
}
|
|
168
169
|
}
|
|
@@ -212,10 +213,10 @@ export class StateManager {
|
|
|
212
213
|
return newState;
|
|
213
214
|
}
|
|
214
215
|
|
|
215
|
-
private
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
return !
|
|
216
|
+
private regionOpened(previousState: WebPageState | null, newState: WebPageState): boolean {
|
|
217
|
+
const previous = Overlay.resolve(previousState ?? {});
|
|
218
|
+
const current = Overlay.resolve(newState);
|
|
219
|
+
return !previous.isOpen && current.isOpen;
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
/**
|