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/action.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { browserErrorMessage, isFatalBrowserError, isNavigationTransitionError }
|
|
|
14
14
|
import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
|
|
15
15
|
import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
|
|
16
16
|
import { Overlay, OverlayPage } from './utils/overlay.js';
|
|
17
|
+
import type { Region } from './utils/region.js';
|
|
17
18
|
import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
|
|
18
19
|
import { safeFilename } from './utils/strings.ts';
|
|
19
20
|
import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';
|
|
@@ -181,7 +182,7 @@ class Action {
|
|
|
181
182
|
focusedElement,
|
|
182
183
|
iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
|
|
183
184
|
});
|
|
184
|
-
if (!frame) await this.
|
|
185
|
+
if (!frame) await this.detectRegion(result).catch((err: Error) => debugLog('Region detection failed:', err.message));
|
|
185
186
|
this.stateManager.updateState(result, codeBlock);
|
|
186
187
|
return result;
|
|
187
188
|
} catch (err) {
|
|
@@ -193,36 +194,36 @@ class Action {
|
|
|
193
194
|
}
|
|
194
195
|
}
|
|
195
196
|
|
|
196
|
-
private async
|
|
197
|
+
private async detectRegion(result: ActionResult): Promise<void> {
|
|
197
198
|
const previousState = this.stateManager.getCurrentState();
|
|
198
199
|
if (!previousState) return;
|
|
199
200
|
const previous = ActionResult.fromState(previousState);
|
|
200
|
-
const
|
|
201
|
+
const previousRegion = previous.overlay;
|
|
201
202
|
const sameUrl = !!previous.url && result.isSameUrl({ url: previous.url });
|
|
202
203
|
const overlayPage = new OverlayPage(this.playwrightHelper.page);
|
|
203
204
|
|
|
204
|
-
if (result.overlay.
|
|
205
|
-
result.overlay =
|
|
205
|
+
if (result.overlay.isModal && previousRegion.isModal && previousRegion.root && previousRegion.type === result.overlay.type && previousRegion.name === result.overlay.name) {
|
|
206
|
+
result.overlay = previousRegion;
|
|
206
207
|
return;
|
|
207
208
|
}
|
|
208
209
|
|
|
209
210
|
if (!previous.html) return;
|
|
210
211
|
|
|
211
212
|
if (previous.html === result.html) {
|
|
212
|
-
if (sameUrl &&
|
|
213
|
+
if (sameUrl && previousRegion.isOpen && previousRegion.xpath && !result.overlay.isModal) result.overlay = previousRegion;
|
|
213
214
|
return;
|
|
214
215
|
}
|
|
215
216
|
|
|
216
|
-
let carried:
|
|
217
|
-
if (sameUrl &&
|
|
218
|
-
if (await overlayPage.isStillOpen(
|
|
219
|
-
carried =
|
|
217
|
+
let carried: Region | null = null;
|
|
218
|
+
if (sameUrl && previousRegion.isOpen && previousRegion.xpath) {
|
|
219
|
+
if (await overlayPage.isStillOpen(previousRegion)) {
|
|
220
|
+
carried = previousRegion;
|
|
220
221
|
} else {
|
|
221
|
-
debugLog(`Region closed: ${
|
|
222
|
-
if (!result.overlay.
|
|
223
|
-
const parent =
|
|
222
|
+
debugLog(`Region closed: ${previousRegion.name || previousRegion.type}`);
|
|
223
|
+
if (!result.overlay.isModal) {
|
|
224
|
+
const parent = previousRegion.parent;
|
|
224
225
|
if (parent?.xpath) {
|
|
225
|
-
const restored =
|
|
226
|
+
const restored = Overlay.resolve({ overlay: parent });
|
|
226
227
|
if (await overlayPage.isStillOpen(restored)) result.overlay = restored;
|
|
227
228
|
}
|
|
228
229
|
return;
|
|
@@ -239,7 +240,7 @@ class Action {
|
|
|
239
240
|
previousHtml: previous.html,
|
|
240
241
|
});
|
|
241
242
|
|
|
242
|
-
if (result.overlay
|
|
243
|
+
if (result.overlay instanceof Overlay) {
|
|
243
244
|
if (detected) result.overlay = result.overlay.withGeometry(detected);
|
|
244
245
|
return;
|
|
245
246
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { RequestResult } from '../../api/request-result.ts';
|
|
2
|
+
import { type RequestStore, isFailedRequest } from '../../api/request-store.ts';
|
|
3
|
+
|
|
4
|
+
export class RequestHaul {
|
|
5
|
+
private start: number;
|
|
6
|
+
|
|
7
|
+
constructor(private store: RequestStore) {
|
|
8
|
+
this.start = store.getMadeRequests().length;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
requests(): RequestResult[] {
|
|
12
|
+
return this.store.getMadeRequests().slice(this.start);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
failed(): RequestResult[] {
|
|
16
|
+
return this.requests().filter(isFailedRequest);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
successfulWrites(): RequestResult[] {
|
|
20
|
+
return this.requests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
byId(): Map<string, RequestResult> {
|
|
24
|
+
const map = new Map<string, RequestResult>();
|
|
25
|
+
for (const request of this.successfulWrites()) {
|
|
26
|
+
const { id } = request.extractIdAndTitle();
|
|
27
|
+
if (id === undefined) continue;
|
|
28
|
+
map.set(String(id), request);
|
|
29
|
+
}
|
|
30
|
+
return map;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -6,21 +6,18 @@ import type { RequestResult } from '../api/request-result.ts';
|
|
|
6
6
|
import type { RequestStore } from '../api/request-store.ts';
|
|
7
7
|
import { extractEndpointDefinition } from '../api/spec-reader.ts';
|
|
8
8
|
import { tag } from '../utils/logger.ts';
|
|
9
|
-
import { RequestMap } from '../utils/request-map.ts';
|
|
10
9
|
import { isDynamicSegment } from '../utils/url-matcher.ts';
|
|
10
|
+
import type { RequestHaul } from './fisherman/request-haul.ts';
|
|
11
11
|
|
|
12
|
-
export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, opts: { spec?: any; baseEndpoint?: string }) {
|
|
12
|
+
export function createFishermanTools(apiClient: ApiClient, requestStore: RequestStore, haul: RequestHaul, opts: { spec?: any; baseEndpoint?: string }) {
|
|
13
13
|
let finished = false;
|
|
14
14
|
let result: FishermanResult | null = null;
|
|
15
|
-
const ledgerStart = requestStore.getMadeRequests().length;
|
|
16
15
|
|
|
17
|
-
const
|
|
18
|
-
const successfulWrites = () => runRequests().filter((r) => r.isWrite && !r.error && r.status >= 200 && r.status < 400);
|
|
19
|
-
const getResult = () => result ?? synthesizeResult(runRequests(), successfulWrites(), false);
|
|
16
|
+
const getResult = () => result ?? synthesizeResult(haul, false);
|
|
20
17
|
const isFinished = () => finished;
|
|
21
18
|
const finishFromText = (text?: string) => {
|
|
22
19
|
finished = true;
|
|
23
|
-
const synthesized = synthesizeResult(
|
|
20
|
+
const synthesized = synthesizeResult(haul, true);
|
|
24
21
|
if (text && synthesized.success) synthesized.summary = text;
|
|
25
22
|
result = synthesized;
|
|
26
23
|
};
|
|
@@ -160,32 +157,12 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
160
157
|
.describe('List of items that could not be created'),
|
|
161
158
|
}),
|
|
162
159
|
execute: async ({ summary, created, failed }) => {
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
165
|
-
tag('warning').log('Fisherman: finish rejected — no successful write request in this run');
|
|
166
|
-
return { finished: false, error: 'No successful write request was made in this run, so nothing was created. Keep working, or call stop if the data cannot be prepared.' };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const createdRequests = new RequestMap(writes);
|
|
170
|
-
|
|
171
|
-
const verified: FishermanResult['created'] = [];
|
|
172
|
-
for (const item of created) {
|
|
173
|
-
if (item.id === undefined) {
|
|
174
|
-
verified.push(item);
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
const request = createdRequests.get(item.id);
|
|
178
|
-
if (!request) {
|
|
179
|
-
tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
verified.push({ ...item, request: request.toEndpoint() });
|
|
183
|
-
}
|
|
184
|
-
if (verified.length === 0) verified.push(...writes.map(toCreatedItem));
|
|
160
|
+
const { result: verified, error } = verifyFinish(haul, { summary, created, failed });
|
|
161
|
+
if (!verified) return { finished: false, error };
|
|
185
162
|
|
|
186
163
|
tag('success').log(`Fisherman done: ${summary}`);
|
|
187
164
|
finished = true;
|
|
188
|
-
result =
|
|
165
|
+
result = verified;
|
|
189
166
|
return { finished: true };
|
|
190
167
|
},
|
|
191
168
|
}),
|
|
@@ -207,8 +184,37 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
|
|
|
207
184
|
return { tools, getResult, isFinished, finishFromText };
|
|
208
185
|
}
|
|
209
186
|
|
|
210
|
-
function
|
|
211
|
-
const
|
|
187
|
+
export function verifyFinish(haul: RequestHaul, input: { summary: string; created: FishermanResult['created']; failed?: FishermanResult['failed'] }): { result: FishermanResult | null; error?: string } {
|
|
188
|
+
const writes = haul.successfulWrites();
|
|
189
|
+
if (writes.length === 0) {
|
|
190
|
+
tag('warning').log('Fisherman: finish rejected — no successful write request in this run');
|
|
191
|
+
return { result: null, error: 'No successful write request was made in this run, so nothing was created. Keep working, or call stop if the data cannot be prepared.' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const createdRequests = haul.byId();
|
|
195
|
+
|
|
196
|
+
const verified: FishermanResult['created'] = [];
|
|
197
|
+
for (const item of input.created) {
|
|
198
|
+
if (item.id === undefined) {
|
|
199
|
+
verified.push(item);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const request = createdRequests.get(String(item.id));
|
|
203
|
+
if (!request) {
|
|
204
|
+
tag('warning').log(`Fisherman: dropped unverified created item ${item.type} (id: ${item.id})`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
verified.push({ ...item, request: request.toEndpoint() });
|
|
208
|
+
}
|
|
209
|
+
if (verified.length === 0) verified.push(...writes.map(toCreatedItem));
|
|
210
|
+
|
|
211
|
+
return { result: { success: true, summary: input.summary, created: verified, failed: input.failed || [] } };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function synthesizeResult(haul: RequestHaul, declaredDone: boolean): FishermanResult {
|
|
215
|
+
const made = haul.requests();
|
|
216
|
+
const writes = haul.successfulWrites();
|
|
217
|
+
const failures = haul.failed();
|
|
212
218
|
let summary = `Stopped before finishing: ${made.length} requests, ${writes.length} successful writes, ${failures.length} failed`;
|
|
213
219
|
const lastFailure = failures[failures.length - 1];
|
|
214
220
|
if (lastFailure) summary += `; last failure: ${lastFailure.toSummary()}`;
|
package/src/ai/fisherman.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import dedent from 'dedent';
|
|
2
2
|
import type { ApiClient } from '../api/api-client.ts';
|
|
3
|
-
import type
|
|
3
|
+
import { type RequestStore, isFailedRequest } from '../api/request-store.ts';
|
|
4
4
|
import { listAllEndpoints } from '../api/spec-reader.ts';
|
|
5
5
|
import { createDebug, tag } from '../utils/logger.ts';
|
|
6
6
|
|
|
@@ -8,6 +8,7 @@ const debugLog = createDebug('explorbot:fisherman');
|
|
|
8
8
|
import { loop } from '../utils/loop.ts';
|
|
9
9
|
import type { Agent } from './agent.ts';
|
|
10
10
|
import { type FishermanResult, createFishermanTools } from './fisherman-tools.ts';
|
|
11
|
+
import { RequestHaul } from './fisherman/request-haul.ts';
|
|
11
12
|
import type { Provider } from './provider.ts';
|
|
12
13
|
import { dataProtectionRules } from './rules.ts';
|
|
13
14
|
|
|
@@ -79,11 +80,11 @@ export class Fisherman implements Agent {
|
|
|
79
80
|
await this.refreshAuth();
|
|
80
81
|
debugLog(`auth headers: ${Object.keys(this.apiClient.getHeaders()).join(', ')}`);
|
|
81
82
|
|
|
82
|
-
const
|
|
83
|
+
const haul = new RequestHaul(this.requestStore);
|
|
84
|
+
const { tools, getResult, isFinished, finishFromText } = createFishermanTools(this.apiClient, this.requestStore, haul, {
|
|
83
85
|
spec: this.spec,
|
|
84
86
|
baseEndpoint: this.baseEndpoint,
|
|
85
87
|
});
|
|
86
|
-
const ledgerStart = this.requestStore.getMadeRequests().length;
|
|
87
88
|
|
|
88
89
|
const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
|
|
89
90
|
conversation.addUserText(this.buildTaskPrompt(instructions));
|
|
@@ -109,7 +110,7 @@ export class Fisherman implements Agent {
|
|
|
109
110
|
return;
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
if (this.isStuckOnEndpoint(
|
|
113
|
+
if (this.isStuckOnEndpoint(haul)) {
|
|
113
114
|
tag('warning').log('Fisherman: repeated failures on the same endpoint — stopping');
|
|
114
115
|
stop();
|
|
115
116
|
return;
|
|
@@ -228,12 +229,12 @@ export class Fisherman implements Agent {
|
|
|
228
229
|
`;
|
|
229
230
|
}
|
|
230
231
|
|
|
231
|
-
private isStuckOnEndpoint(
|
|
232
|
-
const made =
|
|
232
|
+
private isStuckOnEndpoint(haul: RequestHaul): boolean {
|
|
233
|
+
const made = haul.requests();
|
|
233
234
|
if (made.length < REPEATED_FAILURE_LIMIT) return false;
|
|
234
235
|
const recent = made.slice(-REPEATED_FAILURE_LIMIT);
|
|
235
236
|
const first = recent[0];
|
|
236
|
-
return recent.every((r) => (r
|
|
237
|
+
return recent.every((r) => isFailedRequest(r) && r.method === first.method && r.path === first.path);
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
private buildTaskPrompt(instructions: string): string {
|
package/src/ai/pilot.ts
CHANGED
|
@@ -31,6 +31,7 @@ const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
|
|
|
31
31
|
const PILOT_REASONING_LIMIT = 500;
|
|
32
32
|
const PILOT_MESSAGE_LIMIT = 2;
|
|
33
33
|
const PILOT_MESSAGE_MAX_LENGTH = 160;
|
|
34
|
+
const PILOT_REQUEST_LIMIT = 5;
|
|
34
35
|
|
|
35
36
|
export class Pilot implements Agent {
|
|
36
37
|
emoji = '🧭';
|
|
@@ -826,17 +827,17 @@ export class Pilot implements Agent {
|
|
|
826
827
|
lines.push(`h3: ${state.h3 || ''}`);
|
|
827
828
|
lines.push(`h4: ${state.h4 || ''}`);
|
|
828
829
|
|
|
829
|
-
const
|
|
830
|
-
if (
|
|
831
|
-
let line = `
|
|
832
|
-
if (
|
|
830
|
+
const region = state.overlay;
|
|
831
|
+
if (region.isModal) {
|
|
832
|
+
let line = `overlay: ${region.name || region.type}`;
|
|
833
|
+
if (region.root) line += ` (root: ${region.root})`;
|
|
833
834
|
lines.push(line);
|
|
834
|
-
} else if (
|
|
835
|
-
let line = `region: ${
|
|
836
|
-
if (
|
|
835
|
+
} else if (region.isOpen) {
|
|
836
|
+
let line = `region: ${region.name || 'unnamed'} (inline`;
|
|
837
|
+
if (region.root) line += `, root: ${region.root}`;
|
|
837
838
|
lines.push(`${line})`);
|
|
838
839
|
} else {
|
|
839
|
-
lines.push('
|
|
840
|
+
lines.push('overlay: none');
|
|
840
841
|
}
|
|
841
842
|
|
|
842
843
|
const tabs = this.stateManager.otherTabs;
|
|
@@ -1071,9 +1072,13 @@ export class Pilot implements Agent {
|
|
|
1071
1072
|
|
|
1072
1073
|
if (t.output?.pageDiff?.urlChanged) line += `\n moved: ${t.output.pageDiff.previousUrl} → ${t.output.pageDiff.currentUrl}`;
|
|
1073
1074
|
|
|
1074
|
-
const
|
|
1075
|
-
|
|
1076
|
-
|
|
1075
|
+
const pageRequests = t.output?.pageDiff?.requests ?? [];
|
|
1076
|
+
const requests = pageRequests
|
|
1077
|
+
.filter((r: any) => r.status >= 400)
|
|
1078
|
+
.concat(pageRequests.filter((r: any) => r.status < 400))
|
|
1079
|
+
.slice(0, PILOT_REQUEST_LIMIT);
|
|
1080
|
+
if (requests.length > 0) {
|
|
1081
|
+
line += `\n requests: ${requests.map((r: any) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
|
|
1077
1082
|
}
|
|
1078
1083
|
|
|
1079
1084
|
const messages = (t.output?.pageDiff?.messages ?? []).slice(0, PILOT_MESSAGE_LIMIT);
|
|
@@ -1139,15 +1144,15 @@ export class Pilot implements Agent {
|
|
|
1139
1144
|
|
|
1140
1145
|
Diagnostic patterns (use <state>, executed/element/skipped fields, ariaDiff):
|
|
1141
1146
|
- Click failed + button in "disabled buttons" → required field missing. Instruct fill first.
|
|
1142
|
-
- "
|
|
1147
|
+
- "overlay: none" but Tester targets an overlay → overlay closed; re-trigger.
|
|
1143
1148
|
- "region:" in <state> → a large area appeared in place without navigation (subview, wizard step, panel). Direct Tester to act inside it; the rest of the page is still usable.
|
|
1144
1149
|
- Action SUCCESS but ariaDiff empty → may have worked without visible DOM change; check result message.
|
|
1145
|
-
- MultipleElementsFound →
|
|
1150
|
+
- MultipleElementsFound → nothing was clicked. Tell Tester to reuse the same locator with step.opts({ elementIndex: N }) from the numbered elements list.
|
|
1146
1151
|
- Wrong page (settings vs feature) → getVisitedStates() then back() or reset(). Don't try breadcrumbs (SPA back-nav is unreliable).
|
|
1147
1152
|
- Click SUCCESS but executed locator ≠ explanation intent, or "skipped" attempts present → wrong element clicked.
|
|
1148
1153
|
- form(I.type()) SUCCESS but "element" shows a button/link → keys went to wrong element; click the input first.
|
|
1149
1154
|
- ariaDiff shows 5+ added/removed → page entered new mode (editor/modal); call context() before guessing selectors.
|
|
1150
|
-
- Empty dropdown/list when items expected →
|
|
1155
|
+
- Empty dropdown/list when items expected → wait explicitly, then check the state changed: ariaDiff and any GET that loaded data. If still nothing loaded, confirm the empty state with verify().
|
|
1151
1156
|
- Search-and-select needs SEQUENCE: focus trigger → type to filter → click option. Tell Tester to split into separate tool calls.
|
|
1152
1157
|
- Multi-action explanation in one tool call → instruct Tester to split.
|
|
1153
1158
|
|
package/src/ai/provider.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { type AIConfig, configuredModels, modelName as getModelName } from '../c
|
|
|
11
11
|
import { executionController } from '../execution-controller.ts';
|
|
12
12
|
import { Observability } from '../observability.ts';
|
|
13
13
|
import { Stats } from '../stats.ts';
|
|
14
|
+
import { getCliName } from '../utils/cli-name.js';
|
|
14
15
|
import { createDebug, tag } from '../utils/logger.js';
|
|
15
16
|
import { type RetryOptions, withRetry } from '../utils/retry.js';
|
|
16
17
|
import { RulesLoader } from '../utils/rules-loader.ts';
|
|
@@ -51,12 +52,7 @@ export async function flushTelemetry(): Promise<void> {
|
|
|
51
52
|
const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum context', 'token limit', 'too many tokens', 'max_tokens', 'context_length_exceeded', 'output truncated at maxtokens'];
|
|
52
53
|
|
|
53
54
|
function extractCachedTokens(usage: any): number {
|
|
54
|
-
|
|
55
|
-
const direct = usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens;
|
|
56
|
-
if (typeof direct === 'number') return direct;
|
|
57
|
-
const raw = usage.raw;
|
|
58
|
-
const fromRaw = raw?.prompt_tokens_details?.cached_tokens ?? raw?.promptTokensDetails?.cachedTokens;
|
|
59
|
-
return typeof fromRaw === 'number' ? fromRaw : 0;
|
|
55
|
+
return usage?.inputTokenDetails?.cacheReadTokens ?? 0;
|
|
60
56
|
}
|
|
61
57
|
|
|
62
58
|
function abortAfterIdle(ms: number, cancel: { cancelled: boolean }, controller: AbortController): Promise<never> {
|
|
@@ -110,7 +106,7 @@ export class Provider {
|
|
|
110
106
|
|
|
111
107
|
constructor(config: AIConfig) {
|
|
112
108
|
if (!config?.model) {
|
|
113
|
-
throw new AiError(
|
|
109
|
+
throw new AiError(`AI model is not configured. Set ai.model in your config file, or pick one with ${getCliName()} recommended-models`);
|
|
114
110
|
}
|
|
115
111
|
this.config = config;
|
|
116
112
|
this.initLangfuse();
|
|
@@ -243,8 +239,8 @@ export class Provider {
|
|
|
243
239
|
private recordUsage(agentName: string, modelName: string, usage: any): void {
|
|
244
240
|
if (!usage) return;
|
|
245
241
|
Stats.recordTokens(agentName, modelName, {
|
|
246
|
-
input: usage.inputTokens ??
|
|
247
|
-
output: usage.outputTokens ??
|
|
242
|
+
input: usage.inputTokens ?? 0,
|
|
243
|
+
output: usage.outputTokens ?? 0,
|
|
248
244
|
total: usage.totalTokens ?? 0,
|
|
249
245
|
cached: extractCachedTokens(usage),
|
|
250
246
|
});
|
|
@@ -428,7 +424,7 @@ export class Provider {
|
|
|
428
424
|
let invalidRequestFeedbackAdded = false;
|
|
429
425
|
const executedStepMessages: ModelMessage[] = [];
|
|
430
426
|
try {
|
|
431
|
-
|
|
427
|
+
let response = await this.withModelRequestSlot(() =>
|
|
432
428
|
withRetry(async () => {
|
|
433
429
|
const stepMessages: ModelMessage[] = [];
|
|
434
430
|
const onStepEnd = (step: any) => {
|
|
@@ -458,7 +454,7 @@ export class Provider {
|
|
|
458
454
|
|
|
459
455
|
clearActivity();
|
|
460
456
|
|
|
461
|
-
withExecutedSteps(response, executedStepMessages);
|
|
457
|
+
response = withExecutedSteps(response, executedStepMessages);
|
|
462
458
|
|
|
463
459
|
// Log tool usage summary
|
|
464
460
|
if (response.toolCalls && response.toolCalls.length > 0) {
|
|
@@ -651,7 +647,7 @@ export class Provider {
|
|
|
651
647
|
|
|
652
648
|
async processImage(prompt: string, image: string): Promise<any> {
|
|
653
649
|
if (!this.config.visionModel) {
|
|
654
|
-
throw new Error(
|
|
650
|
+
throw new Error(`Vision model not configured. Set ai.visionModel in your config; ${getCliName()} recommended-models lists the providers that serve one`);
|
|
655
651
|
}
|
|
656
652
|
|
|
657
653
|
setActivity(`🤖 Processing image with ${this.config.visionModel}`, 'ai');
|
|
@@ -71,6 +71,14 @@ export async function visuallyAnnotateContainers(page: Page, containers: Array<{
|
|
|
71
71
|
}, containers);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
export async function removeVisualAnnotations(page: Page): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
await page.locator('[data-explorbot-annotation]').evaluateAll((elements) => {
|
|
77
|
+
for (const element of elements) element.remove();
|
|
78
|
+
});
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
|
|
74
82
|
export function WithCoordinates<T extends Constructor>(Base: T) {
|
|
75
83
|
return class extends Base {
|
|
76
84
|
declare explorer: Explorer;
|
|
@@ -85,6 +93,10 @@ export function WithCoordinates<T extends Constructor>(Base: T) {
|
|
|
85
93
|
return this.explorer.withPage((page) => visuallyAnnotateContainers(page, opts?.containers || []));
|
|
86
94
|
}
|
|
87
95
|
|
|
96
|
+
async removeVisualAnnotations(): Promise<void> {
|
|
97
|
+
await this.explorer.withPage((page) => removeVisualAnnotations(page));
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
private async _analyzeScreenshotForVisualProps(): Promise<VisualAnalysisResult> {
|
|
89
101
|
const elements = new Map<string, { coordinates: string | null; color: string | null; icon: string | null }>();
|
|
90
102
|
const emptyResult: VisualAnalysisResult = { elements, pagePurpose: null, primaryActions: null, focusedSection: null };
|
|
@@ -96,7 +108,7 @@ export function WithCoordinates<T extends Constructor>(Base: T) {
|
|
|
96
108
|
|
|
97
109
|
const prompt = dedent`
|
|
98
110
|
This screenshot has two types of annotations:
|
|
99
|
-
- **Section containers**: dashed bordered boxes (no labels on them). A legend at the bottom-
|
|
111
|
+
- **Section containers**: dashed bordered boxes (no labels on them). A legend at the bottom-right maps dashed line colors to section names. Ignore containers for this task.
|
|
100
112
|
- **Interactive elements**: solid bordered boxes with eidx numbers in the top-right corner above the box. Adjacent elements use different colors.
|
|
101
113
|
|
|
102
114
|
For each interactive element (solid border, eidx number), report:
|
|
@@ -88,22 +88,22 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
|
|
91
|
-
const
|
|
92
|
-
if (!
|
|
91
|
+
const region = current.overlay;
|
|
92
|
+
if (!region.isOpen || !region.name) return null;
|
|
93
93
|
|
|
94
94
|
const cached = getCachedResearch(pageStateHash);
|
|
95
95
|
if (!cached) return null;
|
|
96
96
|
|
|
97
|
-
const escaped =
|
|
97
|
+
const escaped = region.name.replace(/"/g, '\\"');
|
|
98
98
|
if (mdq(cached).query(`section3(~"${escaped}")`).count() > 0) {
|
|
99
|
-
debugLog(`Overlay "${
|
|
99
|
+
debugLog(`Overlay "${region.name}" already in cached research, skipping`);
|
|
100
100
|
return null;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
const diff = await current.diff(previous);
|
|
104
104
|
|
|
105
105
|
if (!diff.ariaChanged && diff.htmlParts.length === 0) {
|
|
106
|
-
debugLog(`No diff between current and previous state for overlay "${
|
|
106
|
+
debugLog(`No diff between current and previous state for overlay "${region.name}"`);
|
|
107
107
|
return null;
|
|
108
108
|
}
|
|
109
109
|
|
|
@@ -113,10 +113,10 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
113
113
|
.map((s) => s.rawMarkdown)
|
|
114
114
|
);
|
|
115
115
|
|
|
116
|
-
tag('substep').log(`Researching overlay: ${
|
|
117
|
-
const sectionMarkdown = await this._analyzeExpandedAction('',
|
|
116
|
+
tag('substep').log(`Researching overlay: ${region.name}`);
|
|
117
|
+
const sectionMarkdown = await this._analyzeExpandedAction('', region.name, diff, alreadyExpanded);
|
|
118
118
|
if (!sectionMarkdown) {
|
|
119
|
-
debugLog(`Overlay "${
|
|
119
|
+
debugLog(`Overlay "${region.name}" produced no meaningful expansion`);
|
|
120
120
|
return null;
|
|
121
121
|
}
|
|
122
122
|
|
|
@@ -130,7 +130,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
saveResearch({ hash: pageStateHash }, updated);
|
|
133
|
-
tag('substep').log(`Overlay research appended: ${
|
|
133
|
+
tag('substep').log(`Overlay research appended: ${region.name}`);
|
|
134
134
|
return sectionMarkdown;
|
|
135
135
|
}
|
|
136
136
|
|
|
@@ -3,16 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { parentPort } from 'node:worker_threads';
|
|
4
4
|
import { computeHtmlFingerprint } from '../../utils/html-diff.ts';
|
|
5
5
|
import { isSamePageFamily } from '../../utils/url-matcher.ts';
|
|
6
|
-
|
|
7
|
-
function diceSimilarity(a: Set<string>, b: Set<string>): number {
|
|
8
|
-
let intersection = 0;
|
|
9
|
-
for (const item of a) {
|
|
10
|
-
if (b.has(item)) intersection++;
|
|
11
|
-
}
|
|
12
|
-
const total = a.size + b.size;
|
|
13
|
-
if (total === 0) return 100;
|
|
14
|
-
return Math.round(((2 * intersection) / total) * 100);
|
|
15
|
-
}
|
|
6
|
+
import { diceSimilarity } from '../../utils/similarity.ts';
|
|
16
7
|
|
|
17
8
|
parentPort!.on('message', (data: FingerprintRequest) => {
|
|
18
9
|
const { html, statesDir, maxAgeMs, threshold, url } = data;
|
package/src/ai/researcher.ts
CHANGED
|
@@ -236,7 +236,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
236
236
|
const containers = validContainers.filter((c) => !freshBroken.includes(c.css));
|
|
237
237
|
await this.visuallyAnnotateElements({ containers });
|
|
238
238
|
this.actionResult = await this.explorer.capture({ screenshot: true });
|
|
239
|
-
const visualResult = await this.analyzeScreenshotForVisualProps();
|
|
239
|
+
const visualResult = await this.analyzeScreenshotForVisualProps().finally(() => this.removeVisualAnnotations());
|
|
240
240
|
if (visualResult.elements.size > 0) {
|
|
241
241
|
await this.mergeVisualData(result, visualResult.elements);
|
|
242
242
|
result.parseLocators();
|
package/src/ai/rules.ts
CHANGED
|
@@ -335,6 +335,14 @@ export const actionRule = dedent`
|
|
|
335
335
|
If locator doesn't work, try CSS or XPath locators.
|
|
336
336
|
If nothing works, use I.clickXY(x, y) as last resort.
|
|
337
337
|
|
|
338
|
+
When a click result reports several matches, pick one from its numbered list by position rather than guessing a new locator.
|
|
339
|
+
Reuse the same locator with step.opts({ elementIndex: N }) as the LAST argument. N is the "Element N" number.
|
|
340
|
+
|
|
341
|
+
<example>
|
|
342
|
+
I.click('Remove', step.opts({ elementIndex: 2 }));
|
|
343
|
+
I.click({ role: 'link', text: 'Details' }, '.panel', step.opts({ elementIndex: 1 }));
|
|
344
|
+
</example>
|
|
345
|
+
|
|
338
346
|
For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
|
|
339
347
|
|
|
340
348
|
|
package/src/ai/tester.ts
CHANGED
|
@@ -548,17 +548,17 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
548
548
|
const isNewUrl = this.previousUrl !== currentUrl;
|
|
549
549
|
const isNewState = !isNewUrl && this.previousStateHash !== null && this.previousStateHash !== currentStateHash;
|
|
550
550
|
|
|
551
|
-
if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.
|
|
551
|
+
if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.isOpen) {
|
|
552
552
|
this.regionTransitioned = true;
|
|
553
553
|
}
|
|
554
|
-
this.previousRegionPresent = currentState.overlay.
|
|
554
|
+
this.previousRegionPresent = currentState.overlay.isOpen;
|
|
555
555
|
|
|
556
556
|
this.previousUrl = currentUrl;
|
|
557
557
|
this.previousStateHash = currentStateHash;
|
|
558
558
|
|
|
559
559
|
let context = '';
|
|
560
560
|
|
|
561
|
-
const
|
|
561
|
+
const region = currentState.overlay;
|
|
562
562
|
|
|
563
563
|
const focusedElement = currentState.focusedElement;
|
|
564
564
|
if (focusedElement) {
|
|
@@ -577,29 +577,29 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
577
577
|
`;
|
|
578
578
|
}
|
|
579
579
|
|
|
580
|
-
if (
|
|
581
|
-
const areaName =
|
|
580
|
+
if (region.isModal) {
|
|
581
|
+
const areaName = region.name ? ` "${region.name}"` : '';
|
|
582
582
|
let rootHint = '';
|
|
583
|
-
if (
|
|
583
|
+
if (region.root) rootHint = `\nIts content lives inside \`${region.root}\` — scope locators to it.`;
|
|
584
584
|
context += dedent`
|
|
585
|
-
<
|
|
586
|
-
|
|
587
|
-
Scope all interactions to elements inside this
|
|
588
|
-
Page navigation, filters, and tabs that exist outside it are not actionable while it is open and may share names or roles with elements inside it — prefer the locator inside the
|
|
589
|
-
Use <page_aria> to confirm the element you target is actually inside the
|
|
590
|
-
</
|
|
585
|
+
<overlay>
|
|
586
|
+
An overlay${areaName} is currently open above the page.${rootHint}
|
|
587
|
+
Scope all interactions to elements inside this overlay.
|
|
588
|
+
Page navigation, filters, and tabs that exist outside it are not actionable while it is open and may share names or roles with elements inside it — prefer the locator inside the overlay.
|
|
589
|
+
Use <page_aria> to confirm the element you target is actually inside the overlay.
|
|
590
|
+
</overlay>
|
|
591
591
|
`;
|
|
592
592
|
}
|
|
593
593
|
|
|
594
|
-
if (!
|
|
594
|
+
if (!region.isModal && region.isOpen && isNewState) {
|
|
595
595
|
let rootHint = '';
|
|
596
|
-
if (
|
|
596
|
+
if (region.root) rootHint = `\nIt lives inside \`${region.root}\`.`;
|
|
597
597
|
context += dedent`
|
|
598
|
-
<
|
|
599
|
-
A large new
|
|
598
|
+
<region>
|
|
599
|
+
A large new region "${region.name || 'unnamed region'}" appeared on this page without navigation.${rootHint}
|
|
600
600
|
The scenario most likely continues inside this area — prefer its elements for your next actions.
|
|
601
601
|
The rest of the page (navigation, menus, filters) is still interactive and remains available.
|
|
602
|
-
</
|
|
602
|
+
</region>
|
|
603
603
|
`;
|
|
604
604
|
}
|
|
605
605
|
|
|
@@ -663,7 +663,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
663
663
|
return context;
|
|
664
664
|
}
|
|
665
665
|
|
|
666
|
-
if (
|
|
666
|
+
if (region.isOpen && region.name && this.pageStateHash && this.pageActionResult) {
|
|
667
667
|
const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
|
|
668
668
|
if (overlaySection) {
|
|
669
669
|
context += dedent`
|