explorbot 0.3.5 → 0.4.1

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 (155) hide show
  1. package/bin/explorbot-cli.ts +23 -16
  2. package/boat/api-tester/src/ai/chief.ts +7 -1
  3. package/boat/api-tester/src/ai/curler.ts +7 -1
  4. package/boat/api-tester/src/apibot.ts +10 -4
  5. package/boat/api-tester/src/cli.ts +12 -2
  6. package/boat/api-tester/src/config.ts +28 -8
  7. package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
  8. package/boat/doc-collector/src/cli.ts +6 -1
  9. package/boat/doc-collector/src/docbot.ts +4 -2
  10. package/boat/prima/bin/prima-cli.ts +2 -0
  11. package/boat/prima/src/cli.ts +23 -8
  12. package/boat/prima/src/envelope.ts +35 -9
  13. package/boat/prima/src/prima.ts +23 -10
  14. package/dist/bin/explorbot-cli.js +24 -16
  15. package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
  16. package/dist/boat/api-tester/src/ai/chief.js +6 -1
  17. package/dist/boat/api-tester/src/ai/curler.js +6 -1
  18. package/dist/boat/api-tester/src/apibot.js +7 -3
  19. package/dist/boat/api-tester/src/cli.js +12 -2
  20. package/dist/boat/api-tester/src/config.js +31 -8
  21. package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
  22. package/dist/boat/doc-collector/src/cli.js +6 -1
  23. package/dist/boat/doc-collector/src/docbot.js +4 -2
  24. package/dist/boat/prima/bin/prima-cli.js +2 -0
  25. package/dist/boat/prima/src/cli.js +22 -8
  26. package/dist/boat/prima/src/envelope.js +24 -6
  27. package/dist/boat/prima/src/prima.js +23 -11
  28. package/dist/package.json +2 -2
  29. package/dist/rules/planner/styles/normal.md +1 -1
  30. package/dist/src/action-result.d.ts +9 -1
  31. package/dist/src/action-result.js +57 -18
  32. package/dist/src/action.d.ts +1 -1
  33. package/dist/src/action.js +87 -12
  34. package/dist/src/ai/captain.js +1 -1
  35. package/dist/src/ai/driller.d.ts +0 -1
  36. package/dist/src/ai/driller.js +8 -20
  37. package/dist/src/ai/fisherman-tools.d.ts +9 -0
  38. package/dist/src/ai/fisherman-tools.js +52 -6
  39. package/dist/src/ai/fisherman.d.ts +4 -2
  40. package/dist/src/ai/fisherman.js +48 -27
  41. package/dist/src/ai/historian/codeceptjs.js +1 -1
  42. package/dist/src/ai/historian/playwright.js +1 -1
  43. package/dist/src/ai/navigator.js +1 -1
  44. package/dist/src/ai/pilot.d.ts +1 -0
  45. package/dist/src/ai/pilot.js +13 -1
  46. package/dist/src/ai/planner.js +9 -7
  47. package/dist/src/ai/provider.js +20 -3
  48. package/dist/src/ai/researcher/deep-analysis.js +1 -3
  49. package/dist/src/ai/researcher.js +5 -3
  50. package/dist/src/ai/rules.js +3 -3
  51. package/dist/src/ai/tester.d.ts +3 -0
  52. package/dist/src/ai/tester.js +40 -3
  53. package/dist/src/ai/tools.d.ts +1 -0
  54. package/dist/src/ai/tools.js +13 -6
  55. package/dist/src/api/request-result.d.ts +2 -0
  56. package/dist/src/api/request-result.js +8 -2
  57. package/dist/src/api/request-store.d.ts +3 -2
  58. package/dist/src/api/request-store.js +66 -14
  59. package/dist/src/api/spec-reader.js +1 -1
  60. package/dist/src/commands/config-command.js +1 -1
  61. package/dist/src/commands/drill-command.js +1 -1
  62. package/dist/src/commands/explore-command.d.ts +6 -0
  63. package/dist/src/commands/explore-command.js +39 -3
  64. package/dist/src/commands/freesail-command.js +10 -1
  65. package/dist/src/commands/options/base-option.d.ts +8 -0
  66. package/dist/src/commands/options/base-option.js +12 -0
  67. package/dist/src/commands/options/index.d.ts +5 -0
  68. package/dist/src/commands/options/index.js +5 -0
  69. package/dist/src/commands/options/knowledge-option.d.ts +7 -0
  70. package/dist/src/commands/options/knowledge-option.js +12 -0
  71. package/dist/src/commands/options/ws-option.d.ts +7 -0
  72. package/dist/src/commands/options/ws-option.js +21 -0
  73. package/dist/src/commands/plans-command.js +6 -6
  74. package/dist/src/config.d.ts +1 -0
  75. package/dist/src/config.js +12 -0
  76. package/dist/src/experience-tracker.js +5 -0
  77. package/dist/src/explorbot.d.ts +0 -1
  78. package/dist/src/explorbot.js +24 -37
  79. package/dist/src/knowledge-tracker.d.ts +20 -7
  80. package/dist/src/knowledge-tracker.js +69 -31
  81. package/dist/src/remote.d.ts +0 -3
  82. package/dist/src/remote.js +0 -18
  83. package/dist/src/state-manager.d.ts +5 -1
  84. package/dist/src/state-manager.js +10 -7
  85. package/dist/src/test-plan.d.ts +3 -0
  86. package/dist/src/test-plan.js +27 -0
  87. package/dist/src/utils/aria.d.ts +1 -1
  88. package/dist/src/utils/aria.js +6 -42
  89. package/dist/src/utils/html-diff.d.ts +4 -0
  90. package/dist/src/utils/html-diff.js +62 -7
  91. package/dist/src/utils/html.d.ts +5 -15
  92. package/dist/src/utils/html.js +14 -85
  93. package/dist/src/utils/overlay.d.ts +56 -11
  94. package/dist/src/utils/overlay.js +191 -21
  95. package/dist/src/utils/request-map.d.ts +7 -0
  96. package/dist/src/utils/request-map.js +16 -0
  97. package/dist/src/utils/url-matcher.js +4 -2
  98. package/docs/api-testing/basics.md +15 -0
  99. package/docs/api-testing/planning.md +10 -1
  100. package/docs/reference/commands.md +32 -5
  101. package/docs/reference/websocket.md +1 -0
  102. package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
  103. package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
  104. package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
  105. package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
  106. package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
  107. package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
  108. package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
  109. package/docs/workflow/agentic-usage.md +12 -2
  110. package/docs/workflow/ci.md +1 -0
  111. package/docs/workflow/knowledge.md +46 -2
  112. package/package.json +2 -2
  113. package/rules/planner/styles/normal.md +1 -1
  114. package/src/action-result.ts +61 -22
  115. package/src/action.ts +87 -14
  116. package/src/ai/captain.ts +1 -1
  117. package/src/ai/driller.ts +7 -39
  118. package/src/ai/fisherman-tools.ts +56 -7
  119. package/src/ai/fisherman.ts +48 -28
  120. package/src/ai/historian/codeceptjs.ts +1 -1
  121. package/src/ai/historian/playwright.ts +1 -1
  122. package/src/ai/navigator.ts +1 -1
  123. package/src/ai/pilot.ts +9 -1
  124. package/src/ai/planner.ts +9 -8
  125. package/src/ai/provider.ts +21 -3
  126. package/src/ai/researcher/deep-analysis.ts +1 -2
  127. package/src/ai/researcher.ts +4 -3
  128. package/src/ai/rules.ts +3 -3
  129. package/src/ai/tester.ts +40 -3
  130. package/src/ai/tools.ts +17 -9
  131. package/src/api/request-result.ts +10 -2
  132. package/src/api/request-store.ts +60 -13
  133. package/src/api/spec-reader.ts +1 -1
  134. package/src/commands/config-command.ts +1 -1
  135. package/src/commands/drill-command.ts +1 -1
  136. package/src/commands/explore-command.ts +37 -3
  137. package/src/commands/freesail-command.ts +7 -1
  138. package/src/commands/options/base-option.ts +18 -0
  139. package/src/commands/options/index.ts +7 -0
  140. package/src/commands/options/knowledge-option.ts +14 -0
  141. package/src/commands/options/ws-option.ts +24 -0
  142. package/src/commands/plans-command.ts +6 -6
  143. package/src/config.ts +12 -0
  144. package/src/experience-tracker.ts +5 -1
  145. package/src/explorbot.ts +21 -37
  146. package/src/knowledge-tracker.ts +94 -36
  147. package/src/remote.ts +0 -20
  148. package/src/state-manager.ts +13 -7
  149. package/src/test-plan.ts +29 -0
  150. package/src/utils/aria.ts +7 -44
  151. package/src/utils/html-diff.ts +62 -7
  152. package/src/utils/html.ts +14 -91
  153. package/src/utils/overlay.ts +226 -23
  154. package/src/utils/request-map.ts +19 -0
  155. package/src/utils/url-matcher.ts +3 -2
@@ -32,6 +32,8 @@ export declare class Tester extends TaskAgent implements Agent {
32
32
  seenUiMapUrls: Set<string>;
33
33
  lastAnalyzedStateHash: string | null;
34
34
  stalledIterations: number;
35
+ previousRegionPresent: boolean | null;
36
+ regionTransitioned: boolean;
35
37
  readonly MAX_STALLED_ITERATIONS = 3;
36
38
  skipResearch: (err: Error) => string;
37
39
  constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any);
@@ -96,5 +98,6 @@ interface TestSessionHandlers {
96
98
  }
97
99
  export interface TestOptions {
98
100
  startOnCurrentPage?: boolean;
101
+ deadline?: number;
99
102
  }
100
103
  export {};
@@ -50,6 +50,8 @@ export class Tester extends TaskAgent {
50
50
  seenUiMapUrls = new Set();
51
51
  lastAnalyzedStateHash = null;
52
52
  stalledIterations = 0;
53
+ previousRegionPresent = null;
54
+ regionTransitioned = false;
53
55
  MAX_STALLED_ITERATIONS = 3;
54
56
  skipResearch = (err) => {
55
57
  if (err.name === 'AbortError')
@@ -95,6 +97,8 @@ export class Tester extends TaskAgent {
95
97
  this.seenUiMapUrls.clear();
96
98
  this.lastAnalyzedStateHash = null;
97
99
  this.stalledIterations = 0;
100
+ this.previousRegionPresent = null;
101
+ this.regionTransitioned = false;
98
102
  this.stateManager.clearHistory();
99
103
  this.resetFailureCount();
100
104
  this.pilot?.reset();
@@ -214,10 +218,17 @@ export class Tester extends TaskAgent {
214
218
  const codeceptjsTools = createCodeceptJSTools(this.toolDeps, task);
215
219
  let assertionPerformed = false;
216
220
  let extensions = 0;
221
+ let deadlineReached = false;
217
222
  let shouldContinue = true;
218
223
  while (shouldContinue) {
219
224
  shouldContinue = false;
220
225
  await loop(async ({ stop, pause, iteration, userInput }) => {
226
+ if (opts.deadline != null && Date.now() >= opts.deadline) {
227
+ deadlineReached = true;
228
+ task.addNote('Time budget reached. Stopped');
229
+ stop();
230
+ return;
231
+ }
221
232
  debugLog('iteration', iteration);
222
233
  if (!(await this.explorer.recover()).ok) {
223
234
  task.addNote('Browser page is unavailable');
@@ -369,6 +380,8 @@ export class Tester extends TaskAgent {
369
380
  });
370
381
  if (task.hasFinished)
371
382
  break;
383
+ if (deadlineReached)
384
+ break;
372
385
  if (!(await this.explorer.recover()).ok)
373
386
  break;
374
387
  const finalState = this.getCurrentState();
@@ -403,6 +416,10 @@ export class Tester extends TaskAgent {
403
416
  };
404
417
  }
405
418
  shouldAnalyzeProgress(iteration, currentState) {
419
+ if (this.regionTransitioned) {
420
+ this.regionTransitioned = false;
421
+ return true;
422
+ }
406
423
  if (this.consecutiveFailures >= 3)
407
424
  return true;
408
425
  if (this.consecutiveEmptyResults >= 2)
@@ -468,6 +485,11 @@ export class Tester extends TaskAgent {
468
485
  const currentUrl = currentState.url;
469
486
  const currentStateHash = currentState.hash;
470
487
  const isNewUrl = this.previousUrl !== currentUrl;
488
+ const isNewState = !isNewUrl && this.previousStateHash !== null && this.previousStateHash !== currentStateHash;
489
+ if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.present) {
490
+ this.regionTransitioned = true;
491
+ }
492
+ this.previousRegionPresent = currentState.overlay.present;
471
493
  this.previousUrl = currentUrl;
472
494
  this.previousStateHash = currentStateHash;
473
495
  let context = '';
@@ -491,13 +513,28 @@ export class Tester extends TaskAgent {
491
513
  }
492
514
  if (focusArea.detected) {
493
515
  const areaName = focusArea.name ? ` "${focusArea.name}"` : '';
516
+ let rootHint = '';
517
+ if (focusArea.root)
518
+ rootHint = `\nIts content lives inside \`${focusArea.root}\` — scope locators to it.`;
494
519
  context += dedent `
495
520
  <focus_scope>
496
- A ${focusArea.type}${areaName} is currently open above the page.
521
+ A ${focusArea.type}${areaName} is currently open above the page.${rootHint}
497
522
  Scope all interactions to elements inside this ${focusArea.type}.
498
523
  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 ${focusArea.type}.
499
524
  Use <page_aria> to confirm the element you target is actually inside the ${focusArea.type}.
500
525
  </focus_scope>
526
+ `;
527
+ }
528
+ if (!focusArea.detected && focusArea.present && isNewState) {
529
+ let rootHint = '';
530
+ if (focusArea.root)
531
+ rootHint = `\nIt lives inside \`${focusArea.root}\`.`;
532
+ context += dedent `
533
+ <area_of_interest>
534
+ A large new area "${focusArea.name || 'unnamed area'}" appeared on this page without navigation.${rootHint}
535
+ The scenario most likely continues inside this area — prefer its elements for your next actions.
536
+ The rest of the page (navigation, menus, filters) is still interactive and remains available.
537
+ </area_of_interest>
501
538
  `;
502
539
  }
503
540
  if (currentState.isInsideIframe) {
@@ -520,7 +557,7 @@ export class Tester extends TaskAgent {
520
557
  if (!alreadySeenUiMap) {
521
558
  research = await this.researcher.research(currentState).catch(this.skipResearch);
522
559
  }
523
- this.pageStateHash = currentStateHash;
560
+ this.pageStateHash = currentState.baseHash;
524
561
  this.pageActionResult = currentState;
525
562
  let uiMapSection = '';
526
563
  if (research) {
@@ -557,7 +594,7 @@ export class Tester extends TaskAgent {
557
594
  `;
558
595
  return context;
559
596
  }
560
- if (focusArea.detected && focusArea.name && this.pageStateHash && this.pageActionResult) {
597
+ if (focusArea.present && focusArea.name && this.pageStateHash && this.pageActionResult) {
561
598
  const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
562
599
  if (overlaySection) {
563
600
  context += dedent `
@@ -69,4 +69,5 @@ export declare function withdrawVisionTools(tools: Record<string, any>): void;
69
69
  export declare function clickFailureSuggestion(attempts: Array<{
70
70
  error?: string;
71
71
  }>): string;
72
+ export declare function formatMatchedElements(error: Error | null | undefined): Promise<string | null>;
72
73
  export {};
@@ -7,8 +7,9 @@ import { Stats } from "../stats.js";
7
7
  import { TestResult } from '../test-plan.js';
8
8
  import { LARGE_ARIA_CHANGE_THRESHOLD } from "../utils/aria.js";
9
9
  import { isFatalBrowserError } from "../utils/browser-errors.js";
10
+ import { cleanHtmlSnippet } from "../utils/html.js";
10
11
  import { createDebug, tag } from '../utils/logger.js';
11
- import { compactErrorMessage } from "../utils/strings.js";
12
+ import { compactErrorMessage, normalizeInlineText, truncate } from "../utils/strings.js";
12
13
  import { pause } from '../utils/loop.js';
13
14
  import { WebElement } from "../utils/web-element.js";
14
15
  import { sectionContextRule } from "./rules.js";
@@ -1161,6 +1162,8 @@ export function clickFailureSuggestion(attempts) {
1161
1162
  return "Try xpathCheck() to find the element's actual position, see() for visual analysis, or visualClick() to click by visual appearance.";
1162
1163
  }
1163
1164
  const MAX_DISAMBIGUATE_ELEMENTS = 10;
1165
+ const MAX_DISAMBIGUATE_TEXT = 80;
1166
+ const MAX_DISAMBIGUATE_HTML = 300;
1164
1167
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1165
1168
  async function extractWebElements(error) {
1166
1169
  if (!error || error.name !== 'MultipleElementsFound')
@@ -1172,8 +1175,9 @@ async function extractWebElements(error) {
1172
1175
  for (let i = 0; i < Math.min(elements.length, MAX_DISAMBIGUATE_ELEMENTS); i++) {
1173
1176
  try {
1174
1177
  const xpath = await elements[i].toAbsoluteXPath();
1175
- const html = await elements[i].toSimplifiedHTML();
1176
- result.push({ xpath, html });
1178
+ const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
1179
+ const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
1180
+ result.push({ xpath, html, text });
1177
1181
  }
1178
1182
  catch (e) {
1179
1183
  debugLog('Failed to get details for element %d: %s', i, e);
@@ -1181,17 +1185,20 @@ async function extractWebElements(error) {
1181
1185
  }
1182
1186
  return result.length > 0 ? result : null;
1183
1187
  }
1184
- async function formatMatchedElements(error) {
1188
+ function formatElementList(details) {
1189
+ return details.map((el, i) => `Element ${i + 1}:\nText: "${el.text}"\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1190
+ }
1191
+ export async function formatMatchedElements(error) {
1185
1192
  const details = await extractWebElements(error);
1186
1193
  if (!details)
1187
1194
  return 'Could not fetch element details. Repeat the action to get better info.';
1188
- return details.map((el, i) => `Element ${i + 1}\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1195
+ return formatElementList(details);
1189
1196
  }
1190
1197
  async function disambiguateElements(error, explanation, provider) {
1191
1198
  const elementDetails = await extractWebElements(error);
1192
1199
  if (!elementDetails)
1193
1200
  return null;
1194
- const elementList = elementDetails.map((el, i) => `Element ${i + 1}:\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1201
+ const elementList = formatElementList(elementDetails);
1195
1202
  const schema = z.object({
1196
1203
  position: z.number().nullable().describe('1-based position of the correct element, or null if none match'),
1197
1204
  });
@@ -33,8 +33,10 @@ export declare class RequestResult {
33
33
  get rawResponseBody(): string;
34
34
  set rawResponseBodyValue(value: string);
35
35
  get responseBody(): any;
36
+ get isWrite(): boolean;
36
37
  save(outputDir: string): void;
37
38
  static load(requestFile: string): RequestResult;
39
+ toEndpoint(): string;
38
40
  toSummary(): string;
39
41
  extractIdAndTitle(): {
40
42
  id?: string | number;
@@ -64,6 +64,9 @@ export class RequestResult {
64
64
  return raw;
65
65
  }
66
66
  }
67
+ get isWrite() {
68
+ return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(this.method);
69
+ }
67
70
  save(outputDir) {
68
71
  const requestsDir = path.join(outputDir, 'requests');
69
72
  if (!existsSync(requestsDir)) {
@@ -137,14 +140,17 @@ export class RequestResult {
137
140
  statusText: meta.statusText || '',
138
141
  responseHeaders: meta.responseHeaders || {},
139
142
  timing: Number.parseInt(meta.timing) || 0,
140
- timestamp: new Date(meta.timestamp || Date.now()),
143
+ timestamp: new Date(meta.timestamp || 0),
141
144
  });
142
145
  result.requestFile = requestFile;
143
146
  result.responseFile = responseFile;
144
147
  return result;
145
148
  }
149
+ toEndpoint() {
150
+ return `${this.method} ${this.path}`;
151
+ }
146
152
  toSummary() {
147
- return `${this.method} ${this.path} → ${this.status} (${this.timing}ms)`;
153
+ return `${this.toEndpoint()} → ${this.status} (${this.timing}ms)`;
148
154
  }
149
155
  extractIdAndTitle() {
150
156
  const body = this.responseBody;
@@ -5,6 +5,7 @@ export declare class RequestStore {
5
5
  failedRequests: RequestResult[];
6
6
  onFailedListeners: Array<(r: RequestResult) => void>;
7
7
  outputDir: string;
8
+ sessionStartedAt: Date;
8
9
  constructor(outputDir: string);
9
10
  addCapturedRequest(result: RequestResult): void;
10
11
  addFailedRequest(result: RequestResult): void;
@@ -19,9 +20,9 @@ export declare class RequestStore {
19
20
  getRequestsByEndpoint(pathPrefix: string): RequestResult[];
20
21
  getRequestsByMethod(method: string): RequestResult[];
21
22
  getRequestsByStatus(status: number): RequestResult[];
22
- toEndpointList(): string;
23
+ toEndpointList(scopePath?: string): string;
23
24
  extractAuthHeaders(): Record<string, string>;
24
- findCapturedRequest(method: string, pathPrefix: string): RequestResult | undefined;
25
+ findCapturedRequest(method: string, searchPath: string): RequestResult | undefined;
25
26
  toLog(): string;
26
27
  loadFromDisk(): void;
27
28
  getWriteRequestsForScope(scopePath: string): RequestResult[];
@@ -1,13 +1,15 @@
1
1
  import { existsSync, readdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { isDynamicSegment } from "../utils/url-matcher.js";
3
4
  import { RequestResult } from "./request-result.js";
4
- const AUTH_HEADERS = ['authorization', 'cookie', 'x-api-key', 'x-csrf-token'];
5
+ const AUTH_HEADERS = ['authorization', 'x-api-key', 'x-csrf-token'];
5
6
  export class RequestStore {
6
7
  capturedRequests = [];
7
8
  madeRequests = [];
8
9
  failedRequests = [];
9
10
  onFailedListeners = [];
10
11
  outputDir;
12
+ sessionStartedAt = new Date();
11
13
  constructor(outputDir) {
12
14
  this.outputDir = outputDir;
13
15
  }
@@ -61,12 +63,14 @@ export class RequestStore {
61
63
  getRequestsByStatus(status) {
62
64
  return this.madeRequests.filter((r) => r.status === status);
63
65
  }
64
- toEndpointList() {
66
+ toEndpointList(scopePath) {
67
+ let requests = this.capturedRequests;
68
+ if (scopePath)
69
+ requests = this.getWriteRequestsForScope(scopePath);
65
70
  const seen = new Set();
66
71
  const lines = [];
67
- for (const req of this.capturedRequests) {
68
- const normalized = normalizePathPattern(req.path);
69
- const key = `${req.method} ${normalized}`;
72
+ for (const req of requests) {
73
+ const key = `${req.method} ${normalizePathPattern(req.path)}`;
70
74
  if (seen.has(key))
71
75
  continue;
72
76
  seen.add(key);
@@ -76,21 +80,42 @@ export class RequestStore {
76
80
  }
77
81
  extractAuthHeaders() {
78
82
  const headers = {};
79
- for (let i = this.capturedRequests.length - 1; i >= 0; i--) {
80
- const req = this.capturedRequests[i];
83
+ const sessionCaptures = this.capturedRequests.filter((r) => r.timestamp >= this.sessionStartedAt).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
84
+ for (const req of sessionCaptures) {
81
85
  for (const [key, value] of Object.entries(req.requestHeaders)) {
82
86
  if (AUTH_HEADERS.includes(key.toLowerCase()) && !headers[key]) {
83
87
  headers[key] = value;
84
88
  }
85
89
  }
86
- if (AUTH_HEADERS.every((h) => Object.keys(headers).some((k) => k.toLowerCase() === h)))
87
- break;
88
90
  }
89
91
  return headers;
90
92
  }
91
- findCapturedRequest(method, pathPrefix) {
93
+ findCapturedRequest(method, searchPath) {
92
94
  const upper = method.toUpperCase();
93
- return this.capturedRequests.find((r) => r.method === upper && r.path.startsWith(pathPrefix));
95
+ const search = normalizePathPattern(searchPath).split('/').filter(Boolean);
96
+ let best;
97
+ let bestScore = -1;
98
+ for (const req of this.capturedRequests) {
99
+ if (req.method !== upper)
100
+ continue;
101
+ const segments = normalizePathPattern(req.path).split('/').filter(Boolean);
102
+ if (segments.length < search.length)
103
+ continue;
104
+ if (!search.every((segment, i) => segment === segments[i]))
105
+ continue;
106
+ let score = 0;
107
+ if (segments.length === search.length)
108
+ score += 4;
109
+ if (req.status < 400)
110
+ score += 2;
111
+ if (score < bestScore)
112
+ continue;
113
+ if (score === bestScore && best && req.timestamp <= best.timestamp)
114
+ continue;
115
+ best = req;
116
+ bestScore = score;
117
+ }
118
+ return best;
94
119
  }
95
120
  toLog() {
96
121
  return this.madeRequests.map((r, i) => `${i + 1}. ${r.toSummary()} [${r.id}]`).join('\n');
@@ -100,7 +125,7 @@ export class RequestStore {
100
125
  if (!existsSync(requestsDir))
101
126
  return;
102
127
  const existingIds = new Set(this.capturedRequests.map((r) => r.id));
103
- const files = readdirSync(requestsDir).filter((f) => f.endsWith('.request.yaml'));
128
+ const files = readdirSync(requestsDir).filter((f) => f.startsWith('xhr_') && f.endsWith('.request.yaml'));
104
129
  for (const file of files) {
105
130
  try {
106
131
  const result = RequestResult.load(path.join(requestsDir, file));
@@ -115,7 +140,31 @@ export class RequestStore {
115
140
  }
116
141
  getWriteRequestsForScope(scopePath) {
117
142
  const writeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
118
- return this.capturedRequests.filter((r) => writeMethods.has(r.method) && r.path.startsWith(scopePath));
143
+ const writes = this.capturedRequests.filter((r) => writeMethods.has(r.method));
144
+ const scopeSegments = scopePath.split('/').filter(Boolean);
145
+ if (scopeSegments.length === 0)
146
+ return writes;
147
+ let scoped = [];
148
+ let fewest = Number.POSITIVE_INFINITY;
149
+ let ambiguous = false;
150
+ for (const segment of scopeSegments) {
151
+ if (isDynamicSegment(segment))
152
+ continue;
153
+ const matches = writes.filter((r) => r.path.split('/').includes(segment));
154
+ if (matches.length === 0 || matches.length > fewest)
155
+ continue;
156
+ if (matches.length === fewest) {
157
+ if (!scoped.every((r, i) => r.id === matches[i].id))
158
+ ambiguous = true;
159
+ continue;
160
+ }
161
+ scoped = matches;
162
+ fewest = matches.length;
163
+ ambiguous = false;
164
+ }
165
+ if (ambiguous)
166
+ return [];
167
+ return scoped;
119
168
  }
120
169
  clear() {
121
170
  this.capturedRequests = [];
@@ -124,5 +173,8 @@ export class RequestStore {
124
173
  }
125
174
  }
126
175
  function normalizePathPattern(urlPath) {
127
- return urlPath.replace(/\/[0-9a-f]{24}\b/g, '/{id}').replace(/\/\d+\b/g, '/{id}');
176
+ return urlPath
177
+ .split('/')
178
+ .map((segment) => (segment && isDynamicSegment(segment) ? '{id}' : segment))
179
+ .join('/');
128
180
  }
@@ -5,7 +5,7 @@ import { dereference } from '@scalar/openapi-parser';
5
5
  import { tag } from "../utils/logger.js";
6
6
  export function validateSpecs(specs) {
7
7
  if (!specs?.length) {
8
- throw new Error('API spec is required. Set api.spec in your config file.');
8
+ throw new Error('API spec is required. Pass --spec, set EXPLORBOT_API_SPEC, or set api.spec in your config file.');
9
9
  }
10
10
  }
11
11
  export async function loadSpec(specPaths, outputDir) {
@@ -32,7 +32,7 @@ export class ConfigCommand extends BaseCommand {
32
32
  const dirs = {};
33
33
  if (options.root) {
34
34
  for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
35
- dirs[name] = path.join(options.root, dir);
35
+ dirs[name] = path.resolve(options.root, dir);
36
36
  }
37
37
  }
38
38
  const env = {};
@@ -21,7 +21,7 @@ export class DrillCommand extends BaseCommand {
21
21
  });
22
22
  }
23
23
  parseKnowledgeArg(args) {
24
- const match = args.match(/--knowledge\s+(\S+)/);
24
+ const match = args.match(/--save-knowledge\s+(\S+)/);
25
25
  return match ? match[1] : undefined;
26
26
  }
27
27
  parseMaxArg(args) {
@@ -1,5 +1,7 @@
1
1
  import { type Plan, type Test } from '../test-plan.js';
2
2
  import { BaseCommand, type Suggestion } from './base-command.js';
3
+ export declare const DEADLINE_RESERVE_MS: number;
4
+ export declare const DEADLINE_TEST_ALLOWANCE_MS: number;
3
5
  export declare class ExploreCommand extends BaseCommand {
4
6
  name: string;
5
7
  description: string;
@@ -9,8 +11,11 @@ export declare class ExploreCommand extends BaseCommand {
9
11
  }[];
10
12
  suggestions: Suggestion[];
11
13
  maxTests?: number;
14
+ maxDurationMinutes?: number;
15
+ hardDeadlineAt?: number;
12
16
  dryRun: boolean;
13
17
  testsRun: number;
18
+ deadlineLogged: boolean;
14
19
  completedPlans: Plan[];
15
20
  failedSubPages: Set<string>;
16
21
  oldTestRefs: Set<Test>;
@@ -36,6 +41,7 @@ export declare class ExploreCommand extends BaseCommand {
36
41
  printResults(): void;
37
42
  printNextSteps(savedPlanPath?: string | null): void;
38
43
  isLimitReached(): boolean;
44
+ isDeadlineReached(): boolean;
39
45
  runPendingTests(): Promise<void>;
40
46
  runOneTest(test: Test): Promise<void>;
41
47
  }
@@ -13,11 +13,14 @@ import { safeFilename } from "../utils/strings.js";
13
13
  import { BaseCommand } from './base-command.js';
14
14
  const MAX_SUB_PAGE_ATTEMPTS = 30;
15
15
  const PRIORITY_ORDER = { critical: 0, important: 1, high: 2, normal: 3, low: 4 };
16
+ export const DEADLINE_RESERVE_MS = 3 * 60_000;
17
+ export const DEADLINE_TEST_ALLOWANCE_MS = 5 * 60_000;
16
18
  export class ExploreCommand extends BaseCommand {
17
19
  name = 'explore';
18
20
  description = 'Start web exploration';
19
21
  options = [
20
22
  { flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
23
+ { flags: '--max-duration <minutes>', description: 'Wall-clock budget in minutes; wraps up the session before the limit is hit' },
21
24
  { flags: '--focus <feature>', description: 'Focus area for exploration' },
22
25
  { flags: '--configure <spec>', description: 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"' },
23
26
  { flags: '--dry-run', description: 'Mark picked tests as skipped without executing or generating new ones' },
@@ -28,8 +31,11 @@ export class ExploreCommand extends BaseCommand {
28
31
  { command: 'plan <feature>', hint: 'plan testing' },
29
32
  ];
30
33
  maxTests;
34
+ maxDurationMinutes;
35
+ hardDeadlineAt;
31
36
  dryRun = false;
32
37
  testsRun = 0;
38
+ deadlineLogged = false;
33
39
  completedPlans = [];
34
40
  failedSubPages = new Set();
35
41
  oldTestRefs = new Set();
@@ -43,6 +49,12 @@ export class ExploreCommand extends BaseCommand {
43
49
  if (opts.maxTests) {
44
50
  this.maxTests = Number.parseInt(opts.maxTests, 10);
45
51
  }
52
+ if (opts.maxDuration) {
53
+ this.maxDurationMinutes = Number.parseInt(opts.maxDuration, 10);
54
+ }
55
+ if (this.hardDeadlineAt == null && this.maxDurationMinutes != null) {
56
+ this.hardDeadlineAt = Date.now() + this.maxDurationMinutes * 60_000 - DEADLINE_RESERVE_MS;
57
+ }
46
58
  const feature = opts.focus || remaining.join(' ') || undefined;
47
59
  const cfg = this.parseConfigure(opts.configure);
48
60
  if (cfg.priorities)
@@ -244,9 +256,15 @@ export class ExploreCommand extends BaseCommand {
244
256
  tag('info').log(`Exploring sub-page: ${pick.url} (${pick.reason})`);
245
257
  try {
246
258
  await this.explorBot.visit(pick.url);
259
+ const errorPage = getStateErrorPageError(this.explorBot.stateManager().getCurrentState());
260
+ if (errorPage) {
261
+ tag('warning').log(`Skipping sub-page: ${errorPage.message}`);
262
+ this.failedSubPages.add(normalizeUrl(pick.url));
263
+ continue;
264
+ }
247
265
  await this.runAllStyles(pick.url, undefined, mainPlan, this.completedPlans, styles);
248
266
  const subPlan = this.explorBot.getCurrentPlan();
249
- if (subPlan && !this.completedPlans.includes(subPlan)) {
267
+ if (subPlan?.tests.length && !this.completedPlans.includes(subPlan)) {
250
268
  this.completedPlans.push(subPlan);
251
269
  }
252
270
  knownUrls.add(normalizeUrl(pick.url));
@@ -285,6 +303,11 @@ export class ExploreCommand extends BaseCommand {
285
303
  if (this.dryRun)
286
304
  opts.noSave = true;
287
305
  await this.planWithRetry(feature, opts, pageUrl);
306
+ const plan = this.explorBot.getCurrentPlan();
307
+ if (plan && plan.tests.length === 0) {
308
+ tag('warning').log('Nothing to test on this page, moving on');
309
+ return;
310
+ }
288
311
  await this.runPendingTests();
289
312
  this.rememberCurrentPlan();
290
313
  fresh = false;
@@ -515,7 +538,20 @@ export class ExploreCommand extends BaseCommand {
515
538
  printNextSteps(sections);
516
539
  }
517
540
  isLimitReached() {
518
- return this.maxTests != null && this.testsRun >= this.maxTests;
541
+ if (this.maxTests != null && this.testsRun >= this.maxTests)
542
+ return true;
543
+ return this.isDeadlineReached();
544
+ }
545
+ isDeadlineReached() {
546
+ if (this.hardDeadlineAt == null)
547
+ return false;
548
+ if (Date.now() < this.hardDeadlineAt - DEADLINE_TEST_ALLOWANCE_MS)
549
+ return false;
550
+ if (!this.deadlineLogged) {
551
+ this.deadlineLogged = true;
552
+ tag('info').log(`Time budget reached after ${this.testsRun} test(s): stopping new work and finishing the session`);
553
+ }
554
+ return true;
519
555
  }
520
556
  async runPendingTests() {
521
557
  const plan = this.explorBot.getCurrentPlan();
@@ -539,7 +575,7 @@ export class ExploreCommand extends BaseCommand {
539
575
  test.finish(TestResult.SKIPPED);
540
576
  }
541
577
  else {
542
- await this.explorBot.agentTester().test(test);
578
+ await this.explorBot.agentTester().test(test, { deadline: this.hardDeadlineAt });
543
579
  }
544
580
  this.testsRun++;
545
581
  }
@@ -4,7 +4,7 @@ import { Stats } from '../stats.js';
4
4
  import { tag } from '../utils/logger.js';
5
5
  import { loop } from '../utils/loop.js';
6
6
  import { BaseCommand } from './base-command.js';
7
- import { ExploreCommand } from './explore-command.js';
7
+ import { DEADLINE_RESERVE_MS, DEADLINE_TEST_ALLOWANCE_MS, ExploreCommand } from './explore-command.js';
8
8
  export class FreesailCommand extends BaseCommand {
9
9
  name = 'freesail';
10
10
  description = 'Continuously explore and navigate to new pages autonomously';
@@ -15,6 +15,7 @@ export class FreesailCommand extends BaseCommand {
15
15
  { flags: '--shallow', description: 'Use shallow navigation strategy' },
16
16
  { flags: '--scope <url>', description: 'Limit navigation to URLs starting with this prefix' },
17
17
  { flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
18
+ { flags: '--max-duration <number>', description: 'Wall-clock budget in minutes for the whole run' },
18
19
  ];
19
20
  async execute(args) {
20
21
  Stats.mode = 'freesail';
@@ -26,11 +27,17 @@ export class FreesailCommand extends BaseCommand {
26
27
  strategy = 'shallow';
27
28
  const scope = opts.scope;
28
29
  const maxTests = opts.maxTests ? Number.parseInt(opts.maxTests, 10) : undefined;
30
+ const maxDuration = opts.maxDuration ? Number.parseInt(opts.maxDuration, 10) : undefined;
31
+ let hardDeadlineAt;
32
+ if (maxDuration != null)
33
+ hardDeadlineAt = Date.now() + maxDuration * 60_000 - DEADLINE_RESERVE_MS;
29
34
  await this.explorBot.visitInitialState();
30
35
  let testsRun = 0;
31
36
  await loop(async (ctx) => {
32
37
  if (maxTests != null && testsRun >= maxTests)
33
38
  ctx.stop();
39
+ if (hardDeadlineAt != null && Date.now() >= hardDeadlineAt - DEADLINE_TEST_ALLOWANCE_MS)
40
+ ctx.stop();
34
41
  const stateManager = this.explorBot.stateManager();
35
42
  const state = stateManager.getCurrentState();
36
43
  if (state && !Researcher.getCachedResearch(state)) {
@@ -44,6 +51,8 @@ export class FreesailCommand extends BaseCommand {
44
51
  const exploreCmd = new ExploreCommand(this.explorBot);
45
52
  if (maxTests != null)
46
53
  exploreCmd.maxTests = maxTests - testsRun;
54
+ if (hardDeadlineAt != null)
55
+ exploreCmd.hardDeadlineAt = hardDeadlineAt;
47
56
  await exploreCmd.execute('');
48
57
  const plan = this.explorBot.getCurrentPlan();
49
58
  if (plan)
@@ -0,0 +1,8 @@
1
+ import type { Command } from 'commander';
2
+ export declare abstract class BaseOption {
3
+ abstract flags: string;
4
+ abstract description: string;
5
+ collect?: (value: string, previous: any) => any;
6
+ register(program: Command): void;
7
+ abstract apply(options: Record<string, any>, command: Command): void;
8
+ }
@@ -0,0 +1,12 @@
1
+ export class BaseOption {
2
+ collect;
3
+ register(program) {
4
+ if (this.collect)
5
+ program.option(this.flags, this.description, this.collect);
6
+ if (!this.collect)
7
+ program.option(this.flags, this.description);
8
+ program.hook('preAction', (_thisCommand, actionCommand) => {
9
+ this.apply(actionCommand.optsWithGlobals(), actionCommand);
10
+ });
11
+ }
12
+ }
@@ -0,0 +1,5 @@
1
+ import { KnowledgeOption } from './knowledge-option.js';
2
+ import { WsOption } from './ws-option.js';
3
+ export { BaseOption } from './base-option.js';
4
+ export declare const knowledgeOption: KnowledgeOption;
5
+ export declare const wsOption: WsOption;
@@ -0,0 +1,5 @@
1
+ import { KnowledgeOption } from './knowledge-option.js';
2
+ import { WsOption } from './ws-option.js';
3
+ export { BaseOption } from './base-option.js';
4
+ export const knowledgeOption = new KnowledgeOption();
5
+ export const wsOption = new WsOption();
@@ -0,0 +1,7 @@
1
+ import { BaseOption } from './base-option.js';
2
+ export declare class KnowledgeOption extends BaseOption {
3
+ flags: string;
4
+ description: string;
5
+ collect: (value: string, previous?: string[]) => string[];
6
+ apply(options: Record<string, any>): void;
7
+ }
@@ -0,0 +1,12 @@
1
+ import { KnowledgeTracker } from '../../knowledge-tracker.js';
2
+ import { BaseOption } from './base-option.js';
3
+ export class KnowledgeOption extends BaseOption {
4
+ flags = '--knowledge <text>';
5
+ description = 'Knowledge for this run only, not saved to disk. Markdown text; add url: or endpoint: frontmatter to scope it, otherwise it applies everywhere. Repeatable';
6
+ collect = (value, previous = []) => [...previous, value];
7
+ apply(options) {
8
+ for (const text of options.knowledge || []) {
9
+ KnowledgeTracker.appendSessionKnowledge(text);
10
+ }
11
+ }
12
+ }