explorbot 0.4.4 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/boat/prima/src/prima.ts +8 -3
- package/dist/boat/prima/src/prima.js +10 -3
- package/dist/package.json +1 -1
- package/dist/src/ai/fisherman/tools.js +7 -1
- package/dist/src/ai/fisherman.js +2 -1
- package/dist/src/ai/pilot.d.ts +0 -1
- package/dist/src/ai/pilot.js +8 -24
- package/dist/src/ai/planner.d.ts +4 -0
- package/dist/src/ai/planner.js +28 -0
- package/dist/src/ai/provider.js +3 -1
- package/dist/src/ai/rules.js +8 -7
- package/dist/src/ai/scout/tools.d.ts +17 -0
- package/dist/src/ai/scout/tools.js +130 -0
- package/dist/src/ai/scout.d.ts +21 -0
- package/dist/src/ai/scout.js +150 -0
- package/dist/src/ai/tools.js +56 -30
- package/dist/src/application-spec.d.ts +3 -0
- package/dist/src/application-spec.js +21 -5
- package/dist/src/config.d.ts +6 -1
- package/dist/src/explorbot.d.ts +3 -0
- package/dist/src/explorbot.js +33 -0
- package/dist/src/knowledge-tracker.d.ts +1 -0
- package/dist/src/knowledge-tracker.js +3 -0
- package/dist/src/utils/aria-ref.d.ts +16 -0
- package/dist/src/utils/aria-ref.js +47 -0
- package/dist/src/utils/aria.js +3 -3
- package/dist/src/utils/web-annotate.js +3 -15
- package/dist/src/utils/web-element.d.ts +0 -2
- package/dist/src/utils/web-element.js +0 -8
- package/docs/reference/configuration.md +28 -1
- package/docs/web-testing/agents.md +9 -1
- package/docs/web-testing/planner.md +5 -0
- package/docs/workflow/application-spec.md +4 -0
- package/package.json +1 -1
- package/src/ai/fisherman/tools.ts +8 -1
- package/src/ai/fisherman.ts +2 -1
- package/src/ai/pilot.ts +8 -25
- package/src/ai/planner.ts +33 -0
- package/src/ai/provider.ts +2 -1
- package/src/ai/rules.ts +8 -7
- package/src/ai/scout/tools.ts +150 -0
- package/src/ai/scout.ts +173 -0
- package/src/ai/tools.ts +69 -36
- package/src/application-spec.ts +22 -4
- package/src/config.ts +7 -0
- package/src/explorbot.ts +36 -0
- package/src/knowledge-tracker.ts +4 -0
- package/src/utils/aria-ref.ts +61 -0
- package/src/utils/aria.ts +3 -3
- package/src/utils/web-annotate.ts +3 -15
- package/src/utils/web-element.ts +0 -9
package/src/ai/tools.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { cleanHtmlSnippet } from '../utils/html.ts';
|
|
|
12
12
|
import { createDebug, tag } from '../utils/logger.js';
|
|
13
13
|
import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/strings.ts';
|
|
14
14
|
import { pause } from '../utils/loop.js';
|
|
15
|
+
import { ariaRefSelector, describeRef, refIsGone } from '../utils/aria-ref.ts';
|
|
15
16
|
import { WebElement } from '../utils/web-element.ts';
|
|
16
17
|
import type { ToolDeps } from './agent.ts';
|
|
17
18
|
import { Navigator } from './navigator.ts';
|
|
@@ -66,7 +67,6 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
|
|
|
66
67
|
2. I.click(ARIA, container) - e.g. I.click({"role":"button","text":"Save"}, ".modal")
|
|
67
68
|
3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
|
|
68
69
|
4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
|
|
69
|
-
5. I.clickXY(x, y) - coordinates fallback
|
|
70
70
|
After a result reporting multiple matches, reuse that locator with step.opts({ elementIndex: N }) as the last argument.
|
|
71
71
|
`),
|
|
72
72
|
explanation: z.string().describe('Why you are clicking this element'),
|
|
@@ -79,11 +79,21 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
|
|
|
79
79
|
return failedToolResult('click', 'No commands provided');
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
const
|
|
82
|
+
const trimmedCommands = rawCommands.map((cmd) => cmd.trim());
|
|
83
|
+
const coordinateCommands = trimmedCommands.filter((cmd) => cmd.startsWith('I.clickXY'));
|
|
84
|
+
|
|
85
|
+
if (coordinateCommands.length > 0) {
|
|
86
|
+
activeNote.commit(TestResult.FAILED);
|
|
87
|
+
return failedToolResult('click', `Coordinate commands are not locators: ${coordinateCommands.join(', ')}. A coordinate click always runs, so it cannot tell you whether the element was there.`, {
|
|
88
|
+
suggestion: 'Name the element instead. Use visualClick() when you can see the target but cannot address it, or form() for a deliberate coordinate click such as dismissing a layer.',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const invalidCommands = trimmedCommands.filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.click'));
|
|
83
93
|
|
|
84
94
|
if (invalidCommands.length > 0) {
|
|
85
95
|
activeNote.commit(TestResult.FAILED);
|
|
86
|
-
return failedToolResult('click', `Invalid commands: ${invalidCommands.join(', ')}. Click tool only accepts I.click()
|
|
96
|
+
return failedToolResult('click', `Invalid commands: ${invalidCommands.join(', ')}. Click tool only accepts I.click() commands.`, {
|
|
87
97
|
suggestion: 'Use form() tool for typing text or multiple actions, or exitIframe() to leave iframe context.',
|
|
88
98
|
});
|
|
89
99
|
}
|
|
@@ -111,6 +121,17 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
|
|
|
111
121
|
|
|
112
122
|
if (success) {
|
|
113
123
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
|
|
124
|
+
|
|
125
|
+
if (!hasObservablePageChange(toolResult)) {
|
|
126
|
+
activeNote.commit(TestResult.FAILED);
|
|
127
|
+
return failedToolResult('click', 'Click executed, but no observable page change was captured.', {
|
|
128
|
+
...toolResult,
|
|
129
|
+
attempts,
|
|
130
|
+
code: command,
|
|
131
|
+
suggestion: 'Treat the element as not clicked. It may be covered by another layer, disabled, or the locator may have matched a non-interactive ancestor. Re-locate via xpathCheck(), which reports whether the element is covered or offscreen, before retrying.',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
114
135
|
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
115
136
|
return successToolResult('click', { ...toolResult, attempts, code: command }, action);
|
|
116
137
|
}
|
|
@@ -461,10 +482,18 @@ export function createRefTools({ explorer, stateManager }: ToolDeps, task: Task)
|
|
|
461
482
|
}),
|
|
462
483
|
execute: async ({ ref, element }) => {
|
|
463
484
|
const activeNote = task.startNote(`Click ${element}`);
|
|
485
|
+
|
|
486
|
+
if (await refIsGone(explorer, ref)) {
|
|
487
|
+
activeNote.commit(TestResult.FAILED);
|
|
488
|
+
return failedToolResult('clickRef', `Ref ${ref} names no element on the page any more.`, {
|
|
489
|
+
suggestion: 'The page has been rebuilt since you were given that ref. Call context() and use the ref it gives, or fall back to click() with a locator.',
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
464
493
|
const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
|
|
465
494
|
const action = explorer.action();
|
|
466
495
|
const named = await describeRef(explorer, ref);
|
|
467
|
-
const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(
|
|
496
|
+
const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
|
|
468
497
|
|
|
469
498
|
if (!(await action.attempt(run, `Click ${element}`))) {
|
|
470
499
|
activeNote.commit(TestResult.FAILED);
|
|
@@ -1169,21 +1198,6 @@ export async function commitNote(activeNote: any, result: TestResult, toolResult
|
|
|
1169
1198
|
activeNote.commit(result);
|
|
1170
1199
|
}
|
|
1171
1200
|
|
|
1172
|
-
async function describeRef(explorer: any, ref: string): Promise<{ role: string; text: string } | null> {
|
|
1173
|
-
return Promise.resolve(
|
|
1174
|
-
explorer?.withPage?.((page: any) =>
|
|
1175
|
-
page.locator(`aria-ref=${ref}`).evaluate((el: any) => {
|
|
1176
|
-
const tag = el.tagName.toLowerCase();
|
|
1177
|
-
const roles: Record<string, string> = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
|
|
1178
|
-
const role = el.getAttribute('role') || roles[tag] || tag;
|
|
1179
|
-
const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
|
|
1180
|
-
if (!text) return null;
|
|
1181
|
-
return { role, text };
|
|
1182
|
-
})
|
|
1183
|
-
)
|
|
1184
|
-
).catch(() => null);
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
1201
|
async function hasFocusedElement(explorer: any): Promise<boolean> {
|
|
1188
1202
|
return explorer.withPage((page: any) => page.evaluate(() => !!document.activeElement && document.activeElement !== document.body)).catch(() => true);
|
|
1189
1203
|
}
|
|
@@ -1238,6 +1252,7 @@ function hasObservablePageChange(data?: Record<string, any>): boolean {
|
|
|
1238
1252
|
if (data.pageDiff.urlChanged === true) return true;
|
|
1239
1253
|
if (data.pageDiff.ariaChanges) return true;
|
|
1240
1254
|
if (data.pageDiff.messages?.length) return true;
|
|
1255
|
+
if (data.pageDiff.requests?.length) return true;
|
|
1241
1256
|
return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
|
|
1242
1257
|
}
|
|
1243
1258
|
|
|
@@ -1248,12 +1263,11 @@ export async function failedToolResult(action: string, message: string, data?: R
|
|
|
1248
1263
|
}
|
|
1249
1264
|
|
|
1250
1265
|
const errorTexts = [message, ...(data?.attempts?.map((a: any) => a.error || '') || [])];
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
result.suggestion = multipleElementsSuggestion;
|
|
1266
|
+
if (errorTexts.some((t: string) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
|
|
1267
|
+
const matched = await extractWebElements(error);
|
|
1268
|
+
result.suggestion = getMultipleElementsSuggestion(matched);
|
|
1255
1269
|
result.multipleElementsDetected = true;
|
|
1256
|
-
result.elements =
|
|
1270
|
+
result.elements = formatElementList(matched);
|
|
1257
1271
|
return result;
|
|
1258
1272
|
}
|
|
1259
1273
|
|
|
@@ -1266,11 +1280,16 @@ export async function failedToolResult(action: string, message: string, data?: R
|
|
|
1266
1280
|
return result;
|
|
1267
1281
|
}
|
|
1268
1282
|
|
|
1269
|
-
function getMultipleElementsSuggestion(): string {
|
|
1283
|
+
function getMultipleElementsSuggestion(matched: MatchedElement[] | null): string {
|
|
1284
|
+
const visible = (matched || []).filter((element) => element.visible !== false);
|
|
1285
|
+
let onlyVisible = '';
|
|
1286
|
+
if (matched && visible.length === 1) onlyVisible = `\nOnly element ${matched.indexOf(visible[0]) + 1} is on screen, so that is the one to act on.`;
|
|
1287
|
+
|
|
1270
1288
|
return dedent`
|
|
1271
1289
|
Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
|
|
1272
|
-
Read the numbered elements list and
|
|
1290
|
+
Read the numbered elements list and act on the one you meant by its number:
|
|
1273
1291
|
reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
|
|
1292
|
+
A match reported as not visible can never be acted on — pick one that is.${onlyVisible}
|
|
1274
1293
|
If none of them is the element you want, narrow the locator with a container or its full unique text.
|
|
1275
1294
|
If the list is missing, call xpathCheck() to see what the locator matches.
|
|
1276
1295
|
`;
|
|
@@ -1294,7 +1313,7 @@ export function clickFailureSuggestion(attempts: Array<{ error?: string }>): str
|
|
|
1294
1313
|
}
|
|
1295
1314
|
|
|
1296
1315
|
if (errors.some((e) => e.includes('is not visible'))) {
|
|
1297
|
-
return 'Element is in the DOM but not visible. Reveal it
|
|
1316
|
+
return 'Element is in the DOM but not visible. Reveal it — scroll to it, expand its section, open the panel holding it — or, when the page carries several copies of the same control, target the one that is on screen.';
|
|
1298
1317
|
}
|
|
1299
1318
|
|
|
1300
1319
|
if (errors.some((e) => e.includes('SyntaxError'))) {
|
|
@@ -1319,19 +1338,20 @@ const MAX_DISAMBIGUATE_TEXT = 80;
|
|
|
1319
1338
|
const MAX_DISAMBIGUATE_HTML = 300;
|
|
1320
1339
|
const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
|
|
1321
1340
|
|
|
1322
|
-
async function extractWebElements(error: Error | null | undefined): Promise<
|
|
1341
|
+
async function extractWebElements(error: Error | null | undefined): Promise<MatchedElement[] | null> {
|
|
1323
1342
|
if (!error || error.name !== 'MultipleElementsFound') return null;
|
|
1324
1343
|
|
|
1325
|
-
const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>; toOuterHTML: () => Promise<string>; getText: () => Promise<string | null> }> | undefined;
|
|
1344
|
+
const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>; toOuterHTML: () => Promise<string>; getText: () => Promise<string | null>; isVisible?: () => Promise<boolean> }> | undefined;
|
|
1326
1345
|
if (!elements?.length) return null;
|
|
1327
1346
|
|
|
1328
|
-
const result:
|
|
1347
|
+
const result: MatchedElement[] = [];
|
|
1329
1348
|
for (let i = 0; i < Math.min(elements.length, MAX_DISAMBIGUATE_ELEMENTS); i++) {
|
|
1330
1349
|
try {
|
|
1331
1350
|
const xpath = await elements[i].toAbsoluteXPath();
|
|
1332
1351
|
const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
|
|
1333
1352
|
const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
|
|
1334
|
-
|
|
1353
|
+
const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
|
|
1354
|
+
result.push({ xpath, html, text, visible });
|
|
1335
1355
|
} catch (e) {
|
|
1336
1356
|
debugLog('Failed to get details for element %d: %s', i, e);
|
|
1337
1357
|
}
|
|
@@ -1339,14 +1359,20 @@ async function extractWebElements(error: Error | null | undefined): Promise<Arra
|
|
|
1339
1359
|
return result.length > 0 ? result : null;
|
|
1340
1360
|
}
|
|
1341
1361
|
|
|
1342
|
-
function formatElementList(
|
|
1343
|
-
|
|
1362
|
+
function formatElementList(matched: MatchedElement[] | null): string {
|
|
1363
|
+
if (!matched) return 'Could not fetch element details. Repeat the action to get better info.';
|
|
1364
|
+
return matched
|
|
1365
|
+
.map((el, i) => {
|
|
1366
|
+
const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
|
|
1367
|
+
if (el.visible !== undefined) lines.push(`Visible: ${el.visible}`);
|
|
1368
|
+
lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
|
|
1369
|
+
return lines.join('\n');
|
|
1370
|
+
})
|
|
1371
|
+
.join('\n\n');
|
|
1344
1372
|
}
|
|
1345
1373
|
|
|
1346
1374
|
export async function formatMatchedElements(error: Error | null | undefined): Promise<string | null> {
|
|
1347
|
-
|
|
1348
|
-
if (!details) return 'Could not fetch element details. Repeat the action to get better info.';
|
|
1349
|
-
return formatElementList(details);
|
|
1375
|
+
return formatElementList(await extractWebElements(error));
|
|
1350
1376
|
}
|
|
1351
1377
|
|
|
1352
1378
|
function getNotFoundSuggestion(errorMessage: string): string | null {
|
|
@@ -1362,3 +1388,10 @@ function getNotFoundSuggestion(errorMessage: string): string | null {
|
|
|
1362
1388
|
4. Prefer ARIA locators: { "role": "button", "text": "visible text" }
|
|
1363
1389
|
`;
|
|
1364
1390
|
}
|
|
1391
|
+
|
|
1392
|
+
interface MatchedElement {
|
|
1393
|
+
xpath: string;
|
|
1394
|
+
html: string;
|
|
1395
|
+
text: string;
|
|
1396
|
+
visible?: boolean;
|
|
1397
|
+
}
|
package/src/application-spec.ts
CHANGED
|
@@ -17,7 +17,7 @@ export class ApplicationSpec {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
renderFor(state: ActionResult): string {
|
|
20
|
-
const relevant = this.
|
|
20
|
+
const relevant = this.relevantPages(state);
|
|
21
21
|
if (relevant.length === 0) return '';
|
|
22
22
|
|
|
23
23
|
tag('operation').log(`Found application specification for ${state.url}`);
|
|
@@ -30,6 +30,10 @@ export class ApplicationSpec {
|
|
|
30
30
|
`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
matchedUrls(state: ActionResult): string[] {
|
|
34
|
+
return this.relevantPages(state).map((page) => page.url);
|
|
35
|
+
}
|
|
36
|
+
|
|
33
37
|
get pageCount(): number {
|
|
34
38
|
return this.pages.length;
|
|
35
39
|
}
|
|
@@ -75,12 +79,26 @@ export class ApplicationSpec {
|
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
private resolveSourcePath(sourcePath: string): string {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
return resolveSpecSource(sourcePath);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private relevantPages(state: ActionResult): ApplicationSpecPage[] {
|
|
86
|
+
return this.pages.filter((page) => state.isMatchedBy({ url: page.url }));
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
export function resolveSpecBundlePath(sourcePath: string): string | null {
|
|
91
|
+
const resolved = resolveSpecSource(sourcePath);
|
|
92
|
+
if (!existsSync(resolved)) return null;
|
|
93
|
+
if (statSync(resolved).isDirectory()) return resolved;
|
|
94
|
+
return path.dirname(resolved);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolveSpecSource(sourcePath: string): string {
|
|
98
|
+
if (path.isAbsolute(sourcePath)) return path.resolve(sourcePath);
|
|
99
|
+
return path.resolve(ConfigParser.getInstance().resolveProjectDir(sourcePath));
|
|
100
|
+
}
|
|
101
|
+
|
|
84
102
|
interface ApplicationSpecPage {
|
|
85
103
|
url: string;
|
|
86
104
|
content: string;
|
package/src/config.ts
CHANGED
|
@@ -130,6 +130,11 @@ interface RerunnerAgentConfig extends AgentConfig {
|
|
|
130
130
|
interface PlannerAgentConfig extends AgentConfig {
|
|
131
131
|
styles?: string[];
|
|
132
132
|
stylesDir?: string;
|
|
133
|
+
docsWeight?: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface ScoutAgentConfig extends AgentConfig {
|
|
137
|
+
dirs?: string[];
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
interface ScreencastConfig {
|
|
@@ -154,6 +159,7 @@ interface AgentsConfig {
|
|
|
154
159
|
quartermaster?: AgentConfig;
|
|
155
160
|
historian?: HistorianAgentConfig;
|
|
156
161
|
fisherman?: AgentConfig;
|
|
162
|
+
scout?: ScoutAgentConfig;
|
|
157
163
|
chief?: AgentConfig;
|
|
158
164
|
curler?: AgentConfig;
|
|
159
165
|
rerunner?: RerunnerAgentConfig;
|
|
@@ -293,6 +299,7 @@ export type {
|
|
|
293
299
|
ResearcherAgentConfig,
|
|
294
300
|
NavigatorAgentConfig,
|
|
295
301
|
PlannerAgentConfig,
|
|
302
|
+
ScoutAgentConfig,
|
|
296
303
|
RerunnerAgentConfig,
|
|
297
304
|
HealRecipe,
|
|
298
305
|
Hook,
|
package/src/explorbot.ts
CHANGED
|
@@ -14,12 +14,15 @@ import { AIProvider } from './ai/provider.ts';
|
|
|
14
14
|
import { Quartermaster } from './ai/quartermaster.ts';
|
|
15
15
|
import { Rerunner } from './ai/rerunner.ts';
|
|
16
16
|
import { Researcher } from './ai/researcher.ts';
|
|
17
|
+
import { Scout } from './ai/scout.ts';
|
|
18
|
+
import { loadScoutCorpus } from './ai/scout/tools.ts';
|
|
17
19
|
import { SessionAnalyst } from './ai/session-analyst.ts';
|
|
18
20
|
import { Tester } from './ai/tester.ts';
|
|
19
21
|
import { createAgentTools } from './ai/tools.ts';
|
|
20
22
|
import { ApiClient } from './api/api-client.ts';
|
|
21
23
|
import { RequestStore } from './api/request-store.ts';
|
|
22
24
|
import { loadSpec } from './api/spec-reader.ts';
|
|
25
|
+
import { resolveSpecBundlePath } from './application-spec.ts';
|
|
23
26
|
import type { ExplorbotConfig, ReporterConfig } from './config.js';
|
|
24
27
|
import { ConfigParser } from './config.ts';
|
|
25
28
|
import { ExperienceTracker } from './experience-tracker.ts';
|
|
@@ -231,6 +234,8 @@ export class ExplorBot {
|
|
|
231
234
|
this.agents.planner = this.createAgent((deps) => new Planner(deps, this.agentResearcher()));
|
|
232
235
|
const fisherman = this.agentFisherman();
|
|
233
236
|
if (fisherman) this.agents.planner.setFisherman(fisherman);
|
|
237
|
+
const scout = this.agentScout();
|
|
238
|
+
if (scout) this.agents.planner.setScout(scout);
|
|
234
239
|
}
|
|
235
240
|
return this.agents.planner;
|
|
236
241
|
}
|
|
@@ -367,6 +372,37 @@ export class ExplorBot {
|
|
|
367
372
|
return this.agents.fisherman;
|
|
368
373
|
}
|
|
369
374
|
|
|
375
|
+
agentScout(): Scout | null {
|
|
376
|
+
const scoutConfig = this.config.ai?.agents?.scout;
|
|
377
|
+
if (scoutConfig?.enabled !== true) return null;
|
|
378
|
+
|
|
379
|
+
const dirs = this.scoutCorpusDirs();
|
|
380
|
+
if (dirs.length === 0) {
|
|
381
|
+
tag('warning').log('Scout enabled but no documentation found — set --spec or ai.agents.scout.dirs');
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return (this.agents.scout ||= this.createAgent(({ ai }) => new Scout(ai, loadScoutCorpus(dirs))));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private scoutCorpusDirs(): string[] {
|
|
389
|
+
const dirs: string[] = [];
|
|
390
|
+
|
|
391
|
+
const specPath = this.options.applicationSpec || this.config.dirs?.spec;
|
|
392
|
+
if (specPath) {
|
|
393
|
+
const bundle = resolveSpecBundlePath(specPath);
|
|
394
|
+
const pagesDir = bundle && path.join(bundle, 'pages');
|
|
395
|
+
if (pagesDir && existsSync(pagesDir)) dirs.push(pagesDir);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
for (const dir of this.config.ai?.agents?.scout?.dirs || []) {
|
|
399
|
+
const resolved = this.configParser.resolveProjectDir(dir);
|
|
400
|
+
if (existsSync(resolved)) dirs.push(resolved);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return [...new Set(dirs)];
|
|
404
|
+
}
|
|
405
|
+
|
|
370
406
|
getCurrentPlan(): Plan | undefined {
|
|
371
407
|
return this.currentPlan;
|
|
372
408
|
}
|
package/src/knowledge-tracker.ts
CHANGED
|
@@ -102,6 +102,10 @@ export class KnowledgeTracker {
|
|
|
102
102
|
return this.applicationSpec?.renderFor(state) || '';
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
applicationSpecUrls(state: ActionResult): string[] {
|
|
106
|
+
return this.applicationSpec?.matchedUrls(state) || [];
|
|
107
|
+
}
|
|
108
|
+
|
|
105
109
|
addKnowledge(urlPattern: string, description: string, opts?: { replace?: boolean }): { filename: string; filePath: string; isNewFile: boolean } {
|
|
106
110
|
const configParser = ConfigParser.getInstance();
|
|
107
111
|
const configPath = configParser.getConfigPath();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { WebElement } from './web-element.ts';
|
|
2
|
+
|
|
3
|
+
const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
|
|
4
|
+
const ARIA_REF_PATTERN = /^(f\d+)?e\d+$/i;
|
|
5
|
+
|
|
6
|
+
const REF_ROLES: Record<string, string> = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
|
|
7
|
+
|
|
8
|
+
export function ariaRefSelector(ref: string): string {
|
|
9
|
+
return `aria-ref=${ref}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isAriaRef(ref: string): boolean {
|
|
13
|
+
return ARIA_REF_PATTERN.test(ref);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ariaRefSnapshot(page: any): Promise<string> {
|
|
17
|
+
return page.locator('body').ariaSnapshot({ mode: 'ai' });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseAriaRefs(ariaSnapshot: string): AriaRefEntry[] {
|
|
21
|
+
const entries: AriaRefEntry[] = [];
|
|
22
|
+
for (const line of ariaSnapshot.split('\n')) {
|
|
23
|
+
const match = line.match(REF_LINE_PATTERN);
|
|
24
|
+
if (!match) continue;
|
|
25
|
+
entries.push({ role: match[2], name: match[3] || '', ref: match[4] });
|
|
26
|
+
}
|
|
27
|
+
return entries;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function elementFromAriaRef(page: any, ref: string): Promise<WebElement | null> {
|
|
31
|
+
if (!isAriaRef(ref)) return null;
|
|
32
|
+
return WebElement.fromPlaywrightLocator(page.locator(ariaRefSelector(ref)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function refIsGone(explorer: any, ref: string): Promise<boolean> {
|
|
36
|
+
const count = () => Promise.resolve(explorer?.withPage?.((page: any) => page.locator(ariaRefSelector(ref)).count())).catch(() => undefined);
|
|
37
|
+
if ((await count()) !== 0) return false;
|
|
38
|
+
await Promise.resolve(explorer?.withPage?.(ariaRefSnapshot)).catch(() => null);
|
|
39
|
+
return (await count()) === 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function describeRef(explorer: any, ref: string): Promise<{ role: string; text: string } | null> {
|
|
43
|
+
return Promise.resolve(
|
|
44
|
+
explorer?.withPage?.((page: any) =>
|
|
45
|
+
page.locator(ariaRefSelector(ref)).evaluate((el: any, roles: Record<string, string>) => {
|
|
46
|
+
const tag = el.tagName.toLowerCase();
|
|
47
|
+
const role = el.getAttribute('role') || roles[tag];
|
|
48
|
+
if (!role) return null;
|
|
49
|
+
const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
|
|
50
|
+
if (!text) return null;
|
|
51
|
+
return { role, text };
|
|
52
|
+
}, REF_ROLES)
|
|
53
|
+
)
|
|
54
|
+
).catch(() => null);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AriaRefEntry {
|
|
58
|
+
role: string;
|
|
59
|
+
name: string;
|
|
60
|
+
ref: string;
|
|
61
|
+
}
|
package/src/utils/aria.ts
CHANGED
|
@@ -431,9 +431,9 @@ const formatDiffSection = (label: string, items: string[]): string[] => {
|
|
|
431
431
|
const summary = countBy(items);
|
|
432
432
|
if (summary.size === 0) return [` ${label}: []`];
|
|
433
433
|
|
|
434
|
-
const
|
|
435
|
-
const top =
|
|
436
|
-
const rest =
|
|
434
|
+
const ordered = Array.from(summary.entries());
|
|
435
|
+
const top = ordered.slice(0, TOP_DIFF_ITEMS);
|
|
436
|
+
const rest = ordered.slice(TOP_DIFF_ITEMS);
|
|
437
437
|
|
|
438
438
|
const lines = [` ${label}:`];
|
|
439
439
|
for (const [item, count] of top) {
|
|
@@ -1,27 +1,15 @@
|
|
|
1
|
+
import { ariaRefSnapshot, parseAriaRefs } from './aria-ref.ts';
|
|
1
2
|
import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from './html.ts';
|
|
2
3
|
import { createDebug } from './logger.js';
|
|
3
4
|
import { WebElement } from './web-element.ts';
|
|
4
5
|
|
|
5
6
|
const debugLog = createDebug('explorbot:web-annotate');
|
|
6
7
|
|
|
7
|
-
const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
|
|
8
|
-
|
|
9
8
|
const ANNOTATABLE_ROLES = new Set(['button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'switch', 'combobox', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem']);
|
|
10
9
|
|
|
11
|
-
function parseAriaRefs(ariaSnapshot: string): Array<{ role: string; name: string; ref: string }> {
|
|
12
|
-
const entries: Array<{ role: string; name: string; ref: string }> = [];
|
|
13
|
-
for (const line of ariaSnapshot.split('\n')) {
|
|
14
|
-
const match = line.match(REF_LINE_PATTERN);
|
|
15
|
-
if (!match) continue;
|
|
16
|
-
if (!ANNOTATABLE_ROLES.has(match[2])) continue;
|
|
17
|
-
entries.push({ role: match[2], name: match[3] || '', ref: match[4] });
|
|
18
|
-
}
|
|
19
|
-
return entries;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
10
|
export async function annotatePageElements(page: any): Promise<{ ariaSnapshot: string; elements: WebElement[] }> {
|
|
23
|
-
const ariaSnapshot: string = await page
|
|
24
|
-
const refEntries = parseAriaRefs(ariaSnapshot);
|
|
11
|
+
const ariaSnapshot: string = await ariaRefSnapshot(page);
|
|
12
|
+
const refEntries = parseAriaRefs(ariaSnapshot).filter((entry) => ANNOTATABLE_ROLES.has(entry.role));
|
|
25
13
|
|
|
26
14
|
const byRole = new Map<string, Array<{ name: string; ref: string }>>();
|
|
27
15
|
for (const { role, name, ref } of refEntries) {
|
package/src/utils/web-element.ts
CHANGED
|
@@ -125,15 +125,6 @@ export class WebElement {
|
|
|
125
125
|
return WebElement.fromPlaywrightLocator(page.locator(`[${EXPLORBOT_ATTRS.eidx}="${eidx}"]`));
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
static isAriaRef(ref: string): boolean {
|
|
129
|
-
return /^(f\d+)?e\d+$/i.test(ref);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
static async fromAriaRef(page: any, ref: string): Promise<WebElement | null> {
|
|
133
|
-
if (!WebElement.isAriaRef(ref)) return null;
|
|
134
|
-
return WebElement.fromPlaywrightLocator(page.locator(`aria-ref=${ref}`));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
128
|
static async fromEidxList(page: any, eidxList: string[]): Promise<WebElement[]> {
|
|
138
129
|
const validEidxList = eidxList.filter((eidx) => /^e\d+$/i.test(eidx));
|
|
139
130
|
if (validEidxList.length === 0) return [];
|