explorbot 0.4.2 → 0.4.4

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 (74) hide show
  1. package/bin/explorbot-cli.ts +6 -1
  2. package/boat/api-tester/src/apibot.ts +8 -13
  3. package/boat/api-tester/src/cli.ts +7 -3
  4. package/boat/api-tester/src/config.ts +45 -9
  5. package/boat/prima/src/cli.ts +33 -99
  6. package/boat/prima/src/envelope.ts +3 -1
  7. package/boat/prima/src/help.ts +72 -0
  8. package/boat/prima/src/prima.ts +33 -43
  9. package/dist/bin/explorbot-cli.js +5 -1
  10. package/dist/boat/api-tester/src/apibot.js +7 -6
  11. package/dist/boat/api-tester/src/cli.js +9 -3
  12. package/dist/boat/api-tester/src/config.js +32 -6
  13. package/dist/boat/prima/src/cli.js +30 -86
  14. package/dist/boat/prima/src/envelope.js +2 -1
  15. package/dist/boat/prima/src/help.js +63 -0
  16. package/dist/boat/prima/src/prima.js +29 -41
  17. package/dist/package.json +1 -1
  18. package/dist/src/action-result.d.ts +3 -0
  19. package/dist/src/action-result.js +5 -0
  20. package/dist/src/action.js +12 -1
  21. package/dist/src/ai/fisherman/request-haul.d.ts +1 -0
  22. package/dist/src/ai/fisherman/request-haul.js +3 -0
  23. package/dist/src/ai/fisherman/tools.d.ts +50 -0
  24. package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +78 -13
  25. package/dist/src/ai/fisherman.d.ts +12 -3
  26. package/dist/src/ai/fisherman.js +89 -13
  27. package/dist/src/ai/pilot.d.ts +13 -1
  28. package/dist/src/ai/pilot.js +20 -7
  29. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  30. package/dist/src/ai/researcher/deep-analysis.js +4 -1
  31. package/dist/src/ai/researcher/sections.d.ts +1 -1
  32. package/dist/src/ai/researcher/sections.js +2 -1
  33. package/dist/src/ai/researcher.js +25 -11
  34. package/dist/src/ai/rules.js +2 -0
  35. package/dist/src/ai/tester.d.ts +1 -0
  36. package/dist/src/ai/tester.js +27 -33
  37. package/dist/src/ai/tools.js +5 -0
  38. package/dist/src/api/request-result.js +3 -1
  39. package/dist/src/api/request-store.d.ts +6 -1
  40. package/dist/src/api/request-store.js +55 -17
  41. package/dist/src/api/xhr-capture.d.ts +2 -0
  42. package/dist/src/api/xhr-capture.js +35 -10
  43. package/dist/src/commands/config-command.js +6 -2
  44. package/dist/src/commands/help-json-command.d.ts +31 -0
  45. package/dist/src/commands/help-json-command.js +58 -0
  46. package/dist/src/config.d.ts +3 -0
  47. package/dist/src/config.js +14 -0
  48. package/dist/src/state-manager.js +5 -1
  49. package/docs/api-testing/basics.md +12 -4
  50. package/docs/reference/commands.md +2 -0
  51. package/docs/reference/configuration.md +4 -0
  52. package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
  53. package/docs/workflow/agentic-usage.md +15 -1
  54. package/package.json +1 -1
  55. package/src/action-result.ts +7 -0
  56. package/src/action.ts +14 -2
  57. package/src/ai/fisherman/request-haul.ts +4 -0
  58. package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +93 -20
  59. package/src/ai/fisherman.ts +104 -15
  60. package/src/ai/pilot.ts +20 -7
  61. package/src/ai/researcher/deep-analysis.ts +4 -2
  62. package/src/ai/researcher/sections.ts +2 -2
  63. package/src/ai/researcher.ts +28 -11
  64. package/src/ai/rules.ts +2 -0
  65. package/src/ai/tester.ts +25 -30
  66. package/src/ai/tools.ts +6 -0
  67. package/src/api/request-result.ts +2 -1
  68. package/src/api/request-store.ts +58 -18
  69. package/src/api/xhr-capture.ts +39 -11
  70. package/src/commands/config-command.ts +4 -1
  71. package/src/commands/help-json-command.ts +74 -0
  72. package/src/config.ts +16 -0
  73. package/src/state-manager.ts +6 -1
  74. package/dist/src/ai/fisherman-tools.d.ts +0 -147
@@ -1,7 +1,7 @@
1
1
  import dedent from 'dedent';
2
2
  import { ActionResult } from '../action-result.js';
3
3
  import { setActivity } from '../activity.ts';
4
- import { ConfigParser, type ExplorbotConfig, outputPath } from '../config.ts';
4
+ import { ConfigParser, type ExplorbotConfig, type ResearcherAgentConfig, agentSettings, outputPath } from '../config.ts';
5
5
  import { executionController } from '../execution-controller.ts';
6
6
  import type { ExperienceTracker } from '../experience-tracker.ts';
7
7
  import type Explorer from '../explorer.ts';
@@ -19,7 +19,7 @@ import { annotatePageElements } from '../utils/web-annotate.ts';
19
19
  import type { Agent, AgentDeps } from './agent.js';
20
20
  import type { Navigator } from './navigator.ts';
21
21
  import { ContextLengthError, type Provider } from './provider.js';
22
- import { findSimilarResearch, getCachedResearch, reportResearch, saveResearch } from './researcher/cache.ts';
22
+ import { findSimilarResearch, getCachedResearch, getPreviousResearch, reportResearch, saveResearch } from './researcher/cache.ts';
23
23
  import { type CoordinateMethods, WithCoordinates } from './researcher/coordinates.ts';
24
24
  import { type DeepAnalysisMethods, WithDeepAnalysis } from './researcher/deep-analysis.ts';
25
25
  import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from './researcher/focus.ts';
@@ -62,13 +62,23 @@ export class Researcher extends ResearcherBase implements Agent {
62
62
  constructor(deps: AgentDeps) {
63
63
  super(deps);
64
64
  this.experienceTracker = deps.stateManager.getExperienceTracker();
65
+ this.settings.reasoning ??= 'low';
66
+ }
65
67
 
66
- const ai = deps.config.ai;
67
- if (ai) {
68
- ai.agents ??= {};
69
- ai.agents.researcher ??= {};
70
- ai.agents.researcher.reasoning ??= 'low';
71
- }
68
+ get settings(): ResearcherAgentConfig {
69
+ return agentSettings(this.config, 'researcher');
70
+ }
71
+
72
+ isEnabled(): boolean {
73
+ return this.settings.enabled !== false;
74
+ }
75
+
76
+ enable(): void {
77
+ this.settings.enabled = true;
78
+ }
79
+
80
+ disable(): void {
81
+ this.settings.enabled = false;
72
82
  }
73
83
 
74
84
  protected getNavigator(): Navigator {
@@ -94,7 +104,7 @@ export class Researcher extends ResearcherBase implements Agent {
94
104
 
95
105
  async research(state: WebPageState, opts: { screenshot?: boolean; force?: boolean; deep?: boolean; data?: boolean; fix?: boolean; _retriesLeft?: number } = {}): Promise<string> {
96
106
  const { screenshot = false, force = false, deep = false, data = false, fix = true } = opts;
97
- const maxRetries = (this.config.ai?.agents?.researcher as any)?.retries ?? 2;
107
+ const maxRetries = this.settings.retries ?? 2;
98
108
  let retriesLeft = opts._retriesLeft ?? maxRetries;
99
109
  this.actionResult = ActionResult.fromState(state);
100
110
  const stateHash = this.actionResult.baseHash;
@@ -109,6 +119,13 @@ export class Researcher extends ResearcherBase implements Agent {
109
119
  }
110
120
  }
111
121
 
122
+ if (!this.isEnabled()) {
123
+ debugLog('Researcher is disabled, answering with the recorded map');
124
+ const recorded = getPreviousResearch(stateHash);
125
+ if (recorded) reportResearch(stateHash, recorded);
126
+ return recorded;
127
+ }
128
+
112
129
  Stats.researches++;
113
130
 
114
131
  const sessionName = `researcher: ${state.url}`;
@@ -341,7 +358,7 @@ export class Researcher extends ResearcherBase implements Agent {
341
358
  }
342
359
 
343
360
  private async waitUntilSettled(screenshot: boolean): Promise<boolean> {
344
- const errorPageTimeout = (this.config.ai?.agents?.researcher as any)?.errorPageTimeout ?? 10;
361
+ const errorPageTimeout = this.settings.errorPageTimeout ?? 10;
345
362
  if (errorPageTimeout <= 0) return false;
346
363
 
347
364
  const includeScreenshot = screenshot && this.provider.hasVision();
@@ -374,7 +391,7 @@ export class Researcher extends ResearcherBase implements Agent {
374
391
  }
375
392
 
376
393
  private getConfiguredSections(): Record<string, string> {
377
- const configSections = (this.config.ai?.agents?.researcher as any)?.sections as string[] | undefined;
394
+ const configSections = this.settings.sections;
378
395
  if (!configSections?.length) return POSSIBLE_SECTIONS;
379
396
  const filtered: Record<string, string> = {};
380
397
  for (const key of configSections) {
package/src/ai/rules.ts CHANGED
@@ -173,6 +173,8 @@ export const dataProtectionRules = dedent`
173
173
  Do not use Fisherman or API data preparation to bypass a no-mutation, read-only, search,
174
174
  filter, tab, or list-inspection constraint. Use visible existing data when it is available.
175
175
  If no suitable data exists, report the missing precondition instead of creating data.
176
+ Reading through the API to establish what already exists is not a mutation and stays allowed
177
+ under a read-only constraint.
176
178
 
177
179
  Destructive actions are allowed only against data created by the current scenario
178
180
  or prepared for that scenario by Fisherman/API preconditions. Existing application data must
package/src/ai/tester.ts CHANGED
@@ -53,6 +53,7 @@ export class Tester extends TaskAgent implements Agent {
53
53
  MAX_ITERATIONS = 30;
54
54
  MAX_EXTENSIONS = 2;
55
55
  ASSERTION_TOOLS = ['verify'];
56
+ private pendingReview = '';
56
57
  researcher: Researcher;
57
58
  navigator: Navigator;
58
59
  agentTools: any;
@@ -119,6 +120,7 @@ export class Tester extends TaskAgent implements Agent {
119
120
  this.seenUiMapUrls.clear();
120
121
  this.lastAnalyzedStateHash = null;
121
122
  this.stalledIterations = 0;
123
+ this.pendingReview = '';
122
124
  this.previousRegionPresent = null;
123
125
  this.regionTransitioned = false;
124
126
  this.stateManager.clearHistory();
@@ -333,7 +335,7 @@ export class Tester extends TaskAgent implements Agent {
333
335
  const result = await this.provider.invokeConversation(conversation, tools, {
334
336
  maxToolRoundtrips: 3,
335
337
  toolChoice: 'required',
336
- stopWhen: () => task.hasFinished,
338
+ stopWhen: () => task.hasFinished || !!this.pendingReview,
337
339
  });
338
340
 
339
341
  if (!result) throw new Error('Failed to get response from provider');
@@ -388,6 +390,14 @@ export class Tester extends TaskAgent implements Agent {
388
390
  }
389
391
  }
390
392
 
393
+ if (this.pendingReview && this.pilot) {
394
+ const reviewed = this.pendingReview;
395
+ this.pendingReview = '';
396
+ const reviewState = this.getCurrentState();
397
+ if (reviewed === 'finish') await this.pilot.reviewFinish(task, reviewState, conversation, this.navigator);
398
+ if (reviewed === 'stop') await this.pilot.reviewStop(task, reviewState, conversation);
399
+ }
400
+
391
401
  if (task.hasFinished) {
392
402
  stop();
393
403
  return;
@@ -579,16 +589,19 @@ export class Tester extends TaskAgent implements Agent {
579
589
 
580
590
  if (region.isModal) {
581
591
  const areaName = region.name ? ` "${region.name}"` : '';
582
- let rootHint = '';
583
- if (region.root) rootHint = `\nIts content lives inside \`${region.root}\` — scope locators to it.`;
592
+ let scoping = 'Use <page_aria> to confirm the element you target is actually inside the overlay.';
593
+ if (region.root) {
594
+ scoping = `Its root is \`${region.root}\` — build every locator as ARIA scoped to that root, e.g. I.click({ role: 'button', text: 'Continue' }, '${region.root}')`;
595
+ }
584
596
  context += dedent`
585
597
  <overlay>
586
- An overlay${areaName} is currently open above the page.${rootHint}
587
- Scope all interactions to elements inside this overlay.
588
- Page navigation, filters, and tabs that exist outside it are not actionable while it is open and may share names or roles with elements inside it prefer the locator inside the overlay.
589
- Use <page_aria> to confirm the element you target is actually inside the overlay.
590
- </overlay>
598
+ You are inside an overlay${areaName} opened above the page.
599
+ ${scoping}
600
+ Elements outside the overlay are behind it and not actionable while it is open they may share names or roles with the ones inside, so never target them by bare text.
591
601
  `;
602
+ const regionAria = currentState.getRegionARIA();
603
+ if (regionAria) context += `\nIt holds exactly these elements:\n<overlay_aria>\n${regionAria}\n</overlay_aria>`;
604
+ context += '\n</overlay>\n';
592
605
  }
593
606
 
594
607
  if (!region.isModal && region.isOpen && isNewState) {
@@ -1006,18 +1019,9 @@ export class Tester extends TaskAgent implements Agent {
1006
1019
  }),
1007
1020
  execute: async ({ reason }) => {
1008
1021
  task.addNote(`Stop requested: ${reason}`);
1022
+ this.pendingReview = 'stop';
1009
1023
 
1010
- if (this.pilot) {
1011
- const currentState = this.getCurrentState();
1012
- await this.pilot.reviewStop(task, currentState, conversation);
1013
- if (!task.hasFinished) {
1014
- return {
1015
- success: false,
1016
- action: 'stop',
1017
- message: 'Stop rejected; Continue execution',
1018
- };
1019
- }
1020
- } else {
1024
+ if (!this.pilot) {
1021
1025
  task.addNote(reason, TestResult.FAILED);
1022
1026
  task.finish(TestResult.FAILED);
1023
1027
  }
@@ -1053,18 +1057,9 @@ export class Tester extends TaskAgent implements Agent {
1053
1057
  return { success: true, action: 'finish', message: 'already finished' };
1054
1058
  }
1055
1059
  task.addNote(`Finish requested: ${verify}`);
1060
+ this.pendingReview = 'finish';
1056
1061
 
1057
- if (this.pilot) {
1058
- const currentState = this.getCurrentState();
1059
- await this.pilot.reviewFinish(task, currentState, conversation, this.navigator);
1060
- if (!task.hasFinished) {
1061
- return {
1062
- success: false,
1063
- action: 'finish',
1064
- message: 'Finishing rejected; Continue execution',
1065
- };
1066
- }
1067
- } else {
1062
+ if (!this.pilot) {
1068
1063
  task.addNote('Test finished successfully', TestResult.PASSED);
1069
1064
  task.finish(TestResult.PASSED);
1070
1065
  }
package/src/ai/tools.ts CHANGED
@@ -737,6 +737,12 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
737
737
 
738
738
  const researchResult = await researcher.research(currentState, { screenshot: true, data: true });
739
739
 
740
+ if (!researchResult) {
741
+ return failedToolResult('research', 'No UI map is available for this page.', {
742
+ suggestion: 'Use context() to read the page structure and act on the elements it lists.',
743
+ });
744
+ }
745
+
740
746
  return successToolResult('research', {
741
747
  analysis: researchResult,
742
748
  aria: cap(ActionResult.fromState(currentState).getInteractiveARIA(), ARIA_OUTPUT_CAP),
@@ -124,7 +124,8 @@ export class RequestResult {
124
124
  }
125
125
 
126
126
  writeFileSync(this.requestFile, yaml, 'utf8');
127
- writeFileSync(this.responseFile, this._rawResponseBody || '', 'utf8');
127
+ if (!this._rawResponseBody) return;
128
+ writeFileSync(this.responseFile, this._rawResponseBody, 'utf8');
128
129
  }
129
130
 
130
131
  static load(requestFile: string): RequestResult {
@@ -12,6 +12,7 @@ export class RequestStore {
12
12
  private onFailedListeners: Array<(r: RequestResult) => void> = [];
13
13
  private outputDir: string;
14
14
  private sessionStartedAt = new Date();
15
+ private readEndpointKeys = new Set<string>();
15
16
 
16
17
  constructor(outputDir: string) {
17
18
  this.outputDir = outputDir;
@@ -22,6 +23,14 @@ export class RequestStore {
22
23
  result.save(this.outputDir);
23
24
  }
24
25
 
26
+ addReadRequest(result: RequestResult): void {
27
+ const key = readEndpointKey(result);
28
+ if (this.readEndpointKeys.has(key)) return;
29
+ this.readEndpointKeys.add(key);
30
+ this.capturedRequests.push(result);
31
+ result.save(this.outputDir);
32
+ }
33
+
25
34
  addFailedRequest(result: RequestResult): void {
26
35
  this.failedRequests.push(result);
27
36
  for (const cb of this.onFailedListeners) {
@@ -58,15 +67,15 @@ export class RequestStore {
58
67
  return this.madeRequests[this.madeRequests.length - 1];
59
68
  }
60
69
 
61
- toEndpointList(scopePath?: string): string {
62
- let requests = this.capturedRequests;
63
- if (scopePath) requests = this.getWriteRequestsForScope(scopePath);
70
+ toEndpointList(scopePath?: string, methods: EndpointFamily = 'write'): string {
71
+ let requests = this.capturedRequests.filter((r) => matchesFamily(r, methods));
72
+ if (scopePath) requests = this.getRequestsForScope(scopePath, methods);
64
73
 
65
74
  const seen = new Set<string>();
66
75
  const lines: string[] = [];
67
76
 
68
77
  for (const req of requests) {
69
- const key = `${req.method} ${generalizeUrl(req.path, () => '{id}')}`;
78
+ const key = `${req.method} ${generalizeUrl(req.path, () => '{id}')}${queryParamHint(req)}`;
70
79
  if (seen.has(key)) continue;
71
80
  seen.add(key);
72
81
  lines.push(key);
@@ -134,6 +143,11 @@ export class RequestStore {
134
143
  try {
135
144
  const result = RequestResult.load(path.join(requestsDir, file));
136
145
  if (existingIds.has(result.id)) continue;
146
+ if (!result.isWrite) {
147
+ const key = readEndpointKey(result);
148
+ if (this.readEndpointKeys.has(key)) continue;
149
+ this.readEndpointKeys.add(key);
150
+ }
137
151
  this.capturedRequests.push(result);
138
152
  } catch {
139
153
  // skip invalid files
@@ -142,16 +156,31 @@ export class RequestStore {
142
156
  }
143
157
 
144
158
  getWriteRequestsForScope(scopePath: string): RequestResult[] {
145
- const writes = this.capturedRequests.filter((r) => r.isWrite);
159
+ return this.getRequestsForScope(scopePath, 'write');
160
+ }
161
+
162
+ getReadRequestsForScope(scopePath: string): RequestResult[] {
163
+ return this.getRequestsForScope(scopePath, 'read');
164
+ }
165
+
166
+ clear(): void {
167
+ this.capturedRequests = [];
168
+ this.madeRequests = [];
169
+ this.failedRequests = [];
170
+ this.readEndpointKeys.clear();
171
+ }
172
+
173
+ private getRequestsForScope(scopePath: string, methods: EndpointFamily): RequestResult[] {
174
+ const candidates = this.capturedRequests.filter((r) => matchesFamily(r, methods));
146
175
  const scopeSegments = scopePath.split('/').filter(Boolean);
147
- if (scopeSegments.length === 0) return writes;
176
+ if (scopeSegments.length === 0) return candidates;
148
177
 
149
178
  let scoped: RequestResult[] = [];
150
179
  let fewest = Number.POSITIVE_INFINITY;
151
180
  let ambiguous = false;
152
181
  for (const segment of scopeSegments) {
153
182
  if (isDynamicSegment(segment)) continue;
154
- const matches = writes.filter((r) => r.path.split('/').includes(segment));
183
+ const matches = candidates.filter((r) => r.path.split('/').includes(segment));
155
184
  if (matches.length === 0 || matches.length > fewest) continue;
156
185
  if (matches.length === fewest) {
157
186
  if (!scoped.every((r, i) => r.id === matches[i].id)) ambiguous = true;
@@ -165,21 +194,32 @@ export class RequestStore {
165
194
 
166
195
  return scoped;
167
196
  }
168
-
169
- clear(): void {
170
- this.capturedRequests = [];
171
- this.madeRequests = [];
172
- this.failedRequests = [];
173
- }
174
197
  }
175
198
 
176
199
  export function isFailedRequest(request: RequestResult): boolean {
177
200
  return request.status >= 400 || Boolean(request.error);
178
201
  }
179
202
 
180
- function normalizePathPattern(urlPath: string): string {
181
- return urlPath
182
- .split('/')
183
- .map((segment) => (segment && isDynamicSegment(segment) ? '{id}' : segment))
184
- .join('/');
203
+ function readEndpointKey(result: RequestResult): string {
204
+ return `${result.method} ${generalizeUrl(result.path, () => '{id}')}?${queryParamNames(result).join(',')}`;
185
205
  }
206
+
207
+ function matchesFamily(result: RequestResult, methods: EndpointFamily): boolean {
208
+ if (methods === 'write') return result.isWrite;
209
+ return result.method === 'GET';
210
+ }
211
+
212
+ function queryParamHint(result: RequestResult): string {
213
+ if (result.isWrite) return '';
214
+ const names = queryParamNames(result);
215
+ if (names.length === 0) return '';
216
+ return ` ?${names.join(',')}`;
217
+ }
218
+
219
+ function queryParamNames(result: RequestResult): string[] {
220
+ const query = result.fullUrl.split('?')[1];
221
+ if (!query) return [];
222
+ return [...new Set(new URLSearchParams(query).keys())].sort();
223
+ }
224
+
225
+ export type EndpointFamily = 'read' | 'write';
@@ -60,26 +60,25 @@ export class XhrCapture {
60
60
  this.store.addFailedRequest(failure);
61
61
  }
62
62
 
63
- if (!WRITE_METHODS.has(method)) return;
64
-
65
63
  const contentType = response.headers()['content-type'] || '';
66
64
  if (!JSON_CONTENT_TYPES.test(contentType)) return;
67
65
 
66
+ if (method === 'GET') {
67
+ if (status !== 200) return;
68
+ this.captureReadEndpoint(request, response);
69
+ return;
70
+ }
71
+
72
+ if (!WRITE_METHODS.has(method)) return;
73
+
68
74
  if (status === 304) return;
69
75
 
70
76
  const parsedUrl = new URL(url);
71
77
  const origin = parsedUrl.pathname + parsedUrl.search;
72
78
  const id = generateRequestId(method, parsedUrl.pathname, 'xhr_');
73
79
 
74
- const requestHeaders: Record<string, string> = {};
75
- for (const [k, v] of Object.entries(request.headers())) {
76
- requestHeaders[k] = String(v);
77
- }
78
-
79
- const responseHeaders: Record<string, string> = {};
80
- for (const [k, v] of Object.entries(response.headers())) {
81
- responseHeaders[k] = String(v);
82
- }
80
+ const requestHeaders = this.toHeaderMap(request.headers());
81
+ const responseHeaders = this.toHeaderMap(response.headers());
83
82
 
84
83
  let rawBody = '';
85
84
  try {
@@ -115,4 +114,33 @@ export class XhrCapture {
115
114
 
116
115
  this.store.addCapturedRequest(result);
117
116
  }
117
+
118
+ private captureReadEndpoint(request: any, response: any): void {
119
+ const parsedUrl = new URL(request.url());
120
+ const requestHeaders = this.toHeaderMap(request.headers());
121
+
122
+ const result = new RequestResult({
123
+ id: generateRequestId('GET', parsedUrl.pathname, 'xhr_'),
124
+ method: 'GET',
125
+ path: parsedUrl.pathname,
126
+ fullUrl: parsedUrl.pathname + parsedUrl.search,
127
+ requestHeaders,
128
+ status: response.status(),
129
+ statusText: response.statusText(),
130
+ responseHeaders: {},
131
+ timing: 0,
132
+ timestamp: new Date(),
133
+ });
134
+ result.rawResponseBodyValue = '';
135
+
136
+ this.store.addReadRequest(result);
137
+ }
138
+
139
+ private toHeaderMap(headers: Record<string, unknown>): Record<string, string> {
140
+ const map: Record<string, string> = {};
141
+ for (const [k, v] of Object.entries(headers)) {
142
+ map[k] = String(v);
143
+ }
144
+ return map;
145
+ }
118
146
  }
@@ -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> = {};
@@ -0,0 +1,74 @@
1
+ import type { Command } from 'commander';
2
+ import { EXPLORBOT_ENV_VARS } from '../config.js';
3
+
4
+ const DESCRIPTION = 'Print command definitions as JSON for agents and tools';
5
+
6
+ export class HelpJsonCommand {
7
+ static register(program: Command): void {
8
+ program
9
+ .command('help-json [command...]')
10
+ .description(DESCRIPTION)
11
+ .action(async (names: string[]) => {
12
+ const target = HelpJsonCommand.find(program, names);
13
+ if (!target) {
14
+ console.error(`Unknown command: ${names.join(' ')}`);
15
+ process.exit(1);
16
+ }
17
+ const json = JSON.stringify(HelpJsonCommand.data(target, target === program), null, 2);
18
+ await new Promise<void>((resolve) => process.stdout.write(`${json}\n`, () => resolve()));
19
+ });
20
+ }
21
+
22
+ static data(cmd: Command, root = false): CommandDefinition {
23
+ const helper = cmd.createHelp();
24
+ const definition: CommandDefinition = {
25
+ name: cmd.name(),
26
+ description: cmd.description(),
27
+ aliases: cmd.aliases(),
28
+ arguments: cmd.registeredArguments.map((argument) => ({
29
+ name: argument.name(),
30
+ description: argument.description,
31
+ required: argument.required,
32
+ variadic: argument.variadic,
33
+ })),
34
+ options: helper.visibleOptions(cmd).map((option) => ({
35
+ flags: option.flags,
36
+ description: option.description,
37
+ default: option.defaultValue,
38
+ choices: option.argChoices,
39
+ })),
40
+ commands: helper.visibleCommands(cmd).map((sub) => HelpJsonCommand.data(sub)),
41
+ };
42
+
43
+ if (!root) return definition;
44
+
45
+ definition.version = cmd.version();
46
+ definition.env = EXPLORBOT_ENV_VARS.map((variable) => ({
47
+ name: variable.name,
48
+ description: variable.description,
49
+ required: !!variable.required,
50
+ }));
51
+ return definition;
52
+ }
53
+
54
+ private static find(cmd: Command, names: string[]): Command | undefined {
55
+ let target = cmd;
56
+ for (const name of names) {
57
+ const sub = target.commands.find((candidate) => candidate.name() === name || candidate.aliases().includes(name));
58
+ if (!sub) return undefined;
59
+ target = sub;
60
+ }
61
+ return target;
62
+ }
63
+ }
64
+
65
+ interface CommandDefinition {
66
+ name: string;
67
+ description: string;
68
+ aliases: string[];
69
+ version?: string;
70
+ arguments: { name: string; description: string; required: boolean; variadic: boolean }[];
71
+ options: { flags: string; description: string; default?: unknown; choices?: string[] }[];
72
+ commands: CommandDefinition[];
73
+ env?: { name: string; description: string; required: boolean }[];
74
+ }
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';
@@ -275,6 +276,7 @@ export const EXPLORBOT_ENV_VARS: EnvVar[] = [
275
276
  { name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
276
277
  { name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
277
278
  { name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
279
+ { name: 'EXPLORBOT_API_HEADERS', description: 'Headers sent with every API request, one "Name: value" per line', secret: true },
278
280
  { name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
279
281
  { name: 'EXPLORBOT_MAX_DURATION', description: 'Wall-clock budget in minutes for an explore run; same as --max-duration' },
280
282
  ];
@@ -496,6 +498,7 @@ export class ConfigParser {
496
498
  // For testing purposes only
497
499
  public static resetForTesting(): void {
498
500
  cachedOutputRoot = null;
501
+ runOutputDir = null;
499
502
  if (ConfigParser.instance) {
500
503
  ConfigParser.instance.config = null;
501
504
  ConfigParser.instance.configPath = null;
@@ -744,10 +747,22 @@ export class ConfigParser {
744
747
  }
745
748
  }
746
749
 
750
+ export function setOutputDir(dir: string): void {
751
+ runOutputDir = dir;
752
+ }
753
+
747
754
  export function outputPath(...segments: string[]): string {
755
+ if (runOutputDir) return path.join(runOutputDir, ...segments);
748
756
  return path.join(ConfigParser.getInstance().getOutputDir(), ...segments);
749
757
  }
750
758
 
759
+ export function agentSettings<K extends keyof AgentsConfig>(config: ExplorbotConfig, agent: K): NonNullable<AgentsConfig[K]> {
760
+ const ai = (config.ai ??= { model: null });
761
+ const agents = (ai.agents ??= {}) as Record<K, NonNullable<AgentsConfig[K]>>;
762
+ agents[agent] ??= {} as NonNullable<AgentsConfig[K]>;
763
+ return agents[agent];
764
+ }
765
+
751
766
  export async function resolveModel(spec: string, role: ModelRole = 'model'): Promise<any> {
752
767
  const separator = spec.indexOf('/');
753
768
  if (separator > 0) {
@@ -912,6 +927,7 @@ interface EnvVar {
912
927
  name: string;
913
928
  description: string;
914
929
  required?: boolean;
930
+ secret?: boolean;
915
931
  }
916
932
 
917
933
  export type { ModelRole, EnvVar, ProviderInfo, ConfiguredModel };
@@ -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) {