explorbot 0.4.3 → 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 (87) hide show
  1. package/boat/api-tester/src/apibot.ts +8 -13
  2. package/boat/api-tester/src/cli.ts +7 -3
  3. package/boat/api-tester/src/config.ts +45 -9
  4. package/boat/prima/src/cli.ts +33 -99
  5. package/boat/prima/src/envelope.ts +3 -1
  6. package/boat/prima/src/help.ts +72 -0
  7. package/boat/prima/src/prima.ts +41 -46
  8. package/dist/boat/api-tester/src/apibot.js +7 -6
  9. package/dist/boat/api-tester/src/cli.js +9 -3
  10. package/dist/boat/api-tester/src/config.js +32 -6
  11. package/dist/boat/prima/src/cli.js +30 -86
  12. package/dist/boat/prima/src/envelope.js +2 -1
  13. package/dist/boat/prima/src/help.js +63 -0
  14. package/dist/boat/prima/src/prima.js +39 -44
  15. package/dist/package.json +1 -1
  16. package/dist/src/action-result.d.ts +3 -0
  17. package/dist/src/action-result.js +5 -0
  18. package/dist/src/action.js +12 -1
  19. package/dist/src/ai/fisherman/tools.js +7 -1
  20. package/dist/src/ai/fisherman.js +2 -1
  21. package/dist/src/ai/pilot.d.ts +0 -1
  22. package/dist/src/ai/pilot.js +8 -24
  23. package/dist/src/ai/planner.d.ts +4 -0
  24. package/dist/src/ai/planner.js +28 -0
  25. package/dist/src/ai/provider.js +3 -1
  26. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  27. package/dist/src/ai/researcher/deep-analysis.js +4 -1
  28. package/dist/src/ai/researcher/sections.d.ts +1 -1
  29. package/dist/src/ai/researcher/sections.js +2 -1
  30. package/dist/src/ai/researcher.js +25 -11
  31. package/dist/src/ai/rules.js +8 -7
  32. package/dist/src/ai/scout/tools.d.ts +17 -0
  33. package/dist/src/ai/scout/tools.js +130 -0
  34. package/dist/src/ai/scout.d.ts +21 -0
  35. package/dist/src/ai/scout.js +150 -0
  36. package/dist/src/ai/tester.d.ts +1 -0
  37. package/dist/src/ai/tester.js +27 -33
  38. package/dist/src/ai/tools.js +61 -30
  39. package/dist/src/application-spec.d.ts +3 -0
  40. package/dist/src/application-spec.js +21 -5
  41. package/dist/src/commands/config-command.js +6 -2
  42. package/dist/src/config.d.ts +9 -1
  43. package/dist/src/config.js +14 -0
  44. package/dist/src/explorbot.d.ts +3 -0
  45. package/dist/src/explorbot.js +33 -0
  46. package/dist/src/knowledge-tracker.d.ts +1 -0
  47. package/dist/src/knowledge-tracker.js +3 -0
  48. package/dist/src/state-manager.js +5 -1
  49. package/dist/src/utils/aria-ref.d.ts +16 -0
  50. package/dist/src/utils/aria-ref.js +47 -0
  51. package/dist/src/utils/aria.js +3 -3
  52. package/dist/src/utils/web-annotate.js +3 -15
  53. package/dist/src/utils/web-element.d.ts +0 -2
  54. package/dist/src/utils/web-element.js +0 -8
  55. package/docs/api-testing/basics.md +12 -4
  56. package/docs/reference/commands.md +1 -0
  57. package/docs/reference/configuration.md +28 -1
  58. package/docs/web-testing/agents.md +9 -1
  59. package/docs/web-testing/planner.md +5 -0
  60. package/docs/workflow/agentic-usage.md +3 -1
  61. package/docs/workflow/application-spec.md +4 -0
  62. package/package.json +1 -1
  63. package/src/action-result.ts +7 -0
  64. package/src/action.ts +14 -2
  65. package/src/ai/fisherman/tools.ts +8 -1
  66. package/src/ai/fisherman.ts +2 -1
  67. package/src/ai/pilot.ts +8 -25
  68. package/src/ai/planner.ts +33 -0
  69. package/src/ai/provider.ts +2 -1
  70. package/src/ai/researcher/deep-analysis.ts +4 -2
  71. package/src/ai/researcher/sections.ts +2 -2
  72. package/src/ai/researcher.ts +28 -11
  73. package/src/ai/rules.ts +8 -7
  74. package/src/ai/scout/tools.ts +150 -0
  75. package/src/ai/scout.ts +173 -0
  76. package/src/ai/tester.ts +25 -30
  77. package/src/ai/tools.ts +75 -36
  78. package/src/application-spec.ts +22 -4
  79. package/src/commands/config-command.ts +4 -1
  80. package/src/config.ts +23 -0
  81. package/src/explorbot.ts +36 -0
  82. package/src/knowledge-tracker.ts +4 -0
  83. package/src/state-manager.ts +6 -1
  84. package/src/utils/aria-ref.ts +61 -0
  85. package/src/utils/aria.ts +3 -3
  86. package/src/utils/web-annotate.ts +3 -15
  87. 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)}`, {
@@ -634,6 +657,11 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
634
657
  return failedToolResult('research', 'No current page state available. Navigate to a page first.');
635
658
  }
636
659
  const researchResult = await researcher.research(currentState, { screenshot: true, data: true });
660
+ if (!researchResult) {
661
+ return failedToolResult('research', 'No UI map is available for this page.', {
662
+ suggestion: 'Use context() to read the page structure and act on the elements it lists.',
663
+ });
664
+ }
637
665
  return successToolResult('research', {
638
666
  analysis: researchResult,
639
667
  aria: cap(ActionResult.fromState(currentState).getInteractiveARIA(), ARIA_OUTPUT_CAP),
@@ -1010,17 +1038,6 @@ export async function commitNote(activeNote, result, toolResult, action) {
1010
1038
  }
1011
1039
  activeNote.commit(result);
1012
1040
  }
1013
- async function describeRef(explorer, ref) {
1014
- return Promise.resolve(explorer?.withPage?.((page) => page.locator(`aria-ref=${ref}`).evaluate((el) => {
1015
- const tag = el.tagName.toLowerCase();
1016
- const roles = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
1017
- const role = el.getAttribute('role') || roles[tag] || tag;
1018
- const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
1019
- if (!text)
1020
- return null;
1021
- return { role, text };
1022
- }))).catch(() => null);
1023
- }
1024
1041
  async function hasFocusedElement(explorer) {
1025
1042
  return explorer.withPage((page) => page.evaluate(() => !!document.activeElement && document.activeElement !== document.body)).catch(() => true);
1026
1043
  }
@@ -1080,6 +1097,8 @@ function hasObservablePageChange(data) {
1080
1097
  return true;
1081
1098
  if (data.pageDiff.messages?.length)
1082
1099
  return true;
1100
+ if (data.pageDiff.requests?.length)
1101
+ return true;
1083
1102
  return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1084
1103
  }
1085
1104
  export async function failedToolResult(action, message, data, error) {
@@ -1088,12 +1107,11 @@ export async function failedToolResult(action, message, data, error) {
1088
1107
  result.suggestion = data.suggestion ? `${data.suggestion} ${PAGE_DIFF_SUGGESTION}` : PAGE_DIFF_SUGGESTION;
1089
1108
  }
1090
1109
  const errorTexts = [message, ...(data?.attempts?.map((a) => a.error || '') || [])];
1091
- const hasMultipleElements = errorTexts.some((t) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN));
1092
- const multipleElementsSuggestion = hasMultipleElements ? getMultipleElementsSuggestion() : null;
1093
- if (multipleElementsSuggestion) {
1094
- 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);
1095
1113
  result.multipleElementsDetected = true;
1096
- result.elements = await formatMatchedElements(error);
1114
+ result.elements = formatElementList(matched);
1097
1115
  return result;
1098
1116
  }
1099
1117
  const notFoundSuggestion = getNotFoundSuggestion(message);
@@ -1103,11 +1121,16 @@ export async function failedToolResult(action, message, data, error) {
1103
1121
  }
1104
1122
  return result;
1105
1123
  }
1106
- 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.`;
1107
1129
  return dedent `
1108
1130
  Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
1109
- Read the numbered elements list and click the one you meant by its number:
1131
+ Read the numbered elements list and act on the one you meant by its number:
1110
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}
1111
1134
  If none of them is the element you want, narrow the locator with a container or its full unique text.
1112
1135
  If the list is missing, call xpathCheck() to see what the locator matches.
1113
1136
  `;
@@ -1127,7 +1150,7 @@ export function clickFailureSuggestion(attempts) {
1127
1150
  return 'Element exists but another element covers it. Close the overlapping panel or dialog, then retry.';
1128
1151
  }
1129
1152
  if (errors.some((e) => e.includes('is not visible'))) {
1130
- 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.';
1131
1154
  }
1132
1155
  if (errors.some((e) => e.includes('SyntaxError'))) {
1133
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.';
@@ -1157,7 +1180,8 @@ async function extractWebElements(error) {
1157
1180
  const xpath = await elements[i].toAbsoluteXPath();
1158
1181
  const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
1159
1182
  const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
1160
- result.push({ xpath, html, text });
1183
+ const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
1184
+ result.push({ xpath, html, text, visible });
1161
1185
  }
1162
1186
  catch (e) {
1163
1187
  debugLog('Failed to get details for element %d: %s', i, e);
@@ -1165,14 +1189,21 @@ async function extractWebElements(error) {
1165
1189
  }
1166
1190
  return result.length > 0 ? result : null;
1167
1191
  }
1168
- function formatElementList(details) {
1169
- 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');
1170
1204
  }
1171
1205
  export async function formatMatchedElements(error) {
1172
- const details = await extractWebElements(error);
1173
- if (!details)
1174
- return 'Could not fetch element details. Repeat the action to get better info.';
1175
- return formatElementList(details);
1206
+ return formatElementList(await extractWebElements(error));
1176
1207
  }
1177
1208
  function getNotFoundSuggestion(errorMessage) {
1178
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
+ }
@@ -39,8 +39,12 @@ export class ConfigCommand extends BaseCommand {
39
39
  const env = {};
40
40
  for (const variable of EXPLORBOT_ENV_VARS) {
41
41
  const value = process.env[variable.name];
42
- if (value)
43
- env[variable.name] = value;
42
+ if (!value)
43
+ continue;
44
+ let shown = value;
45
+ if (variable.secret)
46
+ shown = 'set';
47
+ env[variable.name] = shown;
44
48
  }
45
49
  const models = {};
46
50
  const providers = {};
@@ -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;
@@ -269,7 +274,9 @@ export declare class ConfigParser {
269
274
  deepMerge(target: any, source: any): any;
270
275
  ensureDirectory(path: string): void;
271
276
  }
277
+ export declare function setOutputDir(dir: string): void;
272
278
  export declare function outputPath(...segments: string[]): string;
279
+ export declare function agentSettings<K extends keyof AgentsConfig>(config: ExplorbotConfig, agent: K): NonNullable<AgentsConfig[K]>;
273
280
  export declare function resolveModel(spec: string, role?: ModelRole): Promise<any>;
274
281
  export declare function missingModelRoles(provider: string): ModelRole[];
275
282
  export declare class ConfigMissingError extends Error {
@@ -297,5 +304,6 @@ interface EnvVar {
297
304
  name: string;
298
305
  description: string;
299
306
  required?: boolean;
307
+ secret?: boolean;
300
308
  }
301
309
  export type { ModelRole, EnvVar, ProviderInfo, ConfiguredModel };
@@ -27,6 +27,7 @@ export const PROVIDERS = {
27
27
  };
28
28
  export const MODEL_ROLES = ['model', 'visionModel', 'agenticModel'];
29
29
  let cachedOutputRoot = null;
30
+ let runOutputDir = null;
30
31
  const config = {
31
32
  playwright: {
32
33
  browser: 'chromium',
@@ -49,6 +50,7 @@ export const EXPLORBOT_ENV_VARS = [
49
50
  { name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
50
51
  { name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
51
52
  { name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
53
+ { name: 'EXPLORBOT_API_HEADERS', description: 'Headers sent with every API request, one "Name: value" per line', secret: true },
52
54
  { name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
53
55
  { name: 'EXPLORBOT_MAX_DURATION', description: 'Wall-clock budget in minutes for an explore run; same as --max-duration' },
54
56
  ];
@@ -213,6 +215,7 @@ export class ConfigParser {
213
215
  // For testing purposes only
214
216
  static resetForTesting() {
215
217
  cachedOutputRoot = null;
218
+ runOutputDir = null;
216
219
  if (ConfigParser.instance) {
217
220
  ConfigParser.instance.config = null;
218
221
  ConfigParser.instance.configPath = null;
@@ -441,9 +444,20 @@ export class ConfigParser {
441
444
  }
442
445
  }
443
446
  }
447
+ export function setOutputDir(dir) {
448
+ runOutputDir = dir;
449
+ }
444
450
  export function outputPath(...segments) {
451
+ if (runOutputDir)
452
+ return path.join(runOutputDir, ...segments);
445
453
  return path.join(ConfigParser.getInstance().getOutputDir(), ...segments);
446
454
  }
455
+ export function agentSettings(config, agent) {
456
+ const ai = (config.ai ??= { model: null });
457
+ const agents = (ai.agents ??= {});
458
+ agents[agent] ??= {};
459
+ return agents[agent];
460
+ }
447
461
  export async function resolveModel(spec, role = 'model') {
448
462
  const separator = spec.indexOf('/');
449
463
  if (separator > 0) {
@@ -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();
@@ -60,12 +60,16 @@ export class StateManager {
60
60
  updateState(actionResult, codeBlock, trigger = 'manual') {
61
61
  const previousState = this.currentState;
62
62
  const previousHash = previousState?.hash;
63
+ const hashChanged = actionResult.hash !== previousHash;
64
+ if (!hashChanged && previousState?.verifications) {
65
+ const stillTrue = Object.entries(previousState.verifications).filter(([, passed]) => passed);
66
+ actionResult.verifications = { ...Object.fromEntries(stillTrue), ...actionResult.verifications };
67
+ }
63
68
  const newState = actionResult;
64
69
  this.currentState = newState;
65
70
  this.currentState.id = this.nextStateId++;
66
71
  if (newState.url)
67
72
  this.allVisitedUrls.add(normalizeUrl(newState.url));
68
- const hashChanged = actionResult.hash !== previousHash;
69
73
  const regionOpened = !hashChanged && this.regionOpened(previousState, newState);
70
74
  if (hashChanged || regionOpened) {
71
75
  const transition = {
@@ -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)
@@ -30,7 +30,7 @@ export default {
30
30
 
31
31
  - **`baseEndpoint`** (required) — the base URL prepended to every request. Test steps use relative paths like `/users`; Curler adds the base for you.
32
32
  - **`spec`** (required) — one or more OpenAPI specs, given as HTTP(S) URLs or local file paths, in YAML or JSON. Chief uses the spec to plan; Curler uses it to look up schemas. Both agents refuse to run without one.
33
- - **`headers`** — sent with every request. This is where API keys and auth tokens go.
33
+ - **`headers`** — sent with every request. This is where API keys and auth tokens go. `-H "Name: value"` on the command line and `EXPLORBOT_API_HEADERS` add to them without a config file.
34
34
 
35
35
  See the [full configuration reference](../reference/configuration.md) for every option and [providers](../basics/providers.md) for choosing an AI model.
36
36
 
@@ -60,16 +60,24 @@ A matching `teardown` hook runs after all tests finish — use it to clean up da
60
60
 
61
61
  Chief and Curler need three things: where the API is, what its spec says, and how to authenticate. Pass all three on the command line and no config file is needed:
62
62
 
63
+ ```bash
64
+ npx explorbot api explore https://api.example.com/v1 \
65
+ --spec ./openapi.yaml \
66
+ -H "Authorization: Bearer $TOKEN"
67
+ ```
68
+
69
+ `api explore` takes the base endpoint as its argument, so one line covers the whole run: it plans in every style, executes each plan, and reports the totals. The other commands take a path within the API and read the base from `--endpoint`:
70
+
63
71
  ```bash
64
72
  npx explorbot api plan /users \
65
73
  --endpoint https://api.example.com/v1 \
66
74
  --spec ./openapi.yaml \
67
- --knowledge 'Send X-Api-Key: ${env.API_KEY} on every request'
75
+ -H "Authorization: Bearer $TOKEN"
68
76
  ```
69
77
 
70
- `--endpoint` and `--spec` each have an environment twin — `EXPLORBOT_URL` and `EXPLORBOT_API_SPEC` — and the flag wins when both are set. `--knowledge` adds to the facts `EXPLORBOT_KNOWLEDGE` and `EXPLORBOT_KNOWLEDGE_FILE` bring in rather than replacing them. Configure your models once with `npx explorbot init --global` and every run stores its plans and requests per host under `~/.explorbot/sites/<host>/`, so a later `api test` against the same API picks up where the last one left off. Knowledge given on the command line lasts for the run; `api know` is what writes it down.
78
+ Each flag has an environment twin — `EXPLORBOT_URL`, `EXPLORBOT_API_SPEC` and `EXPLORBOT_API_HEADERS` — and the flag wins when both are set. `-H` is repeatable and takes one `Name: value` per use; the variable takes one per line. Headers land on every request, the startup health check included, and merge over any `headers` a config file sets. `--knowledge` adds to the facts `EXPLORBOT_KNOWLEDGE` and `EXPLORBOT_KNOWLEDGE_FILE` bring in rather than replacing them. Configure your models once with `npx explorbot init --global` and every run stores its plans and requests per host under `~/.explorbot/sites/<host>/`, so a later `api test` against the same API picks up where the last one left off. Knowledge given on the command line lasts for the run; `api know` is what writes it down.
71
79
 
72
- `--endpoint` keeps its path prefix: given `https://api.example.com/v1`, steps stay relative (`/users`) and Curler sends them to `https://api.example.com/v1/users`. `api test`, which takes a plan file rather than an endpoint, reads it from the flag or the variable.
80
+ The base endpoint keeps its path prefix: given `https://api.example.com/v1`, steps stay relative (`/users`) and Curler sends them to `https://api.example.com/v1/users`. `api test`, which takes a plan file rather than an endpoint, reads the base from the flag or the variable.
73
81
 
74
82
  ### A dedicated API project
75
83