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
@@ -17,7 +17,7 @@ export class ApplicationSpec {
17
17
  }
18
18
 
19
19
  renderFor(state: ActionResult): string {
20
- const relevant = this.pages.filter((page) => state.isMatchedBy({ url: page.url }));
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
- if (path.isAbsolute(sourcePath)) return path.resolve(sourcePath);
79
- const configParser = ConfigParser.getInstance();
80
- return path.resolve(configParser.resolveProjectDir(sourcePath));
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;
@@ -45,7 +45,10 @@ export class ConfigCommand extends BaseCommand {
45
45
  const env: Record<string, string> = {};
46
46
  for (const variable of EXPLORBOT_ENV_VARS) {
47
47
  const value = process.env[variable.name];
48
- if (value) env[variable.name] = value;
48
+ if (!value) continue;
49
+ let shown = value;
50
+ if (variable.secret) shown = 'set';
51
+ env[variable.name] = shown;
49
52
  }
50
53
 
51
54
  const models: Record<string, string> = {};
package/src/config.ts CHANGED
@@ -22,6 +22,7 @@ export const PROVIDERS: Record<string, ProviderInfo> = {
22
22
  export const MODEL_ROLES: ModelRole[] = ['model', 'visionModel', 'agenticModel'];
23
23
 
24
24
  let cachedOutputRoot: string | null = null;
25
+ let runOutputDir: string | null = null;
25
26
 
26
27
  interface PlaywrightConfig {
27
28
  browser: 'chromium' | 'firefox' | 'webkit';
@@ -129,6 +130,11 @@ interface RerunnerAgentConfig extends AgentConfig {
129
130
  interface PlannerAgentConfig extends AgentConfig {
130
131
  styles?: string[];
131
132
  stylesDir?: string;
133
+ docsWeight?: number;
134
+ }
135
+
136
+ interface ScoutAgentConfig extends AgentConfig {
137
+ dirs?: string[];
132
138
  }
133
139
 
134
140
  interface ScreencastConfig {
@@ -153,6 +159,7 @@ interface AgentsConfig {
153
159
  quartermaster?: AgentConfig;
154
160
  historian?: HistorianAgentConfig;
155
161
  fisherman?: AgentConfig;
162
+ scout?: ScoutAgentConfig;
156
163
  chief?: AgentConfig;
157
164
  curler?: AgentConfig;
158
165
  rerunner?: RerunnerAgentConfig;
@@ -275,6 +282,7 @@ export const EXPLORBOT_ENV_VARS: EnvVar[] = [
275
282
  { name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
276
283
  { name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
277
284
  { name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
285
+ { name: 'EXPLORBOT_API_HEADERS', description: 'Headers sent with every API request, one "Name: value" per line', secret: true },
278
286
  { name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
279
287
  { name: 'EXPLORBOT_MAX_DURATION', description: 'Wall-clock budget in minutes for an explore run; same as --max-duration' },
280
288
  ];
@@ -291,6 +299,7 @@ export type {
291
299
  ResearcherAgentConfig,
292
300
  NavigatorAgentConfig,
293
301
  PlannerAgentConfig,
302
+ ScoutAgentConfig,
294
303
  RerunnerAgentConfig,
295
304
  HealRecipe,
296
305
  Hook,
@@ -496,6 +505,7 @@ export class ConfigParser {
496
505
  // For testing purposes only
497
506
  public static resetForTesting(): void {
498
507
  cachedOutputRoot = null;
508
+ runOutputDir = null;
499
509
  if (ConfigParser.instance) {
500
510
  ConfigParser.instance.config = null;
501
511
  ConfigParser.instance.configPath = null;
@@ -744,10 +754,22 @@ export class ConfigParser {
744
754
  }
745
755
  }
746
756
 
757
+ export function setOutputDir(dir: string): void {
758
+ runOutputDir = dir;
759
+ }
760
+
747
761
  export function outputPath(...segments: string[]): string {
762
+ if (runOutputDir) return path.join(runOutputDir, ...segments);
748
763
  return path.join(ConfigParser.getInstance().getOutputDir(), ...segments);
749
764
  }
750
765
 
766
+ export function agentSettings<K extends keyof AgentsConfig>(config: ExplorbotConfig, agent: K): NonNullable<AgentsConfig[K]> {
767
+ const ai = (config.ai ??= { model: null });
768
+ const agents = (ai.agents ??= {}) as Record<K, NonNullable<AgentsConfig[K]>>;
769
+ agents[agent] ??= {} as NonNullable<AgentsConfig[K]>;
770
+ return agents[agent];
771
+ }
772
+
751
773
  export async function resolveModel(spec: string, role: ModelRole = 'model'): Promise<any> {
752
774
  const separator = spec.indexOf('/');
753
775
  if (separator > 0) {
@@ -912,6 +934,7 @@ interface EnvVar {
912
934
  name: string;
913
935
  description: string;
914
936
  required?: boolean;
937
+ secret?: boolean;
915
938
  }
916
939
 
917
940
  export type { ModelRole, EnvVar, ProviderInfo, ConfiguredModel };
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
  }
@@ -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();
@@ -143,13 +143,18 @@ export class StateManager {
143
143
  updateState(actionResult: ActionResult, codeBlock?: string, trigger: 'manual' | 'navigation' | 'automatic' = 'manual'): WebPageState {
144
144
  const previousState = this.currentState;
145
145
  const previousHash = previousState?.hash;
146
+ const hashChanged = actionResult.hash !== previousHash;
147
+
148
+ if (!hashChanged && previousState?.verifications) {
149
+ const stillTrue = Object.entries(previousState.verifications).filter(([, passed]) => passed);
150
+ actionResult.verifications = { ...Object.fromEntries(stillTrue), ...actionResult.verifications };
151
+ }
146
152
 
147
153
  const newState = actionResult;
148
154
  this.currentState = newState;
149
155
  this.currentState.id = this.nextStateId++;
150
156
  if (newState.url) this.allVisitedUrls.add(normalizeUrl(newState.url));
151
157
 
152
- const hashChanged = actionResult.hash !== previousHash;
153
158
  const regionOpened = !hashChanged && this.regionOpened(previousState, newState);
154
159
 
155
160
  if (hashChanged || regionOpened) {
@@ -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 sorted = Array.from(summary.entries()).sort(([aItem, aCount], [bItem, bCount]) => bCount - aCount || aItem.localeCompare(bItem));
435
- const top = sorted.slice(0, TOP_DIFF_ITEMS);
436
- const rest = sorted.slice(TOP_DIFF_ITEMS);
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.locator('body').ariaSnapshot({ mode: 'ai' });
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) {
@@ -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 [];