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.
Files changed (51) hide show
  1. package/boat/prima/src/prima.ts +8 -3
  2. package/dist/boat/prima/src/prima.js +10 -3
  3. package/dist/package.json +1 -1
  4. package/dist/src/ai/fisherman/tools.js +7 -1
  5. package/dist/src/ai/fisherman.js +2 -1
  6. package/dist/src/ai/pilot.d.ts +0 -1
  7. package/dist/src/ai/pilot.js +8 -24
  8. package/dist/src/ai/planner.d.ts +4 -0
  9. package/dist/src/ai/planner.js +28 -0
  10. package/dist/src/ai/provider.js +3 -1
  11. package/dist/src/ai/rules.js +8 -7
  12. package/dist/src/ai/scout/tools.d.ts +17 -0
  13. package/dist/src/ai/scout/tools.js +130 -0
  14. package/dist/src/ai/scout.d.ts +21 -0
  15. package/dist/src/ai/scout.js +150 -0
  16. package/dist/src/ai/tools.js +56 -30
  17. package/dist/src/application-spec.d.ts +3 -0
  18. package/dist/src/application-spec.js +21 -5
  19. package/dist/src/config.d.ts +6 -1
  20. package/dist/src/explorbot.d.ts +3 -0
  21. package/dist/src/explorbot.js +33 -0
  22. package/dist/src/knowledge-tracker.d.ts +1 -0
  23. package/dist/src/knowledge-tracker.js +3 -0
  24. package/dist/src/utils/aria-ref.d.ts +16 -0
  25. package/dist/src/utils/aria-ref.js +47 -0
  26. package/dist/src/utils/aria.js +3 -3
  27. package/dist/src/utils/web-annotate.js +3 -15
  28. package/dist/src/utils/web-element.d.ts +0 -2
  29. package/dist/src/utils/web-element.js +0 -8
  30. package/docs/reference/configuration.md +28 -1
  31. package/docs/web-testing/agents.md +9 -1
  32. package/docs/web-testing/planner.md +5 -0
  33. package/docs/workflow/application-spec.md +4 -0
  34. package/package.json +1 -1
  35. package/src/ai/fisherman/tools.ts +8 -1
  36. package/src/ai/fisherman.ts +2 -1
  37. package/src/ai/pilot.ts +8 -25
  38. package/src/ai/planner.ts +33 -0
  39. package/src/ai/provider.ts +2 -1
  40. package/src/ai/rules.ts +8 -7
  41. package/src/ai/scout/tools.ts +150 -0
  42. package/src/ai/scout.ts +173 -0
  43. package/src/ai/tools.ts +69 -36
  44. package/src/application-spec.ts +22 -4
  45. package/src/config.ts +7 -0
  46. package/src/explorbot.ts +36 -0
  47. package/src/knowledge-tracker.ts +4 -0
  48. package/src/utils/aria-ref.ts +61 -0
  49. package/src/utils/aria.ts +3 -3
  50. package/src/utils/web-annotate.ts +3 -15
  51. package/src/utils/web-element.ts +0 -9
@@ -11,6 +11,7 @@ import { cleanHtmlSnippet } from "../utils/html.js";
11
11
  import { createDebug, tag } from '../utils/logger.js';
12
12
  import { compactErrorMessage, normalizeInlineText, truncate } from "../utils/strings.js";
13
13
  import { pause } from '../utils/loop.js';
14
+ import { ariaRefSelector, describeRef, refIsGone } from "../utils/aria-ref.js";
14
15
  import { WebElement } from "../utils/web-element.js";
15
16
  import { sectionContextRule } from "./rules.js";
16
17
  import { isInteractive } from "./task-agent.js";
@@ -52,7 +53,6 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
52
53
  2. I.click(ARIA, container) - e.g. I.click({"role":"button","text":"Save"}, ".modal")
53
54
  3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
54
55
  4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
55
- 5. I.clickXY(x, y) - coordinates fallback
56
56
  After a result reporting multiple matches, reuse that locator with step.opts({ elementIndex: N }) as the last argument.
57
57
  `),
58
58
  explanation: z.string().describe('Why you are clicking this element'),
@@ -63,10 +63,18 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
63
63
  activeNote.commit(TestResult.FAILED);
64
64
  return failedToolResult('click', 'No commands provided');
65
65
  }
66
- const invalidCommands = rawCommands.map((cmd) => cmd.trim()).filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.click'));
66
+ const trimmedCommands = rawCommands.map((cmd) => cmd.trim());
67
+ const coordinateCommands = trimmedCommands.filter((cmd) => cmd.startsWith('I.clickXY'));
68
+ if (coordinateCommands.length > 0) {
69
+ activeNote.commit(TestResult.FAILED);
70
+ 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.`, {
71
+ 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.',
72
+ });
73
+ }
74
+ const invalidCommands = trimmedCommands.filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.click'));
67
75
  if (invalidCommands.length > 0) {
68
76
  activeNote.commit(TestResult.FAILED);
69
- return failedToolResult('click', `Invalid commands: ${invalidCommands.join(', ')}. Click tool only accepts I.click() or I.clickXY() commands.`, {
77
+ return failedToolResult('click', `Invalid commands: ${invalidCommands.join(', ')}. Click tool only accepts I.click() commands.`, {
70
78
  suggestion: 'Use form() tool for typing text or multiple actions, or exitIframe() to leave iframe context.',
71
79
  });
72
80
  }
@@ -91,6 +99,15 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
91
99
  ambiguityError = action.lastError;
92
100
  if (success) {
93
101
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, command);
102
+ if (!hasObservablePageChange(toolResult)) {
103
+ activeNote.commit(TestResult.FAILED);
104
+ return failedToolResult('click', 'Click executed, but no observable page change was captured.', {
105
+ ...toolResult,
106
+ attempts,
107
+ code: command,
108
+ 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.',
109
+ });
110
+ }
94
111
  await commitNote(activeNote, TestResult.PASSED, toolResult, action);
95
112
  return successToolResult('click', { ...toolResult, attempts, code: command }, action);
96
113
  }
@@ -385,10 +402,16 @@ export function createRefTools({ explorer, stateManager }, task) {
385
402
  }),
386
403
  execute: async ({ ref, element }) => {
387
404
  const activeNote = task.startNote(`Click ${element}`);
405
+ if (await refIsGone(explorer, ref)) {
406
+ activeNote.commit(TestResult.FAILED);
407
+ return failedToolResult('clickRef', `Ref ${ref} names no element on the page any more.`, {
408
+ 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.',
409
+ });
410
+ }
388
411
  const previousState = ActionResult.fromState(stateManager.getCurrentState());
389
412
  const action = explorer.action();
390
413
  const named = await describeRef(explorer, ref);
391
- const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
414
+ const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
392
415
  if (!(await action.attempt(run, `Click ${element}`))) {
393
416
  activeNote.commit(TestResult.FAILED);
394
417
  return failedToolResult('clickRef', `Ref ${ref} could not be clicked: ${errorText(action.lastError)}`, {
@@ -1015,17 +1038,6 @@ export async function commitNote(activeNote, result, toolResult, action) {
1015
1038
  }
1016
1039
  activeNote.commit(result);
1017
1040
  }
1018
- async function describeRef(explorer, ref) {
1019
- return Promise.resolve(explorer?.withPage?.((page) => page.locator(`aria-ref=${ref}`).evaluate((el) => {
1020
- const tag = el.tagName.toLowerCase();
1021
- const roles = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
1022
- const role = el.getAttribute('role') || roles[tag] || tag;
1023
- const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
1024
- if (!text)
1025
- return null;
1026
- return { role, text };
1027
- }))).catch(() => null);
1028
- }
1029
1041
  async function hasFocusedElement(explorer) {
1030
1042
  return explorer.withPage((page) => page.evaluate(() => !!document.activeElement && document.activeElement !== document.body)).catch(() => true);
1031
1043
  }
@@ -1085,6 +1097,8 @@ function hasObservablePageChange(data) {
1085
1097
  return true;
1086
1098
  if (data.pageDiff.messages?.length)
1087
1099
  return true;
1100
+ if (data.pageDiff.requests?.length)
1101
+ return true;
1088
1102
  return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1089
1103
  }
1090
1104
  export async function failedToolResult(action, message, data, error) {
@@ -1093,12 +1107,11 @@ export async function failedToolResult(action, message, data, error) {
1093
1107
  result.suggestion = data.suggestion ? `${data.suggestion} ${PAGE_DIFF_SUGGESTION}` : PAGE_DIFF_SUGGESTION;
1094
1108
  }
1095
1109
  const errorTexts = [message, ...(data?.attempts?.map((a) => a.error || '') || [])];
1096
- const hasMultipleElements = errorTexts.some((t) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN));
1097
- const multipleElementsSuggestion = hasMultipleElements ? getMultipleElementsSuggestion() : null;
1098
- if (multipleElementsSuggestion) {
1099
- result.suggestion = multipleElementsSuggestion;
1110
+ if (errorTexts.some((t) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
1111
+ const matched = await extractWebElements(error);
1112
+ result.suggestion = getMultipleElementsSuggestion(matched);
1100
1113
  result.multipleElementsDetected = true;
1101
- result.elements = await formatMatchedElements(error);
1114
+ result.elements = formatElementList(matched);
1102
1115
  return result;
1103
1116
  }
1104
1117
  const notFoundSuggestion = getNotFoundSuggestion(message);
@@ -1108,11 +1121,16 @@ export async function failedToolResult(action, message, data, error) {
1108
1121
  }
1109
1122
  return result;
1110
1123
  }
1111
- function getMultipleElementsSuggestion() {
1124
+ function getMultipleElementsSuggestion(matched) {
1125
+ const visible = (matched || []).filter((element) => element.visible !== false);
1126
+ let onlyVisible = '';
1127
+ if (matched && visible.length === 1)
1128
+ onlyVisible = `\nOnly element ${matched.indexOf(visible[0]) + 1} is on screen, so that is the one to act on.`;
1112
1129
  return dedent `
1113
1130
  Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
1114
- Read the numbered elements list and click the one you meant by its number:
1131
+ Read the numbered elements list and act on the one you meant by its number:
1115
1132
  reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
1133
+ A match reported as not visible can never be acted on — pick one that is.${onlyVisible}
1116
1134
  If none of them is the element you want, narrow the locator with a container or its full unique text.
1117
1135
  If the list is missing, call xpathCheck() to see what the locator matches.
1118
1136
  `;
@@ -1132,7 +1150,7 @@ export function clickFailureSuggestion(attempts) {
1132
1150
  return 'Element exists but another element covers it. Close the overlapping panel or dialog, then retry.';
1133
1151
  }
1134
1152
  if (errors.some((e) => e.includes('is not visible'))) {
1135
- return 'Element is in the DOM but not visible. Reveal it first — scroll to it, expand its section, or open the panel holding it.';
1153
+ 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.';
1136
1154
  }
1137
1155
  if (errors.some((e) => e.includes('SyntaxError'))) {
1138
1156
  return 'The command string never parsed as JavaScript — quotes or brackets do not match. No element was looked up, so this tells you nothing about the page. Re-emit the same intent as valid CodeceptJS.';
@@ -1162,7 +1180,8 @@ async function extractWebElements(error) {
1162
1180
  const xpath = await elements[i].toAbsoluteXPath();
1163
1181
  const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
1164
1182
  const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
1165
- result.push({ xpath, html, text });
1183
+ const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
1184
+ result.push({ xpath, html, text, visible });
1166
1185
  }
1167
1186
  catch (e) {
1168
1187
  debugLog('Failed to get details for element %d: %s', i, e);
@@ -1170,14 +1189,21 @@ async function extractWebElements(error) {
1170
1189
  }
1171
1190
  return result.length > 0 ? result : null;
1172
1191
  }
1173
- function formatElementList(details) {
1174
- return details.map((el, i) => `Element ${i + 1}:\nText: "${el.text}"\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1192
+ function formatElementList(matched) {
1193
+ if (!matched)
1194
+ return 'Could not fetch element details. Repeat the action to get better info.';
1195
+ return matched
1196
+ .map((el, i) => {
1197
+ const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
1198
+ if (el.visible !== undefined)
1199
+ lines.push(`Visible: ${el.visible}`);
1200
+ lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
1201
+ return lines.join('\n');
1202
+ })
1203
+ .join('\n\n');
1175
1204
  }
1176
1205
  export async function formatMatchedElements(error) {
1177
- const details = await extractWebElements(error);
1178
- if (!details)
1179
- return 'Could not fetch element details. Repeat the action to get better info.';
1180
- return formatElementList(details);
1206
+ return formatElementList(await extractWebElements(error));
1181
1207
  }
1182
1208
  function getNotFoundSuggestion(errorMessage) {
1183
1209
  if (!errorMessage.includes('not found')) {
@@ -4,10 +4,13 @@ export declare class ApplicationSpec {
4
4
  readonly sourcePath: string;
5
5
  constructor(sourcePath: string);
6
6
  renderFor(state: ActionResult): string;
7
+ matchedUrls(state: ActionResult): string[];
7
8
  get pageCount(): number;
8
9
  load(): void;
9
10
  resolveSourcePath(sourcePath: string): string;
11
+ relevantPages(state: ActionResult): ApplicationSpecPage[];
10
12
  }
13
+ export declare function resolveSpecBundlePath(sourcePath: string): string | null;
11
14
  interface ApplicationSpecPage {
12
15
  url: string;
13
16
  content: string;
@@ -13,7 +13,7 @@ export class ApplicationSpec {
13
13
  this.load();
14
14
  }
15
15
  renderFor(state) {
16
- const relevant = this.pages.filter((page) => state.isMatchedBy({ url: page.url }));
16
+ const relevant = this.relevantPages(state);
17
17
  if (relevant.length === 0)
18
18
  return '';
19
19
  tag('operation').log(`Found application specification for ${state.url}`);
@@ -25,6 +25,9 @@ export class ApplicationSpec {
25
25
  </application_spec>
26
26
  `;
27
27
  }
28
+ matchedUrls(state) {
29
+ return this.relevantPages(state).map((page) => page.url);
30
+ }
28
31
  get pageCount() {
29
32
  return this.pages.length;
30
33
  }
@@ -63,9 +66,22 @@ export class ApplicationSpec {
63
66
  }
64
67
  }
65
68
  resolveSourcePath(sourcePath) {
66
- if (path.isAbsolute(sourcePath))
67
- return path.resolve(sourcePath);
68
- const configParser = ConfigParser.getInstance();
69
- return path.resolve(configParser.resolveProjectDir(sourcePath));
69
+ return resolveSpecSource(sourcePath);
70
+ }
71
+ relevantPages(state) {
72
+ return this.pages.filter((page) => state.isMatchedBy({ url: page.url }));
70
73
  }
71
74
  }
75
+ export function resolveSpecBundlePath(sourcePath) {
76
+ const resolved = resolveSpecSource(sourcePath);
77
+ if (!existsSync(resolved))
78
+ return null;
79
+ if (statSync(resolved).isDirectory())
80
+ return resolved;
81
+ return path.dirname(resolved);
82
+ }
83
+ function resolveSpecSource(sourcePath) {
84
+ if (path.isAbsolute(sourcePath))
85
+ return path.resolve(sourcePath);
86
+ return path.resolve(ConfigParser.getInstance().resolveProjectDir(sourcePath));
87
+ }
@@ -105,6 +105,10 @@ interface RerunnerAgentConfig extends AgentConfig {
105
105
  interface PlannerAgentConfig extends AgentConfig {
106
106
  styles?: string[];
107
107
  stylesDir?: string;
108
+ docsWeight?: number;
109
+ }
110
+ interface ScoutAgentConfig extends AgentConfig {
111
+ dirs?: string[];
108
112
  }
109
113
  interface ScreencastConfig {
110
114
  size?: {
@@ -129,6 +133,7 @@ interface AgentsConfig {
129
133
  quartermaster?: AgentConfig;
130
134
  historian?: HistorianAgentConfig;
131
135
  fisherman?: AgentConfig;
136
+ scout?: ScoutAgentConfig;
132
137
  chief?: AgentConfig;
133
138
  curler?: AgentConfig;
134
139
  rerunner?: RerunnerAgentConfig;
@@ -221,7 +226,7 @@ interface ExplorbotConfig {
221
226
  type RuleEntry = string | Record<string, string>;
222
227
  export declare const EXPLORBOT_CONFIG_PATHS: string[];
223
228
  export declare const EXPLORBOT_ENV_VARS: EnvVar[];
224
- export type { ExplorbotConfig, PlaywrightConfig, AIConfig, HtmlConfig, ActionConfig, AgentConfig, AgentsConfig, HistorianAgentConfig, ResearcherAgentConfig, NavigatorAgentConfig, PlannerAgentConfig, RerunnerAgentConfig, HealRecipe, Hook, HookConfig, HooksConfig, PlaywrightHook, CodeceptJSHook, HookPatternMap, RuleEntry, ReporterConfig, ApiConfig, WebConfig, ApiHookFn, };
229
+ export type { ExplorbotConfig, PlaywrightConfig, AIConfig, HtmlConfig, ActionConfig, AgentConfig, AgentsConfig, HistorianAgentConfig, ResearcherAgentConfig, NavigatorAgentConfig, PlannerAgentConfig, ScoutAgentConfig, RerunnerAgentConfig, HealRecipe, Hook, HookConfig, HooksConfig, PlaywrightHook, CodeceptJSHook, HookPatternMap, RuleEntry, ReporterConfig, ApiConfig, WebConfig, ApiHookFn, };
225
230
  export declare class ConfigParser {
226
231
  static instance: ConfigParser;
227
232
  static recommended: Record<string, Record<string, string>> | null;
@@ -12,6 +12,7 @@ import { AIProvider } from './ai/provider.js';
12
12
  import { Quartermaster } from './ai/quartermaster.js';
13
13
  import { Rerunner } from './ai/rerunner.js';
14
14
  import { Researcher } from './ai/researcher.js';
15
+ import { Scout } from './ai/scout.js';
15
16
  import { SessionAnalyst } from './ai/session-analyst.js';
16
17
  import { Tester } from './ai/tester.js';
17
18
  import { RequestStore } from './api/request-store.js';
@@ -99,6 +100,8 @@ export declare class ExplorBot {
99
100
  agentDriller(): Driller;
100
101
  agentSessionAnalyst(): SessionAnalyst;
101
102
  agentFisherman(): Fisherman | null;
103
+ agentScout(): Scout | null;
104
+ scoutCorpusDirs(): string[];
102
105
  getCurrentPlan(): Plan | undefined;
103
106
  getSuite(): Suite | null;
104
107
  getPlanFeature(): string | undefined;
@@ -12,12 +12,15 @@ import { AIProvider } from "./ai/provider.js";
12
12
  import { Quartermaster } from "./ai/quartermaster.js";
13
13
  import { Rerunner } from "./ai/rerunner.js";
14
14
  import { Researcher } from "./ai/researcher.js";
15
+ import { Scout } from "./ai/scout.js";
16
+ import { loadScoutCorpus } from "./ai/scout/tools.js";
15
17
  import { SessionAnalyst } from "./ai/session-analyst.js";
16
18
  import { Tester } from "./ai/tester.js";
17
19
  import { createAgentTools } from "./ai/tools.js";
18
20
  import { ApiClient } from "./api/api-client.js";
19
21
  import { RequestStore } from "./api/request-store.js";
20
22
  import { loadSpec } from "./api/spec-reader.js";
23
+ import { resolveSpecBundlePath } from "./application-spec.js";
21
24
  import { ConfigParser } from "./config.js";
22
25
  import { ExperienceTracker } from "./experience-tracker.js";
23
26
  import Explorer from "./explorer.js";
@@ -184,6 +187,9 @@ export class ExplorBot {
184
187
  const fisherman = this.agentFisherman();
185
188
  if (fisherman)
186
189
  this.agents.planner.setFisherman(fisherman);
190
+ const scout = this.agentScout();
191
+ if (scout)
192
+ this.agents.planner.setScout(scout);
187
193
  }
188
194
  return this.agents.planner;
189
195
  }
@@ -310,6 +316,33 @@ export class ExplorBot {
310
316
  }
311
317
  return this.agents.fisherman;
312
318
  }
319
+ agentScout() {
320
+ const scoutConfig = this.config.ai?.agents?.scout;
321
+ if (scoutConfig?.enabled !== true)
322
+ return null;
323
+ const dirs = this.scoutCorpusDirs();
324
+ if (dirs.length === 0) {
325
+ tag('warning').log('Scout enabled but no documentation found — set --spec or ai.agents.scout.dirs');
326
+ return null;
327
+ }
328
+ return (this.agents.scout ||= this.createAgent(({ ai }) => new Scout(ai, loadScoutCorpus(dirs))));
329
+ }
330
+ scoutCorpusDirs() {
331
+ const dirs = [];
332
+ const specPath = this.options.applicationSpec || this.config.dirs?.spec;
333
+ if (specPath) {
334
+ const bundle = resolveSpecBundlePath(specPath);
335
+ const pagesDir = bundle && path.join(bundle, 'pages');
336
+ if (pagesDir && existsSync(pagesDir))
337
+ dirs.push(pagesDir);
338
+ }
339
+ for (const dir of this.config.ai?.agents?.scout?.dirs || []) {
340
+ const resolved = this.configParser.resolveProjectDir(dir);
341
+ if (existsSync(resolved))
342
+ dirs.push(resolved);
343
+ }
344
+ return [...new Set(dirs)];
345
+ }
313
346
  getCurrentPlan() {
314
347
  return this.currentPlan;
315
348
  }
@@ -16,6 +16,7 @@ export declare class KnowledgeTracker {
16
16
  renderEndpointKnowledge(endpoint: string): string;
17
17
  renderRelevantContext(state: ActionResult): string;
18
18
  renderApplicationSpec(state: ActionResult): string;
19
+ applicationSpecUrls(state: ActionResult): string[];
19
20
  addKnowledge(urlPattern: string, description: string, opts?: {
20
21
  replace?: boolean;
21
22
  }): {
@@ -79,6 +79,9 @@ export class KnowledgeTracker {
79
79
  renderApplicationSpec(state) {
80
80
  return this.applicationSpec?.renderFor(state) || '';
81
81
  }
82
+ applicationSpecUrls(state) {
83
+ return this.applicationSpec?.matchedUrls(state) || [];
84
+ }
82
85
  addKnowledge(urlPattern, description, opts) {
83
86
  const configParser = ConfigParser.getInstance();
84
87
  const configPath = configParser.getConfigPath();
@@ -0,0 +1,16 @@
1
+ import { WebElement } from './web-element.js';
2
+ export declare function ariaRefSelector(ref: string): string;
3
+ export declare function isAriaRef(ref: string): boolean;
4
+ export declare function ariaRefSnapshot(page: any): Promise<string>;
5
+ export declare function parseAriaRefs(ariaSnapshot: string): AriaRefEntry[];
6
+ export declare function elementFromAriaRef(page: any, ref: string): Promise<WebElement | null>;
7
+ export declare function refIsGone(explorer: any, ref: string): Promise<boolean>;
8
+ export declare function describeRef(explorer: any, ref: string): Promise<{
9
+ role: string;
10
+ text: string;
11
+ } | null>;
12
+ export interface AriaRefEntry {
13
+ role: string;
14
+ name: string;
15
+ ref: string;
16
+ }
@@ -0,0 +1,47 @@
1
+ import { WebElement } from "./web-element.js";
2
+ const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
3
+ const ARIA_REF_PATTERN = /^(f\d+)?e\d+$/i;
4
+ const REF_ROLES = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
5
+ export function ariaRefSelector(ref) {
6
+ return `aria-ref=${ref}`;
7
+ }
8
+ export function isAriaRef(ref) {
9
+ return ARIA_REF_PATTERN.test(ref);
10
+ }
11
+ export function ariaRefSnapshot(page) {
12
+ return page.locator('body').ariaSnapshot({ mode: 'ai' });
13
+ }
14
+ export function parseAriaRefs(ariaSnapshot) {
15
+ const entries = [];
16
+ for (const line of ariaSnapshot.split('\n')) {
17
+ const match = line.match(REF_LINE_PATTERN);
18
+ if (!match)
19
+ continue;
20
+ entries.push({ role: match[2], name: match[3] || '', ref: match[4] });
21
+ }
22
+ return entries;
23
+ }
24
+ export async function elementFromAriaRef(page, ref) {
25
+ if (!isAriaRef(ref))
26
+ return null;
27
+ return WebElement.fromPlaywrightLocator(page.locator(ariaRefSelector(ref)));
28
+ }
29
+ export async function refIsGone(explorer, ref) {
30
+ const count = () => Promise.resolve(explorer?.withPage?.((page) => page.locator(ariaRefSelector(ref)).count())).catch(() => undefined);
31
+ if ((await count()) !== 0)
32
+ return false;
33
+ await Promise.resolve(explorer?.withPage?.(ariaRefSnapshot)).catch(() => null);
34
+ return (await count()) === 0;
35
+ }
36
+ export async function describeRef(explorer, ref) {
37
+ return Promise.resolve(explorer?.withPage?.((page) => page.locator(ariaRefSelector(ref)).evaluate((el, roles) => {
38
+ const tag = el.tagName.toLowerCase();
39
+ const role = el.getAttribute('role') || roles[tag];
40
+ if (!role)
41
+ return null;
42
+ const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
43
+ if (!text)
44
+ return null;
45
+ return { role, text };
46
+ }, REF_ROLES))).catch(() => null);
47
+ }
@@ -434,9 +434,9 @@ const formatDiffSection = (label, items) => {
434
434
  const summary = countBy(items);
435
435
  if (summary.size === 0)
436
436
  return [` ${label}: []`];
437
- const sorted = Array.from(summary.entries()).sort(([aItem, aCount], [bItem, bCount]) => bCount - aCount || aItem.localeCompare(bItem));
438
- const top = sorted.slice(0, TOP_DIFF_ITEMS);
439
- const rest = sorted.slice(TOP_DIFF_ITEMS);
437
+ const ordered = Array.from(summary.entries());
438
+ const top = ordered.slice(0, TOP_DIFF_ITEMS);
439
+ const rest = ordered.slice(TOP_DIFF_ITEMS);
440
440
  const lines = [` ${label}:`];
441
441
  for (const [item, count] of top) {
442
442
  let suffix = '';
@@ -1,24 +1,12 @@
1
+ import { ariaRefSnapshot, parseAriaRefs } from "./aria-ref.js";
1
2
  import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from "./html.js";
2
3
  import { createDebug } from './logger.js';
3
4
  import { WebElement } from "./web-element.js";
4
5
  const debugLog = createDebug('explorbot:web-annotate');
5
- const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
6
6
  const ANNOTATABLE_ROLES = new Set(['button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'switch', 'combobox', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem']);
7
- function parseAriaRefs(ariaSnapshot) {
8
- const entries = [];
9
- for (const line of ariaSnapshot.split('\n')) {
10
- const match = line.match(REF_LINE_PATTERN);
11
- if (!match)
12
- continue;
13
- if (!ANNOTATABLE_ROLES.has(match[2]))
14
- continue;
15
- entries.push({ role: match[2], name: match[3] || '', ref: match[4] });
16
- }
17
- return entries;
18
- }
19
7
  export async function annotatePageElements(page) {
20
- const ariaSnapshot = await page.locator('body').ariaSnapshot({ mode: 'ai' });
21
- const refEntries = parseAriaRefs(ariaSnapshot);
8
+ const ariaSnapshot = await ariaRefSnapshot(page);
9
+ const refEntries = parseAriaRefs(ariaSnapshot).filter((entry) => ANNOTATABLE_ROLES.has(entry.role));
22
10
  const byRole = new Map();
23
11
  for (const { role, name, ref } of refEntries) {
24
12
  let list = byRole.get(role);
@@ -36,8 +36,6 @@ export declare class WebElement {
36
36
  static fromXPathMatch(m: XPathMatch): WebElement;
37
37
  static fromPlaywrightLocator(locator: any): Promise<WebElement | null>;
38
38
  static fromEidx(page: any, eidx: string): Promise<WebElement | null>;
39
- static isAriaRef(ref: string): boolean;
40
- static fromAriaRef(page: any, ref: string): Promise<WebElement | null>;
41
39
  static fromEidxList(page: any, eidxList: string[]): Promise<WebElement[]>;
42
40
  static commonAncestor(page: any, eidxList: string[]): Promise<WebElement | null>;
43
41
  static findByXPath(html: string, xpath: string): Promise<{
@@ -111,14 +111,6 @@ export class WebElement {
111
111
  static async fromEidx(page, eidx) {
112
112
  return WebElement.fromPlaywrightLocator(page.locator(`[${EXPLORBOT_ATTRS.eidx}="${eidx}"]`));
113
113
  }
114
- static isAriaRef(ref) {
115
- return /^(f\d+)?e\d+$/i.test(ref);
116
- }
117
- static async fromAriaRef(page, ref) {
118
- if (!WebElement.isAriaRef(ref))
119
- return null;
120
- return WebElement.fromPlaywrightLocator(page.locator(`aria-ref=${ref}`));
121
- }
122
114
  static async fromEidxList(page, eidxList) {
123
115
  const validEidxList = eidxList.filter((eidx) => /^e\d+$/i.test(eidx));
124
116
  if (validEidxList.length === 0)
@@ -179,6 +179,7 @@ Each agent takes its own model and system prompt.
179
179
  | `rerunner` | Heals failing steps when re-running generated tests |
180
180
  | `analyst` | Writes the end-of-session markdown report |
181
181
  | `fisherman` | Prepares test data through API requests |
182
+ | `scout` | Retrieves relevant documentation for the Planner |
182
183
  | `chief` | API test planning |
183
184
  | `curler` | API test execution |
184
185
 
@@ -206,7 +207,7 @@ agents: {
206
207
  | `beforeHook` | `Hook \| HookPatternMap` | Code to run before agent execution |
207
208
  | `afterHook` | `Hook \| HookPatternMap` | Code to run after agent execution |
208
209
 
209
- Some agents take extra options: `pilot` accepts `stepsToReview` (recent steps reviewed per check, default 5); `planner` accepts `styles` (see [Planning styles](#planning-styles)); `rerunner` accepts `healLimit` (max heal attempts, default 3) and `recipes` (custom heal recipes, see [Rerunning Tests](../web-testing/rerun.md)). Researcher and Historian options are documented below.
210
+ Some agents take extra options: `pilot` accepts `stepsToReview` (recent steps reviewed per check, default 5); `planner` accepts `styles` (see [Planning styles](#planning-styles)) and `docsWeight` (share of scenarios grounded in documentation when Scout is enabled, default 70); `scout` accepts `dirs` (see [Scout agent](#scout-agent)); `rerunner` accepts `healLimit` (max heal attempts, default 3) and `recipes` (custom heal recipes, see [Rerunning Tests](../web-testing/rerun.md)). Researcher and Historian options are documented below.
210
211
 
211
212
  See [Agent hooks](../web-testing/hooks.md) for hook configuration.
212
213
 
@@ -257,6 +258,31 @@ See [AI providers](../basics/providers.md) for recommended models and provider s
257
258
 
258
259
  Fisherman prepares test data over the API before a scenario runs, and can also answer questions about data that already exists without creating or changing anything. Pilot reaches this read-only capability through its `askApi(question)` tool, calling it to check whether suitable data is already there — or to get the exact name or id of an existing record — before deciding whether to create anything through `precondition()`. In replicate mode, where Fisherman learns the API by watching browser traffic instead of reading a spec, the read endpoints it can query come from successful GET requests observed in the browser, alongside the write endpoints already captured from XHR traffic. The endpoint list shown to the model names only the path and its query-parameter names, never their values; the underlying capture on disk holds the full request URL and headers — what write captures already hold — but no response body.
259
260
 
261
+ ### Scout agent
262
+
263
+ Scout retrieves documentation relevant to the page being planned and hands it to the Planner as a `<docs_context>` block, so scenarios can be grounded in what the application documents say. It is opt-in and needs documentation collected beforehand:
264
+
265
+ ```javascript
266
+ ai: {
267
+ agents: {
268
+ scout: {
269
+ enabled: true, // Opt in — Scout never runs without this
270
+ dirs: ['docs'], // Extra markdown directories to search, beyond the spec bundle
271
+ },
272
+ planner: {
273
+ docsWeight: 70, // Roughly 70% of scenarios exercise documented behavior, the rest explore beyond it
274
+ },
275
+ },
276
+ },
277
+ ```
278
+
279
+ | Option | Type | Description |
280
+ |--------|------|-------------|
281
+ | `enabled` | `boolean` | Turn Scout on. Default: off. |
282
+ | `dirs` | `string[]` | Markdown directories added to the corpus, resolved relative to the project |
283
+
284
+ The corpus combines the [application spec](../workflow/application-spec.md) bundle (`--spec` / `EXPLORBOT_SPEC` / `dirs.spec`, set by `explorbot docs collect`) with the `dirs` above. Scout scans it with the same `bash` + `readFile` tools Captain uses: the corpus is loaded into an in-memory sandbox and the model itself runs `rg` (or `grep` — whichever is installed) to explore it. One of the two must be on PATH — Scout fails loudly when neither is found. Pages already injected for the current URL as `<application_spec>` are excluded from the Scout corpus, so the two blocks never duplicate each other. Files under `dirs` that carry no page URL are listed as hand-written notes for Scout to inspect when they are relevant.
285
+
260
286
  ## Playwright settings
261
287
 
262
288
  ### Browser selection
@@ -512,6 +538,7 @@ export default {
512
538
  quartermaster: { /* ... */ },
513
539
  historian: { /* ... */ },
514
540
  fisherman: { /* ... */ },
541
+ scout: { enabled: true, dirs: ['docs'] }, // Documentation retrieval for the Planner
515
542
  rerunner: { /* ... */ },
516
543
  analyst: { /* ... */ },
517
544
  },
@@ -44,7 +44,7 @@ See [Researcher Agent](./researcher.md) for configuration and usage.
44
44
 
45
45
  Generates test scenarios from research findings.
46
46
 
47
- The Planner writes business-focused scenarios with priority levels (critical/important/high/normal/low) and expected outcomes for verification. It balances positive and negative cases, skips scenarios you already have, and cycles through planning styles (normal, psycho, curious) to broaden coverage across iterations. You can add your own styles and page-specific rules.
47
+ The Planner writes business-focused scenarios with priority levels (critical/important/high/normal/low) and expected outcomes for verification. It balances positive and negative cases, skips scenarios you already have, and cycles through planning styles (normal, psycho, curious) to broaden coverage across iterations. You can add your own styles and page-specific rules. With the Scout agent enabled, it also plans from collected documentation, weighted by `docsWeight`.
48
48
 
49
49
  Commands that use Planner:
50
50
  - `/plan [--focus <feature>]`
@@ -52,6 +52,14 @@ Commands that use Planner:
52
52
 
53
53
  See [Planner Agent](./planner.md) for planning styles, customization, and configuration.
54
54
 
55
+ ## Scout Agent
56
+
57
+ Retrieves documentation relevant to the page being planned.
58
+
59
+ Scout searches the collected documentation corpus — the [application spec](../workflow/application-spec.md) from `explorbot docs collect` plus any extra markdown directories you configure — and reports the documented capabilities, states and transitions that matter for the current page and focus. The Planner receives them as a `<docs_context>` block and grounds part of its scenarios in them. Pages already injected as `<application_spec>` for the current URL are not repeated. Scout is opt-in (`ai.agents.scout.enabled`) and searches with ripgrep or grep, falling back to an in-process scan when neither is installed.
60
+
61
+ See [Configuration: Scout agent](../reference/configuration.md#scout-agent).
62
+
55
63
  ## Tester Agent
56
64
 
57
65
  Runs the planned scenarios.
@@ -53,6 +53,11 @@ ai: {
53
53
  | `styles` | `string[]` | `['normal', 'curious', 'psycho']` | Style names and cycling order |
54
54
  | `rules` | `RuleEntry[]` | `[]` | URL-aware rule files from `rules/planner/` |
55
55
  | `systemPrompt` | `string` | - | Inline instructions appended to the prompt |
56
+ | `docsWeight` | `number` | `70` | With Scout enabled, the rough share of scenarios exercising documented behavior; the rest explore beyond the documentation |
57
+
58
+ ## Planning from documentation
59
+
60
+ With the [Scout agent](../reference/configuration.md#scout-agent) enabled, the Planner also receives a `<docs_context>` block — capabilities, states and transitions retrieved from collected documentation that are relevant to the current page and focus. `docsWeight` steers the mix: at `70` roughly seven of ten scenarios exercise documented behavior and three explore what the documentation does not cover. Set it to `100` for documentation-only planning, or lower it to lean on the Planner's own reading of the page. Pages whose documentation is already injected as `<application_spec>` are not repeated in `<docs_context>`.
56
61
 
57
62
  ## Planning Styles
58
63
 
@@ -75,3 +75,7 @@ Screenshots and other relative links may be included for readers, but Explorbot
75
75
  ## Validation
76
76
 
77
77
  Explorbot rejects a bundle when `index.md` or `pages/` is missing, when it contains no page files, or when a page has an unsupported format, version, or missing URL.
78
+
79
+ ## Scout
80
+
81
+ Beyond the per-URL injection, the same bundle feeds the [Scout agent](../reference/configuration.md#scout-agent): when Scout is enabled, it searches `pages/` (and any extra `ai.agents.scout.dirs`) for documentation relevant to the page being planned and reports it to the Planner. Pages already injected for the current URL are excluded from scouting, so the two channels never duplicate each other.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",